Compare commits
136 Commits
94f8466d1c
...
carousel-s
| Author | SHA1 | Date | |
|---|---|---|---|
| a3caafaf20 | |||
| b9b0dce40b | |||
| b2fd65c8cb | |||
| 7ef9939d8a | |||
| 43bd6320b8 | |||
| aaf8089ba6 | |||
| 38a43a3a99 | |||
| 4fdedb87c8 | |||
| 5d4d518af8 | |||
| cad4852878 | |||
| e6af56a417 | |||
| ffec244113 | |||
| 52abffb4c9 | |||
| ef0aa2a847 | |||
| 7b69ed6df8 | |||
| 35ccdc0a22 | |||
| 643608f25d | |||
| 17f7a7eaf2 | |||
| 854f365715 | |||
| 114435a56d | |||
| e5e296ca98 | |||
| ec6fe77567 | |||
| d4cb0b7435 | |||
| 64e252da94 | |||
| 9a463facce | |||
| 7c824392b7 | |||
| e5f74ec13e | |||
| 8060d50a6f | |||
| 13a0c98d1d | |||
| 9161c4bde2 | |||
| a3dac17b7c | |||
| 5bc3a1a456 | |||
| 878e1561ea | |||
| 1ecb70135a | |||
| 9930a4bb99 | |||
| 572743f16d | |||
| 6853e04db0 | |||
| f406f684dc | |||
| 52e9cc360f | |||
| d1d3033c15 | |||
| 9eb5e4fa32 | |||
| b3deb4d518 | |||
| 99366b5fc4 | |||
| 13a235e99f | |||
| 0659f3c1b0 | |||
| f64ce6168d | |||
| fe193bb062 | |||
| 0ddf05746a | |||
| 6ca8e59b0b | |||
| 6e1df928ad | |||
| b3495bc102 | |||
| 05e5ac71b7 | |||
| 9d744ff3cc | |||
| da3983d5c4 | |||
| 9a00375568 | |||
| a8eb404ff3 | |||
| 4f1179d5cd | |||
| 75a9b4ca1e | |||
| f76fee23bf | |||
| 5eb937ab3e | |||
| 27713572b5 | |||
| 61086efbcf | |||
| a65898e74f | |||
| 12286f57f7 | |||
| c980439bc9 | |||
| 3cb4237f59 | |||
| c7bd376292 | |||
| a0c0068997 | |||
| c07cf58086 | |||
| 4756bfdc57 | |||
| ced29a6571 | |||
| 37f4297972 | |||
| 67f3ae4d34 | |||
| 834de012b4 | |||
| fc30d81f8f | |||
| 2c2560f5a2 | |||
| 7cf6b4e6ae | |||
| 30a7725769 | |||
| b185e0b129 | |||
| 29c409fad8 | |||
| c890cce37a | |||
| b400c4d6c3 | |||
| f8af51b75c | |||
| 58966aec19 | |||
| 495747af84 | |||
| 42cc081d49 | |||
| f5ba778e46 | |||
| a713141420 | |||
| b08b4b4129 | |||
| d9eae3f07c | |||
| f87978ea9c | |||
| e218ab5c37 | |||
| f00e20b1ac | |||
| ef55d1583f | |||
| 6a31ea64ed | |||
| 00ff6a6b72 | |||
| 8b0960d366 | |||
| 6c741577ce | |||
| 576e6d77c1 | |||
| 0bb1b411f4 | |||
| c55e311241 | |||
| d5ca19f770 | |||
| 29b0d8151b | |||
| bb6604e819 | |||
| 7e7f017768 | |||
| c366311c16 | |||
| 21d4bc6ad9 | |||
| eb3768ae30 | |||
| 168d44d262 | |||
| 8f1ba610fc | |||
| 3f6f2134ad | |||
| bfbdf98468 | |||
| 516000a25f | |||
| 4ca4af0e8b | |||
| 1a452f52ca | |||
| cd912025f1 | |||
| 47b24d26ff | |||
| 546135988e | |||
| 847f755ba4 | |||
| fbb6ec213b | |||
| 2a7384e979 | |||
| 156dfcf397 | |||
| 72ba2e200a | |||
| d226b1810a | |||
| 3efc68e893 | |||
| 7b97d19e48 | |||
| 0cfc6cb0ab | |||
| 92c3ec8082 | |||
| 7c2851e958 | |||
| 455cf5cce6 | |||
| 92be07cb47 | |||
| 13d87e44c8 | |||
| a2268f863b | |||
| a6b76a9be7 | |||
| c0af925420 | |||
| 54f5fa95a6 |
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
@@ -1,3 +0,0 @@
|
||||
github: ajbura
|
||||
liberapay: ajbura
|
||||
open_collective: cinny
|
||||
57
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
57
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,57 +0,0 @@
|
||||
name: 🐞 Bug Report
|
||||
description: Report a bug
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## First of all
|
||||
1. Please search for [existing issues](https://github.com/ajbura/cinny/issues?q=is%3Aissue) about this problem first.
|
||||
2. Make sure Cinny is up to date.
|
||||
3. Make sure it's an issue with Cinny and not something else you are using.
|
||||
4. Remember to be friendly.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear description of what the bug is. Include screenshots if applicable.
|
||||
placeholder: Bug description
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Reproduction
|
||||
description: Steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. Go to ...
|
||||
2. Click on ...
|
||||
3. See error
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: A clear description of what you expected to happen.
|
||||
|
||||
- type: textarea
|
||||
id: info
|
||||
attributes:
|
||||
label: Platform and versions
|
||||
description: "Provide OS, browser and Cinny version with your Homeserver."
|
||||
placeholder: |
|
||||
1. OS: [e.g. Windows 10, MacOS]
|
||||
2. Browser: [e.g. chrome 99.5, firefox 97.2]
|
||||
3. Cinny version: [e.g. 1.8.1 (app.cinny.in)]
|
||||
4. Matrix homeserver: [e.g. matrix.org]
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context about the problem here.
|
||||
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,4 +0,0 @@
|
||||
contact_links:
|
||||
- name: 💬 Matrix Chat
|
||||
url: https://matrix.to/#/#cinny:matrix.org
|
||||
about: Ask questions and talk to other Cinny users and the maintainers
|
||||
33
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
33
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,33 +0,0 @@
|
||||
name: 💡 Feature Request
|
||||
description: Suggest an idea
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Describe the problem
|
||||
description: A clear description of the problem this feature would solve
|
||||
placeholder: "I'm always frustrated when..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: "Describe the solution you'd like"
|
||||
description: A clear description of what change you would like
|
||||
placeholder: "I would like to..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: "Any alternative solutions you've considered"
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context about the problem here.
|
||||
22
.github/PULL_REQUEST_TEMPLATE.md
vendored
22
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,22 +0,0 @@
|
||||
<!-- Please read https://github.com/ajbura/cinny/blob/dev/CONTRIBUTING.md before submitting your pull request -->
|
||||
|
||||
### Description
|
||||
<!-- Please include a summary of the change. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
|
||||
|
||||
|
||||
Fixes #
|
||||
|
||||
#### Type of change
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] This change requires a documentation update
|
||||
|
||||
### Checklist:
|
||||
|
||||
- [ ] My code follows the style guidelines of this project
|
||||
- [ ] I have performed a self-review of my own code
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
3
.github/SECURITY.md
vendored
3
.github/SECURITY.md
vendored
@@ -1,3 +0,0 @@
|
||||
# Reporting a Vulnerability
|
||||
|
||||
**If you've found a security vulnerability, please report it to cinnyapp@gmail.com**
|
||||
30
.github/dependabot.yml
vendored
30
.github/dependabot.yml
vendored
@@ -1,30 +0,0 @@
|
||||
# Docs: <https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/customizing-dependency-updates>
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
# - package-ecosystem: npm
|
||||
# directory: /
|
||||
# schedule:
|
||||
# interval: weekly
|
||||
# day: "tuesday"
|
||||
# time: "01:00"
|
||||
# timezone: "Asia/Kolkata"
|
||||
# open-pull-requests-limit: 15
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: "tuesday"
|
||||
time: "01:00"
|
||||
timezone: "Asia/Kolkata"
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: "tuesday"
|
||||
time: "01:00"
|
||||
timezone: "Asia/Kolkata"
|
||||
open-pull-requests-limit: 5
|
||||
14
.github/renovate.json
vendored
14
.github/renovate.json
vendored
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended", ":dependencyDashboardApproval"],
|
||||
"labels": ["Dependencies"],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchUpdateTypes": ["lockFileMaintenance"]
|
||||
}
|
||||
],
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true
|
||||
},
|
||||
"dependencyDashboard": true
|
||||
}
|
||||
40
.github/workflows/build-pull-request.yml
vendored
40
.github/workflows/build-pull-request.yml
vendored
@@ -1,40 +0,0 @@
|
||||
name: Build pull request
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: ['opened', 'synchronize']
|
||||
|
||||
jobs:
|
||||
build-pull-request:
|
||||
name: Build pull request
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PR_NUMBER: ${{github.event.number}}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.2.0
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4.4.0
|
||||
with:
|
||||
node-version: 20.12.2
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build app
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
run: npm run build
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
with:
|
||||
name: preview
|
||||
path: dist
|
||||
retention-days: 1
|
||||
- name: Save pr number
|
||||
run: echo ${PR_NUMBER} > ./pr.txt
|
||||
- name: Upload pr number
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
with:
|
||||
name: pr
|
||||
path: ./pr.txt
|
||||
retention-days: 1
|
||||
36
.github/workflows/cla.yml
vendored
36
.github/workflows/cla.yml
vendored
@@ -1,36 +0,0 @@
|
||||
name: 'CLA Assistant'
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened, closed, synchronize]
|
||||
|
||||
jobs:
|
||||
CLAssistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'CLA Assistant'
|
||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||
# Beta Release
|
||||
uses: cla-assistant/github-action@v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# the below token should have repo scope and must be manually added by you in the repository's secret
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }}
|
||||
with:
|
||||
path-to-signatures: 'signatures.json'
|
||||
path-to-document: 'https://github.com/cinnyapp/cla/blob/main/cla.md' # e.g. a CLA or a DCO document
|
||||
# branch should not be protected
|
||||
branch: 'main'
|
||||
allowlist: ajbura,bot*
|
||||
|
||||
#below are the optional inputs - If the optional inputs are not given, then default values will be taken
|
||||
remote-organization-name: cinnyapp
|
||||
remote-repository-name: cla
|
||||
#create-file-commit-message: 'For example: Creating file for storing CLA Signatures'
|
||||
#signed-commit-message: 'For example: $contributorName has signed the CLA in #$pullRequestNo'
|
||||
#custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign'
|
||||
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
|
||||
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
|
||||
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
|
||||
#use-dco-flag: true - If you are using DCO instead of CLA
|
||||
56
.github/workflows/deploy-pull-request.yml
vendored
56
.github/workflows/deploy-pull-request.yml
vendored
@@ -1,56 +0,0 @@
|
||||
name: Deploy PR to Netlify
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build pull request"]
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
deploy-pull-request:
|
||||
name: Deploy pull request
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: Download pr number
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5
|
||||
with:
|
||||
workflow: ${{ github.event.workflow.id }}
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: pr
|
||||
- name: Output pr number
|
||||
id: pr
|
||||
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
|
||||
- name: Download artifact
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5
|
||||
with:
|
||||
workflow: ${{ github.event.workflow.id }}
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: preview
|
||||
path: dist
|
||||
- name: Deploy to Netlify
|
||||
id: netlify
|
||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}"
|
||||
alias: ${{ steps.pr.outputs.id }}
|
||||
# These don't work because we're in workflow_run
|
||||
enable-pull-request-comment: false
|
||||
enable-commit-comment: false
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }}
|
||||
timeout-minutes: 1
|
||||
- name: Comment preview on PR
|
||||
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
pr_number: ${{ steps.pr.outputs.id }}
|
||||
comment_tag: ${{ steps.pr.outputs.id }}
|
||||
message: |
|
||||
Preview: ${{ steps.netlify.outputs.deploy-url }}
|
||||
⚠️ Exercise caution. Use test accounts. ⚠️
|
||||
19
.github/workflows/docker-pr.yml
vendored
19
.github/workflows/docker-pr.yml
vendored
@@ -1,19 +0,0 @@
|
||||
name: 'Docker check'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'Dockerfile'
|
||||
- '.github/workflows/docker-pr.yml'
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.2.0
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v6.18.0
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
26
.github/workflows/lockfile.yml
vendored
26
.github/workflows/lockfile.yml
vendored
@@ -1,26 +0,0 @@
|
||||
name: NPM Lockfile Changes
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'package-lock.json'
|
||||
|
||||
jobs:
|
||||
lockfile_changes:
|
||||
runs-on: ubuntu-latest
|
||||
# Permission overwrite is required for Dependabot PRs, see "Common issues" below.
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.2.0
|
||||
- name: NPM Lockfile Changes
|
||||
uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Optional inputs, can be deleted safely if you are happy with default values.
|
||||
collapsibleThreshold: 25
|
||||
failOnDowngrade: false
|
||||
path: package-lock.json
|
||||
updateComment: true
|
||||
39
.github/workflows/netlify-dev.yml
vendored
39
.github/workflows/netlify-dev.yml
vendored
@@ -1,39 +0,0 @@
|
||||
name: Deploy to Netlify (dev)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
deploy-to-netlify:
|
||||
name: Deploy to Netlify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.2.0
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4.4.0
|
||||
with:
|
||||
node-version: 20.12.2
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build app
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
run: npm run build
|
||||
- name: Deploy to Netlify
|
||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: 'Dev deploy ${{ github.sha }}'
|
||||
enable-commit-comment: false
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
production-deploy: true
|
||||
github-deployment-environment: nightly
|
||||
github-deployment-description: 'Nightly deployment on each commit to dev branch'
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_DEV }}
|
||||
timeout-minutes: 1
|
||||
99
.github/workflows/prod-deploy.yml
vendored
99
.github/workflows/prod-deploy.yml
vendored
@@ -1,99 +0,0 @@
|
||||
name: Production deploy
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
deploy-and-tarball:
|
||||
name: Netlify deploy and tarball
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.2.0
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4.4.0
|
||||
with:
|
||||
node-version: 20.12.2
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build app
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
run: npm run build
|
||||
- name: Deploy to Netlify
|
||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: 'Prod deploy ${{ github.ref_name }}'
|
||||
enable-commit-comment: false
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
production-deploy: true
|
||||
github-deployment-environment: stable
|
||||
github-deployment-description: 'Stable deployment on each release'
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_APP }}
|
||||
timeout-minutes: 1
|
||||
- name: Get version from tag
|
||||
id: vars
|
||||
run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT
|
||||
- name: Create tar.gz
|
||||
run: tar -czvf cinny-${{ steps.vars.outputs.tag }}.tar.gz dist
|
||||
- name: Sign tar.gz
|
||||
run: |
|
||||
echo '${{ secrets.GNUPG_KEY }}' | gpg --batch --import
|
||||
# Sadly a few lines in the private key match a few lines in the public key,
|
||||
# As a result just --export --armor gives us a few lines replaced with ***
|
||||
# making it useless for importing the signing key. Instead, we dump it as
|
||||
# non-armored and hex-encode it so that its printable.
|
||||
echo "PGP Signing key, in raw PGP format in hex. Import with cat ... | xxd -r -p - | gpg --import"
|
||||
gpg --export | xxd -p
|
||||
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||
- name: Upload tagged release
|
||||
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836
|
||||
with:
|
||||
files: |
|
||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc
|
||||
|
||||
publish-image:
|
||||
name: Push Docker image to Docker Hub, ghcr
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.2.0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.6.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.11.1
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3.5.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Login to the Container registry
|
||||
uses: docker/login-action@v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5.8.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_USERNAME }}/cinny
|
||||
ghcr.io/${{ github.repository }}
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6.18.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -1,44 +0,0 @@
|
||||
# Contributing to Cinny
|
||||
|
||||
First off, thanks for taking the time to contribute! ❤️
|
||||
|
||||
All types of contributions are encouraged and valued. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉
|
||||
|
||||
> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
|
||||
> - Star the project
|
||||
> - Tweet about it (tag @cinnyapp)
|
||||
> - Refer this project in your project's readme
|
||||
> - Mention the project at local meetups and tell your friends/colleagues
|
||||
> - [Donate to us](https://cinny.in/#sponsor)
|
||||
|
||||
## Bug reports
|
||||
|
||||
Bug reports and feature suggestions must use descriptive and concise titles and be submitted to [GitHub Issues](https://github.com/ajbura/cinny/issues). Please use the search function to make sure that you are not submitting duplicates, and that a similar report or request has not already been resolved or rejected.
|
||||
|
||||
## Pull requests
|
||||
|
||||
> ### Legal Notice
|
||||
> When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license.
|
||||
|
||||
**NOTE: If you want to add new features, please discuss with maintainers before coding or opening a pull request.** This is to ensure that we are on same track and following our roadmap.
|
||||
|
||||
**Please use clean, concise titles for your pull requests.** We use commit squashing, so the final commit in the dev branch will carry the title of the pull request. For easier sorting in changelog, start your pull request titles using one of the verbs "Add", "Change", "Remove", or "Fix" (present tense).
|
||||
|
||||
Example:
|
||||
|
||||
|Not ideal|Better|
|
||||
|---|----|
|
||||
|Fixed markAllAsRead in RoomTimeline|Fix read marker when paginating room timeline|
|
||||
|
||||
It is not always possible to phrase every change in such a manner, but it is desired.
|
||||
|
||||
**The smaller the set of changes in the pull request is, the quicker it can be reviewed and merged.** Splitting tasks into multiple smaller pull requests is often preferable.
|
||||
|
||||
Also, we use [ESLint](https://eslint.org/) for clean and stylistically consistent code syntax, so make sure your pull request follow it.
|
||||
|
||||
**For any query or design discussion, join our [Matrix room](https://matrix.to/#/#cinny:matrix.org).**
|
||||
|
||||
## Helpful links
|
||||
- [BEM methodology](http://getbem.com/introduction/)
|
||||
- [Atomic design](https://bradfrost.com/blog/post/atomic-web-design/)
|
||||
- [Matrix JavaScript SDK documentation](https://matrix-org.github.io/matrix-js-sdk/index.html)
|
||||
@@ -1,3 +1,3 @@
|
||||
export default {
|
||||
base: '/',
|
||||
base: './',
|
||||
};
|
||||
|
||||
BIN
build_output.txt
Normal file
BIN
build_output.txt
Normal file
Binary file not shown.
12
capacitor.config.json
Normal file
12
capacitor.config.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"appId": "com.paarrot.app",
|
||||
"appName": "Paarrot",
|
||||
"webDir": "dist",
|
||||
"bundledWebRuntime": false,
|
||||
"plugins": {
|
||||
"LocalNotifications": {
|
||||
"smallIcon": "ic_stat_paarrot",
|
||||
"iconColor": "#FF8A00"
|
||||
}
|
||||
}
|
||||
}
|
||||
172
docs/API-QUICKSTART.md
Normal file
172
docs/API-QUICKSTART.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# Paarrot API Quick Start
|
||||
|
||||
A local HTTP API server for controlling Paarrot from external devices like Stream Deck, scripts, and automation tools.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
The API server starts automatically when you run Paarrot. It listens on `http://127.0.0.1:33384`.
|
||||
|
||||
### Test the API
|
||||
|
||||
**Option 1: Using Postman**
|
||||
|
||||
Import the Postman collection for easy testing:
|
||||
1. Open Postman
|
||||
2. Click **Import** → **File**
|
||||
3. Select `paarrot-api.postman_collection.json`
|
||||
4. All endpoints will be ready to use!
|
||||
|
||||
The collection is automatically updated on git push.
|
||||
|
||||
**Option 2: Using the test script**
|
||||
```bash
|
||||
node test-api.js
|
||||
```
|
||||
|
||||
**Option 3: Using curl**
|
||||
```bash
|
||||
./test-api.sh
|
||||
```
|
||||
|
||||
**Option 4: Manual curl commands**
|
||||
```bash
|
||||
# Health check
|
||||
curl http://127.0.0.1:33384/health
|
||||
|
||||
# Toggle mute
|
||||
curl -X POST http://127.0.0.1:33384/mute/toggle
|
||||
|
||||
# Send message
|
||||
curl -X POST http://127.0.0.1:33384/message/current \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message": "Hello from API!"}'
|
||||
```
|
||||
|
||||
## 📝 Quick Reference
|
||||
|
||||
### Common Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/health` | GET | Check if API is running |
|
||||
| `/status` | GET | Get app status (mute, deafen, etc.) |
|
||||
| `/mute/toggle` | POST | Toggle microphone mute |
|
||||
| `/deafen/toggle` | POST | Toggle deafen |
|
||||
| `/channels` | GET | Get list of rooms |
|
||||
| `/channel` | POST | Switch to a room |
|
||||
| `/message/current` | POST | Send message to current room |
|
||||
|
||||
## 🎮 Stream Deck Integration
|
||||
|
||||
1. Install the "API Ninja" or "HTTP Request" plugin
|
||||
2. Create buttons with these settings:
|
||||
|
||||
**Mute Toggle Button:**
|
||||
- URL: `http://127.0.0.1:33384/mute/toggle`
|
||||
- Method: POST
|
||||
|
||||
**Quick Message Button:**
|
||||
- URL: `http://127.0.0.1:33384/message/current`
|
||||
- Method: POST
|
||||
- Body: `{"message": "BRB!"}`
|
||||
|
||||
## 🔧 Integration with Paarrot
|
||||
|
||||
To enable full functionality, you need to implement the action handlers in the Cinny frontend:
|
||||
|
||||
1. Import the API handler in your app initialization:
|
||||
```typescript
|
||||
import { initPaarrotAPI } from './app/paarrot-api';
|
||||
|
||||
// After Matrix client is initialized
|
||||
initPaarrotAPI(matrixClient);
|
||||
```
|
||||
|
||||
2. Implement the TODO items in `cinny/src/app/paarrot-api.ts`:
|
||||
- Mute/unmute logic (WebRTC audio)
|
||||
- Deafen logic (WebRTC audio output)
|
||||
- Navigation to rooms (router integration)
|
||||
- Get current room from URL/state
|
||||
|
||||
## 📖 Full Documentation
|
||||
|
||||
See [API.md](API.md) for complete API documentation including:
|
||||
- All available endpoints
|
||||
- Request/response formats
|
||||
- Error handling
|
||||
- Code examples in multiple languages
|
||||
- Detailed integration guide
|
||||
|
||||
## 🛠️ Configuration
|
||||
|
||||
Edit `electron/api-server.js` to customize:
|
||||
- Port number (default: 33384)
|
||||
- Timeout duration (default: 10s)
|
||||
- CORS settings
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
**API not responding:**
|
||||
1. Check if Paarrot is running
|
||||
2. Look for "Paarrot API server listening" in console logs
|
||||
3. Verify port 33384 is not in use: `lsof -i :33384`
|
||||
|
||||
**Actions not working:**
|
||||
1. Check browser console for errors
|
||||
2. Ensure `initPaarrotAPI()` is called in your app
|
||||
3. Implement the TODO items in `paarrot-api.ts`
|
||||
|
||||
**Port in use:**
|
||||
Edit `electron/api-server.js` and change the port number in the constructor.
|
||||
|
||||
## 📦 Files
|
||||
|
||||
- `electron/api-server.js` - API server implementation
|
||||
- `cinny/src/app/paarrot-api.ts` - Client-side handler (needs implementation)
|
||||
- `test-api.js` - Node.js test script
|
||||
- `test-api.sh` - Bash test script
|
||||
- `API.md` - Full documentation
|
||||
|
||||
## 🔐 Security
|
||||
|
||||
- API only listens on localhost (127.0.0.1)
|
||||
- Not accessible from network
|
||||
- No authentication required (local only)
|
||||
- CORS enabled for all origins (safe since localhost only)
|
||||
|
||||
## 💡 Examples
|
||||
|
||||
**Python Script:**
|
||||
```python
|
||||
import requests
|
||||
|
||||
def toggle_mute():
|
||||
r = requests.post('http://127.0.0.1:33384/mute/toggle')
|
||||
print(r.json())
|
||||
|
||||
toggle_mute()
|
||||
```
|
||||
|
||||
**JavaScript:**
|
||||
```javascript
|
||||
async function sendQuickMessage(msg) {
|
||||
const res = await fetch('http://127.0.0.1:33384/message/current', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: msg })
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
1. Run Paarrot: `npm run dev`
|
||||
2. Test the API: `node test-api.js`
|
||||
3. Integrate the handler: Import and call `initPaarrotAPI()`
|
||||
4. Implement the TODO items in `paarrot-api.ts`
|
||||
5. Create Stream Deck buttons or automation scripts!
|
||||
|
||||
---
|
||||
|
||||
For more details, see [API.md](API.md)
|
||||
537
docs/API.md
Normal file
537
docs/API.md
Normal file
@@ -0,0 +1,537 @@
|
||||
# Paarrot API Server Documentation
|
||||
|
||||
The Paarrot API server provides a local HTTP API for controlling the Paarrot desktop app from external applications like Stream Deck, keyboard macros, scripts, and other automation tools.
|
||||
|
||||
## Server Details
|
||||
|
||||
- **Base URL**: `http://127.0.0.1:33384`
|
||||
- **Protocol**: HTTP
|
||||
- **Content-Type**: `application/json`
|
||||
- **CORS**: Enabled for all origins
|
||||
|
||||
## Authentication
|
||||
|
||||
Currently, no authentication is required. The API server only listens on localhost (127.0.0.1) for security.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Health Check
|
||||
|
||||
Check if the API server is running.
|
||||
|
||||
**Endpoint**: `GET /health`
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"app": "Paarrot API",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Status
|
||||
|
||||
Get the current status of the app (mute, deafen, current room, etc.).
|
||||
|
||||
**Endpoint**: `GET /status`
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"muted": false,
|
||||
"deafened": false,
|
||||
"currentRoom": "!abc123:matrix.org",
|
||||
"connected": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Mute/Unmute
|
||||
|
||||
Set the mute status.
|
||||
|
||||
**Endpoint**: `POST /mute`
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"muted": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"muted": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Toggle Mute
|
||||
|
||||
Toggle the current mute status.
|
||||
|
||||
**Endpoint**: `POST /mute/toggle`
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"muted": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Deafen/Undeafen
|
||||
|
||||
Set the deafen status (deafening also mutes).
|
||||
|
||||
**Endpoint**: `POST /deafen`
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"deafened": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"deafened": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Toggle Deafen
|
||||
|
||||
Toggle the current deafen status.
|
||||
|
||||
**Endpoint**: `POST /deafen/toggle`
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"deafened": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Change Channel/Room
|
||||
|
||||
Switch to a different Matrix room.
|
||||
|
||||
**Endpoint**: `POST /channel`
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"roomId": "!abc123:matrix.org"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"roomId": "!abc123:matrix.org",
|
||||
"roomName": "General"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Channels/Rooms
|
||||
|
||||
Get a list of available rooms/channels.
|
||||
|
||||
**Endpoint**: `GET /channels`
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"roomId": "!abc123:matrix.org",
|
||||
"name": "General",
|
||||
"server": "matrix.org",
|
||||
"isDirect": false,
|
||||
"avatar": null
|
||||
},
|
||||
{
|
||||
"roomId": "!def456:matrix.org",
|
||||
"name": "Random",
|
||||
"server": "matrix.org",
|
||||
"isDirect": false,
|
||||
"avatar": null
|
||||
},
|
||||
{
|
||||
"roomId": "!xyz789:example.com",
|
||||
"name": "General",
|
||||
"server": "example.com",
|
||||
"isDirect": false,
|
||||
"avatar": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: The `server` field helps distinguish between rooms with the same name on different servers.
|
||||
|
||||
---
|
||||
|
||||
### Send Message
|
||||
|
||||
Send a message to a specific room.
|
||||
|
||||
**Endpoint**: `POST /message`
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"roomId": "!abc123:matrix.org",
|
||||
"message": "Hello from the API!"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"eventId": "$abc123",
|
||||
"roomId": "!abc123:matrix.org"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Send Message to Current Room
|
||||
|
||||
Send a message to the currently active room.
|
||||
|
||||
**Endpoint**: `POST /message/current`
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"message": "Hello from the API!"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"eventId": "$abc123",
|
||||
"roomId": "!abc123:matrix.org"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Current Room
|
||||
|
||||
Get information about the currently active room.
|
||||
|
||||
**Endpoint**: `GET /room/current`
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"roomId": "!abc123:matrix.org",
|
||||
"name": "General",
|
||||
"server": "matrix.org",
|
||||
"avatar": "mxc://matrix.org/abc123",
|
||||
"isDirect": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
All endpoints return error responses in the following format:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Error message here"
|
||||
}
|
||||
```
|
||||
|
||||
Common HTTP status codes:
|
||||
- `200 OK` - Request successful
|
||||
- `400 Bad Request` - Missing or invalid parameters
|
||||
- `404 Not Found` - Endpoint not found
|
||||
- `500 Internal Server Error` - Server error
|
||||
|
||||
---
|
||||
|
||||
## Example Usage
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
# Toggle mute
|
||||
curl -X POST http://127.0.0.1:33384/mute/toggle
|
||||
|
||||
# Send message to current room
|
||||
curl -X POST http://127.0.0.1:33384/message/current \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message": "Hello!"}'
|
||||
|
||||
# Change channel
|
||||
curl -X POST http://127.0.0.1:33384/channel \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"roomId": "!abc123:matrix.org"}'
|
||||
```
|
||||
|
||||
### JavaScript/Node.js
|
||||
|
||||
```javascript
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
// Toggle mute
|
||||
async function toggleMute() {
|
||||
const response = await fetch('http://127.0.0.1:33384/mute/toggle', {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await response.json();
|
||||
console.log('Muted:', data.data.muted);
|
||||
}
|
||||
|
||||
// Send message
|
||||
async function sendMessage(roomId, message) {
|
||||
const response = await fetch('http://127.0.0.1:33384/message', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ roomId, message })
|
||||
});
|
||||
const data = await response.json();
|
||||
console.log('Message sent:', data.data.eventId);
|
||||
}
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
# Toggle mute
|
||||
def toggle_mute():
|
||||
response = requests.post('http://127.0.0.1:33384/mute/toggle')
|
||||
data = response.json()
|
||||
print('Muted:', data['data']['muted'])
|
||||
|
||||
# Send message
|
||||
def send_message(room_id, message):
|
||||
response = requests.post('http://127.0.0.1:33384/message', json={
|
||||
'roomId': room_id,
|
||||
'message': message
|
||||
})
|
||||
data = response.json()
|
||||
print('Message sent:', data['data']['eventId'])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stream Deck Integration
|
||||
|
||||
To integrate with Stream Deck:
|
||||
|
||||
1. Install the "API Ninja" or "HTTP Request" plugin for Stream Deck
|
||||
2. Create a button with the following configuration:
|
||||
- **URL**: `http://127.0.0.1:33384/mute/toggle`
|
||||
- **Method**: POST
|
||||
- **Headers**: `Content-Type: application/json`
|
||||
|
||||
Example configurations:
|
||||
|
||||
**Mute Toggle Button**:
|
||||
- URL: `http://127.0.0.1:33384/mute/toggle`
|
||||
- Method: POST
|
||||
|
||||
**Deafen Toggle Button**:
|
||||
- URL: `http://127.0.0.1:33384/deafen/toggle`
|
||||
- Method: POST
|
||||
|
||||
**Send Quick Message**:
|
||||
- URL: `http://127.0.0.1:33384/message/current`
|
||||
- Method: POST
|
||||
- Body: `{"message": "BRB!"}`
|
||||
|
||||
---
|
||||
|
||||
## Client-Side Implementation
|
||||
|
||||
To enable the API functionality in the Paarrot app, you need to implement the action handlers in your Matrix client code. The API server sends actions via IPC to the renderer process, which should handle them and respond.
|
||||
|
||||
### Example Handler (TypeScript/React)
|
||||
|
||||
Add this to your app initialization:
|
||||
|
||||
```typescript
|
||||
// Listen for API actions from the Electron API server
|
||||
if (window.electron?.api?.onAction) {
|
||||
window.electron.api.onAction(async (action: {
|
||||
action: string;
|
||||
params: any;
|
||||
responseChannel: string;
|
||||
}) => {
|
||||
try {
|
||||
let result;
|
||||
|
||||
switch (action.action) {
|
||||
case 'toggle-mute':
|
||||
// Toggle microphone mute
|
||||
result = await toggleMicrophone();
|
||||
break;
|
||||
|
||||
case 'set-mute':
|
||||
// Set microphone mute state
|
||||
result = await setMicrophone(action.params.muted);
|
||||
break;
|
||||
|
||||
case 'toggle-deafen':
|
||||
// Toggle deafen (mute speakers)
|
||||
result = await toggleDeafen();
|
||||
break;
|
||||
|
||||
case 'set-deafen':
|
||||
// Set deafen state
|
||||
result = await setDeafen(action.params.deafened);
|
||||
break;
|
||||
|
||||
case 'change-channel':
|
||||
// Navigate to a different room
|
||||
result = await changeRoom(action.params.roomId);
|
||||
break;
|
||||
|
||||
case 'get-channels':
|
||||
// Get list of rooms
|
||||
result = await getRoomList();
|
||||
break;
|
||||
|
||||
case 'send-message':
|
||||
// Send message to specific room
|
||||
result = await sendMessage(action.params.roomId, action.params.message);
|
||||
break;
|
||||
|
||||
case 'send-message-current':
|
||||
// Send message to current room
|
||||
result = await sendMessageToCurrent(action.params.message);
|
||||
break;
|
||||
|
||||
case 'get-current-room':
|
||||
// Get current room info
|
||||
result = await getCurrentRoomInfo();
|
||||
break;
|
||||
|
||||
case 'get-status':
|
||||
// Get current app status
|
||||
result = await getAppStatus();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown action: ${action.action}`);
|
||||
}
|
||||
|
||||
// Send success response back
|
||||
window.electron.api.sendResponse(action.responseChannel, {
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
// Send error response back
|
||||
window.electron.api.sendResponse(action.responseChannel, {
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
The API server can be configured by modifying `electron/api-server.js`:
|
||||
|
||||
- **Default Port**: 33384 (can be changed in the constructor)
|
||||
- **Bind Address**: 127.0.0.1 (localhost only for security)
|
||||
- **Timeout**: 10 seconds for actions
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
1. The API server only listens on localhost (127.0.0.1) and is not accessible from the network
|
||||
2. No authentication is implemented - anyone with local access can control the app
|
||||
3. Consider adding API key authentication if needed for your use case
|
||||
4. CORS is enabled for all origins since it's localhost only
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**API server not starting**:
|
||||
- Check if port 33384 is already in use
|
||||
- Check console logs for errors
|
||||
- Verify Express is installed: `npm install express cors body-parser`
|
||||
|
||||
**Actions not working**:
|
||||
- Ensure the client-side handler is implemented
|
||||
- Check browser console for errors
|
||||
- Verify the action names match between API server and client
|
||||
|
||||
**Timeout errors**:
|
||||
- Actions must respond within 10 seconds
|
||||
- Check if the Matrix client is initialized
|
||||
- Verify the action handler is registered
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential features to add:
|
||||
- WebSocket support for real-time events
|
||||
- Authentication with API keys
|
||||
- Voice channel controls (join/leave)
|
||||
- User status updates
|
||||
- Notification controls
|
||||
- Screen sharing controls
|
||||
157
docs/PLUGINS.md
Normal file
157
docs/PLUGINS.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Paarrot Plugin System
|
||||
|
||||
The Paarrot desktop client includes a powerful plugin system that allows you to extend and customize your Matrix experience.
|
||||
|
||||
## What are Plugins?
|
||||
|
||||
Plugins are small JavaScript modules that run inside Paarrot and can:
|
||||
- Add custom commands
|
||||
- Modify the UI
|
||||
- React to Matrix events
|
||||
- Add new settings sections
|
||||
- Enhance functionality
|
||||
|
||||
## Installing Plugins
|
||||
|
||||
### From the Plugin Marketplace
|
||||
|
||||
1. Open **Settings** → **Plugins**
|
||||
2. Browse the **Marketplace** tab
|
||||
3. Click **Install** on any plugin you want to add
|
||||
4. The plugin will be downloaded and enabled automatically
|
||||
|
||||
### Manual Installation
|
||||
|
||||
1. Download a plugin ZIP file
|
||||
2. Extract it to your plugins directory:
|
||||
- **Windows**: `%APPDATA%\paarrot\plugins\`
|
||||
- **Linux**: `~/.config/Paarrot/plugins/`
|
||||
- **macOS**: `~/Library/Application Support/Paarrot/plugins/`
|
||||
3. Restart Paarrot
|
||||
4. Enable the plugin in **Settings** → **Plugins** → **Installed**
|
||||
|
||||
## Managing Plugins
|
||||
|
||||
### Viewing Installed Plugins
|
||||
|
||||
Go to **Settings** → **Plugins** → **Installed** to see all installed plugins.
|
||||
|
||||
### Enabling/Disabling Plugins
|
||||
|
||||
Click the toggle switch next to any plugin to enable or disable it. Disabled plugins won't run or use resources.
|
||||
|
||||
### Uninstalling Plugins
|
||||
|
||||
Click the **Uninstall** button next to any plugin to remove it completely.
|
||||
|
||||
### Searching Plugins
|
||||
|
||||
Use the search bar to filter plugins by name, description, author, or tags.
|
||||
|
||||
## Plugin Security
|
||||
|
||||
⚠️ **Important Security Information**
|
||||
|
||||
Plugins run with access to your Matrix client and can:
|
||||
- Read your messages
|
||||
- Send messages on your behalf
|
||||
- Access your room list
|
||||
- Modify app behavior
|
||||
|
||||
**Only install plugins from trusted sources!**
|
||||
|
||||
### Safety Tips
|
||||
|
||||
1. **Check the author**: Install plugins from known developers
|
||||
2. **Read reviews**: Look for community feedback
|
||||
3. **Review permissions**: Understand what the plugin can do
|
||||
4. **Keep updated**: Update plugins regularly for security fixes
|
||||
5. **Report issues**: If a plugin behaves suspiciously, report it immediately
|
||||
|
||||
## Developing Plugins
|
||||
|
||||
Want to create your own plugin? Check out the [Plugin API Reference](docs/PLUGIN_API.md).
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. Create a plugin directory with:
|
||||
- `index.js` - Your plugin code
|
||||
- `plugin-metadata.json` - Plugin information
|
||||
|
||||
2. Implement the plugin interface:
|
||||
```javascript
|
||||
module.exports = {
|
||||
onLoad: async (context) => {
|
||||
// Your plugin initialization
|
||||
console.log('Plugin loaded!');
|
||||
},
|
||||
onUnload: async () => {
|
||||
// Cleanup when disabled
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
3. Test locally by copying to your plugins directory
|
||||
|
||||
4. Publish to the [Plugin Directory](https://github.com/Paarrot/Plugin-Directory)
|
||||
|
||||
## Plugin API
|
||||
|
||||
Plugins have access to:
|
||||
- **Matrix Client**: Full matrix-js-sdk client instance
|
||||
- **React**: For building UI components
|
||||
- **Commands**: Register custom slash commands
|
||||
- **UI Buttons**: Inject buttons into 11 locations across the app (nav lists, toolbars, headers, sidebar)
|
||||
- **UI Renderers**: Custom message and content renderers
|
||||
- **Settings**: Add custom settings sections per plugin
|
||||
- **Themes**: Register custom themes that appear in the theme selector
|
||||
- **Matrix Events**: Hook into raw Matrix events
|
||||
- **Timers**: Background intervals and timeouts, auto-cleaned on unload
|
||||
- **Notifications**: System notifications
|
||||
|
||||
See the [Plugin API Reference](docs/PLUGIN_API.md) and [Button Registration API](docs/PLUGIN_BUTTON_API.md) for complete documentation.
|
||||
|
||||
## Example Plugins
|
||||
|
||||
Check out the `example-plugin/` directory for a simple example, or the `plugins/example-showcase-plugin/` for a full demonstration of all 11 UI button locations.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugin Won't Load
|
||||
|
||||
1. Check the browser console for errors (F12 → Console)
|
||||
2. Look for `[PluginLoader]` messages
|
||||
3. Verify `index.js` exists in the plugin directory
|
||||
4. Ensure `plugin-metadata.json` is valid JSON
|
||||
|
||||
### Plugin Crashes
|
||||
|
||||
1. Disable the plugin in Settings → Plugins
|
||||
2. Check console for error stack traces
|
||||
3. Report the issue to the plugin author
|
||||
4. Try reinstalling the plugin
|
||||
|
||||
### Performance Issues
|
||||
|
||||
If Paarrot becomes slow:
|
||||
1. Disable plugins one by one to identify the culprit
|
||||
2. Check for memory leaks in the console
|
||||
3. Contact the plugin author with details
|
||||
|
||||
## Plugin Directory
|
||||
|
||||
The official Paarrot Plugin Directory is maintained at:
|
||||
https://github.com/Paarrot/Plugin-Directory
|
||||
|
||||
Submit your plugins there to make them available in the marketplace!
|
||||
|
||||
## Support
|
||||
|
||||
- **Plugin API Reference**: [docs/PLUGIN_API.md](docs/PLUGIN_API.md)
|
||||
- **Button API Reference**: [docs/PLUGIN_BUTTON_API.md](docs/PLUGIN_BUTTON_API.md)
|
||||
- **Issues**: https://github.com/Paarrot/cinny-desktop/issues
|
||||
- **Community**: Join our Matrix room for plugin development help
|
||||
|
||||
## License
|
||||
|
||||
Plugins are independent works and have their own licenses. Check each plugin's metadata for license information.
|
||||
1282
docs/PLUGIN_API.md
Normal file
1282
docs/PLUGIN_API.md
Normal file
File diff suppressed because it is too large
Load Diff
432
docs/PLUGIN_BUTTON_API.md
Normal file
432
docs/PLUGIN_BUTTON_API.md
Normal file
@@ -0,0 +1,432 @@
|
||||
# Plugin Button Registration API
|
||||
|
||||
An easy way to register plugin buttons in various UI locations with positioning and grouping support.
|
||||
|
||||
## Example Plugins
|
||||
|
||||
- **[example-button-plugin/](example-button-plugin/)** - Simple examples showing basic usage patterns
|
||||
- **[example-showcase-plugin/](example-showcase-plugin/)** - Complete demonstration of ALL 11 locations ⭐
|
||||
|
||||
## UI Locations
|
||||
|
||||
Buttons render in two visual styles depending on location — **nav list rows** (icon + label, full width) or **icon buttons** (compact, toolbar/header style).
|
||||
|
||||
### Nav List Rows
|
||||
|
||||
These render as full-width list entries matching the style of built-in items like "Create Room" and "Message Search":
|
||||
|
||||
| Location | Where it appears |
|
||||
|---|---|
|
||||
| `channel-list` | Space channel list, below Lobby and Message Search |
|
||||
| `home-section` | Home panel, inside the Rooms category above the room list |
|
||||
| `direct-messages` | Direct Messages panel, below "Create Chat" and above the CHATS dropdown |
|
||||
|
||||
### Icon Buttons
|
||||
|
||||
These render as compact icon buttons inline with other toolbar/header controls:
|
||||
|
||||
| Location | Where it appears |
|
||||
|---|---|
|
||||
| `text-composer-toolbar` | Message composer toolbar (alongside emoji, sticker buttons) |
|
||||
| `composer-actions` | Left side of the composer, beside the `+` attach button |
|
||||
| `room-header` | Top room header bar, before the ⋮ menu button |
|
||||
| `room-menu` | Room ⋮ dropdown menu |
|
||||
| `message-actions` | Message hover action bar |
|
||||
| `user-menu` | Right-click popup on the user avatar |
|
||||
| `search-notification-section` | Notifications page header (right side) |
|
||||
| `sidebar-actions` | Left sidebar — appears in **two** places: above the Explore Servers icon, and above the Search icon in the sticky bottom section |
|
||||
|
||||
> 💡 **Tip:** Install the [example-showcase-plugin](example-showcase-plugin/) to see exactly where each location appears in the UI!
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Simple Button
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
name: 'my-plugin',
|
||||
version: '1.0.0',
|
||||
|
||||
onLoad: (context) => {
|
||||
context.ui.registerButton({
|
||||
id: 'my-button',
|
||||
location: 'text-composer-toolbar',
|
||||
label: 'My Action',
|
||||
icon: '🎨',
|
||||
onClick: () => {
|
||||
context.log('Button clicked!');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
onUnload: () => {
|
||||
// Buttons are automatically cleaned up on plugin unload
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Positioning
|
||||
|
||||
### Before/After Positioning
|
||||
|
||||
Place your button before or after existing buttons:
|
||||
|
||||
```javascript
|
||||
context.ui.registerButton({
|
||||
id: 'my-button',
|
||||
location: 'text-composer-toolbar',
|
||||
label: 'My Action',
|
||||
icon: '✨',
|
||||
position: {
|
||||
before: 'emoji-picker-button', // Place before emoji picker
|
||||
// OR
|
||||
after: 'sticker-button' // Place after sticker button
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Grouping Buttons
|
||||
|
||||
Group related buttons together:
|
||||
|
||||
```javascript
|
||||
context.ui.registerButton({
|
||||
id: 'action-1',
|
||||
location: 'text-composer-toolbar',
|
||||
label: 'Action 1',
|
||||
icon: '1️⃣',
|
||||
position: {
|
||||
group: 'my-plugin-tools',
|
||||
order: 1 // Lower numbers appear first
|
||||
}
|
||||
});
|
||||
|
||||
context.ui.registerButton({
|
||||
id: 'action-2',
|
||||
location: 'text-composer-toolbar',
|
||||
label: 'Action 2',
|
||||
icon: '2️⃣',
|
||||
position: {
|
||||
group: 'my-plugin-tools',
|
||||
order: 2
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Combined Positioning and Grouping
|
||||
|
||||
Place a group before/after other elements:
|
||||
|
||||
```javascript
|
||||
context.ui.registerButton({
|
||||
id: 'tool-1',
|
||||
location: 'text-composer-toolbar',
|
||||
label: 'Tool 1',
|
||||
icon: '🔧',
|
||||
position: {
|
||||
group: 'my-tools',
|
||||
after: 'emoji-picker-button',
|
||||
order: 1
|
||||
}
|
||||
});
|
||||
|
||||
context.ui.registerButton({
|
||||
id: 'tool-2',
|
||||
location: 'text-composer-toolbar',
|
||||
label: 'Tool 2',
|
||||
icon: '🔨',
|
||||
position: {
|
||||
group: 'my-tools',
|
||||
order: 2
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
name: 'custom-tools',
|
||||
version: '1.0.0',
|
||||
|
||||
onLoad: (context) => {
|
||||
// Nav list row — appears below "Create Chat" in the DMs panel
|
||||
context.ui.registerButton({
|
||||
id: 'dm-favorites',
|
||||
location: 'direct-messages',
|
||||
label: 'Favourites',
|
||||
icon: '⭐',
|
||||
onClick: () => context.log('Favourites clicked!')
|
||||
});
|
||||
|
||||
// Nav list row — appears in the space channel list
|
||||
context.ui.registerButton({
|
||||
id: 'quick-access',
|
||||
location: 'channel-list',
|
||||
label: 'Quick Access',
|
||||
icon: '⚡',
|
||||
onClick: () => context.log('Quick access clicked!')
|
||||
});
|
||||
|
||||
// Icon button — appears in the message composer toolbar
|
||||
context.ui.registerButton({
|
||||
id: 'format-bold',
|
||||
location: 'text-composer-toolbar',
|
||||
label: 'Bold',
|
||||
icon: '𝐁',
|
||||
position: {
|
||||
group: 'custom-formatting',
|
||||
order: 1
|
||||
},
|
||||
onClick: () => context.log('Bold clicked!')
|
||||
});
|
||||
|
||||
// Icon button — appears in the room header
|
||||
context.ui.registerButton({
|
||||
id: 'special-search',
|
||||
location: 'room-header',
|
||||
label: 'Special Search',
|
||||
icon: '🔍',
|
||||
position: { after: 'search-button' },
|
||||
onClick: () => context.log('Special search clicked!')
|
||||
});
|
||||
|
||||
// Sidebar icon — appears above Explore and above Search
|
||||
context.ui.registerButton({
|
||||
id: 'plugin-panel',
|
||||
location: 'sidebar-actions',
|
||||
label: 'Plugin Panel',
|
||||
icon: '🧩',
|
||||
onClick: () => context.log('Plugin panel clicked!')
|
||||
});
|
||||
},
|
||||
|
||||
onUnload: () => {
|
||||
// All registered buttons are automatically cleaned up
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Unregistering Buttons
|
||||
|
||||
Buttons are automatically unregistered when the plugin is unloaded. To manually unregister:
|
||||
|
||||
```javascript
|
||||
context.ui.unregisterButton('my-button-id');
|
||||
```
|
||||
|
||||
## TypeScript Types
|
||||
|
||||
```typescript
|
||||
import type { UIButtonDefinition, UIButtonPosition, UILocation } from '@paarrot/plugin-manager';
|
||||
|
||||
const button: UIButtonDefinition = {
|
||||
id: 'my-button',
|
||||
location: 'text-composer-toolbar',
|
||||
label: 'My Action',
|
||||
icon: '🎨',
|
||||
position: {
|
||||
group: 'my-tools',
|
||||
after: 'emoji-picker',
|
||||
order: 1
|
||||
},
|
||||
onClick: () => console.log('Clicked!')
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Simple Button
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
name: 'my-plugin',
|
||||
version: '1.0.0',
|
||||
|
||||
onLoad: (context) => {
|
||||
context.ui.registerButton({
|
||||
id: 'my-button',
|
||||
location: 'text-composer-toolbar',
|
||||
label: 'My Action',
|
||||
icon: '🎨',
|
||||
onClick: () => {
|
||||
context.log('Button clicked!');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
onUnload: () => {
|
||||
// Buttons are automatically cleaned up on plugin unload
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Positioning
|
||||
|
||||
### Before/After Positioning
|
||||
|
||||
Place your button before or after existing buttons:
|
||||
|
||||
```javascript
|
||||
context.ui.registerButton({
|
||||
id: 'my-button',
|
||||
location: 'text-composer-toolbar',
|
||||
label: 'My Action',
|
||||
icon: '✨',
|
||||
position: {
|
||||
before: 'emoji-picker-button', // Place before emoji picker
|
||||
// OR
|
||||
after: 'sticker-button' // Place after sticker button
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Grouping Buttons
|
||||
|
||||
Group related buttons together:
|
||||
|
||||
```javascript
|
||||
context.ui.registerButton({
|
||||
id: 'action-1',
|
||||
location: 'text-composer-toolbar',
|
||||
label: 'Action 1',
|
||||
icon: '1️⃣',
|
||||
position: {
|
||||
group: 'my-plugin-tools',
|
||||
order: 1 // Lower numbers appear first
|
||||
}
|
||||
});
|
||||
|
||||
context.ui.registerButton({
|
||||
id: 'action-2',
|
||||
location: 'text-composer-toolbar',
|
||||
label: 'Action 2',
|
||||
icon: '2️⃣',
|
||||
position: {
|
||||
group: 'my-plugin-tools',
|
||||
order: 2
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Combined Positioning and Grouping
|
||||
|
||||
Place a group before/after other elements:
|
||||
|
||||
```javascript
|
||||
context.ui.registerButton({
|
||||
id: 'tool-1',
|
||||
location: 'text-composer-toolbar',
|
||||
label: 'Tool 1',
|
||||
icon: '🔧',
|
||||
position: {
|
||||
group: 'my-tools',
|
||||
after: 'emoji-picker-button',
|
||||
order: 1
|
||||
}
|
||||
});
|
||||
|
||||
context.ui.registerButton({
|
||||
id: 'tool-2',
|
||||
location: 'text-composer-toolbar',
|
||||
label: 'Tool 2',
|
||||
icon: '🔨',
|
||||
position: {
|
||||
group: 'my-tools',
|
||||
order: 2
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
name: 'custom-tools',
|
||||
version: '1.0.0',
|
||||
|
||||
onLoad: (context) => {
|
||||
// Nav list row — appears below "Create Chat" in the DMs panel
|
||||
context.ui.registerButton({
|
||||
id: 'dm-favorites',
|
||||
location: 'direct-messages',
|
||||
label: 'Favourites',
|
||||
icon: '⭐',
|
||||
onClick: () => context.log('Favourites clicked!')
|
||||
});
|
||||
|
||||
// Nav list row — appears in the space channel list
|
||||
context.ui.registerButton({
|
||||
id: 'quick-access',
|
||||
location: 'channel-list',
|
||||
label: 'Quick Access',
|
||||
icon: '⚡',
|
||||
onClick: () => context.log('Quick access clicked!')
|
||||
});
|
||||
|
||||
// Icon button — appears in the message composer toolbar
|
||||
context.ui.registerButton({
|
||||
id: 'format-bold',
|
||||
location: 'text-composer-toolbar',
|
||||
label: 'Bold',
|
||||
icon: '𝐁',
|
||||
position: {
|
||||
group: 'custom-formatting',
|
||||
order: 1
|
||||
},
|
||||
onClick: () => context.log('Bold clicked!')
|
||||
});
|
||||
|
||||
// Icon button — appears in the room header
|
||||
context.ui.registerButton({
|
||||
id: 'special-search',
|
||||
location: 'room-header',
|
||||
label: 'Special Search',
|
||||
icon: '🔍',
|
||||
position: { after: 'search-button' },
|
||||
onClick: () => context.log('Special search clicked!')
|
||||
});
|
||||
|
||||
// Sidebar icon — appears above Explore and above Search
|
||||
context.ui.registerButton({
|
||||
id: 'plugin-panel',
|
||||
location: 'sidebar-actions',
|
||||
label: 'Plugin Panel',
|
||||
icon: '🧩',
|
||||
onClick: () => context.log('Plugin panel clicked!')
|
||||
});
|
||||
},
|
||||
|
||||
onUnload: () => {
|
||||
// All registered buttons are automatically cleaned up
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Unregistering Buttons
|
||||
|
||||
Buttons are automatically unregistered when the plugin is unloaded. To manually unregister:
|
||||
|
||||
```javascript
|
||||
context.ui.unregisterButton('my-button-id');
|
||||
```
|
||||
|
||||
## TypeScript Types
|
||||
|
||||
```typescript
|
||||
import type { UIButtonDefinition, UIButtonPosition, UILocation } from '@paarrot/plugin-manager';
|
||||
|
||||
const button: UIButtonDefinition = {
|
||||
id: 'my-button',
|
||||
location: 'text-composer-toolbar',
|
||||
label: 'My Action',
|
||||
icon: '🎨',
|
||||
position: {
|
||||
group: 'my-tools',
|
||||
after: 'emoji-picker',
|
||||
order: 1
|
||||
},
|
||||
onClick: () => console.log('Clicked!')
|
||||
};
|
||||
```
|
||||
207
docs/PLUGIN_SYSTEM_IMPLEMENTATION.md
Normal file
207
docs/PLUGIN_SYSTEM_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Plugin System Wishlist - Implementation Summary
|
||||
|
||||
All requested features have been implemented! 🎉
|
||||
|
||||
## ✅ Implemented Features
|
||||
|
||||
### 1. ✅ Enhanced Slash Commands
|
||||
- **Simple commands**: `ctx.commands.register({ name: "shrug", run: () => "¯\\_(ツ)_/¯" })`
|
||||
- **Commands with args**: `ctx.commands.register({ name: "echo", args: ["text"], run: ({ text }) => text })`
|
||||
- Automatic argument parsing
|
||||
- Execution API: `ctx.commands.execute(name, args)`
|
||||
|
||||
### 2. ✅ Message Interceptors
|
||||
- **Before send**: `ctx.messages.onBeforeSend((msg) => { msg.content = "modified" })`
|
||||
- **On receive**: `ctx.messages.onReceive((msg) => { /* process */ })`
|
||||
- Full MessageContext with content, roomId, metadata
|
||||
- Async support for interceptors
|
||||
|
||||
### 3. ✅ Custom Renderers & Button Registration
|
||||
- **Register renderer**: `ctx.ui.registerRenderer("message", (msg, defaultRenderer) => { /* render */ })`
|
||||
- **Unregister renderer**: `ctx.ui.unregisterRenderer("message")`
|
||||
- **Register button**: `ctx.ui.registerButton({ id, location, label, icon, onClick })`
|
||||
- **Unregister button**: `ctx.ui.unregisterButton(id)`
|
||||
- 11 UI locations across nav lists, toolbars, headers, sidebar, and menus
|
||||
- Nav list rows (icon + label) for `channel-list`, `home-section`, `direct-messages`
|
||||
- Icon buttons for all other locations
|
||||
- Positioning and grouping support (`before`, `after`, `group`, `order`)
|
||||
- Auto-cleanup on plugin unload
|
||||
- See [PLUGIN_BUTTON_API.md](PLUGIN_BUTTON_API.md) for full reference
|
||||
|
||||
### 4. ✅ Settings UI Per Plugin
|
||||
- **Define schema**: `ctx.settings.define({ theme: { type: "select", options: [...] } })`
|
||||
- **Supported types**: string, number, boolean, select, color
|
||||
- **Get/Set**: `ctx.settings.get(key)` / `ctx.settings.set(key, value)`
|
||||
- Auto-persisted to localStorage
|
||||
- No JSON editing needed!
|
||||
|
||||
### 5. ✅ Hot Reload
|
||||
- **API**: `pluginRegistry.reloadPlugin(pluginId, newPlugin, newContext)`
|
||||
- Auto-cleanup of old plugin resources
|
||||
- Maintains enabled/disabled state
|
||||
- Dev-friendly workflow
|
||||
|
||||
### 6. ✅ Dependency System
|
||||
- **Declare**: `dependencies: { "paarrot.core-utils": "^1.0.0" }`
|
||||
- **Require**: `const utils = ctx.require("paarrot.utils")`
|
||||
- Error handling for missing dependencies
|
||||
- Plugin exports system
|
||||
|
||||
### 7. ✅ Raw Matrix Events Hook
|
||||
- **Full SDK access**: `ctx.matrix.on("m.room.message", (ev) => { })`
|
||||
- **Any event type**: Room.timeline, sync, RoomState.events, etc.
|
||||
- Direct Matrix.js SDK integration
|
||||
- Power users will go nuts!
|
||||
|
||||
### 8. ✅ Background Tasks / Intervals
|
||||
- **Interval**: `ctx.timers.setInterval(() => { /* cursed shit */ }, 10000)`
|
||||
- **Timeout**: `ctx.timers.setTimeout(() => { }, 5000)`
|
||||
- **Clear**: `ctx.timers.clearInterval(id)` / `ctx.timers.clearTimeout(id)`
|
||||
- Auto-cleanup on plugin unload
|
||||
- Perfect for bots, reminders, auto-cleanup
|
||||
|
||||
### 9. ✅ Notifications API
|
||||
- **Simple**: `ctx.notify("Oi")`
|
||||
- **Advanced**: `ctx.notify({ title: "Oi", body: "New message from Steve", type: "info" })`
|
||||
- Action buttons support
|
||||
- Custom duration
|
||||
|
||||
### 10. ✅ Plugin Logs Panel
|
||||
- **Logging**: `ctx.log()`, `ctx.warn()`, `ctx.error()`
|
||||
- Per-plugin log storage
|
||||
- Visible in `pluginRegistry.getLogs(pluginId)`
|
||||
- Cleared on plugin unload
|
||||
- Max 1000 logs retained
|
||||
|
||||
## 🎯 Bonus Features
|
||||
|
||||
- **Plugin exports**: Share functionality between plugins
|
||||
- **Automatic cleanup**: Timers, handlers, resources auto-cleaned on unload
|
||||
- **Error isolation**: Plugin errors don't crash the app
|
||||
- **Type safety**: Full TypeScript definitions
|
||||
- **Sandboxed execution**: Plugins run in controlled environment
|
||||
- **Rich context**: Full Matrix client, React, all APIs
|
||||
|
||||
## 📁 Files Changed
|
||||
|
||||
### Core System
|
||||
- [`PluginAPI.ts`](cinny/src/app/features/settings/plugins/PluginAPI.ts) - Enhanced with all APIs
|
||||
- [`PluginLoader.tsx`](cinny/src/app/features/settings/plugins/PluginLoader.tsx) - Complete rewrite with context creation
|
||||
- [`index.ts`](cinny/src/app/features/settings/plugins/index.ts) - Exports updated
|
||||
|
||||
### Documentation
|
||||
- [`PLUGIN_DEVELOPMENT.md`](cinny/src/app/features/settings/plugins/PLUGIN_DEVELOPMENT.md) - Comprehensive guide
|
||||
- [`PLUGINS.md`](PLUGINS.md) - User guide
|
||||
- [`example-plugin/index.js`](example-plugin/index.js) - Full-featured example
|
||||
|
||||
### Integration
|
||||
- [`ClientRoot.tsx`](cinny/src/app/pages/client/ClientRoot.tsx) - PluginLoader integrated
|
||||
- [`main.js`](electron/main.js) - IPC handler for reading plugin code
|
||||
- [`preload.js`](electron/preload.js) - readPluginCode API exposed
|
||||
- [`ext.d.ts`](cinny/src/ext.d.ts) - TypeScript definitions
|
||||
|
||||
## 🚀 Usage Examples
|
||||
|
||||
### Command with Args
|
||||
```javascript
|
||||
ctx.commands.register({
|
||||
name: "echo",
|
||||
args: ["text"],
|
||||
run: ({ text }) => text
|
||||
});
|
||||
```
|
||||
|
||||
### Message Interceptor
|
||||
```javascript
|
||||
ctx.messages.onBeforeSend((msg) => {
|
||||
if (msg.content === "brb") {
|
||||
msg.content = "be right back ya legend";
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Settings
|
||||
```javascript
|
||||
ctx.settings.define({
|
||||
theme: { type: "select", options: ["dark", "light"] },
|
||||
spamFilter: { type: "boolean", default: true }
|
||||
});
|
||||
```
|
||||
|
||||
### Background Tasks
|
||||
```javascript
|
||||
ctx.timers.setInterval(() => {
|
||||
// do cursed shit every 10s
|
||||
}, 10000);
|
||||
```
|
||||
|
||||
### Raw Matrix Events
|
||||
```javascript
|
||||
ctx.matrix.on("m.room.message", (ev) => {
|
||||
ctx.log('Message:', ev.getContent().body);
|
||||
});
|
||||
```
|
||||
|
||||
### Plugin Dependencies
|
||||
```javascript
|
||||
const utils = ctx.require("paarrot.utils");
|
||||
utils.doSomething();
|
||||
```
|
||||
|
||||
## 🎨 Plugin API Summary
|
||||
|
||||
```typescript
|
||||
interface PluginContext {
|
||||
pluginId: string;
|
||||
matrixClient: MatrixClient;
|
||||
React: typeof import('react');
|
||||
|
||||
commands: {
|
||||
register: (command: PluginCommand) => void;
|
||||
unregister: (name: string) => void;
|
||||
execute: (name: string, args: Record<string, any>) => Promise<string | void>;
|
||||
};
|
||||
|
||||
messages: {
|
||||
onBeforeSend: (interceptor: MessageInterceptor) => void;
|
||||
onReceive: (interceptor: MessageInterceptor) => void;
|
||||
};
|
||||
|
||||
ui: {
|
||||
registerButton: (button: UIButtonDefinition) => void;
|
||||
unregisterButton: (id: string) => void;
|
||||
registerRenderer: (type: string, renderer: CustomRenderer) => void;
|
||||
unregisterRenderer: (type: string) => void;
|
||||
};
|
||||
|
||||
settings: {
|
||||
define: (schema: SettingsSchema) => void;
|
||||
get: <T = any>(key: string) => T | undefined;
|
||||
set: (key: string, value: any) => void;
|
||||
};
|
||||
|
||||
matrix: {
|
||||
on: (eventType: string, handler: (event: MatrixEvent) => void) => void;
|
||||
off: (eventType: string, handler: (event: MatrixEvent) => void) => void;
|
||||
};
|
||||
|
||||
timers: {
|
||||
setInterval: (callback: () => void, ms: number) => number;
|
||||
setTimeout: (callback: () => void, ms: number) => number;
|
||||
clearInterval: (id: number) => void;
|
||||
clearTimeout: (id: number) => void;
|
||||
};
|
||||
|
||||
notify: (options: NotificationOptions | string) => void;
|
||||
log: (...args: any[]) => void;
|
||||
warn: (...args: any[]) => void;
|
||||
error: (...args: any[]) => void;
|
||||
require: (pluginId: string) => any;
|
||||
}
|
||||
```
|
||||
|
||||
## 🔥 The Result
|
||||
|
||||
A **production-ready plugin system** that rivals Discord, VSCode, and other extensible apps. Plugin developers will have everything they need to build powerful extensions without fighting the framework.
|
||||
|
||||
**No compromises. All features implemented. Ready to ship.**
|
||||
21
index.html
21
index.html
@@ -2,10 +2,11 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<base href="./" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
||||
<title>Cinny</title>
|
||||
<meta name="name" content="Cinny" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover" />
|
||||
<title>Paarrot</title>
|
||||
<meta name="name" content="Paarrot" />
|
||||
<meta name="author" content="Ajay Bura" />
|
||||
<meta
|
||||
name="description"
|
||||
@@ -13,12 +14,12 @@
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="cinny, cinnyapp, cinnychat, matrix, matrix client, matrix.org, element"
|
||||
content="paarrot, matrix, matrix client, matrix.org, element"
|
||||
/>
|
||||
|
||||
<meta property="og:title" content="Cinny" />
|
||||
<meta property="og:url" content="https://cinny.in" />
|
||||
<meta property="og:image" content="https://cinny.in/assets/favicon-48x48.png" />
|
||||
<meta property="og:title" content="Paarrot" />
|
||||
<meta property="og:url" content="https://github.com/Paarrot/Paarrot-Desktop" />
|
||||
<meta property="og:image" content="./public/res/android/android-chrome-192x192.png" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
|
||||
@@ -29,8 +30,8 @@
|
||||
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="application-name" content="Cinny" />
|
||||
<meta name="apple-mobile-web-app-title" content="Cinny" />
|
||||
<meta name="application-name" content="Paarrot" />
|
||||
<meta name="apple-mobile-web-app-title" content="Paarrot" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
@@ -85,7 +86,7 @@
|
||||
href="./public/res/apple/apple-touch-icon-180x180.png"
|
||||
/>
|
||||
</head>
|
||||
<body id="appBody">
|
||||
<body id="appBody" style="background-color: #262626;">
|
||||
<script>
|
||||
window.global ||= window;
|
||||
</script>
|
||||
|
||||
154
package-lock.json
generated
154
package-lock.json
generated
@@ -12,10 +12,22 @@
|
||||
"@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",
|
||||
"@capacitor/core": "8.3.4",
|
||||
"@capacitor/local-notifications": "8.2.0",
|
||||
"@fontsource/inter": "4.5.14",
|
||||
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
|
||||
"@tanstack/react-query": "5.24.1",
|
||||
"@tanstack/react-query-devtools": "5.24.1",
|
||||
"@tanstack/react-virtual": "3.2.0",
|
||||
"@tauri-apps/api": "2.9.1",
|
||||
"@tauri-apps/plugin-dialog": "2.6.0",
|
||||
"@tauri-apps/plugin-fs": "2.4.5",
|
||||
"@tauri-apps/plugin-http": "2.5.7",
|
||||
"@tauri-apps/plugin-notification": "2.3.3",
|
||||
"@tauri-apps/plugin-opener": "2",
|
||||
"@tauri-apps/plugin-process": "2.3.1",
|
||||
"@tauri-apps/plugin-updater": "2.10.0",
|
||||
"@types/pako": "2.0.4",
|
||||
"@vanilla-extract/css": "1.9.3",
|
||||
"@vanilla-extract/recipes": "0.3.0",
|
||||
"@vanilla-extract/vite-plugin": "3.7.1",
|
||||
@@ -33,6 +45,7 @@
|
||||
"file-saver": "2.0.5",
|
||||
"focus-trap-react": "10.0.2",
|
||||
"folds": "2.4.0",
|
||||
"gif.js": "0.2.0",
|
||||
"html-dom-parser": "4.0.0",
|
||||
"html-react-parser": "4.2.0",
|
||||
"i18next": "23.12.2",
|
||||
@@ -44,8 +57,10 @@
|
||||
"linkify-react": "4.1.3",
|
||||
"linkifyjs": "4.1.3",
|
||||
"livekit-client": "2.17.0",
|
||||
"lottie-web": "5.13.0",
|
||||
"matrix-js-sdk": "38.2.0",
|
||||
"millify": "6.1.0",
|
||||
"pako": "2.1.0",
|
||||
"pdfjs-dist": "4.2.67",
|
||||
"prismjs": "1.30.0",
|
||||
"react": "18.2.0",
|
||||
@@ -1656,6 +1671,24 @@
|
||||
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@capacitor/core": {
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.4.tgz",
|
||||
"integrity": "sha512-CqRQCkb6HXxcx/N7s+hHTN6ef2CmamFiRMITwm4qB840ph56mS42bzUgn6tKCP+RZjdDweiRHj9ytDDeN6jFag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/local-notifications": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.2.0.tgz",
|
||||
"integrity": "sha512-fvLY0w2w4MiX+DD4+Wv4DOwOLdzKZsMDwAcRv/Juudd+QbKbn69s6cM3xVqPwAiDqfnqsY4/S8xtQD6M73wY2A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/hash": {
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
|
||||
@@ -2321,6 +2354,10 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@paarrot/plugin-manager": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git#6c389381df3966568a40c39d8661f9a4300f7819"
|
||||
},
|
||||
"node_modules/@react-aria/breadcrumbs": {
|
||||
"version": "3.5.20",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/breadcrumbs/-/breadcrumbs-3.5.20.tgz",
|
||||
@@ -4523,6 +4560,99 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
|
||||
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
||||
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-fs": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz",
|
||||
"integrity": "sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-http": {
|
||||
"version": "2.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.7.tgz",
|
||||
"integrity": "sha512-+F2lEH/c9b0zSsOXKq+5hZNcd9F4IIKCK1T17RqMwpCmVnx2aoqY8yIBccCd25HTYUb3j6NPVbRax/m00hKG8A==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-http/node_modules/@tauri-apps/api": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
||||
"integrity": "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-process": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
|
||||
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-updater": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.0.tgz",
|
||||
"integrity": "sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-updater/node_modules/@tauri-apps/api": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -4612,6 +4742,12 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz",
|
||||
"integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA=="
|
||||
},
|
||||
"node_modules/@types/pako": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prismjs": {
|
||||
"version": "1.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.0.tgz",
|
||||
@@ -7398,6 +7534,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gif.js": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gif.js/-/gif.js-0.2.0.tgz",
|
||||
"integrity": "sha512-bYxCoT8OZKmbxY8RN4qDiYuj4nrQDTzgLRcFVovyona1PTWNePzI4nzOmotnlOFIzTk/ZxAHtv+TfVLiBWj/hw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
@@ -8617,6 +8759,12 @@
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lottie-web": {
|
||||
"version": "5.13.0",
|
||||
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
|
||||
"integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -9210,6 +9358,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
|
||||
15
package.json
15
package.json
@@ -23,10 +23,22 @@
|
||||
"@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",
|
||||
"@capacitor/core": "8.3.4",
|
||||
"@capacitor/local-notifications": "8.2.0",
|
||||
"@fontsource/inter": "4.5.14",
|
||||
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
|
||||
"@tanstack/react-query": "5.24.1",
|
||||
"@tanstack/react-query-devtools": "5.24.1",
|
||||
"@tanstack/react-virtual": "3.2.0",
|
||||
"@tauri-apps/api": "2.9.1",
|
||||
"@tauri-apps/plugin-dialog": "2.6.0",
|
||||
"@tauri-apps/plugin-fs": "2.4.5",
|
||||
"@tauri-apps/plugin-http": "2.5.7",
|
||||
"@tauri-apps/plugin-notification": "2.3.3",
|
||||
"@tauri-apps/plugin-opener": "2",
|
||||
"@tauri-apps/plugin-process": "2.3.1",
|
||||
"@tauri-apps/plugin-updater": "2.10.0",
|
||||
"@types/pako": "2.0.4",
|
||||
"@vanilla-extract/css": "1.9.3",
|
||||
"@vanilla-extract/recipes": "0.3.0",
|
||||
"@vanilla-extract/vite-plugin": "3.7.1",
|
||||
@@ -44,6 +56,7 @@
|
||||
"file-saver": "2.0.5",
|
||||
"focus-trap-react": "10.0.2",
|
||||
"folds": "2.4.0",
|
||||
"gif.js": "0.2.0",
|
||||
"html-dom-parser": "4.0.0",
|
||||
"html-react-parser": "4.2.0",
|
||||
"i18next": "23.12.2",
|
||||
@@ -55,8 +68,10 @@
|
||||
"linkify-react": "4.1.3",
|
||||
"linkifyjs": "4.1.3",
|
||||
"livekit-client": "2.17.0",
|
||||
"lottie-web": "5.13.0",
|
||||
"matrix-js-sdk": "38.2.0",
|
||||
"millify": "6.1.0",
|
||||
"pako": "2.1.0",
|
||||
"pdfjs-dist": "4.2.67",
|
||||
"prismjs": "1.30.0",
|
||||
"react": "18.2.0",
|
||||
|
||||
3
public/gif.worker.js
Normal file
3
public/gif.worker.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Cinny",
|
||||
"short_name": "Cinny",
|
||||
"name": "Paarrot",
|
||||
"short_name": "Paarrot",
|
||||
"description": "Yet another matrix client",
|
||||
"dir": "auto",
|
||||
"lang": "en-US",
|
||||
|
||||
15
public/res/svg/notification.svg
Normal file
15
public/res/svg/notification.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(1.05922,0,0,1.05922,-0.562821,-0.480534)">
|
||||
<g transform="matrix(1.6762,0,0,1.6762,-4.50707,-10.7901)">
|
||||
<path d="M4.624,14.382C4.624,14.382 1.358,10.79 5.108,8.336C5.501,8.316 7.14,8.899 7.18,10.368C7.22,11.836 5.641,11.172 5.108,11.856C4.575,12.54 4.454,14.121 4.624,14.382Z"/>
|
||||
</g>
|
||||
<g transform="matrix(1.6762,0,0,1.6762,-4.50707,-10.7901)">
|
||||
<path d="M5.08,13.547C5.08,13.547 7.28,14.842 7.291,10.74C7.592,10.971 8.588,12.107 7.713,13.777C6.838,15.446 5.392,14.322 5.08,13.547Z"/>
|
||||
</g>
|
||||
<g transform="matrix(1.6762,0,0,1.6762,-4.50707,-10.7901)">
|
||||
<path d="M5.921,14.763C5.584,14.449 6.374,15.403 6.704,16.642C6.873,16.232 8.282,17.562 11.166,14.247C12.324,12.581 13.244,11.279 11.249,8.874C9.254,6.469 6.507,6.615 5.469,7.581C4.431,8.546 5.35,7.566 6.653,8.667C7.52,9.399 7.444,10.431 7.444,10.431C7.774,10.551 9.12,12.332 7.935,14.099C7.205,15.187 6.259,15.076 5.921,14.763Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
5487
public/res/svg/paarrot-highlight.svg
Normal file
5487
public/res/svg/paarrot-highlight.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 909 KiB |
5487
public/res/svg/paarrot-unread.svg
Normal file
5487
public/res/svg/paarrot-unread.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 909 KiB |
5634
public/res/svg/paarrot.svg
Normal file
5634
public/res/svg/paarrot.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 889 KiB |
BIN
public/sound/Call_Depart.ogg
Normal file
BIN
public/sound/Call_Depart.ogg
Normal file
Binary file not shown.
BIN
public/sound/Call_Dialing.ogg
Normal file
BIN
public/sound/Call_Dialing.ogg
Normal file
Binary file not shown.
BIN
public/sound/Call_Joined.ogg
Normal file
BIN
public/sound/Call_Joined.ogg
Normal file
Binary file not shown.
BIN
public/sound/Mic_Mute.ogg
Normal file
BIN
public/sound/Mic_Mute.ogg
Normal file
Binary file not shown.
BIN
public/sound/Mic_Unmuted.ogg
Normal file
BIN
public/sound/Mic_Unmuted.ogg
Normal file
Binary file not shown.
BIN
public/sound/Speakers_Deafen.ogg
Normal file
BIN
public/sound/Speakers_Deafen.ogg
Normal file
Binary file not shown.
BIN
public/sound/Speakers_Undeafen.ogg
Normal file
BIN
public/sound/Speakers_Undeafen.ogg
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
189
src/app/components/AngleSelector.tsx
Normal file
189
src/app/components/AngleSelector.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import React, { useRef, useCallback, useState } from 'react';
|
||||
import { Box, Text, toRem, color } from 'folds';
|
||||
|
||||
/**
|
||||
* Props for the AngleSelector component
|
||||
*/
|
||||
export interface AngleSelectorProps {
|
||||
/** Current angle in degrees (0-360) */
|
||||
value: number;
|
||||
/** Callback when angle changes */
|
||||
onChange: (angle: number) => void;
|
||||
/** Size of the selector in pixels */
|
||||
size?: number;
|
||||
/** Whether the component is disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A circular angle selector similar to Photoshop's drop shadow angle control.
|
||||
* Click or drag around the circle to set an angle in degrees.
|
||||
*/
|
||||
export function AngleSelector({
|
||||
value,
|
||||
onChange,
|
||||
size = 80,
|
||||
disabled = false,
|
||||
}: AngleSelectorProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
/**
|
||||
* Calculate angle from mouse/touch position relative to center
|
||||
*/
|
||||
const calculateAngle = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
if (!containerRef.current) return value;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
|
||||
const deltaX = clientX - centerX;
|
||||
const deltaY = clientY - centerY;
|
||||
|
||||
// Calculate angle in radians, then convert to degrees
|
||||
// atan2 returns angle from -PI to PI, where 0 is pointing right
|
||||
// We adjust so 0deg is pointing up (like CSS gradients)
|
||||
let angle = Math.atan2(deltaX, -deltaY) * (180 / Math.PI);
|
||||
|
||||
// Normalize to 0-360 range
|
||||
if (angle < 0) angle += 360;
|
||||
|
||||
return Math.round(angle);
|
||||
},
|
||||
[value]
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle mouse/touch start
|
||||
*/
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (disabled) return;
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
const newAngle = calculateAngle(e.clientX, e.clientY);
|
||||
onChange(newAngle);
|
||||
},
|
||||
[disabled, calculateAngle, onChange]
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle mouse/touch move
|
||||
*/
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!isDragging || disabled) return;
|
||||
const newAngle = calculateAngle(e.clientX, e.clientY);
|
||||
onChange(newAngle);
|
||||
},
|
||||
[isDragging, disabled, calculateAngle, onChange]
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle mouse/touch end
|
||||
*/
|
||||
const handlePointerUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
// Calculate line endpoint position
|
||||
const lineLength = (size / 2) - 8;
|
||||
const radians = ((value - 90) * Math.PI) / 180;
|
||||
const lineX = Math.cos(radians) * lineLength;
|
||||
const lineY = Math.sin(radians) * lineLength;
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100" alignItems="Center">
|
||||
<Box
|
||||
ref={containerRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
style={{
|
||||
width: toRem(size),
|
||||
height: toRem(size),
|
||||
borderRadius: '50%',
|
||||
border: `${toRem(2)} solid ${color.Surface.ContainerLine}`,
|
||||
backgroundColor: color.Surface.Container,
|
||||
position: 'relative',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
touchAction: 'none',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Center dot */}
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: toRem(6),
|
||||
height: toRem(6),
|
||||
borderRadius: '50%',
|
||||
backgroundColor: color.Secondary.Main,
|
||||
}}
|
||||
/>
|
||||
{/* Direction line */}
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: toRem(2),
|
||||
height: toRem(lineLength),
|
||||
backgroundColor: color.Primary.Main,
|
||||
transformOrigin: 'center top',
|
||||
transform: `translate(-50%, 0) rotate(${value}deg)`,
|
||||
borderRadius: toRem(1),
|
||||
}}
|
||||
/>
|
||||
{/* Angle indicator dot at end of line */}
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: toRem(10),
|
||||
height: toRem(10),
|
||||
borderRadius: '50%',
|
||||
backgroundColor: color.Primary.Main,
|
||||
transform: `translate(calc(-50% + ${lineX}px), calc(-50% + ${lineY}px))`,
|
||||
boxShadow: `0 0 ${toRem(4)} ${color.Primary.Main}`,
|
||||
}}
|
||||
/>
|
||||
{/* Cardinal direction markers */}
|
||||
{[0, 90, 180, 270].map((deg) => {
|
||||
const markerRadians = ((deg - 90) * Math.PI) / 180;
|
||||
const markerDist = (size / 2) - 3;
|
||||
const markerX = Math.cos(markerRadians) * markerDist;
|
||||
const markerY = Math.sin(markerRadians) * markerDist;
|
||||
return (
|
||||
<Box
|
||||
key={deg}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: toRem(4),
|
||||
height: toRem(4),
|
||||
borderRadius: '50%',
|
||||
backgroundColor: color.Surface.OnContainer,
|
||||
opacity: 0.3,
|
||||
transform: `translate(calc(-50% + ${markerX}px), calc(-50% + ${markerY}px))`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Text size="T200">{value}°</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default AngleSelector;
|
||||
27
src/app/components/AnimatedOutlet.tsx
Normal file
27
src/app/components/AnimatedOutlet.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* Wrapper for Outlet that adds route-based animation
|
||||
* Forces remount on route change by using location as key
|
||||
*/
|
||||
export function AnimatedOutlet() {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={location.pathname}
|
||||
data-route-transition="true"
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,11 @@ import { trimTrailingSlash } from '../utils/common';
|
||||
|
||||
const getClientConfig = async (): Promise<ClientConfig> => {
|
||||
const url = `${trimTrailingSlash(import.meta.env.BASE_URL)}/config.json`;
|
||||
console.log('[Config] Loading config from:', url);
|
||||
const config = await fetch(url, { method: 'GET' });
|
||||
return config.json();
|
||||
const json = await config.json();
|
||||
console.log('[Config] Loaded config:', json);
|
||||
return json;
|
||||
};
|
||||
|
||||
type ClientConfigLoaderProps = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import React, { ReactNode, useCallback, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
|
||||
import { Modal, Overlay, OverlayBackdrop, OverlayCenter, PopOutContainerProvider } from 'folds';
|
||||
import { stopPropagation } from '../utils/keyboard';
|
||||
|
||||
type Modal500Props = {
|
||||
@@ -8,6 +8,9 @@ type Modal500Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
export function Modal500({ requestClose, children }: Modal500Props) {
|
||||
const [modalEl, setModalEl] = useState<HTMLDivElement | null>(null);
|
||||
const modalRef = useCallback((el: HTMLDivElement | null) => setModalEl(el), []);
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
@@ -19,8 +22,10 @@ export function Modal500({ requestClose, children }: Modal500Props) {
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal size="500" variant="Background">
|
||||
{children}
|
||||
<Modal ref={modalRef} size="500" variant="Background">
|
||||
<PopOutContainerProvider value={modalEl ?? undefined}>
|
||||
{children}
|
||||
</PopOutContainerProvider>
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
|
||||
@@ -23,14 +23,18 @@ import {
|
||||
ThumbnailContent,
|
||||
UnsupportedContent,
|
||||
VideoContent,
|
||||
VideoFrameThumbnail,
|
||||
} from './message';
|
||||
import { UrlPreviewCard, UrlPreviewHolder } from './url-preview';
|
||||
import { UrlPreviewCard, UrlPreviewHolder, YouTubeEmbed, isYouTubeUrl, TikTokEmbed, isTikTokUrl, GiphyEmbed, isGiphyUrl } from './url-preview';
|
||||
import { Image, MediaControl, Video } from './media';
|
||||
import { ImageViewer } from './image-viewer';
|
||||
import { VideoViewer } from './video-viewer';
|
||||
import { PdfViewer } from './Pdf-viewer';
|
||||
import { TextViewer } from './text-viewer';
|
||||
import { testMatrixTo } from '../plugins/matrix-to';
|
||||
import { IImageContent } from '../../types/matrix/common';
|
||||
import { filterDisabledUrls } from '../hooks/useRoomEmbedFilters';
|
||||
import { pluginRegistry } from '../features/settings/plugins/PluginAPI';
|
||||
|
||||
type RenderMessageContentProps = {
|
||||
displayName: string;
|
||||
@@ -44,6 +48,7 @@ type RenderMessageContentProps = {
|
||||
htmlReactParserOptions: HTMLReactParserOptions;
|
||||
linkifyOpts: Opts;
|
||||
outlineAttachment?: boolean;
|
||||
disabledEmbedPatterns?: string[];
|
||||
};
|
||||
export function RenderMessageContent({
|
||||
displayName,
|
||||
@@ -57,16 +62,42 @@ export function RenderMessageContent({
|
||||
htmlReactParserOptions,
|
||||
linkifyOpts,
|
||||
outlineAttachment,
|
||||
disabledEmbedPatterns,
|
||||
}: RenderMessageContentProps) {
|
||||
const renderUrlsPreview = (urls: string[]) => {
|
||||
const filteredUrls = urls.filter((url) => !testMatrixTo(url));
|
||||
let filteredUrls = urls.filter((url) => !testMatrixTo(url));
|
||||
filteredUrls = filterDisabledUrls(filteredUrls, disabledEmbedPatterns ?? []);
|
||||
if (filteredUrls.length === 0) return undefined;
|
||||
|
||||
// Separate special URLs from generic ones
|
||||
const youtubeUrls = filteredUrls.filter(isYouTubeUrl);
|
||||
const tiktokUrls = filteredUrls.filter(isTikTokUrl);
|
||||
const giphyUrls = filteredUrls.filter(isGiphyUrl);
|
||||
const otherUrls = filteredUrls.filter((url) => !isYouTubeUrl(url) && !isTikTokUrl(url) && !isGiphyUrl(url));
|
||||
|
||||
return (
|
||||
<UrlPreviewHolder>
|
||||
{filteredUrls.map((url) => (
|
||||
<UrlPreviewCard key={url} url={url} ts={ts} />
|
||||
<>
|
||||
{/* Render YouTube embeds (ad-free via yt-dlp) */}
|
||||
{youtubeUrls.map((url) => (
|
||||
<YouTubeEmbed key={url} url={url} />
|
||||
))}
|
||||
</UrlPreviewHolder>
|
||||
{/* Render TikTok embeds */}
|
||||
{tiktokUrls.map((url) => (
|
||||
<TikTokEmbed key={url} url={url} />
|
||||
))}
|
||||
{/* Render Giphy embeds */}
|
||||
{giphyUrls.map((url) => (
|
||||
<GiphyEmbed key={url} url={url} />
|
||||
))}
|
||||
{/* Render standard URL previews for other links */}
|
||||
{otherUrls.length > 0 && (
|
||||
<UrlPreviewHolder>
|
||||
{otherUrls.map((url) => (
|
||||
<UrlPreviewCard key={url} url={url} ts={ts} />
|
||||
))}
|
||||
</UrlPreviewHolder>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
const renderCaption = () => {
|
||||
@@ -128,6 +159,24 @@ export function RenderMessageContent({
|
||||
</>
|
||||
);
|
||||
|
||||
// Check for custom renderer from plugins BEFORE standard type handling
|
||||
// so plugins can intercept any msgtype including custom ones.
|
||||
const customRendererEarly = pluginRegistry.getRenderer('message');
|
||||
if (customRendererEarly) {
|
||||
try {
|
||||
const messageDataEarly = {
|
||||
msgtype: msgType,
|
||||
...getContent(),
|
||||
};
|
||||
const customContentEarly = customRendererEarly(messageDataEarly, () => null);
|
||||
if (customContentEarly) {
|
||||
return customContentEarly as React.ReactElement;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[RenderMessageContent] Custom renderer error (early):', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (msgType === MsgType.Text) {
|
||||
return (
|
||||
<MText
|
||||
@@ -209,26 +258,46 @@ export function RenderMessageContent({
|
||||
<MVideo
|
||||
content={getContent()}
|
||||
renderAsFile={renderFile}
|
||||
renderVideoContent={({ body, info, ...props }) => (
|
||||
<VideoContent
|
||||
body={body}
|
||||
info={info}
|
||||
{...props}
|
||||
renderThumbnail={
|
||||
mediaAutoLoad
|
||||
? () => (
|
||||
<ThumbnailContent
|
||||
info={info}
|
||||
renderImage={(src) => (
|
||||
<Image alt={body} title={body} src={src} loading="lazy" />
|
||||
)}
|
||||
/>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
renderVideo={(p) => <Video {...p} />}
|
||||
/>
|
||||
)}
|
||||
renderVideoContent={({ body, info, mimeType, url, encInfo, ...props }) => {
|
||||
const hasValidThumbnail =
|
||||
(typeof info.thumbnail_file?.url === 'string' || typeof info.thumbnail_url === 'string') &&
|
||||
typeof info.thumbnail_info?.mimetype === 'string';
|
||||
|
||||
return (
|
||||
<VideoContent
|
||||
body={body}
|
||||
info={info}
|
||||
mimeType={mimeType}
|
||||
url={url}
|
||||
encInfo={encInfo}
|
||||
autoPlay={mediaAutoLoad}
|
||||
{...props}
|
||||
renderThumbnail={
|
||||
mediaAutoLoad
|
||||
? () => hasValidThumbnail ? (
|
||||
<ThumbnailContent
|
||||
info={info}
|
||||
renderImage={(src) => (
|
||||
<Image alt={body} title={body} src={src} loading="lazy" />
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<VideoFrameThumbnail
|
||||
mimeType={mimeType}
|
||||
url={url}
|
||||
encInfo={encInfo}
|
||||
renderImage={(src) => (
|
||||
<Image alt={body} title={body} src={src} loading="lazy" />
|
||||
)}
|
||||
/>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
renderViewer={(p) => <VideoViewer {...p} />}
|
||||
renderVideo={(p) => <Video {...p} />}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
outlined={outlineAttachment}
|
||||
/>
|
||||
{renderCaption()}
|
||||
@@ -264,5 +333,36 @@ export function RenderMessageContent({
|
||||
return <MBadEncrypted />;
|
||||
}
|
||||
|
||||
// Check for custom renderer from plugins FIRST
|
||||
const customRenderer = pluginRegistry.getRenderer('message');
|
||||
console.log('[RenderMessageContent] customRenderer function:', typeof customRenderer);
|
||||
if (customRenderer) {
|
||||
try {
|
||||
const messageData = {
|
||||
msgtype: msgType,
|
||||
...getContent(),
|
||||
};
|
||||
console.log('[RenderMessageContent] Calling customRenderer with msgtype:', msgType);
|
||||
console.log('[RenderMessageContent] messageData:', messageData);
|
||||
const customContent = customRenderer(
|
||||
messageData,
|
||||
() => {
|
||||
console.log('[RenderMessageContent] defaultRenderer called');
|
||||
return null;
|
||||
}
|
||||
);
|
||||
console.log('[RenderMessageContent] customRenderer returned:', customContent);
|
||||
console.log('[RenderMessageContent] customContent type:', typeof customContent);
|
||||
if (customContent) {
|
||||
console.log('[RenderMessageContent] Returning custom content');
|
||||
return customContent as React.ReactElement;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[RenderMessageContent] Custom renderer error:', error);
|
||||
}
|
||||
} else {
|
||||
console.log('[RenderMessageContent] No custom renderer registered');
|
||||
}
|
||||
|
||||
return <UnsupportedContent />;
|
||||
}
|
||||
|
||||
104
src/app/components/RouteTransition.tsx
Normal file
104
src/app/components/RouteTransition.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { css } from '@vanilla-extract/css';
|
||||
|
||||
const transitionContainer = css({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
const transitionContent = css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
const animateIn = css({
|
||||
animation: 'routeFadeSlideIn 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'@keyframes': {
|
||||
routeFadeSlideIn: {
|
||||
from: {
|
||||
opacity: 0,
|
||||
transform: 'translateX(12px)',
|
||||
},
|
||||
to: {
|
||||
opacity: 1,
|
||||
transform: 'translateX(0)',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const animateOut = css({
|
||||
animation: 'routeFadeSlideOut 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards',
|
||||
'@keyframes': {
|
||||
routeFadeSlideOut: {
|
||||
from: {
|
||||
opacity: 1,
|
||||
transform: 'translateX(0)',
|
||||
},
|
||||
to: {
|
||||
opacity: 0,
|
||||
transform: 'translateX(-12px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
interface RouteTransitionProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps content in a transition animation that triggers on route change
|
||||
*/
|
||||
export function RouteTransition({ children }: RouteTransitionProps) {
|
||||
const location = useLocation();
|
||||
const [displayLocation, setDisplayLocation] = useState(location);
|
||||
const [transitionStage, setTransitionStage] = useState<'entering' | 'exiting' | 'idle'>('idle');
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname !== displayLocation.pathname) {
|
||||
// Start exit animation
|
||||
setTransitionStage('exiting');
|
||||
|
||||
// Clear any existing timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// Wait for exit animation, then update location and enter
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setDisplayLocation(location);
|
||||
setTransitionStage('entering');
|
||||
|
||||
// Reset to idle after enter animation
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setTransitionStage('idle');
|
||||
}, 250);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [location, displayLocation]);
|
||||
|
||||
const className = transitionStage === 'entering'
|
||||
? `${transitionContent} ${animateIn}`
|
||||
: transitionStage === 'exiting'
|
||||
? `${transitionContent} ${animateOut}`
|
||||
: transitionContent;
|
||||
|
||||
return (
|
||||
<div className={transitionContainer}>
|
||||
<div className={className} key={displayLocation.pathname}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,10 +31,12 @@ export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
|
||||
const authMetadata = promiseFulfilledResult(result[2]);
|
||||
let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
|
||||
|
||||
try {
|
||||
validatedAuthMetadata = validateAuthMetadata(authMetadata);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (authMetadata) {
|
||||
try {
|
||||
validatedAuthMetadata = validateAuthMetadata(authMetadata);
|
||||
} catch (e) {
|
||||
// Server returned auth metadata but it failed validation — ignore silently.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
244
src/app/components/account-switcher/AccountSwitcher.tsx
Normal file
244
src/app/components/account-switcher/AccountSwitcher.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Avatar, Box, Icon, Icons, MenuItem, Text, config } from 'folds';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { sessionsAtom, Session } from '../../state/sessions';
|
||||
import { setLocalStorageItem } from '../../state/utils/atomWithLocalStorage';
|
||||
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useUserProfile } from '../../hooks/useUserProfile';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { UserAvatar } from '../user-avatar';
|
||||
|
||||
type AccountSwitcherProps = {
|
||||
currentUserId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads and displays an avatar for a session that is not the active client.
|
||||
* Fetches the profile directly via the homeserver REST API using stored credentials.
|
||||
*/
|
||||
function OtherSessionAvatar({ session }: { session: Session }) {
|
||||
const [src, setSrc] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${session.baseUrl}/_matrix/client/v3/profile/${encodeURIComponent(session.userId)}`,
|
||||
{ headers: { Authorization: `Bearer ${session.accessToken}` } }
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (data.avatar_url) {
|
||||
const match = (data.avatar_url as string).match(/^mxc:\/\/([^/]+)\/(.+)$/);
|
||||
if (match) {
|
||||
setSrc(
|
||||
`${session.baseUrl}/_matrix/media/v3/thumbnail/${match[1]}/${match[2]}?width=32&height=32&method=crop`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore — avatar just won't show
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [session.userId, session.baseUrl, session.accessToken]);
|
||||
|
||||
return (
|
||||
<Avatar size="300" style={{ width: '1.75rem', height: '1.75rem', minWidth: '1.75rem' }}>
|
||||
<UserAvatar
|
||||
userId={session.userId}
|
||||
src={src}
|
||||
alt={session.userId}
|
||||
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
const deleteAllMatrixDatabases = async (): Promise<void> => {
|
||||
console.log('[AccountSwitcher] Deleting all Matrix IndexedDB databases...');
|
||||
|
||||
// List of known Matrix database names/patterns to delete
|
||||
const dbsToDelete = [
|
||||
'matrix-js-sdk::matrix-sdk-crypto', // Shared crypto store (causes conflicts)
|
||||
];
|
||||
|
||||
// Try to get all databases if the API is available
|
||||
try {
|
||||
if ('databases' in indexedDB) {
|
||||
const allDbs = await (indexedDB as any).databases();
|
||||
const matrixDbs = allDbs
|
||||
.map((db: any) => db.name)
|
||||
.filter((name: string) =>
|
||||
name && (
|
||||
name.startsWith('matrix-js-sdk') ||
|
||||
name.includes('@@') ||
|
||||
name.startsWith('sync@@') ||
|
||||
name.startsWith('crypto@@')
|
||||
)
|
||||
);
|
||||
dbsToDelete.push(...matrixDbs);
|
||||
console.log('[AccountSwitcher] Found Matrix databases:', matrixDbs);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[AccountSwitcher] Could not enumerate databases:', e);
|
||||
}
|
||||
|
||||
// Delete each database with timeout
|
||||
const deletePromises = dbsToDelete.map(async (dbName) => {
|
||||
try {
|
||||
const deletePromise = new Promise<void>((resolve, reject) => {
|
||||
const request = indexedDB.deleteDatabase(dbName);
|
||||
request.onsuccess = () => {
|
||||
console.log(`[AccountSwitcher] ✅ Deleted database: ${dbName}`);
|
||||
resolve();
|
||||
};
|
||||
request.onerror = () => {
|
||||
console.warn(`[AccountSwitcher] ⚠️ Failed to delete: ${dbName}`, request.error);
|
||||
resolve(); // Don't reject, just continue
|
||||
};
|
||||
request.onblocked = () => {
|
||||
console.warn(`[AccountSwitcher] ⚠️ Delete blocked: ${dbName}`);
|
||||
resolve(); // Don't reject, reload will handle it
|
||||
};
|
||||
});
|
||||
|
||||
// Add 1-second timeout per database
|
||||
const timeoutPromise = new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
console.warn(`[AccountSwitcher] ⏱️ Delete timeout: ${dbName}`);
|
||||
resolve();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
await Promise.race([deletePromise, timeoutPromise]);
|
||||
} catch (error) {
|
||||
console.warn(`[AccountSwitcher] Error deleting ${dbName}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(deletePromises);
|
||||
console.log('[AccountSwitcher] ✅ Database cleanup complete');
|
||||
};
|
||||
|
||||
export function AccountSwitcher({ currentUserId }: AccountSwitcherProps) {
|
||||
const sessions = useAtomValue(sessionsAtom);
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const currentProfile = useUserProfile(currentUserId ?? '');
|
||||
const currentAvatarUrl = currentProfile.avatarUrl
|
||||
? mxcUrlToHttp(mx, currentProfile.avatarUrl, useAuthentication, 32, 32, 'crop') ?? undefined
|
||||
: undefined;
|
||||
|
||||
const handleSwitchAccount = async (session: Session) => {
|
||||
if (session.userId === currentUserId) return;
|
||||
|
||||
console.log('[AccountSwitcher] Switching account from', currentUserId, 'to', session.userId);
|
||||
|
||||
// Stop the current client before switching
|
||||
if (mx) {
|
||||
console.log('[AccountSwitcher] Stopping current Matrix client...');
|
||||
try {
|
||||
mx.stopClient();
|
||||
console.log('[AccountSwitcher] Matrix client stopped');
|
||||
} catch (error) {
|
||||
console.error('[AccountSwitcher] Error stopping client:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Set the new current session
|
||||
setLocalStorageItem('currentSessionUserId', session.userId);
|
||||
|
||||
// Delete all Matrix databases to prevent conflicts
|
||||
await deleteAllMatrixDatabases();
|
||||
|
||||
// Small delay to ensure cleanup is complete
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Reload the page to initialize the new session
|
||||
console.log('[AccountSwitcher] Reloading page...');
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
// If no current user, don't show anything
|
||||
if (!currentUserId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter out other sessions (not current)
|
||||
const otherSessions = sessions.filter(s => s.userId !== currentUserId);
|
||||
const hasMultipleAccounts = sessions.length > 1;
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="L400" style={{ paddingInline: config.space.S200, paddingBlock: config.space.S100 }}>
|
||||
{hasMultipleAccounts ? 'Switch Account' : 'Accounts'}
|
||||
</Text>
|
||||
|
||||
{/* Show current account */}
|
||||
{currentUserId && (
|
||||
<MenuItem
|
||||
key={currentUserId}
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="SurfaceVariant"
|
||||
disabled
|
||||
style={{ paddingInline: config.space.S200 }}
|
||||
after={<Icon size="100" src={Icons.Check} filled />}
|
||||
>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Avatar size="300" style={{ width: '1.75rem', height: '1.75rem', minWidth: '1.75rem' }}>
|
||||
<UserAvatar
|
||||
userId={currentUserId}
|
||||
src={currentAvatarUrl}
|
||||
alt={currentUserId}
|
||||
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
<Box direction="Column" style={{ gap: '2px' }}>
|
||||
<Text size="T300" truncate>
|
||||
{currentUserId}
|
||||
</Text>
|
||||
{getMxIdServer(currentUserId) && (
|
||||
<Text size="T200" priority="300" truncate>
|
||||
{getMxIdServer(currentUserId)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{/* Show other accounts if any */}
|
||||
{otherSessions.map((session) => {
|
||||
const server = getMxIdServer(session.userId);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={session.userId}
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={() => handleSwitchAccount(session)}
|
||||
variant="Surface"
|
||||
style={{ paddingInline: config.space.S200 }}
|
||||
>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<OtherSessionAvatar session={session} />
|
||||
<Box direction="Column" style={{ gap: '2px' }}>
|
||||
<Text size="T300" truncate>
|
||||
{session.userId}
|
||||
</Text>
|
||||
{server && (
|
||||
<Text size="T200" priority="300" truncate>
|
||||
{server}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
src/app/components/account-switcher/index.ts
Normal file
1
src/app/components/account-switcher/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AccountSwitcher } from './AccountSwitcher';
|
||||
@@ -0,0 +1,22 @@
|
||||
import React, { ComponentProps, forwardRef } from 'react';
|
||||
import { AvatarImage } from 'folds';
|
||||
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
|
||||
export type AuthenticatedAvatarImageProps = ComponentProps<typeof AvatarImage>;
|
||||
|
||||
/**
|
||||
* AvatarImage component that handles Matrix authenticated media.
|
||||
* Automatically fetches authenticated media URLs with the proper Authorization header
|
||||
* and renders them as blob URLs.
|
||||
*/
|
||||
export const AuthenticatedAvatarImage = forwardRef<HTMLImageElement, AuthenticatedAvatarImageProps>(
|
||||
({ src, ...props }, ref) => {
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
||||
|
||||
return <AvatarImage ref={ref} {...props} src={authenticatedSrc} />;
|
||||
}
|
||||
);
|
||||
|
||||
AuthenticatedAvatarImage.displayName = 'AuthenticatedAvatarImage';
|
||||
@@ -0,0 +1,45 @@
|
||||
import React, { ImgHTMLAttributes, forwardRef, useEffect, useState } from 'react';
|
||||
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
|
||||
export interface AuthenticatedMediaProps
|
||||
extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
|
||||
/** The source URL of the media (may be an authenticated media URL) */
|
||||
src?: string;
|
||||
/** Override for authentication - if not provided, uses useMediaAuthentication hook */
|
||||
useAuthentication?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Image component that handles Matrix authenticated media.
|
||||
* Automatically fetches authenticated media URLs with the proper Authorization header
|
||||
* and renders them as blob URLs.
|
||||
*/
|
||||
export const AuthenticatedMedia = forwardRef<HTMLImageElement, AuthenticatedMediaProps>(
|
||||
({ src, useAuthentication: useAuthOverride, ...props }, ref) => {
|
||||
const useAuthFromServer = useMediaAuthentication();
|
||||
const useAuthentication = useAuthOverride ?? useAuthFromServer;
|
||||
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
||||
|
||||
return <img ref={ref} {...props} src={authenticatedSrc} />;
|
||||
}
|
||||
);
|
||||
|
||||
AuthenticatedMedia.displayName = 'AuthenticatedMedia';
|
||||
|
||||
/**
|
||||
* A simpler authenticated img component for use in places like custom HTML parsers.
|
||||
* This component handles its own authentication state.
|
||||
*/
|
||||
export interface AuthenticatedImgProps extends ImgHTMLAttributes<HTMLImageElement> {}
|
||||
|
||||
export const AuthenticatedImg = forwardRef<HTMLImageElement, AuthenticatedImgProps>(
|
||||
({ src, ...props }, ref) => {
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
||||
|
||||
return <img ref={ref} {...props} src={authenticatedSrc} />;
|
||||
}
|
||||
);
|
||||
|
||||
AuthenticatedImg.displayName = 'AuthenticatedImg';
|
||||
4
src/app/components/authenticated-media/index.ts
Normal file
4
src/app/components/authenticated-media/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { AuthenticatedMedia, AuthenticatedImg } from './AuthenticatedMedia';
|
||||
export type { AuthenticatedMediaProps, AuthenticatedImgProps } from './AuthenticatedMedia';
|
||||
export { AuthenticatedAvatarImage } from './AuthenticatedAvatarImage';
|
||||
export type { AuthenticatedAvatarImageProps } from './AuthenticatedAvatarImage';
|
||||
@@ -82,6 +82,16 @@ export const createRoomEncryptionState = () => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const createRoomPowerLevelsState = () => ({
|
||||
type: StateEvent.RoomPowerLevels,
|
||||
state_key: '',
|
||||
content: {
|
||||
events: {
|
||||
'org.matrix.msc3401.call.member': 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type CreateRoomData = {
|
||||
version: string;
|
||||
type?: RoomType;
|
||||
@@ -98,6 +108,8 @@ export type CreateRoomData = {
|
||||
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => {
|
||||
const initialState: ICreateRoomStateEvent[] = [];
|
||||
|
||||
// initialState.push(createRoomPowerLevelsState()); // Removed: causes 403 when setting permissions before admin
|
||||
|
||||
if (data.encryption) {
|
||||
initialState.push(createRoomEncryptionState());
|
||||
}
|
||||
|
||||
180
src/app/components/custom-status/CustomStatusDialog.tsx
Normal file
180
src/app/components/custom-status/CustomStatusDialog.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useState, useCallback, ChangeEventHandler } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
config,
|
||||
Dialog,
|
||||
Input,
|
||||
Text,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useUserPresence } from '../../hooks/useUserPresence';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
|
||||
/**
|
||||
* Props for the CustomStatusDialog component
|
||||
*/
|
||||
export interface CustomStatusDialogProps {
|
||||
/** Whether the dialog is currently open */
|
||||
open: boolean;
|
||||
/** Callback function invoked when the dialog should close */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* CustomStatusDialog Component
|
||||
*
|
||||
* Provides a dialog interface for users to set or clear their custom status message.
|
||||
* The status is stored in Matrix presence and will be visible to other users.
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns The custom status dialog component
|
||||
*/
|
||||
export function CustomStatusDialog({ open, onClose }: CustomStatusDialogProps) {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId();
|
||||
const presence = useUserPresence(userId ?? '');
|
||||
|
||||
const [statusText, setStatusText] = useState(presence?.status || '');
|
||||
|
||||
/**
|
||||
* Handles input changes for the status text field
|
||||
* @param evt - The change event from the input
|
||||
*/
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||
setStatusText(evt.currentTarget.value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Async callback to save the status to Matrix presence
|
||||
*/
|
||||
const [saveState, saveStatus] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
await mx.setPresence({
|
||||
presence: presence?.presence || 'online',
|
||||
status_msg: statusText,
|
||||
});
|
||||
onClose();
|
||||
}, [mx, presence?.presence, statusText, onClose])
|
||||
);
|
||||
|
||||
/**
|
||||
* Async callback to clear the status from Matrix presence
|
||||
*/
|
||||
const [clearState, clearStatus] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
await mx.setPresence({
|
||||
presence: presence?.presence || 'online',
|
||||
status_msg: '',
|
||||
});
|
||||
setStatusText('');
|
||||
onClose();
|
||||
}, [mx, presence?.presence, onClose])
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles form submission to save the custom status
|
||||
* @param evt - The form submit event
|
||||
*/
|
||||
const handleSubmit = (evt: React.FormEvent) => {
|
||||
evt.preventDefault();
|
||||
saveStatus();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the clear status button click
|
||||
*/
|
||||
const handleClear = () => {
|
||||
clearStatus();
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: onClose,
|
||||
clickOutsideDeactivates: true,
|
||||
}}
|
||||
>
|
||||
<Dialog>
|
||||
<Box
|
||||
as="form"
|
||||
onSubmit={handleSubmit}
|
||||
direction="Column"
|
||||
gap="400"
|
||||
style={{ padding: config.space.S400 }}
|
||||
>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="H4">Set Custom Status</Text>
|
||||
<Text size="T300" priority="400">
|
||||
Your custom status will be visible to other users
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box direction="Column" gap="200">
|
||||
<Input
|
||||
value={statusText}
|
||||
onChange={handleChange}
|
||||
placeholder="What's on your mind?"
|
||||
maxLength={100}
|
||||
autoFocus
|
||||
disabled={saveState.status === AsyncStatus.Loading || clearState.status === AsyncStatus.Loading}
|
||||
/>
|
||||
{statusText.length > 0 && (
|
||||
<Text size="T200" priority="300">
|
||||
{statusText.length}/100 characters
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{(saveState.status === AsyncStatus.Error || clearState.status === AsyncStatus.Error) && (
|
||||
<Text size="T300" priority="400" style={{ color: 'var(--color-critical-main)' }}>
|
||||
Failed to update status. Please try again.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Box direction="Row" gap="200" justifyContent="End">
|
||||
<Button
|
||||
type="button"
|
||||
variant="Secondary"
|
||||
onClick={onClose}
|
||||
disabled={saveState.status === AsyncStatus.Loading || clearState.status === AsyncStatus.Loading}
|
||||
>
|
||||
<Text size="B400">Cancel</Text>
|
||||
</Button>
|
||||
{presence?.status && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
onClick={handleClear}
|
||||
disabled={saveState.status === AsyncStatus.Loading || clearState.status === AsyncStatus.Loading}
|
||||
>
|
||||
<Text size="B400">Clear Status</Text>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="Primary"
|
||||
disabled={saveState.status === AsyncStatus.Loading || clearState.status === AsyncStatus.Loading}
|
||||
>
|
||||
<Text size="B400">
|
||||
{saveState.status === AsyncStatus.Loading ? 'Saving...' : 'Save'}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
1
src/app/components/custom-status/index.ts
Normal file
1
src/app/components/custom-status/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CustomStatusDialog, type CustomStatusDialogProps } from './CustomStatusDialog';
|
||||
317
src/app/components/direct-list/DirectList.tsx
Normal file
317
src/app/components/direct-list/DirectList.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import React, { MouseEventHandler, forwardRef, useMemo, useState } from 'react';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Menu,
|
||||
MenuItem,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Text,
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { factoryRoomIdByActivity } from '../../utils/sort';
|
||||
import {
|
||||
NavButton,
|
||||
NavCategory,
|
||||
NavCategoryHeader,
|
||||
NavEmptyCenter,
|
||||
NavEmptyLayout,
|
||||
NavItem,
|
||||
NavItemContent,
|
||||
} from '../nav';
|
||||
import { getDirectCreatePath, getDirectRoomPath } from '../../pages/pathUtils';
|
||||
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
|
||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||
import { VirtualTile } from '../virtualizer';
|
||||
import { RoomNavCategoryButton, RoomNavItem } from '../../features/room-nav';
|
||||
import { makeNavCategoryId } from '../../state/closedNavCategories';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
import { useCategoryHandler } from '../../hooks/useCategoryHandler';
|
||||
import { useDirectRooms } from '../../pages/client/direct/useDirectRooms';
|
||||
import { useClosedNavCategoriesAtom } from '../../state/hooks/closedNavCategories';
|
||||
import { useRoomsUnread } from '../../state/hooks/unread';
|
||||
import { useMarkRoomsAsRead } from '../../hooks/useMarkAsRead';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { PluginButtonSlot } from '../../features/settings/plugins/PluginButtonSlot';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import {
|
||||
getRoomNotificationMode,
|
||||
useRoomsNotificationPreferencesContext,
|
||||
} from '../../hooks/useRoomsNotificationPreferences';
|
||||
import { useDirectCreateSelected } from '../../hooks/router/useDirectSelected';
|
||||
import { useRoomListReorder, useListReorderAnimation } from '../../hooks/useRoomListReorder';
|
||||
|
||||
/**
|
||||
* Props for the DirectMenu component
|
||||
*/
|
||||
type DirectMenuProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Menu component for Direct Messages category actions
|
||||
*/
|
||||
const DirectMenu = forwardRef<HTMLDivElement, DirectMenuProps>(({ requestClose }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const orphanRooms = useDirectRooms();
|
||||
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
|
||||
const markRoomsAsRead = useMarkRoomsAsRead(mx);
|
||||
|
||||
const handleMarkAsRead = () => {
|
||||
if (!unread) return;
|
||||
markRoomsAsRead(orphanRooms, hideActivity);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
onClick={handleMarkAsRead}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.CheckTwice} />}
|
||||
radii="300"
|
||||
aria-disabled={!unread}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Mark as Read
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Props for the DirectListHeader component
|
||||
*/
|
||||
type DirectListHeaderProps = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Header component for the Direct Messages list with menu
|
||||
*/
|
||||
export function DirectListHeader({ title = 'Direct Messages' }: DirectListHeaderProps) {
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
const cords = evt.currentTarget.getBoundingClientRect();
|
||||
setMenuAnchor((currentState) => {
|
||||
if (currentState) return undefined;
|
||||
return cords;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box alignItems="Center" grow="Yes" gap="300">
|
||||
<Box grow="Yes">
|
||||
<Text size="H4" truncate>
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box gap="100" alignItems="Center">
|
||||
<PluginButtonSlot location="direct-messages" />
|
||||
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
|
||||
<Icon src={Icons.VerticalDots} size="200" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={6}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<DirectMenu requestClose={() => setMenuAnchor(undefined)} />
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state component when there are no direct messages
|
||||
*/
|
||||
export function DirectListEmpty() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<NavEmptyCenter>
|
||||
<NavEmptyLayout
|
||||
icon={<Icon size="600" src={Icons.Mention} />}
|
||||
title={
|
||||
<Text size="H5" align="Center">
|
||||
No Direct Messages
|
||||
</Text>
|
||||
}
|
||||
content={
|
||||
<Text size="T300" align="Center">
|
||||
You do not have any direct messages yet.
|
||||
</Text>
|
||||
}
|
||||
options={
|
||||
<Button variant="Secondary" size="300" onClick={() => navigate(getDirectCreatePath())}>
|
||||
<Text size="B300" truncate>
|
||||
Direct Message
|
||||
</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</NavEmptyCenter>
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct');
|
||||
|
||||
/**
|
||||
* Props for the DirectList component
|
||||
*/
|
||||
export type DirectListProps = {
|
||||
scrollRef: React.RefObject<HTMLDivElement>;
|
||||
showCreateButton?: boolean;
|
||||
showCategoryHeader?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reusable component that renders the virtualized list of direct message rooms.
|
||||
* Can be embedded in any PageNavContent context.
|
||||
*/
|
||||
export function DirectList({
|
||||
scrollRef,
|
||||
showCreateButton = true,
|
||||
showCategoryHeader = true,
|
||||
}: DirectListProps) {
|
||||
const mx = useMatrixClient();
|
||||
const directs = useDirectRooms();
|
||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||
const navigate = useNavigate();
|
||||
const createDirectSelected = useDirectCreateSelected();
|
||||
const selectedRoomId = useSelectedRoom();
|
||||
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
||||
|
||||
const reorderTrigger = useRoomListReorder(mx, directs);
|
||||
|
||||
const sortedDirects = useMemo(() => {
|
||||
const items = Array.from(directs).sort(factoryRoomIdByActivity(mx));
|
||||
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
|
||||
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
|
||||
}
|
||||
return items;
|
||||
}, [mx, directs, closedCategories, roomToUnread, selectedRoomId, reorderTrigger]);
|
||||
|
||||
const animationRef = useListReorderAnimation(sortedDirects);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: sortedDirects.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 38,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
|
||||
closedCategories.has(categoryId)
|
||||
);
|
||||
|
||||
const noRoomToDisplay = directs.length === 0;
|
||||
|
||||
if (noRoomToDisplay) {
|
||||
return <DirectListEmpty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="300">
|
||||
{showCreateButton && (
|
||||
<NavCategory>
|
||||
<NavItem variant="Background" radii="400" aria-selected={createDirectSelected}>
|
||||
<NavButton onClick={() => navigate(getDirectCreatePath())}>
|
||||
<NavItemContent>
|
||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||
<Avatar size="200" radii="400">
|
||||
<Icon src={Icons.Plus} size="100" />
|
||||
</Avatar>
|
||||
<Box as="span" grow="Yes">
|
||||
<Text as="span" size="Inherit" truncate>
|
||||
Create Chat
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavButton>
|
||||
</NavItem>
|
||||
</NavCategory>
|
||||
)}
|
||||
<NavCategory>
|
||||
{showCategoryHeader && (
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
|
||||
data-category-id={DEFAULT_CATEGORY_ID}
|
||||
onClick={handleCategoryClick}
|
||||
>
|
||||
Chats
|
||||
</RoomNavCategoryButton>
|
||||
</NavCategoryHeader>
|
||||
)}
|
||||
<div
|
||||
ref={animationRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: virtualizer.getTotalSize(),
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const roomId = sortedDirects[vItem.index];
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return null;
|
||||
const selected = selectedRoomId === roomId;
|
||||
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
key={roomId}
|
||||
ref={virtualizer.measureElement}
|
||||
data-room-id={roomId}
|
||||
>
|
||||
<RoomNavItem
|
||||
room={room}
|
||||
selected={selected}
|
||||
showAvatar
|
||||
direct
|
||||
linkPath={getDirectRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
||||
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
|
||||
/>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</NavCategory>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
2
src/app/components/direct-list/index.ts
Normal file
2
src/app/components/direct-list/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { DirectList, DirectListHeader, DirectListEmpty } from './DirectList';
|
||||
export type { DirectListProps } from './DirectList';
|
||||
@@ -6,9 +6,11 @@ import React, {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { Box, Scroll, Text } from 'folds';
|
||||
import { Descendant, Editor, createEditor } from 'slate';
|
||||
import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate';
|
||||
import {
|
||||
Slate,
|
||||
Editable,
|
||||
@@ -23,6 +25,7 @@ import { RenderElement, RenderLeaf } from './Elements';
|
||||
import { CustomElement } from './slate';
|
||||
import * as css from './Editor.css';
|
||||
import { toggleKeyboardShortcut } from './keyboard';
|
||||
import { createCommandElement } from './utils';
|
||||
|
||||
const initialValue: CustomElement[] = [
|
||||
{
|
||||
@@ -52,8 +55,25 @@ const withVoid = (editor: Editor): Editor => {
|
||||
return editor;
|
||||
};
|
||||
|
||||
type CommandValidatorFn = (commandName: string) => boolean;
|
||||
|
||||
let commandValidator: CommandValidatorFn | null = null;
|
||||
|
||||
export const setCommandValidator = (validator: CommandValidatorFn) => {
|
||||
commandValidator = validator;
|
||||
};
|
||||
|
||||
const withCommandAutoConvert = (editor: Editor): Editor => {
|
||||
// Removed auto-conversion on space to prevent focus issues
|
||||
// Commands are now highlighted visually via decorations
|
||||
// and executed when Enter is pressed
|
||||
return editor;
|
||||
};
|
||||
|
||||
export const useEditor = (): Editor => {
|
||||
const [editor] = useState(() => withInline(withVoid(withReact(withHistory(createEditor())))));
|
||||
const [editor] = useState(() =>
|
||||
withCommandAutoConvert(withInline(withVoid(withReact(withHistory(createEditor())))))
|
||||
);
|
||||
return editor;
|
||||
};
|
||||
|
||||
@@ -97,6 +117,49 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||
|
||||
const renderLeaf = useCallback((props: RenderLeafProps) => <RenderLeaf {...props} />, []);
|
||||
|
||||
const decorate = useCallback(([node, path]: [any, number[]]) => {
|
||||
const ranges: any[] = [];
|
||||
|
||||
// Only decorate text nodes in the first paragraph
|
||||
if (
|
||||
path.length === 2 &&
|
||||
path[0] === 0 &&
|
||||
path[1] === 0 &&
|
||||
SlateText.isText(node)
|
||||
) {
|
||||
const firstChild = editor.children[0];
|
||||
|
||||
if (SlateElement.isElement(firstChild) && firstChild.type === BlockType.Paragraph) {
|
||||
const [firstInline, secondInline] = firstChild.children;
|
||||
|
||||
// Don't decorate if we already have a CommandElement
|
||||
if (SlateElement.isElement(secondInline) && secondInline.type === BlockType.Command) {
|
||||
return ranges;
|
||||
}
|
||||
|
||||
// Check if the text matches /command pattern
|
||||
const text = node.text;
|
||||
const match = text.match(/^(\s*\/\w+)/);
|
||||
|
||||
if (match && commandValidator) {
|
||||
const commandText = match[1].trim();
|
||||
const commandName = commandText.substring(1);
|
||||
|
||||
if (commandValidator(commandName)) {
|
||||
// Create decoration for the command text
|
||||
ranges.push({
|
||||
anchor: { path, offset: 0 },
|
||||
focus: { path, offset: match[1].length },
|
||||
pendingCommand: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}, [editor]);
|
||||
|
||||
const handleKeydown: KeyboardEventHandler = useCallback(
|
||||
(evt) => {
|
||||
onKeyDown?.(evt);
|
||||
@@ -106,6 +169,48 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||
[editor, onKeyDown]
|
||||
);
|
||||
|
||||
const handleBeforeInput = useCallback(
|
||||
(event: Event) => {
|
||||
const inputEvent = event as InputEvent;
|
||||
|
||||
// Handle autocorrect replacement that causes text duplication
|
||||
if (inputEvent.inputType === 'insertReplacementText' ||
|
||||
inputEvent.inputType === 'insertFromComposition') {
|
||||
const { selection } = editor;
|
||||
if (!selection) return;
|
||||
|
||||
// Get the data being inserted
|
||||
const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain');
|
||||
|
||||
if (data) {
|
||||
event.preventDefault();
|
||||
|
||||
// If there's selected text, delete it first
|
||||
if (selection && !Range.isCollapsed(selection)) {
|
||||
Transforms.delete(editor, { at: selection });
|
||||
}
|
||||
|
||||
// Insert the replacement text
|
||||
editor.insertText(data);
|
||||
}
|
||||
}
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
const editableRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const editableElement = editableRef.current?.querySelector('[data-slate-editor="true"]');
|
||||
if (!editableElement) return;
|
||||
|
||||
editableElement.addEventListener('beforeinput', handleBeforeInput, { capture: true });
|
||||
|
||||
return () => {
|
||||
editableElement.removeEventListener('beforeinput', handleBeforeInput, { capture: true });
|
||||
};
|
||||
}, [handleBeforeInput]);
|
||||
|
||||
const renderPlaceholder = useCallback(
|
||||
({ attributes, children }: RenderPlaceholderProps) => (
|
||||
<span {...attributes} className={css.EditorPlaceholderContainer}>
|
||||
@@ -135,6 +240,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||
size="300"
|
||||
visibility="Hover"
|
||||
hideTrack
|
||||
ref={editableRef}
|
||||
>
|
||||
<Editable
|
||||
data-editable-name={editableName}
|
||||
@@ -143,6 +249,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||
renderPlaceholder={renderPlaceholder}
|
||||
renderElement={renderElement}
|
||||
renderLeaf={renderLeaf}
|
||||
decorate={decorate}
|
||||
onKeyDown={handleKeydown}
|
||||
onKeyUp={onKeyUp}
|
||||
onPaste={onPaste}
|
||||
|
||||
@@ -269,6 +269,13 @@ export function RenderLeaf({ attributes, leaf, children }: RenderLeafProps) {
|
||||
{child}
|
||||
</span>
|
||||
);
|
||||
if (leaf.pendingCommand)
|
||||
child = (
|
||||
<span className={css.Command({ active: true })} {...attributes}>
|
||||
<InlineChromiumBugfix />
|
||||
{child}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (child !== children) return child;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { JoinRule, MatrixClient } from 'matrix-js-sdk';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
|
||||
import { getDirectRoomAvatarUrl } from '../../../utils/room';
|
||||
import { getDirectRoomAvatarUrl, guessPerfectParent } from '../../../utils/room';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { AutocompleteQuery } from './autocompleteQuery';
|
||||
import { AutocompleteMenu } from './AutocompleteMenu';
|
||||
@@ -18,6 +18,7 @@ import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||
import { factoryRoomIdByActivity } from '../../../utils/sort';
|
||||
import { RoomAvatar, RoomIcon } from '../../room-avatar';
|
||||
import { getViaServers } from '../../../plugins/via-servers';
|
||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||
|
||||
type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
|
||||
|
||||
@@ -78,6 +79,7 @@ export function RoomMentionAutocomplete({
|
||||
}: RoomMentionAutocompleteProps) {
|
||||
const mx = useMatrixClient();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
|
||||
const allRooms = useAtomValue(allRoomsAtom).sort(factoryRoomIdByActivity(mx));
|
||||
|
||||
@@ -103,6 +105,42 @@ export function RoomMentionAutocomplete({
|
||||
else resetSearch();
|
||||
}, [query.text, search, resetSearch]);
|
||||
|
||||
// Build space hierarchy path for a room
|
||||
const getSpacePath = useCallback((rId: string): string | null => {
|
||||
// Skip DMs
|
||||
if (mDirects.has(rId)) return null;
|
||||
|
||||
const buildParentChain = (currentRoomId: string): string[] => {
|
||||
const parents = roomToParents.get(currentRoomId);
|
||||
if (!parents || parents.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parentsList = Array.from(parents);
|
||||
let chosenParent: string;
|
||||
|
||||
if (parentsList.length === 1) {
|
||||
chosenParent = parentsList[0];
|
||||
} else {
|
||||
const orphanParents = parentsList.filter(p => !roomToParents.has(p));
|
||||
if (orphanParents.length > 0) {
|
||||
chosenParent = guessPerfectParent(mx, currentRoomId, orphanParents) ?? orphanParents[0];
|
||||
} else {
|
||||
chosenParent = parentsList[0];
|
||||
}
|
||||
}
|
||||
|
||||
const parentRoom = mx.getRoom(chosenParent);
|
||||
if (!parentRoom?.name) return [];
|
||||
|
||||
const upperChain = buildParentChain(chosenParent);
|
||||
return [...upperChain, parentRoom.name];
|
||||
};
|
||||
|
||||
const parentChain = buildParentChain(rId);
|
||||
return parentChain.length > 0 ? parentChain.join(' > ') : null;
|
||||
}, [mx, mDirects, roomToParents]);
|
||||
|
||||
const handleAutocomplete: MentionAutoCompleteHandler = (roomAliasOrId, name) => {
|
||||
const mentionRoom = mx.getRoom(roomAliasOrId);
|
||||
const viaServers = mentionRoom ? getViaServers(mentionRoom) : undefined;
|
||||
@@ -144,6 +182,11 @@ export function RoomMentionAutocomplete({
|
||||
|
||||
const handleSelect = () => handleAutocomplete(room.getCanonicalAlias() ?? rId, room.name);
|
||||
|
||||
// Get space hierarchy path or canonical alias or empty string
|
||||
const spacePath = getSpacePath(rId);
|
||||
const alias = room.getCanonicalAlias();
|
||||
const secondaryText = spacePath || alias || '';
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={rId}
|
||||
@@ -154,9 +197,11 @@ export function RoomMentionAutocomplete({
|
||||
}
|
||||
onClick={handleSelect}
|
||||
after={
|
||||
<Text size="T200" priority="300" truncate>
|
||||
{room.getCanonicalAlias() ?? ''}
|
||||
</Text>
|
||||
secondaryText && (
|
||||
<Text size="T200" priority="300" truncate>
|
||||
{secondaryText}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
before={
|
||||
<Avatar size="200">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * from './autocomplete';
|
||||
export * from './utils';
|
||||
export * from './Editor';
|
||||
export { CustomEditor, useEditor, setCommandValidator } from './Editor';
|
||||
export * from './Elements';
|
||||
export * from './keyboard';
|
||||
export * from './output';
|
||||
|
||||
1
src/app/components/editor/slate.d.ts
vendored
1
src/app/components/editor/slate.d.ts
vendored
@@ -18,6 +18,7 @@ export type FormattedText = Text & {
|
||||
strikeThrough?: boolean;
|
||||
code?: boolean;
|
||||
spoiler?: boolean;
|
||||
pendingCommand?: boolean;
|
||||
};
|
||||
|
||||
export type LinkElement = {
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useEmojiGroupIcons } from './useEmojiGroupIcons';
|
||||
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
|
||||
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
|
||||
import { useEmojiUsage } from '../../hooks/useEmojiUsage';
|
||||
import { isUserId, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { editableActiveElement, targetFromEvent } from '../../utils/dom';
|
||||
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
import { EmojiBoardTab, EmojiType } from './types';
|
||||
import { VirtualTile } from '../virtualizer';
|
||||
|
||||
const MOST_USED_GROUP_ID = 'most_used_group';
|
||||
const RECENT_GROUP_ID = 'recent_group';
|
||||
const SEARCH_GROUP_ID = 'search_group';
|
||||
|
||||
@@ -72,19 +73,29 @@ const useGroups = (
|
||||
imagePacks: ImagePack[]
|
||||
): [EmojiGroupItem[], StickerGroupItem[]] => {
|
||||
const mx = useMatrixClient();
|
||||
const { getTopEmojis } = useEmojiUsage();
|
||||
|
||||
const recentEmojis = useRecentEmoji(mx, 21);
|
||||
const labels = useEmojiGroupLabels();
|
||||
|
||||
const emojiGroupItems = useMemo(() => {
|
||||
const g: EmojiGroupItem[] = [];
|
||||
if (tab !== EmojiBoardTab.Emoji) return g;
|
||||
|
||||
g.push({
|
||||
id: RECENT_GROUP_ID,
|
||||
name: 'Recent',
|
||||
items: recentEmojis,
|
||||
});
|
||||
// Add most-used emojis
|
||||
const topShortcodes = getTopEmojis(21);
|
||||
if (topShortcodes.length > 0) {
|
||||
const mostUsedEmojis = topShortcodes
|
||||
.map((shortcode) => emojis.find((e) => e.shortcode === shortcode))
|
||||
.filter((e): e is IEmoji => e !== undefined);
|
||||
|
||||
if (mostUsedEmojis.length > 0) {
|
||||
g.push({
|
||||
id: MOST_USED_GROUP_ID,
|
||||
name: 'Most Used',
|
||||
items: mostUsedEmojis,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
imagePacks.forEach((pack) => {
|
||||
let label = pack.meta.name;
|
||||
@@ -108,7 +119,7 @@ const useGroups = (
|
||||
});
|
||||
|
||||
return g;
|
||||
}, [mx, recentEmojis, labels, imagePacks, tab]);
|
||||
}, [mx, labels, imagePacks, tab, getTopEmojis]);
|
||||
|
||||
const stickerGroupItems = useMemo(() => {
|
||||
const g: StickerGroupItem[] = [];
|
||||
@@ -187,10 +198,10 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
|
||||
<Sidebar>
|
||||
<SidebarStack>
|
||||
<GroupIcon
|
||||
active={activeGroupId === RECENT_GROUP_ID}
|
||||
id={RECENT_GROUP_ID}
|
||||
label="Recent"
|
||||
icon={Icons.RecentClock}
|
||||
active={activeGroupId === MOST_USED_GROUP_ID}
|
||||
id={MOST_USED_GROUP_ID}
|
||||
label="Most Used"
|
||||
icon={Icons.Heart}
|
||||
onClick={handleScrollToGroup}
|
||||
/>
|
||||
</SidebarStack>
|
||||
@@ -378,13 +389,30 @@ export function EmojiBoard({
|
||||
addToRecentEmoji = true,
|
||||
}: EmojiBoardProps) {
|
||||
const mx = useMatrixClient();
|
||||
const { trackEmojiUsage, getMostUsedEmoji } = useEmojiUsage();
|
||||
|
||||
const emojiTab = tab === EmojiBoardTab.Emoji;
|
||||
const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker;
|
||||
|
||||
// Get default emoji - use most used or fallback to slight_smile
|
||||
const defaultPreview = useMemo((): PreviewData => {
|
||||
if (!emojiTab) return undefined;
|
||||
|
||||
const mostUsedShortcode = getMostUsedEmoji();
|
||||
if (mostUsedShortcode) {
|
||||
// Find emoji by shortcode
|
||||
const emoji = emojis.find((e) => e.shortcode === mostUsedShortcode);
|
||||
if (emoji) {
|
||||
return { key: emoji.unicode, shortcode: emoji.shortcode };
|
||||
}
|
||||
}
|
||||
// Fallback to default
|
||||
return DefaultEmojiPreview;
|
||||
}, [emojiTab, getMostUsedEmoji]);
|
||||
|
||||
const previewAtom = useMemo(
|
||||
() => createPreviewDataAtom(emojiTab ? DefaultEmojiPreview : undefined),
|
||||
[emojiTab]
|
||||
() => createPreviewDataAtom(defaultPreview),
|
||||
[defaultPreview]
|
||||
);
|
||||
const activeGroupIdAtom = useMemo(() => atom<string | undefined>(undefined), []);
|
||||
const setActiveGroupId = useSetAtom(activeGroupIdAtom);
|
||||
@@ -439,6 +467,10 @@ export function EmojiBoard({
|
||||
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||
if (!evt.altKey && !evt.shiftKey && addToRecentEmoji) {
|
||||
addRecentEmoji(mx, emojiInfo.data);
|
||||
// Track emoji usage for favorites
|
||||
trackEmojiUsage(emojiInfo.shortcode).catch((err) => {
|
||||
console.error('Failed to track emoji usage:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (emojiInfo.type === EmojiType.CustomEmoji) {
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Box,
|
||||
Text,
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
Button,
|
||||
Icon,
|
||||
@@ -25,6 +24,7 @@ import { createUploadAtom, UploadSuccess } from '../../state/upload';
|
||||
import { CompactUploadCardRenderer } from '../upload-card';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { PackMetaReader } from '../../plugins/custom-emoji';
|
||||
import { AuthenticatedAvatarImage } from '../authenticated-media';
|
||||
|
||||
type ImagePackAvatarProps = {
|
||||
url?: string;
|
||||
@@ -34,7 +34,7 @@ function ImagePackAvatar({ url, name }: ImagePackAvatarProps) {
|
||||
return (
|
||||
<Avatar size="500" className={ContainerColor({ variant: 'Secondary' })}>
|
||||
{url ? (
|
||||
<AvatarImage src={url} alt={name ?? 'Unknown'} />
|
||||
<AuthenticatedAvatarImage src={url} alt={name ?? 'Unknown'} />
|
||||
) : (
|
||||
<AvatarFallback>
|
||||
<Text size="H2">{nameInitials(name ?? 'Unknown')}</Text>
|
||||
|
||||
@@ -7,6 +7,8 @@ import * as css from './ImageViewer.css';
|
||||
import { useZoom } from '../../hooks/useZoom';
|
||||
import { usePan } from '../../hooks/usePan';
|
||||
import { downloadMedia } from '../../utils/matrix';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getCurrentAccessToken } from '../../utils/auth';
|
||||
|
||||
export type ImageViewerProps = {
|
||||
alt: string;
|
||||
@@ -16,12 +18,29 @@ export type ImageViewerProps = {
|
||||
|
||||
export const ImageViewer = as<'div', ImageViewerProps>(
|
||||
({ className, alt, src, requestClose, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
||||
|
||||
const handleDownload = async () => {
|
||||
const fileContent = await downloadMedia(src);
|
||||
FileSaver.saveAs(fileContent, alt);
|
||||
try {
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const fileContent = await downloadMedia(src, getCurrentAccessToken());
|
||||
FileSaver.saveAs(fileContent, alt);
|
||||
} catch (error) {
|
||||
console.warn('[ImageViewer] Failed to download media:', error);
|
||||
// Fallback: try to fetch via standard fetch as blob
|
||||
try {
|
||||
const response = await fetch(src);
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
FileSaver.saveAs(blob, alt);
|
||||
}
|
||||
} catch {
|
||||
// If all else fails, open in new tab to let browser handle it
|
||||
window.open(src, '_blank');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -88,6 +107,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
||||
}}
|
||||
src={src}
|
||||
alt={alt}
|
||||
draggable={false}
|
||||
onMouseDown={onMouseDown}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
557
src/app/components/join-space-prompt/JoinSpacePrompt.tsx
Normal file
557
src/app/components/join-space-prompt/JoinSpacePrompt.tsx
Normal file
@@ -0,0 +1,557 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Dialog,
|
||||
Overlay,
|
||||
OverlayCenter,
|
||||
OverlayBackdrop,
|
||||
Header,
|
||||
config,
|
||||
Box,
|
||||
Text,
|
||||
IconButton,
|
||||
Icon,
|
||||
Icons,
|
||||
color,
|
||||
Button,
|
||||
Spinner,
|
||||
Scroll,
|
||||
Checkbox,
|
||||
Avatar,
|
||||
Badge,
|
||||
} from 'folds';
|
||||
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { RoomType, Membership } from '../../../types/matrix/room';
|
||||
import { RoomAvatar } from '../room-avatar';
|
||||
import { nameInitials } from '../../utils/common';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import {
|
||||
setRoomNotificationPreference,
|
||||
RoomNotificationMode,
|
||||
} from '../../hooks/useRoomsNotificationPreferences';
|
||||
|
||||
const PER_PAGE_COUNT = 100;
|
||||
const MAX_PAGES = 5;
|
||||
const HIERARCHY_TIMEOUT_MS = 10000;
|
||||
const JOIN_DELAY_MS = 500;
|
||||
|
||||
/** Join rules that allow automatic joining */
|
||||
const AUTO_JOINABLE_RULES = ['public', 'restricted', 'knock_restricted'];
|
||||
|
||||
/**
|
||||
* Extracts the server name from a Matrix room ID
|
||||
*/
|
||||
function getServerFromRoomId(roomId: string): string {
|
||||
const parts = roomId.split(':');
|
||||
return parts.length > 1 ? parts[1] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a room/space can be automatically joined based on its join rule
|
||||
*/
|
||||
function canAutoJoin(hierarchyRoom: IHierarchyRoom): boolean {
|
||||
const { join_rule: joinRule } = hierarchyRoom;
|
||||
if (!joinRule) return false;
|
||||
return AUTO_JOINABLE_RULES.includes(joinRule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delays execution for a specified time
|
||||
*/
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
type HierarchyRoomItemProps = {
|
||||
room: IHierarchyRoom;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
useAuthentication: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
function HierarchyRoomItem({
|
||||
room,
|
||||
selected,
|
||||
onToggle,
|
||||
useAuthentication,
|
||||
disabled,
|
||||
}: HierarchyRoomItemProps) {
|
||||
const mx = useMatrixClient();
|
||||
const roomIsSpace = room.room_type === RoomType.Space;
|
||||
const avatarUrl = room.avatar_url
|
||||
? mxcUrlToHttp(mx, room.avatar_url, useAuthentication, 48, 48, 'crop')
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="label"
|
||||
gap="300"
|
||||
alignItems="Center"
|
||||
style={{
|
||||
padding: config.space.S200,
|
||||
borderRadius: config.radii.R300,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
size="300"
|
||||
variant="Primary"
|
||||
checked={selected}
|
||||
onClick={disabled ? undefined : onToggle}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Avatar size="200">
|
||||
<RoomAvatar
|
||||
roomId={room.room_id}
|
||||
src={avatarUrl ?? undefined}
|
||||
alt={room.name || room.room_id}
|
||||
renderFallback={() => (
|
||||
<Text as="span" size="T200">
|
||||
{nameInitials(room.name || room.room_id)}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
<Box direction="Column" grow="Yes" style={{ minWidth: 0 }}>
|
||||
<Text size="T300" truncate>
|
||||
{room.name || room.room_id}
|
||||
</Text>
|
||||
{room.topic && (
|
||||
<Text size="T200" priority="400" truncate>
|
||||
{room.topic}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{roomIsSpace && (
|
||||
<Badge variant="Secondary" fill="Soft" outlined>
|
||||
<Text size="L400">Space</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type JoinSpacePromptProps = {
|
||||
roomIdOrAlias: string;
|
||||
viaServers?: string[];
|
||||
onDone: (roomId: string) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
type JoinPhase = 'initial' | 'joining-space' | 'selecting-rooms' | 'joining-rooms';
|
||||
|
||||
export function JoinSpacePrompt({
|
||||
roomIdOrAlias,
|
||||
viaServers,
|
||||
onDone,
|
||||
onCancel,
|
||||
}: JoinSpacePromptProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const [phase, setPhase] = useState<JoinPhase>('initial');
|
||||
const [spaceId, setSpaceId] = useState<string | null>(null);
|
||||
const [hierarchyRooms, setHierarchyRooms] = useState<IHierarchyRoom[]>([]);
|
||||
const [selectedRoomIds, setSelectedRoomIds] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<{ current: number; total: number } | null>(null);
|
||||
const [canSkipLoading, setCanSkipLoading] = useState(false);
|
||||
|
||||
/**
|
||||
* Skips to the space (used when loading takes too long)
|
||||
*/
|
||||
const handleSkipToSpace = () => {
|
||||
if (spaceId) {
|
||||
onDone(spaceId);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Joins the space first
|
||||
*/
|
||||
const handleJoinSpace = async () => {
|
||||
setPhase('joining-space');
|
||||
setError(null);
|
||||
setCanSkipLoading(false);
|
||||
|
||||
let joinedSpaceId: string;
|
||||
|
||||
try {
|
||||
const spaceRoom = await mx.joinRoom(roomIdOrAlias, { viaServers });
|
||||
joinedSpaceId = spaceRoom.roomId;
|
||||
setSpaceId(joinedSpaceId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to join space');
|
||||
setPhase('initial');
|
||||
return;
|
||||
}
|
||||
|
||||
// Space is joined - allow skipping from here
|
||||
setCanSkipLoading(true);
|
||||
|
||||
// Now fetch the hierarchy with a timeout
|
||||
setLoading(true);
|
||||
|
||||
const fetchWithTimeout = async (): Promise<IHierarchyRoom[]> => {
|
||||
const allRooms: IHierarchyRoom[] = [];
|
||||
let nextBatch: string | undefined;
|
||||
let pageCount = 0;
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Timeout')), HIERARCHY_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
while (pageCount < MAX_PAGES) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const result = await Promise.race([
|
||||
mx.getRoomHierarchy(joinedSpaceId, PER_PAGE_COUNT, undefined, false, nextBatch),
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
allRooms.push(...result.rooms);
|
||||
nextBatch = result.next_batch;
|
||||
pageCount += 1;
|
||||
|
||||
if (!nextBatch) break;
|
||||
}
|
||||
|
||||
return allRooms;
|
||||
};
|
||||
|
||||
try {
|
||||
const allRooms = await fetchWithTimeout();
|
||||
|
||||
// Filter out items we can't join (excluding the space itself)
|
||||
const joinableRooms = allRooms.filter((room) => {
|
||||
// Skip the space itself
|
||||
if (room.room_id === joinedSpaceId) return false;
|
||||
// Check if already joined
|
||||
const existingRoom = mx.getRoom(room.room_id);
|
||||
if (existingRoom?.getMyMembership() === Membership.Join) {
|
||||
return false;
|
||||
}
|
||||
return canAutoJoin(room);
|
||||
});
|
||||
|
||||
setHierarchyRooms(joinableRooms);
|
||||
// Select all by default
|
||||
setSelectedRoomIds(new Set(joinableRooms.map((r) => r.room_id)));
|
||||
} catch {
|
||||
// Hierarchy fetch failed or timed out - show empty selection
|
||||
setHierarchyRooms([]);
|
||||
setSelectedRoomIds(new Set());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setPhase('selecting-rooms');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Joins selected rooms
|
||||
*/
|
||||
const handleJoinSelectedRooms = async () => {
|
||||
if (!spaceId) return;
|
||||
|
||||
setPhase('joining-rooms');
|
||||
setError(null);
|
||||
|
||||
const spaceServer = getServerFromRoomId(spaceId);
|
||||
|
||||
// Get selected rooms
|
||||
const roomsToJoin = hierarchyRooms.filter((room) => selectedRoomIds.has(room.room_id));
|
||||
|
||||
// Separate spaces and rooms - join spaces first
|
||||
const spacesToJoin = roomsToJoin.filter((r) => r.room_type === RoomType.Space);
|
||||
const regularRoomsToJoin = roomsToJoin.filter((r) => r.room_type !== RoomType.Space);
|
||||
|
||||
const total = roomsToJoin.length;
|
||||
let current = 0;
|
||||
|
||||
if (total > 0) {
|
||||
setProgress({ current, total });
|
||||
}
|
||||
|
||||
const joinItem = async (item: IHierarchyRoom, isSpace: boolean): Promise<void> => {
|
||||
try {
|
||||
const itemServer = getServerFromRoomId(item.room_id);
|
||||
const itemViaServers = [itemServer, spaceServer].filter(Boolean);
|
||||
|
||||
await mx.joinRoom(item.room_id, { viaServers: itemViaServers });
|
||||
|
||||
// Set notification preferences for rooms (not spaces)
|
||||
if (!isSpace) {
|
||||
await setRoomNotificationPreference(
|
||||
mx,
|
||||
item.room_id,
|
||||
RoomNotificationMode.SpecialMessages,
|
||||
RoomNotificationMode.Unset
|
||||
);
|
||||
}
|
||||
|
||||
await delay(JOIN_DELAY_MS);
|
||||
} catch {
|
||||
// Continue with other items even if one fails
|
||||
} finally {
|
||||
current += 1;
|
||||
setProgress({ current, total });
|
||||
}
|
||||
};
|
||||
|
||||
// Join spaces first, then rooms
|
||||
await spacesToJoin.reduce(
|
||||
(promise, space) => promise.then(() => joinItem(space, true)),
|
||||
Promise.resolve()
|
||||
);
|
||||
|
||||
await regularRoomsToJoin.reduce(
|
||||
(promise, room) => promise.then(() => joinItem(room, false)),
|
||||
Promise.resolve()
|
||||
);
|
||||
|
||||
setProgress(null);
|
||||
onDone(spaceId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Skip room selection and just finish
|
||||
*/
|
||||
const handleSkip = () => {
|
||||
if (spaceId) {
|
||||
onDone(spaceId);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRoom = (roomId: string) => {
|
||||
setSelectedRoomIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(roomId)) {
|
||||
newSet.delete(roomId);
|
||||
} else {
|
||||
newSet.add(roomId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedRoomIds(new Set(hierarchyRooms.map((r) => r.room_id)));
|
||||
};
|
||||
|
||||
const selectNone = () => {
|
||||
setSelectedRoomIds(new Set());
|
||||
};
|
||||
|
||||
const isJoining = phase === 'joining-space' || phase === 'joining-rooms';
|
||||
const spaces = useMemo(
|
||||
() => hierarchyRooms.filter((r) => r.room_type === RoomType.Space),
|
||||
[hierarchyRooms]
|
||||
);
|
||||
const rooms = useMemo(
|
||||
() => hierarchyRooms.filter((r) => r.room_type !== RoomType.Space),
|
||||
[hierarchyRooms]
|
||||
);
|
||||
|
||||
const getButtonText = () => {
|
||||
if (phase === 'initial') return 'Join Space';
|
||||
if (phase === 'joining-space') return loading ? 'Loading Rooms...' : 'Joining Space...';
|
||||
if (phase === 'selecting-rooms') {
|
||||
const selectedCount = selectedRoomIds.size;
|
||||
return selectedCount > 0 ? `Join ${selectedCount} Rooms` : 'Done';
|
||||
}
|
||||
if (progress) return `Joining: ${progress.current}/${progress.total}`;
|
||||
return 'Joining...';
|
||||
};
|
||||
|
||||
const handlePrimaryAction = () => {
|
||||
if (phase === 'initial') {
|
||||
handleJoinSpace();
|
||||
} else if (phase === 'selecting-rooms') {
|
||||
if (selectedRoomIds.size > 0) {
|
||||
handleJoinSelectedRooms();
|
||||
} else {
|
||||
handleSkip();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const canCancel = phase === 'initial' || phase === 'selecting-rooms' || canSkipLoading;
|
||||
|
||||
const getDeactivateHandler = () => {
|
||||
if (!canCancel) return undefined;
|
||||
if (canSkipLoading) return handleSkipToSpace;
|
||||
return onCancel;
|
||||
};
|
||||
|
||||
const getCloseHandler = () => {
|
||||
if (phase === 'selecting-rooms') return handleSkip;
|
||||
if (canSkipLoading) return handleSkipToSpace;
|
||||
return onCancel;
|
||||
};
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: getDeactivateHandler(),
|
||||
clickOutsideDeactivates: canCancel,
|
||||
escapeDeactivates: canCancel ? stopPropagation : false,
|
||||
}}
|
||||
>
|
||||
<Dialog variant="Surface" style={{ maxWidth: '500px', width: '90vw' }}>
|
||||
<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">
|
||||
{phase === 'selecting-rooms' ? 'Select Rooms to Join' : 'Join Space'}
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="300"
|
||||
onClick={getCloseHandler()}
|
||||
radii="300"
|
||||
disabled={phase === 'joining-space' && !canSkipLoading}
|
||||
>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
{phase === 'initial' && (
|
||||
<Text priority="400" size="T300">
|
||||
Join this space to see and select which rooms to join.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{phase === 'joining-space' && (
|
||||
<Box direction="Column" gap="200" alignItems="Center">
|
||||
<Box justifyContent="Center" alignItems="Center" gap="200">
|
||||
<Spinner size="200" />
|
||||
<Text size="T300">
|
||||
{loading ? 'Loading space contents...' : 'Joining space...'}
|
||||
</Text>
|
||||
</Box>
|
||||
{canSkipLoading && (
|
||||
<Button size="300" variant="Secondary" fill="Soft" onClick={handleSkipToSpace}>
|
||||
<Text size="B300">Skip to Space</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{phase === 'selecting-rooms' && hierarchyRooms.length > 0 && (
|
||||
<>
|
||||
<Box direction="Column" gap="200">
|
||||
<Text priority="400" size="T300">
|
||||
Select which rooms and spaces to join:
|
||||
</Text>
|
||||
<Box gap="200">
|
||||
<Button size="300" variant="Secondary" fill="Soft" onClick={selectAll}>
|
||||
<Text size="B300">Select All</Text>
|
||||
</Button>
|
||||
<Button size="300" variant="Secondary" fill="Soft" onClick={selectNone}>
|
||||
<Text size="B300">Select None</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Scroll
|
||||
hideTrack
|
||||
visibility="Hover"
|
||||
style={{ maxHeight: '300px', flexGrow: 1 }}
|
||||
>
|
||||
<Box direction="Column" gap="200">
|
||||
{spaces.length > 0 && (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400" style={{ color: color.Secondary.Main }}>
|
||||
Spaces ({spaces.length})
|
||||
</Text>
|
||||
{spaces.map((room) => (
|
||||
<HierarchyRoomItem
|
||||
key={room.room_id}
|
||||
room={room}
|
||||
selected={selectedRoomIds.has(room.room_id)}
|
||||
onToggle={() => toggleRoom(room.room_id)}
|
||||
useAuthentication={useAuthentication}
|
||||
disabled={isJoining}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{rooms.length > 0 && (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400" style={{ color: color.Secondary.Main }}>
|
||||
Rooms ({rooms.length})
|
||||
</Text>
|
||||
{rooms.map((room) => (
|
||||
<HierarchyRoomItem
|
||||
key={room.room_id}
|
||||
room={room}
|
||||
selected={selectedRoomIds.has(room.room_id)}
|
||||
onToggle={() => toggleRoom(room.room_id)}
|
||||
useAuthentication={useAuthentication}
|
||||
disabled={isJoining}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Scroll>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'selecting-rooms' && hierarchyRooms.length === 0 && (
|
||||
<Text priority="400" size="T300">
|
||||
This space has no additional rooms to join.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{phase === 'joining-rooms' && (
|
||||
<Box justifyContent="Center" alignItems="Center" gap="200">
|
||||
<Spinner size="200" />
|
||||
<Text size="T300">
|
||||
{progress
|
||||
? `Joining rooms: ${progress.current}/${progress.total}`
|
||||
: 'Joining rooms...'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="Primary"
|
||||
onClick={handlePrimaryAction}
|
||||
disabled={isJoining}
|
||||
before={isJoining ? <Spinner fill="Solid" variant="Primary" size="200" /> : undefined}
|
||||
aria-disabled={isJoining}
|
||||
>
|
||||
<Text size="B400">{getButtonText()}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
1
src/app/components/join-space-prompt/index.ts
Normal file
1
src/app/components/join-space-prompt/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './JoinSpacePrompt';
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -16,10 +16,85 @@ import {
|
||||
Button,
|
||||
Spinner,
|
||||
} from 'folds';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { MatrixError, Room } from 'matrix-js-sdk';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { getSpaceChildren, isSpace } from '../../utils/room';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { Membership } from '../../../types/matrix/room';
|
||||
|
||||
const LEAVE_DELAY_MS = 300;
|
||||
|
||||
/**
|
||||
* Delays execution for a specified time
|
||||
*/
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves multiple rooms sequentially with rate limiting
|
||||
*/
|
||||
async function leaveRoomsSequentially(
|
||||
mx: ReturnType<typeof useMatrixClient>,
|
||||
roomIds: string[],
|
||||
onProgress?: (current: number, total: number) => void
|
||||
): Promise<void> {
|
||||
const total = roomIds.length;
|
||||
|
||||
for (let i = 0; i < roomIds.length; i++) {
|
||||
const roomId = roomIds[i];
|
||||
onProgress?.(i, total);
|
||||
|
||||
try {
|
||||
await mx.leave(roomId);
|
||||
await delay(LEAVE_DELAY_MS);
|
||||
} catch {
|
||||
// Continue leaving other rooms even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.(total, total);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets direct child room IDs from a space that can be left (excluding DMs and subspaces)
|
||||
*/
|
||||
function getDirectChildRoomsToLeave(
|
||||
mx: ReturnType<typeof useMatrixClient>,
|
||||
spaceRoom: Room,
|
||||
mDirects: Set<string>
|
||||
): string[] {
|
||||
const childIds = getSpaceChildren(spaceRoom);
|
||||
|
||||
return childIds.filter((childId) => {
|
||||
// Skip DMs
|
||||
if (mDirects.has(childId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const childRoom = mx.getRoom(childId);
|
||||
if (!childRoom) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip subspaces (only leave rooms, not nested spaces)
|
||||
if (isSpace(childRoom)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only include rooms we're actually joined to
|
||||
if (childRoom.getMyMembership() !== Membership.Join) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
type LeaveSpacePromptProps = {
|
||||
roomId: string;
|
||||
@@ -28,11 +103,29 @@ type LeaveSpacePromptProps = {
|
||||
};
|
||||
export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptProps) {
|
||||
const mx = useMatrixClient();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const [progress, setProgress] = useState<{ current: number; total: number } | null>(null);
|
||||
|
||||
const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>(
|
||||
useCallback(async () => {
|
||||
mx.leave(roomId);
|
||||
}, [mx, roomId])
|
||||
const spaceRoom = mx.getRoom(roomId);
|
||||
|
||||
if (spaceRoom) {
|
||||
// Get direct child rooms to leave (excluding DMs and subspaces)
|
||||
const childRoomsToLeave = getDirectChildRoomsToLeave(mx, spaceRoom, mDirects);
|
||||
|
||||
if (childRoomsToLeave.length > 0) {
|
||||
// Leave child rooms first
|
||||
await leaveRoomsSequentially(mx, childRoomsToLeave, (current, total) => {
|
||||
setProgress({ current, total });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clear progress and leave the space itself
|
||||
setProgress(null);
|
||||
await mx.leave(roomId);
|
||||
}, [mx, roomId, mDirects])
|
||||
);
|
||||
|
||||
const handleLeave = () => {
|
||||
@@ -45,6 +138,13 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
|
||||
}
|
||||
}, [leaveState, onDone]);
|
||||
|
||||
const isLoading = leaveState.status === AsyncStatus.Loading;
|
||||
const getButtonText = () => {
|
||||
if (!isLoading) return 'Leave';
|
||||
if (progress) return `Leaving Rooms: ${progress.current}/${progress.total}`;
|
||||
return 'Leaving Space...';
|
||||
};
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
@@ -74,7 +174,10 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
|
||||
</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>
|
||||
<Text priority="400">
|
||||
Are you sure you want to leave this space? You will also leave all rooms directly
|
||||
in this space (except DMs).
|
||||
</Text>
|
||||
{leaveState.status === AsyncStatus.Error && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||
Failed to leave space! {leaveState.error.message}
|
||||
@@ -86,18 +189,11 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
|
||||
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
|
||||
isLoading ? <Spinner fill="Solid" variant="Critical" size="200" /> : undefined
|
||||
}
|
||||
aria-disabled={isLoading || leaveState.status === AsyncStatus.Success}
|
||||
>
|
||||
<Text size="B400">
|
||||
{leaveState.status === AsyncStatus.Loading ? 'Leaving...' : 'Leave'}
|
||||
</Text>
|
||||
<Text size="B400">{getButtonText()}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Dialog>
|
||||
|
||||
446
src/app/components/manage-rooms-prompt/ManageRoomsPrompt.tsx
Normal file
446
src/app/components/manage-rooms-prompt/ManageRoomsPrompt.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
import React, { useMemo, useState, useCallback, useRef, useEffect, MouseEvent } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Dialog,
|
||||
Overlay,
|
||||
OverlayCenter,
|
||||
OverlayBackdrop,
|
||||
Header,
|
||||
config,
|
||||
Box,
|
||||
Text,
|
||||
IconButton,
|
||||
Icon,
|
||||
Icons,
|
||||
color,
|
||||
Button,
|
||||
Spinner,
|
||||
Scroll,
|
||||
Checkbox,
|
||||
Badge,
|
||||
Line,
|
||||
Avatar,
|
||||
} from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { RoomAvatar } from '../room-avatar';
|
||||
import { nameInitials } from '../../utils/common';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { getRoomAvatarUrl } from '../../utils/room';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
|
||||
const BASE_DELAY_MS = 1000;
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
/**
|
||||
* Delays execution for a specified time
|
||||
*/
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the retry delay from a rate limit error, or returns a default backoff
|
||||
*/
|
||||
function getRetryDelay(error: unknown, attempt: number): number {
|
||||
// Check for Matrix rate limit error with retry_after_ms
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'data' in error &&
|
||||
error.data &&
|
||||
typeof error.data === 'object' &&
|
||||
'retry_after_ms' in error.data &&
|
||||
typeof error.data.retry_after_ms === 'number'
|
||||
) {
|
||||
return error.data.retry_after_ms + 100; // Add small buffer
|
||||
}
|
||||
// Exponential backoff: 2s, 4s, 8s...
|
||||
return Math.min(2000 * 2 ** attempt, 30000);
|
||||
}
|
||||
|
||||
type RoomItemProps = {
|
||||
room: Room;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
onShiftClick: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Individual room item component with checkbox
|
||||
*/
|
||||
function RoomItem({ room, selected, onToggle, onShiftClick, disabled }: RoomItemProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const avatarUrl = getRoomAvatarUrl(mx, room, 32, useAuthentication);
|
||||
const roomName = room.name || getMxIdLocalPart(room.roomId) || room.roomId;
|
||||
const memberCount = room.getJoinedMemberCount();
|
||||
|
||||
const handleClick = (evt: MouseEvent<HTMLLabelElement>) => {
|
||||
if (disabled) return;
|
||||
if (evt.shiftKey) {
|
||||
evt.preventDefault();
|
||||
onShiftClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="label"
|
||||
gap="300"
|
||||
alignItems="Center"
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
padding: config.space.S200,
|
||||
borderRadius: config.radii.R300,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
background: selected ? 'var(--bg-surface-active)' : undefined,
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
size="300"
|
||||
variant="Primary"
|
||||
checked={selected}
|
||||
onClick={disabled ? undefined : onToggle}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Avatar size="200">
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={avatarUrl}
|
||||
alt={roomName}
|
||||
renderFallback={() => (
|
||||
<Text size="H6">{nameInitials(roomName, 2)}</Text>
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
<Box direction="Column" grow="Yes" style={{ minWidth: 0 }}>
|
||||
<Text size="T300" truncate>
|
||||
{roomName}
|
||||
</Text>
|
||||
<Text size="T200" style={{ opacity: 0.7 }} truncate>
|
||||
{memberCount} {memberCount === 1 ? 'member' : 'members'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type LeavePhase = 'selecting' | 'leaving' | 'done';
|
||||
|
||||
type ManageRoomsPromptProps = {
|
||||
rooms: Room[];
|
||||
onDone: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dialog for managing multiple rooms with multi-select and mass leave functionality
|
||||
*/
|
||||
export function ManageRoomsPrompt({ rooms, onDone, onCancel }: ManageRoomsPromptProps) {
|
||||
const mx = useMatrixClient();
|
||||
const [selectedRoomIds, setSelectedRoomIds] = useState<Set<string>>(new Set());
|
||||
const [phase, setPhase] = useState<LeavePhase>('selecting');
|
||||
const [leaveProgress, setLeaveProgress] = useState<{ current: number; total: number } | null>(
|
||||
null
|
||||
);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const lastSelectedIndex = useRef<number | null>(null);
|
||||
|
||||
const sortedRooms = useMemo(
|
||||
() =>
|
||||
[...rooms].sort((a, b) => {
|
||||
const nameA = a.name?.toLowerCase() || '';
|
||||
const nameB = b.name?.toLowerCase() || '';
|
||||
return nameA.localeCompare(nameB);
|
||||
}),
|
||||
[rooms]
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles toggling individual room selection
|
||||
*/
|
||||
const handleToggle = useCallback((roomId: string, index: number) => {
|
||||
setSelectedRoomIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(roomId)) {
|
||||
newSet.delete(roomId);
|
||||
} else {
|
||||
newSet.add(roomId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
lastSelectedIndex.current = index;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handles shift-click for range selection
|
||||
*/
|
||||
const handleShiftClick = useCallback(
|
||||
(roomId: string, index: number) => {
|
||||
if (lastSelectedIndex.current === null) {
|
||||
handleToggle(roomId, index);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = Math.min(lastSelectedIndex.current, index);
|
||||
const end = Math.max(lastSelectedIndex.current, index);
|
||||
|
||||
setSelectedRoomIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
sortedRooms.slice(start, end + 1).forEach((r) => newSet.add(r.roomId));
|
||||
return newSet;
|
||||
});
|
||||
lastSelectedIndex.current = index;
|
||||
},
|
||||
[handleToggle, sortedRooms]
|
||||
);
|
||||
|
||||
/**
|
||||
* Select all rooms
|
||||
*/
|
||||
const handleSelectAll = useCallback(() => {
|
||||
setSelectedRoomIds(new Set(sortedRooms.map((r) => r.roomId)));
|
||||
}, [sortedRooms]);
|
||||
|
||||
/**
|
||||
* Deselect all rooms
|
||||
*/
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
setSelectedRoomIds(new Set());
|
||||
lastSelectedIndex.current = null;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Leaves a single room with retry on rate limit
|
||||
*/
|
||||
const leaveRoom = useCallback(
|
||||
async (roomId: string, attempt = 0): Promise<boolean> => {
|
||||
try {
|
||||
await mx.leave(roomId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Check if it's a rate limit error (429)
|
||||
const isRateLimited =
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'httpStatus' in error &&
|
||||
error.httpStatus === 429;
|
||||
|
||||
if (isRateLimited && attempt < MAX_RETRIES) {
|
||||
const retryDelay = getRetryDelay(error, attempt);
|
||||
await delay(retryDelay);
|
||||
return leaveRoom(roomId, attempt + 1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[mx]
|
||||
);
|
||||
|
||||
/**
|
||||
* Performs mass leave operation
|
||||
*/
|
||||
const handleMassLeave = useCallback(async () => {
|
||||
if (selectedRoomIds.size === 0) return;
|
||||
|
||||
setPhase('leaving');
|
||||
setErrorMessage(null);
|
||||
const roomsToLeave = Array.from(selectedRoomIds);
|
||||
const total = roomsToLeave.length;
|
||||
let failedCount = 0;
|
||||
|
||||
// Process rooms sequentially with rate limiting
|
||||
const processRoom = async (index: number): Promise<void> => {
|
||||
if (index >= roomsToLeave.length) {
|
||||
setPhase('done');
|
||||
if (failedCount > 0) {
|
||||
setErrorMessage(`Failed to leave ${failedCount} room(s).`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const roomId = roomsToLeave[index];
|
||||
setLeaveProgress({ current: index + 1, total });
|
||||
|
||||
const success = await leaveRoom(roomId);
|
||||
if (!success) {
|
||||
failedCount += 1;
|
||||
}
|
||||
|
||||
// Rate limiting delay before next room
|
||||
if (index < roomsToLeave.length - 1) {
|
||||
await delay(BASE_DELAY_MS);
|
||||
}
|
||||
|
||||
await processRoom(index + 1);
|
||||
};
|
||||
|
||||
await processRoom(0);
|
||||
}, [leaveRoom, selectedRoomIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'done' && !errorMessage) {
|
||||
// Auto close on success after short delay
|
||||
const timer = setTimeout(onDone, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [phase, errorMessage, onDone]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (phase === 'leaving') {
|
||||
return (
|
||||
<Box
|
||||
style={{ padding: config.space.S400, minHeight: '200px' }}
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
gap="400"
|
||||
>
|
||||
<Spinner size="600" variant="Secondary" />
|
||||
<Text>
|
||||
Leaving rooms... {leaveProgress?.current} / {leaveProgress?.total}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === 'done') {
|
||||
return (
|
||||
<Box
|
||||
style={{ padding: config.space.S400, minHeight: '200px' }}
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
gap="400"
|
||||
>
|
||||
{errorMessage ? (
|
||||
<>
|
||||
<Icon src={Icons.Warning} size="600" style={{ color: color.Warning.Main }} />
|
||||
<Text style={{ color: color.Warning.Main }}>{errorMessage}</Text>
|
||||
<Button variant="Secondary" onClick={onDone}>
|
||||
<Text size="B300">Close</Text>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon src={Icons.Check} size="600" style={{ color: color.Success.Main }} />
|
||||
<Text style={{ color: color.Success.Main }}>Successfully left all selected rooms!</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Selection phase
|
||||
return (
|
||||
<>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="300">
|
||||
<Box justifyContent="SpaceBetween" alignItems="Center">
|
||||
<Text size="T300" style={{ opacity: 0.8 }}>
|
||||
{selectedRoomIds.size} of {sortedRooms.length} rooms selected
|
||||
</Text>
|
||||
<Box gap="200">
|
||||
<Button variant="Secondary" size="300" onClick={handleSelectAll}>
|
||||
<Text size="B300">Select All</Text>
|
||||
</Button>
|
||||
<Button
|
||||
variant="Secondary"
|
||||
size="300"
|
||||
onClick={handleDeselectAll}
|
||||
disabled={selectedRoomIds.size === 0}
|
||||
>
|
||||
<Text size="B300">Deselect All</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<Text size="T200" style={{ opacity: 0.6 }}>
|
||||
Tip: Hold Shift and click to select a range of rooms
|
||||
</Text>
|
||||
</Box>
|
||||
<Line variant="Surface" size="300" />
|
||||
<Box style={{ flexGrow: 1, minHeight: 0 }}>
|
||||
<Scroll hideTrack visibility="Hover" style={{ maxHeight: '400px' }}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||
{sortedRooms.map((room, index) => (
|
||||
<RoomItem
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
selected={selectedRoomIds.has(room.roomId)}
|
||||
onToggle={() => handleToggle(room.roomId, index)}
|
||||
onShiftClick={() => handleShiftClick(room.roomId, index)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
<Line variant="Surface" size="300" />
|
||||
<Box
|
||||
style={{ padding: config.space.S400 }}
|
||||
justifyContent="SpaceBetween"
|
||||
alignItems="Center"
|
||||
gap="300"
|
||||
>
|
||||
<Button variant="Secondary" fill="Soft" onClick={onCancel}>
|
||||
<Text size="B300">Cancel</Text>
|
||||
</Button>
|
||||
<Button
|
||||
variant="Critical"
|
||||
onClick={handleMassLeave}
|
||||
disabled={selectedRoomIds.size === 0}
|
||||
>
|
||||
<Icon src={Icons.ArrowGoRight} size="200" />
|
||||
<Text size="B300">Leave Selected ({selectedRoomIds.size})</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: phase === 'selecting' ? onCancel : undefined,
|
||||
clickOutsideDeactivates: phase === 'selecting',
|
||||
escapeDeactivates: phase === 'selecting' ? stopPropagation : false,
|
||||
}}
|
||||
>
|
||||
<Dialog variant="Surface" style={{ width: '500px', maxWidth: '90vw' }}>
|
||||
<Header
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
variant="Surface"
|
||||
size="500"
|
||||
>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Text size="H4">Manage Rooms</Text>
|
||||
{selectedRoomIds.size > 0 && (
|
||||
<Badge variant="Secondary" radii="Pill">
|
||||
<Text size="L400">{selectedRoomIds.size}</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
{phase === 'selecting' && (
|
||||
<IconButton size="300" onClick={onCancel} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Header>
|
||||
{renderContent()}
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
1
src/app/components/manage-rooms-prompt/index.ts
Normal file
1
src/app/components/manage-rooms-prompt/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ManageRoomsPrompt';
|
||||
@@ -4,6 +4,12 @@ import * as css from './media.css';
|
||||
|
||||
export const Image = forwardRef<HTMLImageElement, ImgHTMLAttributes<HTMLImageElement>>(
|
||||
({ className, alt, ...props }, ref) => (
|
||||
<img className={classNames(css.Image, className)} alt={alt} {...props} ref={ref} />
|
||||
<img
|
||||
className={classNames(css.Image, className)}
|
||||
alt={alt}
|
||||
draggable={false}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,10 +1,104 @@
|
||||
import React, { VideoHTMLAttributes, forwardRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import * as css from './media.css';
|
||||
import React, { VideoHTMLAttributes, forwardRef, useRef, useState, useEffect } from 'react';
|
||||
|
||||
export const Video = forwardRef<HTMLVideoElement, VideoHTMLAttributes<HTMLVideoElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<video className={classNames(css.Video, className)} {...props} ref={ref} />
|
||||
)
|
||||
import classNames from 'classnames';
|
||||
import { Box, IconButton, Icon, Icons, Tooltip, TooltipProvider, Text } from 'folds';
|
||||
import * as css from './media.css';
|
||||
import * as videoCss from './videoControls.css';
|
||||
|
||||
type VideoProps = VideoHTMLAttributes<HTMLVideoElement> & {
|
||||
disableControls?: boolean;
|
||||
onExpand?: () => void;
|
||||
};
|
||||
|
||||
export const Video = forwardRef<HTMLVideoElement, VideoProps>(
|
||||
({ className, disableControls, onExpand, ...props }, ref) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [showControls, setShowControls] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof ref === 'function') {
|
||||
ref(videoRef.current);
|
||||
} else if (ref) {
|
||||
ref.current = videoRef.current;
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onExpand) return;
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handleFullscreenRequest = (evt: Event) => {
|
||||
evt.preventDefault();
|
||||
evt.stopImmediatePropagation();
|
||||
onExpand();
|
||||
};
|
||||
|
||||
video.addEventListener('webkitbeginfullscreen', handleFullscreenRequest);
|
||||
return () => {
|
||||
video.removeEventListener('webkitbeginfullscreen', handleFullscreenRequest);
|
||||
};
|
||||
}, [onExpand]);
|
||||
|
||||
if (disableControls) {
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<video
|
||||
className={classNames(css.Video, className)}
|
||||
{...props}
|
||||
ref={videoRef}
|
||||
loop={props.autoPlay}
|
||||
muted={props.autoPlay}
|
||||
playsInline
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={videoCss.VideoContainer}
|
||||
onMouseEnter={() => setShowControls(true)}
|
||||
onMouseLeave={() => setShowControls(false)}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<video
|
||||
className={classNames(css.Video, className)}
|
||||
{...props}
|
||||
ref={videoRef}
|
||||
loop={props.autoPlay}
|
||||
muted={props.autoPlay}
|
||||
playsInline
|
||||
controlsList={onExpand ? 'nofullscreen' : undefined}
|
||||
onDoubleClick={onExpand ? (e) => { e.stopPropagation(); onExpand(); } : undefined}
|
||||
/>
|
||||
|
||||
{onExpand && (
|
||||
<Box className={classNames(videoCss.VideoControlsOverlay, !showControls && videoCss.VideoControlsOverlayHidden)} gap="200">
|
||||
<TooltipProvider
|
||||
tooltip={
|
||||
<Tooltip variant="Secondary">
|
||||
<Text size="T200">Expand</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
position="Bottom"
|
||||
align="Center"
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
variant="Secondary"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={onExpand}
|
||||
aria-label="Expand video"
|
||||
>
|
||||
<Icon size="50" src={Icons.Plus} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset } from 'folds';
|
||||
import { DefaultReset, toRem } from 'folds';
|
||||
|
||||
export const Image = style([
|
||||
DefaultReset,
|
||||
{
|
||||
objectFit: 'cover',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
borderRadius: 'inherit',
|
||||
imageRendering: 'auto',
|
||||
backfaceVisibility: 'hidden',
|
||||
transform: 'translateZ(0)',
|
||||
},
|
||||
]);
|
||||
|
||||
export const Video = style([
|
||||
DefaultReset,
|
||||
{
|
||||
objectFit: 'contain',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
},
|
||||
]);
|
||||
|
||||
35
src/app/components/media/videoControls.css.ts
Normal file
35
src/app/components/media/videoControls.css.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, config } from 'folds';
|
||||
|
||||
export const VideoContainer = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
]);
|
||||
|
||||
export const VideoControlsOverlay = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
top: config.space.S200,
|
||||
right: config.space.S200,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: config.space.S200,
|
||||
zIndex: 10,
|
||||
pointerEvents: 'auto',
|
||||
opacity: 1,
|
||||
transition: 'opacity 0.15s ease',
|
||||
},
|
||||
]);
|
||||
|
||||
export const VideoControlsOverlayHidden = style({
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
downloadMedia,
|
||||
mxcUrlToHttp,
|
||||
} from '../../utils/matrix';
|
||||
import { getCurrentAccessToken } from '../../utils/auth';
|
||||
|
||||
const badgeStyles = { maxWidth: toRem(100) };
|
||||
|
||||
@@ -28,9 +29,11 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
|
||||
const [downloadState, download] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const accessToken = getCurrentAccessToken();
|
||||
const fileContent = encInfo
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||
: await downloadMedia(mediaUrl);
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
||||
: await downloadMedia(mediaUrl, accessToken);
|
||||
|
||||
const fileURL = URL.createObjectURL(fileContent);
|
||||
FileSaver.saveAs(fileURL, filename);
|
||||
|
||||
@@ -80,7 +80,10 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
|
||||
const { body, formatted_body: customBody } = content;
|
||||
|
||||
if (typeof body !== 'string') return <BrokenContent />;
|
||||
const trimmedBody = trimReplyFromBody(body);
|
||||
const trimmedBody = trimReplyFromBody(body).trim();
|
||||
const trimmedCustomBody = typeof customBody === 'string'
|
||||
? customBody.trim().replace(/(<br\s*\/?>\s*)+$/gi, '')
|
||||
: undefined;
|
||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
||||
|
||||
@@ -93,7 +96,7 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
|
||||
>
|
||||
{renderBody({
|
||||
body: trimmedBody,
|
||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||
customBody: trimmedCustomBody,
|
||||
})}
|
||||
{edited && <MessageEditedContent />}
|
||||
</MessageTextBody>
|
||||
@@ -119,7 +122,10 @@ export function MEmote({
|
||||
const { body, formatted_body: customBody } = content;
|
||||
|
||||
if (typeof body !== 'string') return <BrokenContent />;
|
||||
const trimmedBody = trimReplyFromBody(body);
|
||||
const trimmedBody = trimReplyFromBody(body).trim();
|
||||
const trimmedCustomBody = typeof customBody === 'string'
|
||||
? customBody.trim().replace(/(<br\s*\/?>\s*)+$/gi, '')
|
||||
: undefined;
|
||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
||||
|
||||
@@ -133,7 +139,7 @@ export function MEmote({
|
||||
<b>{`${displayName} `}</b>
|
||||
{renderBody({
|
||||
body: trimmedBody,
|
||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||
customBody: trimmedCustomBody,
|
||||
})}
|
||||
{edited && <MessageEditedContent />}
|
||||
</MessageTextBody>
|
||||
@@ -152,7 +158,10 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
|
||||
const { body, formatted_body: customBody } = content;
|
||||
|
||||
if (typeof body !== 'string') return <BrokenContent />;
|
||||
const trimmedBody = trimReplyFromBody(body);
|
||||
const trimmedBody = trimReplyFromBody(body).trim();
|
||||
const trimmedCustomBody = typeof customBody === 'string'
|
||||
? customBody.trim().replace(/(<br\s*\/?>\s*)+$/gi, '')
|
||||
: undefined;
|
||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
||||
|
||||
@@ -165,7 +174,7 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
|
||||
>
|
||||
{renderBody({
|
||||
body: trimmedBody,
|
||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||
customBody: trimmedCustomBody,
|
||||
})}
|
||||
{edited && <MessageEditedContent />}
|
||||
</MessageTextBody>
|
||||
@@ -195,15 +204,10 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
||||
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),
|
||||
}}
|
||||
>
|
||||
<Attachment outlined={outlined} transparent>
|
||||
<AttachmentBox>
|
||||
{renderImageContent({
|
||||
body: content.body || 'Image',
|
||||
info: imgInfo,
|
||||
@@ -245,12 +249,10 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
||||
return <BrokenContent />;
|
||||
}
|
||||
|
||||
const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400);
|
||||
|
||||
const filename = content.filename ?? content.body ?? 'Video';
|
||||
|
||||
return (
|
||||
<Attachment outlined={outlined}>
|
||||
<Attachment outlined={outlined} transparent>
|
||||
<AttachmentHeader>
|
||||
<FileHeader
|
||||
body={filename}
|
||||
@@ -265,11 +267,7 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
||||
}
|
||||
/>
|
||||
</AttachmentHeader>
|
||||
<AttachmentBox
|
||||
style={{
|
||||
height: toRem(height < 48 ? 48 : height),
|
||||
}}
|
||||
>
|
||||
<AttachmentBox>
|
||||
{renderVideoContent({
|
||||
body: content.body || 'Video',
|
||||
info: videoInfo,
|
||||
|
||||
@@ -32,7 +32,9 @@ export const Reply = style({
|
||||
|
||||
export const ReplyContent = style({
|
||||
opacity: config.opacity.P300,
|
||||
|
||||
overflow: 'clip',
|
||||
overflowY: 'clip',
|
||||
lineHeight: '1.5',
|
||||
selectors: {
|
||||
[`${Reply}:hover &`]: {
|
||||
opacity: config.opacity.P500,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
|
||||
import { EventTimelineSet, Room } from 'matrix-js-sdk';
|
||||
import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
|
||||
import { getMemberAvatarMxc, getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { LinePlaceholder } from './placeholder';
|
||||
import { randomNumberBetween } from '../../utils/common';
|
||||
@@ -12,6 +12,7 @@ import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
|
||||
import { useRoomEvent } from '../../hooks/useRoomEvent';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||
import { useOtherUserColor } from '../../hooks/useUserColor';
|
||||
|
||||
type ReplyLayoutProps = {
|
||||
userColor?: string;
|
||||
@@ -57,6 +58,8 @@ type ReplyProps = {
|
||||
replyEventId: string;
|
||||
threadRootId?: string | undefined;
|
||||
onClick?: MouseEventHandler | undefined;
|
||||
/** Called when the thread indicator badge is clicked. Receives the thread root event ID. */
|
||||
onOpenThread?: (threadRootId: string) => void;
|
||||
getMemberPowerTag?: GetMemberPowerTag;
|
||||
accessibleTagColors?: Map<string, string>;
|
||||
legacyUsernameColor?: boolean;
|
||||
@@ -70,6 +73,7 @@ export const Reply = as<'div', ReplyProps>(
|
||||
replyEventId,
|
||||
threadRootId,
|
||||
onClick,
|
||||
onOpenThread,
|
||||
getMemberPowerTag,
|
||||
accessibleTagColors,
|
||||
legacyUsernameColor,
|
||||
@@ -86,10 +90,13 @@ export const Reply = as<'div', ReplyProps>(
|
||||
|
||||
const { body } = replyEvent?.getContent() ?? {};
|
||||
const sender = replyEvent?.getSender();
|
||||
const senderAvatarMxc = sender ? getMemberAvatarMxc(room, sender) : undefined;
|
||||
const customUserColor = useOtherUserColor(sender ?? '', senderAvatarMxc);
|
||||
const powerTag = sender ? getMemberPowerTag?.(sender) : undefined;
|
||||
const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined;
|
||||
|
||||
const usernameColor = legacyUsernameColor ? colorMXID(sender ?? replyEventId) : tagColor;
|
||||
// Priority: custom user color > tag color (non-legacy) > colorMXID (legacy)
|
||||
const usernameColor = customUserColor ?? (legacyUsernameColor ? colorMXID(sender ?? replyEventId) : tagColor);
|
||||
|
||||
const fallbackBody = replyEvent?.isRedacted() ? (
|
||||
<MessageDeletedContent />
|
||||
@@ -103,7 +110,11 @@ export const Reply = as<'div', ReplyProps>(
|
||||
return (
|
||||
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
|
||||
{threadRootId && (
|
||||
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
|
||||
<ThreadIndicator
|
||||
as="button"
|
||||
data-event-id={threadRootId}
|
||||
onClick={onOpenThread ? () => onOpenThread(threadRootId) : onClick}
|
||||
/>
|
||||
)}
|
||||
<ReplyLayout
|
||||
as="button"
|
||||
|
||||
@@ -8,8 +8,7 @@ export const Attachment = recipe({
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
borderRadius: config.radii.R400,
|
||||
overflow: 'hidden',
|
||||
maxWidth: '100%',
|
||||
width: toRem(400),
|
||||
maxWidth: toRem(400),
|
||||
},
|
||||
variants: {
|
||||
outlined: {
|
||||
@@ -17,6 +16,11 @@ export const Attachment = recipe({
|
||||
boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
|
||||
},
|
||||
},
|
||||
transparent: {
|
||||
true: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -29,9 +33,8 @@ export const AttachmentHeader = style({
|
||||
export const AttachmentBox = style([
|
||||
DefaultReset,
|
||||
{
|
||||
maxWidth: '100%',
|
||||
maxHeight: toRem(600),
|
||||
width: toRem(400),
|
||||
maxWidth: toRem(400),
|
||||
maxHeight: toRem(400),
|
||||
overflow: 'hidden',
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -4,11 +4,11 @@ import classNames from 'classnames';
|
||||
import * as css from './Attachment.css';
|
||||
|
||||
export const Attachment = as<'div', css.AttachmentVariants>(
|
||||
({ className, outlined, ...props }, ref) => (
|
||||
({ className, outlined, transparent, ...props }, ref) => (
|
||||
<Box
|
||||
display="InlineFlex"
|
||||
direction="Column"
|
||||
className={classNames(css.Attachment({ outlined }), className)}
|
||||
className={classNames(css.Attachment({ outlined, transparent }), className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
downloadMedia,
|
||||
mxcUrlToHttp,
|
||||
} from '../../../utils/matrix';
|
||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
|
||||
const PLAY_TIME_THROTTLE_OPS = {
|
||||
@@ -55,9 +56,11 @@ export function AudioContent({
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const accessToken = getCurrentAccessToken();
|
||||
const fileContent = encInfo
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||
: await downloadMedia(mediaUrl);
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
||||
: await downloadMedia(mediaUrl, accessToken);
|
||||
return URL.createObjectURL(fileContent);
|
||||
}, [mx, url, useAuthentication, mimeType, encInfo])
|
||||
);
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
mxcUrlToHttp,
|
||||
} from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||
import { ModalWide } from '../../../styles/Modal.css';
|
||||
|
||||
const renderErrorButton = (retry: () => void, text: string) => (
|
||||
@@ -87,9 +88,11 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
|
||||
const [textState, loadText] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const accessToken = getCurrentAccessToken();
|
||||
const fileContent = encInfo
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||
: await downloadMedia(mediaUrl);
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
||||
: await downloadMedia(mediaUrl, accessToken);
|
||||
|
||||
const text = fileContent.text();
|
||||
setTextViewer(true);
|
||||
@@ -177,9 +180,11 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
|
||||
const [pdfState, loadPdf] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const accessToken = getCurrentAccessToken();
|
||||
const fileContent = encInfo
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||
: await downloadMedia(mediaUrl);
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
||||
: await downloadMedia(mediaUrl, accessToken);
|
||||
setPdfViewer(true);
|
||||
return URL.createObjectURL(fileContent);
|
||||
}, [mx, url, useAuthentication, mimeType, encInfo])
|
||||
@@ -254,9 +259,11 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
|
||||
const [downloadState, download] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const accessToken = getCurrentAccessToken();
|
||||
const fileContent = encInfo
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||
: await downloadMedia(mediaUrl);
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
||||
: await downloadMedia(mediaUrl, accessToken);
|
||||
|
||||
const fileURL = URL.createObjectURL(fileContent);
|
||||
FileSaver.saveAs(fileURL, body);
|
||||
|
||||
@@ -31,6 +31,48 @@ import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../util
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { ModalWide } from '../../../styles/Modal.css';
|
||||
import { validBlurHash } from '../../../utils/blurHash';
|
||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||
|
||||
/**
|
||||
* Fetches media with authentication headers and returns a blob URL.
|
||||
* This is needed because service workers don't work reliably in Tauri/WebKit.
|
||||
* Falls back to unauthenticated request if authenticated request fails with 401.
|
||||
* If all attempts fail, returns the original URL to let the browser try directly.
|
||||
*/
|
||||
const fetchAuthenticatedMedia = async (
|
||||
url: string,
|
||||
accessToken: string | null
|
||||
): Promise<string> => {
|
||||
try {
|
||||
let response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||
});
|
||||
|
||||
// If we got a 401 and we tried with auth, fallback to unauthenticated request
|
||||
if (!response.ok && response.status === 401 && accessToken) {
|
||||
console.warn('[ImageContent] Auth failed (401), attempting unauthenticated fallback for:', url);
|
||||
response = await fetch(url, { method: 'GET' });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`[ImageContent] Failed to fetch authenticated media (${response.status}), falling back to original URL`);
|
||||
return url;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
} catch (error) {
|
||||
console.warn('[ImageContent] Error fetching authenticated media, falling back to original URL:', error);
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a URL is an authenticated media endpoint.
|
||||
*/
|
||||
const isAuthenticatedMediaUrl = (url: string): boolean =>
|
||||
url.includes('/_matrix/client/v1/media/download') ||
|
||||
url.includes('/_matrix/client/v1/media/thumbnail');
|
||||
|
||||
type RenderViewerProps = {
|
||||
src: string;
|
||||
@@ -88,12 +130,20 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const accessToken = getCurrentAccessToken();
|
||||
if (encInfo) {
|
||||
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||
decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo)
|
||||
const fileContent = await downloadEncryptedMedia(
|
||||
mediaUrl,
|
||||
(encBuf) => decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo),
|
||||
accessToken
|
||||
);
|
||||
return URL.createObjectURL(fileContent);
|
||||
}
|
||||
// For authenticated media, fetch with auth header and return blob URL
|
||||
if (useAuthentication && isAuthenticatedMediaUrl(mediaUrl)) {
|
||||
return fetchAuthenticatedMedia(mediaUrl, accessToken);
|
||||
}
|
||||
return mediaUrl;
|
||||
}, [mx, url, useAuthentication, mimeType, encInfo])
|
||||
);
|
||||
@@ -131,7 +181,6 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
<Modal
|
||||
className={ModalWide}
|
||||
size="500"
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
>
|
||||
{renderViewer({
|
||||
src: srcState.data,
|
||||
|
||||
@@ -2,9 +2,43 @@ 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 { decryptFile, downloadEncryptedMedia, isAuthenticatedMediaUrl, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
|
||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||
|
||||
/**
|
||||
* Fetches media with authentication headers and returns a blob URL.
|
||||
* Falls back to unauthenticated request if authenticated request fails with 401.
|
||||
* If all attempts fail, returns the original URL to let the browser try directly.
|
||||
*/
|
||||
const fetchAuthenticatedMedia = async (
|
||||
url: string,
|
||||
accessToken: string | null
|
||||
): Promise<string> => {
|
||||
try {
|
||||
let response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||
});
|
||||
|
||||
// If we got a 401 and we tried with auth, fallback to unauthenticated request
|
||||
if (!response.ok && response.status === 401 && accessToken) {
|
||||
console.warn('[ThumbnailContent] Auth failed (401), attempting unauthenticated fallback for:', url);
|
||||
response = await fetch(url, { method: 'GET' });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`[ThumbnailContent] Failed to fetch authenticated media (${response.status}), falling back to original URL`);
|
||||
return url;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
} catch (error) {
|
||||
console.warn('[ThumbnailContent] Error fetching authenticated media, falling back to original URL:', error);
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
export type ThumbnailContentProps = {
|
||||
info: IThumbnailContent;
|
||||
@@ -14,30 +48,42 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
// Check if we have valid thumbnail data
|
||||
const thumbInfo = info.thumbnail_info;
|
||||
const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url;
|
||||
const hasValidThumbnail = typeof thumbMxcUrl === 'string' && typeof thumbInfo?.mimetype === 'string';
|
||||
|
||||
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;
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const accessToken = getCurrentAccessToken();
|
||||
if (encInfo) {
|
||||
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||
decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo)
|
||||
const fileContent = await downloadEncryptedMedia(
|
||||
mediaUrl,
|
||||
(encBuf) => decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo),
|
||||
accessToken
|
||||
);
|
||||
return URL.createObjectURL(fileContent);
|
||||
}
|
||||
|
||||
// For authenticated media, fetch with auth header and return blob URL
|
||||
if (useAuthentication && isAuthenticatedMediaUrl(mediaUrl)) {
|
||||
return fetchAuthenticatedMedia(mediaUrl, accessToken);
|
||||
}
|
||||
|
||||
return mediaUrl;
|
||||
}, [mx, info, useAuthentication])
|
||||
}, [mx, thumbMxcUrl, thumbInfo, info.thumbnail_file, useAuthentication])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadThumbSrc();
|
||||
}, [loadThumbSrc]);
|
||||
// Only attempt to load thumbnail if we have valid data
|
||||
if (hasValidThumbnail) {
|
||||
loadThumbSrc();
|
||||
}
|
||||
}, [hasValidThumbnail, loadThumbSrc]);
|
||||
|
||||
return thumbSrcState.status === AsyncStatus.Success ? renderImage(thumbSrcState.data) : null;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
Chip,
|
||||
Icon,
|
||||
Icons,
|
||||
Modal,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
@@ -14,6 +18,7 @@ import {
|
||||
} from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import { BlurhashCanvas } from 'react-blurhash';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import {
|
||||
IThumbnailContent,
|
||||
@@ -30,9 +35,17 @@ import {
|
||||
downloadMedia,
|
||||
mxcUrlToHttp,
|
||||
} from '../../../utils/matrix';
|
||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { validBlurHash } from '../../../utils/blurHash';
|
||||
import { ModalWide } from '../../../styles/Modal.css';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
|
||||
type RenderViewerProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
requestClose: () => void;
|
||||
};
|
||||
type RenderVideoProps = {
|
||||
title: string;
|
||||
src: string;
|
||||
@@ -40,6 +53,7 @@ type RenderVideoProps = {
|
||||
onError: () => void;
|
||||
autoPlay: boolean;
|
||||
controls: boolean;
|
||||
onExpand?: () => void;
|
||||
};
|
||||
type VideoContentProps = {
|
||||
body: string;
|
||||
@@ -51,6 +65,7 @@ type VideoContentProps = {
|
||||
markedAsSpoiler?: boolean;
|
||||
spoilerReason?: string;
|
||||
renderThumbnail?: () => ReactNode;
|
||||
renderViewer?: (props: RenderViewerProps) => ReactNode;
|
||||
renderVideo: (props: RenderVideoProps) => ReactNode;
|
||||
};
|
||||
export const VideoContent = as<'div', VideoContentProps>(
|
||||
@@ -66,6 +81,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||
markedAsSpoiler,
|
||||
spoilerReason,
|
||||
renderThumbnail,
|
||||
renderViewer,
|
||||
renderVideo,
|
||||
...props
|
||||
},
|
||||
@@ -77,16 +93,19 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||
|
||||
const [load, setLoad] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [viewer, setViewer] = useState(false);
|
||||
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
||||
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||
// Always use current session's token to avoid stale tokens during account switches
|
||||
const accessToken = getCurrentAccessToken();
|
||||
const fileContent = encInfo
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||
decryptFile(encBuf, mimeType, encInfo)
|
||||
decryptFile(encBuf, mimeType, encInfo), accessToken
|
||||
)
|
||||
: await downloadMedia(mediaUrl);
|
||||
: await downloadMedia(mediaUrl, accessToken);
|
||||
return URL.createObjectURL(fileContent);
|
||||
}, [mx, url, useAuthentication, mimeType, encInfo])
|
||||
);
|
||||
@@ -110,6 +129,28 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||
|
||||
return (
|
||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||
{renderViewer && srcState.status === AsyncStatus.Success && (
|
||||
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setViewer(false),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal className={ModalWide} size="500">
|
||||
{renderViewer({
|
||||
src: srcState.data,
|
||||
alt: body,
|
||||
requestClose: () => setViewer(false),
|
||||
})}
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
{typeof blurHash === 'string' && !load && (
|
||||
<BlurhashCanvas
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
@@ -151,6 +192,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||
onError: handleError,
|
||||
autoPlay: true,
|
||||
controls: true,
|
||||
onExpand: renderViewer ? () => setViewer(true) : undefined,
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
132
src/app/components/message/content/VideoFrameThumbnail.tsx
Normal file
132
src/app/components/message/content/VideoFrameThumbnail.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { decryptFile, downloadEncryptedMedia, downloadMedia, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||
|
||||
/**
|
||||
* Extracts the first frame from a video file and renders it as a thumbnail.
|
||||
* This is used as a fallback when no thumbnail metadata is provided.
|
||||
*/
|
||||
export type VideoFrameThumbnailProps = {
|
||||
mimeType: string;
|
||||
url: string;
|
||||
encInfo?: EncryptedAttachmentInfo;
|
||||
renderImage: (src: string) => ReactNode;
|
||||
};
|
||||
|
||||
export function VideoFrameThumbnail({
|
||||
mimeType,
|
||||
url,
|
||||
encInfo,
|
||||
renderImage,
|
||||
}: VideoFrameThumbnailProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [thumbnailSrc, setThumbnailSrc] = useState<string | null>(null);
|
||||
|
||||
const [videoSrcState, loadVideoSrc] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||
const accessToken = getCurrentAccessToken();
|
||||
|
||||
const fileContent = encInfo
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||
decryptFile(encBuf, mimeType, encInfo), accessToken
|
||||
)
|
||||
: await downloadMedia(mediaUrl, accessToken);
|
||||
|
||||
return URL.createObjectURL(fileContent);
|
||||
}, [mx, url, useAuthentication, mimeType, encInfo])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadVideoSrc();
|
||||
}, [loadVideoSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoSrcState.status !== AsyncStatus.Success) return undefined;
|
||||
|
||||
const videoUrl = videoSrcState.status === AsyncStatus.Success ? videoSrcState.data : null;
|
||||
if (!videoUrl) return undefined;
|
||||
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.crossOrigin = 'anonymous';
|
||||
video.src = videoUrl;
|
||||
|
||||
let isCleanedUp = false;
|
||||
const handlers: {
|
||||
loadedData?: () => void;
|
||||
seeked?: () => void;
|
||||
error?: () => void;
|
||||
} = {};
|
||||
|
||||
const cleanup = () => {
|
||||
if (isCleanedUp) return;
|
||||
isCleanedUp = true;
|
||||
|
||||
if (handlers.loadedData) video.removeEventListener('loadeddata', handlers.loadedData);
|
||||
if (handlers.seeked) video.removeEventListener('seeked', handlers.seeked);
|
||||
if (handlers.error) video.removeEventListener('error', handlers.error);
|
||||
video.src = '';
|
||||
video.load();
|
||||
};
|
||||
|
||||
handlers.loadedData = () => {
|
||||
try {
|
||||
video.currentTime = Math.min(0.1, video.duration / 2);
|
||||
} catch {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
handlers.seeked = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const thumbnailUrl = URL.createObjectURL(blob);
|
||||
setThumbnailSrc(thumbnailUrl);
|
||||
}
|
||||
cleanup();
|
||||
}, 'image/jpeg', 0.85);
|
||||
} catch {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
handlers.error = () => {
|
||||
cleanup();
|
||||
};
|
||||
|
||||
video.addEventListener('loadeddata', handlers.loadedData);
|
||||
video.addEventListener('seeked', handlers.seeked);
|
||||
video.addEventListener('error', handlers.error);
|
||||
|
||||
return () => {
|
||||
cleanup();
|
||||
if (thumbnailSrc) {
|
||||
URL.revokeObjectURL(thumbnailSrc);
|
||||
}
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
};
|
||||
}, [videoSrcState, thumbnailSrc]);
|
||||
|
||||
return thumbnailSrc ? renderImage(thumbnailSrc) : null;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './ThumbnailContent';
|
||||
export * from './ImageContent';
|
||||
export * from './VideoContent';
|
||||
export * from './VideoFrameThumbnail';
|
||||
export * from './AudioContent';
|
||||
export * from './FileContent';
|
||||
export * from './FallbackContent';
|
||||
|
||||
@@ -5,19 +5,24 @@ export const RelativeBase = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
borderRadius: '7px',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
]);
|
||||
|
||||
export const AbsoluteContainer = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
borderRadius: 'inherit',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ import classNames from 'classnames';
|
||||
import * as css from './layout.css';
|
||||
|
||||
export const MessageBase = as<'div', css.MessageBaseVariants>(
|
||||
({ className, highlight, selected, collapse, autoCollapse, space, ...props }, ref) => (
|
||||
({ className, highlight, selected, collapse, autoCollapse, space, newMessage, ...props }, ref) => (
|
||||
<div
|
||||
className={classNames(
|
||||
css.MessageBase({ highlight, selected, collapse, autoCollapse, space }),
|
||||
css.MessageBase({ highlight, selected, collapse, autoCollapse, space, newMessage }),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -58,6 +58,17 @@ const highlightAnime = keyframes({
|
||||
backgroundColor: color.Primary.Container,
|
||||
},
|
||||
});
|
||||
|
||||
export const newMessageAnime = keyframes({
|
||||
'0%': {
|
||||
opacity: 0,
|
||||
transform: 'translateY(8px)',
|
||||
},
|
||||
'100%': {
|
||||
opacity: 1,
|
||||
transform: 'translateY(0)',
|
||||
},
|
||||
});
|
||||
const HighlightVariant = styleVariants({
|
||||
true: {
|
||||
animation: `${highlightAnime} 2000ms ease-in-out`,
|
||||
@@ -71,6 +82,13 @@ const SelectedVariant = styleVariants({
|
||||
},
|
||||
});
|
||||
|
||||
const NewMessageVariant = styleVariants({
|
||||
true: {
|
||||
animation: `${newMessageAnime} 300ms ease-out`,
|
||||
animationFillMode: 'backwards',
|
||||
},
|
||||
});
|
||||
|
||||
const AutoCollapse = style({
|
||||
selectors: {
|
||||
[`&+&`]: {
|
||||
@@ -86,6 +104,12 @@ export const MessageBase = recipe({
|
||||
marginTop: SpacingVar,
|
||||
padding: `${config.space.S100} ${config.space.S200} ${config.space.S100} ${config.space.S400}`,
|
||||
borderRadius: `0 ${config.radii.R400} ${config.radii.R400} 0`,
|
||||
transition: 'background-color 150ms ease-out',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
backgroundColor: color.Surface.ContainerHover,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
variants: {
|
||||
@@ -100,6 +124,7 @@ export const MessageBase = recipe({
|
||||
},
|
||||
highlight: HighlightVariant,
|
||||
selected: SelectedVariant,
|
||||
newMessage: NewMessageVariant,
|
||||
},
|
||||
defaultVariants: {
|
||||
space: '400',
|
||||
@@ -163,8 +188,13 @@ export const BubbleLeftArrow = style({
|
||||
|
||||
export const Username = style({
|
||||
overflow: 'hidden',
|
||||
overflowY: 'clip',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
lineHeight: '1.5',
|
||||
// Allow text selection while keeping button clickable
|
||||
userSelect: 'text',
|
||||
WebkitUserSelect: 'text',
|
||||
selectors: {
|
||||
'button&': {
|
||||
cursor: 'pointer',
|
||||
@@ -182,6 +212,9 @@ export const UsernameBold = style({
|
||||
export const MessageTextBody = recipe({
|
||||
base: {
|
||||
wordBreak: 'break-word',
|
||||
overflow: 'clip',
|
||||
overflowY: 'clip',
|
||||
lineHeight: '1.5',
|
||||
},
|
||||
variants: {
|
||||
preWrap: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Text, as } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import * as css from './styles.css';
|
||||
|
||||
export const NavItemContent = as<'p', ComponentProps<typeof Text>>(
|
||||
export const NavItemContent = as<'span', ComponentProps<typeof Text>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<Text className={classNames(css.NavItemContent, className)} size="T300" {...props} ref={ref} />
|
||||
)
|
||||
|
||||
@@ -126,3 +126,7 @@ export const NavItemContent = style({
|
||||
export const NavItemOptions = style({
|
||||
paddingRight: config.space.S200,
|
||||
});
|
||||
|
||||
export const ThreadItem = style({
|
||||
minHeight: toRem(24),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import classNames from 'classnames';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import * as css from './style.css';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
|
||||
|
||||
type PageRootProps = {
|
||||
nav: ReactNode;
|
||||
@@ -39,6 +40,7 @@ export function PageNav({ size, children }: ClientDrawerLayoutProps & css.PageNa
|
||||
>
|
||||
<Box grow="Yes" direction="Column">
|
||||
{children}
|
||||
<SidebarDockedCallPanel />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -62,14 +62,15 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
||||
type AvatarPresenceProps = {
|
||||
badge: ReactNode;
|
||||
variant?: ContainerColor;
|
||||
badgeBackgroundColor?: string;
|
||||
};
|
||||
export const AvatarPresence = as<'div', AvatarPresenceProps>(
|
||||
({ as: AsAvatarPresence, badge, variant = 'Surface', children, ...props }, ref) => (
|
||||
({ as: AsAvatarPresence, badge, variant = 'Surface', badgeBackgroundColor, children, ...props }, ref) => (
|
||||
<Box as={AsAvatarPresence} className={css.AvatarPresence} {...props} ref={ref}>
|
||||
{badge && (
|
||||
<div
|
||||
className={css.AvatarPresenceBadge}
|
||||
style={{ backgroundColor: color[variant].Container }}
|
||||
style={{ backgroundColor: badgeBackgroundColor || color[variant].Container }}
|
||||
>
|
||||
{badge}
|
||||
</div>
|
||||
|
||||
35
src/app/components/presence/PresenceAvatar.tsx
Normal file
35
src/app/components/presence/PresenceAvatar.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import * as css from './styles.css';
|
||||
import { Presence } from '../../hooks/useUserPresence';
|
||||
|
||||
type PresenceAvatarProps = {
|
||||
/** The presence state to display */
|
||||
presence?: Presence;
|
||||
/** The avatar element to wrap */
|
||||
children: ReactNode;
|
||||
/** Additional className */
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps an avatar with a left-side presence indicator border.
|
||||
* Shows green for online, yellow/orange for unavailable/away, grey for offline.
|
||||
*/
|
||||
export function PresenceAvatar({ presence, children, className }: PresenceAvatarProps) {
|
||||
if (!presence) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(css.PresenceAvatarContainer, css.PresenceIndicator, className, {
|
||||
[css.PresenceOnline]: presence === Presence.Online,
|
||||
[css.PresenceUnavailable]: presence === Presence.Unavailable,
|
||||
[css.PresenceOffline]: presence === Presence.Offline,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from './Presence';
|
||||
export * from './PresenceAvatar';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { config } from 'folds';
|
||||
import { config, color } from 'folds';
|
||||
|
||||
export const AvatarPresence = style({
|
||||
display: 'flex',
|
||||
@@ -20,3 +20,41 @@ export const AvatarPresenceBadge = style({
|
||||
borderRadius: config.radii.Pill,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const PresenceAvatarContainer = style({
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
alignSelf: 'flex-start',
|
||||
'::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: '10%',
|
||||
height: '80%',
|
||||
width: '3px',
|
||||
borderTopRightRadius: '2px',
|
||||
borderBottomRightRadius: '2px',
|
||||
zIndex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export const PresenceIndicator = style({});
|
||||
|
||||
export const PresenceOnline = style({
|
||||
'::before': {
|
||||
backgroundColor: color.Success.Main,
|
||||
},
|
||||
});
|
||||
|
||||
export const PresenceUnavailable = style({
|
||||
'::before': {
|
||||
backgroundColor: color.Warning.Main,
|
||||
},
|
||||
});
|
||||
|
||||
export const PresenceOffline = style({
|
||||
'::before': {
|
||||
backgroundColor: color.Secondary.Main,
|
||||
},
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user