45 Commits

Author SHA1 Message Date
3488606b68 feat: Add dynamic width calculation for carousel items based on image aspect ratio 2026-05-17 13:46:12 +10:00
a3caafaf20 feat: Implement image carousel functionality with metadata handling and UI enhancements 2026-05-17 13:24:44 +10:00
b9b0dce40b fix: Remove unnecessary size prop from Modal in ShareRoomPicker component 2026-05-13 11:12:14 +10:00
b2fd65c8cb feat: Enhance editor functionality with autocorrect handling and text replacement
feat: Implement confirmation dialog for room sharing in ShareRoomPicker

fix: Improve server input synchronization and blur handling in ServerPicker

feat: Integrate UnifiedPush for background sync in Android, including pusher management

refactor: Streamline Android share handling and file upload processes

fix: Update notification handling to use Capacitor's local notifications API
2026-05-13 10:40:30 +10:00
7ef9939d8a fix: Change Code component display style from inline-block to inline 2026-05-13 08:00:22 +10:00
43bd6320b8 Refactor code structure for improved readability and maintainability 2026-05-13 05:47:11 +10:00
aaf8089ba6 feat: Trim whitespace from message bodies and enhance emoji usage integration 2026-05-13 05:17:23 +10:00
38a43a3a99 feat: Add emoji usage tracking hook and integrate with EmojiBoard for usage statistics 2026-05-13 04:59:13 +10:00
4fdedb87c8 feat: Implement microphone monitoring feature; enhance text overflow handling and line height across components 2026-05-13 03:37:32 +10:00
5d4d518af8 feat: Add detailed logging for user profile components and image metadata extraction; enhance color handling utilities 2026-04-30 20:23:44 +10:00
cad4852878 feat: Enhance scrolling behavior and styles across components; add centralized scroll utilities
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 22:51:50 +10:00
e6af56a417 Merge branch 'main' of http://synbox.ruv.wtf:8418/litruv/cinny 2026-04-28 15:47:31 +10:00
ffec244113 feat: Refactor Android share and background sync utilities; streamline notification handling with dynamic imports 2026-04-28 15:46:19 +10:00
52abffb4c9 feat: integrate scroll to latest behavior setting into unread info calculation 2026-04-26 19:10:06 +10:00
ef0aa2a847 feat: Implement Android share functionality and background sync
- Add ShareRoomPicker component for selecting rooms to share Android intents.
- Introduce AndroidShare utility for handling shared files and payloads from Android.
- Implement background synchronization with UnifiedPush for Android notifications.
- Enhance notification handling in Tauri and Capacitor environments.
- Update TypeScript configuration for better module resolution.
- Create Capacitor configuration file for Android integration.
- Add SVG icon for notifications.
- Refactor media URL authentication checks in matrix utility.
2026-04-26 14:32:16 +10:00
7b69ed6df8 feat: add VideoViewer component for enhanced video playback experience; implement download functionality and viewer modal 2026-04-24 08:38:22 +10:00
35ccdc0a22 feat: implement room list reordering and animation hooks; add scroll to latest behavior settings 2026-04-23 14:53:00 +10:00
643608f25d feat: add notifications and invites routes to the router and update path utilities 2026-04-23 06:33:54 +10:00
17f7a7eaf2 feat: update Direct component to include invites and notifications navigation items 2026-04-23 06:13:40 +10:00
854f365715 feat: update plugin index and base URLs to support environment variables 2026-04-23 05:11:11 +10:00
114435a56d feat: add fullscreen and exit fullscreen icons for video component 2026-04-23 04:41:30 +10:00
e5e296ca98 feat: update links to point to the new GitHub repository for Paarrot-Desktop 2026-04-22 03:28:41 +10:00
ec6fe77567 feat: implement LRU caching for user colors and avatar metadata to optimize performance 2026-04-22 02:47:21 +10:00
d4cb0b7435 moved docs 2026-04-22 00:39:42 +10:00
64e252da94 feat: add plugin button and navigation slots for enhanced plugin integration across various components 2026-04-22 00:18:55 +10:00
9a463facce feat: add Giphy support with embed component and power levels state management 2026-04-21 21:35:47 +10:00
7c824392b7 feat: enhance incoming call notification logic to track seen calls and filter fresh calls 2026-04-21 07:57:28 +10:00
e5f74ec13e feat: implement command handling and custom rendering in editor and message components 2026-04-21 07:25:11 +10:00
8060d50a6f feat: implement CORS proxy for Matrix homeservers during development and refactor call handling logic 2026-04-20 22:23:42 +10:00
13a0c98d1d feat: enhance call handling by prioritizing homeservers based on active call members 2026-04-20 21:15:46 +10:00
9161c4bde2 feat: implement remote homeserver handling for LiveKit calls 2026-04-20 21:09:22 +10:00
a3dac17b7c Merge branch 'main' of http://synbox.ruv.wtf:8418/litruv/cinny 2026-04-19 07:57:01 +10:00
5bc3a1a456 fix: update default homeserver and add missing entries to homeserver list 2026-04-19 07:56:24 +10:00
878e1561ea fix: update default homeserver to the correct index 2026-04-19 07:55:15 +10:00
1ecb70135a Merge branch 'main' of http://synbox.ruv.wtf:8418/litruv/cinny 2026-04-19 07:37:11 +10:00
9930a4bb99 fix: remove unnecessary dependency from useCallService effect 2026-04-19 07:36:57 +10:00
572743f16d fix: remove unnecessary dependency from useCallService effect 2026-04-19 07:36:31 +10:00
6853e04db0 fix: update plugin-manager dependency URL to use git protocol 2026-04-19 07:00:22 +10:00
f406f684dc fix: update plugin-manager dependency URL to point to the correct location 2026-04-19 06:58:18 +10:00
52e9cc360f Refactor plugin management system
- Updated PluginLoader to utilize the new plugin marketplace manager for loading and managing plugins.
- Simplified the plugin loading process by removing unnecessary state management and directly integrating with the plugin marketplace.
- Enhanced error handling during plugin installation and uninstallation processes.
- Removed legacy code related to Electron-specific plugin handling, streamlining the codebase for web compatibility.
- Updated Plugins component to fetch marketplace plugins and installed plugins using the new plugin marketplace manager.
- Refactored types related to plugins to import from the new plugin manager module, ensuring consistency and reducing redundancy.
- Removed unused calling configuration from client settings and adjusted related types accordingly.
- Cleaned up room state events by removing references to LiveKit service URLs, aligning with the updated architecture.
2026-04-19 06:52:42 +10:00
d1d3033c15 feat(plugins): implement plugin loading and management system
- Added PluginLoader component to dynamically load and initialize plugins.
- Created Plugins component for managing installed and marketplace plugins.
- Introduced PluginAPI for plugin interaction and settings management.
- Defined types for plugin metadata, installed plugins, and plugin index.
- Implemented settings rendering for plugins based on their schema.
- Integrated marketplace plugin fetching and installation logic.
- Added support for enabling/disabling and uninstalling plugins.
2026-04-18 02:55:44 +10:00
9eb5e4fa32 chore: remove outdated GitHub configuration files and workflows 2026-04-17 18:55:32 +10:00
b3deb4d518 feat: enhance audio testing functionality with microphone and speaker state management 2026-04-12 16:26:12 +10:00
99366b5fc4 feat: add Catppuccin Mocha theme and update theme handling in useTheme hook 2026-04-10 01:36:17 +10:00
13a235e99f fix: simplify profile color preview logic in ProfileBanner component 2026-04-09 02:15:24 +10:00
137 changed files with 9673 additions and 1324 deletions

3
.github/FUNDING.yml vendored
View File

@@ -1,3 +0,0 @@
github: ajbura
liberapay: ajbura
open_collective: cinny

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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
View File

@@ -1,3 +0,0 @@
# Reporting a Vulnerability
**If you've found a security vulnerability, please report it to cinnyapp@gmail.com**

View File

@@ -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
View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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. ⚠️

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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)

BIN
build_output.txt Normal file

Binary file not shown.

12
capacitor.config.json Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

432
docs/PLUGIN_BUTTON_API.md Normal file
View 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!')
};
```

View 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.**

View File

@@ -18,7 +18,7 @@
/> />
<meta property="og:title" content="Paarrot" /> <meta property="og:title" content="Paarrot" />
<meta property="og:url" content="https://synbox.ruv.wtf:8418/litruv/cinny-desktop" /> <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:image" content="./public/res/android/android-chrome-192x192.png" />
<meta <meta
property="og:description" property="og:description"

25
package-lock.json generated
View File

@@ -12,7 +12,10 @@
"@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop": "1.1.6",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@capacitor/core": "8.3.4",
"@capacitor/local-notifications": "8.2.0",
"@fontsource/inter": "4.5.14", "@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": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.2.0", "@tanstack/react-virtual": "3.2.0",
@@ -1668,6 +1671,24 @@
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==", "integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)" "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": { "node_modules/@emotion/hash": {
"version": "0.9.2", "version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
@@ -2333,6 +2354,10 @@
"node": ">= 8" "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": { "node_modules/@react-aria/breadcrumbs": {
"version": "3.5.20", "version": "3.5.20",
"resolved": "https://registry.npmjs.org/@react-aria/breadcrumbs/-/breadcrumbs-3.5.20.tgz", "resolved": "https://registry.npmjs.org/@react-aria/breadcrumbs/-/breadcrumbs-3.5.20.tgz",

View File

@@ -23,7 +23,10 @@
"@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop": "1.1.6",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@capacitor/core": "8.3.4",
"@capacitor/local-notifications": "8.2.0",
"@fontsource/inter": "4.5.14", "@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": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.2.0", "@tanstack/react-virtual": "3.2.0",

View 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

View File

@@ -1,6 +1,6 @@
import React, { ReactNode } from 'react'; import React, { ReactNode, useCallback, useState } from 'react';
import FocusTrap from 'focus-trap-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'; import { stopPropagation } from '../utils/keyboard';
type Modal500Props = { type Modal500Props = {
@@ -8,23 +8,8 @@ type Modal500Props = {
children: ReactNode; children: ReactNode;
}; };
export function Modal500({ requestClose, children }: Modal500Props) { export function Modal500({ requestClose, children }: Modal500Props) {
const handleClickOutside = (e: MouseEvent | TouchEvent | FocusEvent) => { const [modalEl, setModalEl] = useState<HTMLDivElement | null>(null);
const target = e.target as HTMLElement; const modalRef = useCallback((el: HTMLDivElement | null) => setModalEl(el), []);
// Don't close if clicking on a popout menu, overlay, or other portaled content
// Check for common portal container elements and popout menus
if (
target.closest('[role="menu"]') ||
target.closest('[role="dialog"]') ||
target.closest('[data-floating-ui-portal]') ||
target.closest('.folds-overlay') ||
target.closest('.popout')
) {
return false;
}
return true;
};
return ( return (
<Overlay open backdrop={<OverlayBackdrop />}> <Overlay open backdrop={<OverlayBackdrop />}>
@@ -32,13 +17,15 @@ export function Modal500({ requestClose, children }: Modal500Props) {
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
clickOutsideDeactivates: handleClickOutside, clickOutsideDeactivates: true,
onDeactivate: requestClose, onDeactivate: requestClose,
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
}} }}
> >
<Modal size="500" variant="Background"> <Modal ref={modalRef} size="500" variant="Background">
{children} <PopOutContainerProvider value={modalEl ?? undefined}>
{children}
</PopOutContainerProvider>
</Modal> </Modal>
</FocusTrap> </FocusTrap>
</OverlayCenter> </OverlayCenter>

View File

@@ -25,14 +25,16 @@ import {
VideoContent, VideoContent,
VideoFrameThumbnail, VideoFrameThumbnail,
} from './message'; } from './message';
import { UrlPreviewCard, UrlPreviewHolder, YouTubeEmbed, isYouTubeUrl, TikTokEmbed, isTikTokUrl } from './url-preview'; import { UrlPreviewCard, UrlPreviewHolder, YouTubeEmbed, isYouTubeUrl, TikTokEmbed, isTikTokUrl, GiphyEmbed, isGiphyUrl } from './url-preview';
import { Image, MediaControl, Video } from './media'; import { Image, MediaControl, Video } from './media';
import { ImageViewer } from './image-viewer'; import { ImageViewer } from './image-viewer';
import { VideoViewer } from './video-viewer';
import { PdfViewer } from './Pdf-viewer'; import { PdfViewer } from './Pdf-viewer';
import { TextViewer } from './text-viewer'; import { TextViewer } from './text-viewer';
import { testMatrixTo } from '../plugins/matrix-to'; import { testMatrixTo } from '../plugins/matrix-to';
import { IImageContent } from '../../types/matrix/common'; import { IImageContent } from '../../types/matrix/common';
import { filterDisabledUrls } from '../hooks/useRoomEmbedFilters'; import { filterDisabledUrls } from '../hooks/useRoomEmbedFilters';
import { pluginRegistry } from '../features/settings/plugins/PluginAPI';
type RenderMessageContentProps = { type RenderMessageContentProps = {
displayName: string; displayName: string;
@@ -70,7 +72,8 @@ export function RenderMessageContent({
// Separate special URLs from generic ones // Separate special URLs from generic ones
const youtubeUrls = filteredUrls.filter(isYouTubeUrl); const youtubeUrls = filteredUrls.filter(isYouTubeUrl);
const tiktokUrls = filteredUrls.filter(isTikTokUrl); const tiktokUrls = filteredUrls.filter(isTikTokUrl);
const otherUrls = filteredUrls.filter((url) => !isYouTubeUrl(url) && !isTikTokUrl(url)); const giphyUrls = filteredUrls.filter(isGiphyUrl);
const otherUrls = filteredUrls.filter((url) => !isYouTubeUrl(url) && !isTikTokUrl(url) && !isGiphyUrl(url));
return ( return (
<> <>
@@ -82,6 +85,10 @@ export function RenderMessageContent({
{tiktokUrls.map((url) => ( {tiktokUrls.map((url) => (
<TikTokEmbed key={url} url={url} /> <TikTokEmbed key={url} url={url} />
))} ))}
{/* Render Giphy embeds */}
{giphyUrls.map((url) => (
<GiphyEmbed key={url} url={url} />
))}
{/* Render standard URL previews for other links */} {/* Render standard URL previews for other links */}
{otherUrls.length > 0 && ( {otherUrls.length > 0 && (
<UrlPreviewHolder> <UrlPreviewHolder>
@@ -152,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) { if (msgType === MsgType.Text) {
return ( return (
<MText <MText
@@ -234,7 +259,6 @@ export function RenderMessageContent({
content={getContent()} content={getContent()}
renderAsFile={renderFile} renderAsFile={renderFile}
renderVideoContent={({ body, info, mimeType, url, encInfo, ...props }) => { renderVideoContent={({ body, info, mimeType, url, encInfo, ...props }) => {
// Check if there's valid thumbnail metadata
const hasValidThumbnail = const hasValidThumbnail =
(typeof info.thumbnail_file?.url === 'string' || typeof info.thumbnail_url === 'string') && (typeof info.thumbnail_file?.url === 'string' || typeof info.thumbnail_url === 'string') &&
typeof info.thumbnail_info?.mimetype === 'string'; typeof info.thumbnail_info?.mimetype === 'string';
@@ -246,11 +270,11 @@ export function RenderMessageContent({
mimeType={mimeType} mimeType={mimeType}
url={url} url={url}
encInfo={encInfo} encInfo={encInfo}
autoPlay={mediaAutoLoad}
{...props} {...props}
renderThumbnail={ renderThumbnail={
mediaAutoLoad mediaAutoLoad
? () => hasValidThumbnail ? ( ? () => hasValidThumbnail ? (
// Try to load the provided thumbnail first
<ThumbnailContent <ThumbnailContent
info={info} info={info}
renderImage={(src) => ( renderImage={(src) => (
@@ -258,7 +282,6 @@ export function RenderMessageContent({
)} )}
/> />
) : ( ) : (
// Fallback: Extract first frame from the video itself
<VideoFrameThumbnail <VideoFrameThumbnail
mimeType={mimeType} mimeType={mimeType}
url={url} url={url}
@@ -270,6 +293,7 @@ export function RenderMessageContent({
) )
: undefined : undefined
} }
renderViewer={(p) => <VideoViewer {...p} />}
renderVideo={(p) => <Video {...p} />} renderVideo={(p) => <Video {...p} />}
/> />
); );
@@ -309,5 +333,36 @@ export function RenderMessageContent({
return <MBadEncrypted />; 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 />; return <UnsupportedContent />;
} }

View File

@@ -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 = { export type CreateRoomData = {
version: string; version: string;
type?: RoomType; type?: RoomType;
@@ -98,6 +108,8 @@ export type CreateRoomData = {
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => { export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => {
const initialState: ICreateRoomStateEvent[] = []; const initialState: ICreateRoomStateEvent[] = [];
// initialState.push(createRoomPowerLevelsState()); // Removed: causes 403 when setting permissions before admin
if (data.encryption) { if (data.encryption) {
initialState.push(createRoomEncryptionState()); initialState.push(createRoomEncryptionState());
} }

View File

@@ -42,6 +42,7 @@ import { useClosedNavCategoriesAtom } from '../../state/hooks/closedNavCategorie
import { useRoomsUnread } from '../../state/hooks/unread'; import { useRoomsUnread } from '../../state/hooks/unread';
import { useMarkRoomsAsRead } from '../../hooks/useMarkAsRead'; import { useMarkRoomsAsRead } from '../../hooks/useMarkAsRead';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { PluginButtonSlot } from '../../features/settings/plugins/PluginButtonSlot';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { import {
@@ -49,6 +50,7 @@ import {
useRoomsNotificationPreferencesContext, useRoomsNotificationPreferencesContext,
} from '../../hooks/useRoomsNotificationPreferences'; } from '../../hooks/useRoomsNotificationPreferences';
import { useDirectCreateSelected } from '../../hooks/router/useDirectSelected'; import { useDirectCreateSelected } from '../../hooks/router/useDirectSelected';
import { useRoomListReorder, useListReorderAnimation } from '../../hooks/useRoomListReorder';
/** /**
* Props for the DirectMenu component * Props for the DirectMenu component
@@ -121,7 +123,8 @@ export function DirectListHeader({ title = 'Direct Messages' }: DirectListHeader
{title} {title}
</Text> </Text>
</Box> </Box>
<Box> <Box gap="100" alignItems="Center">
<PluginButtonSlot location="direct-messages" />
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}> <IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
<Icon src={Icons.VerticalDots} size="200" /> <Icon src={Icons.VerticalDots} size="200" />
</IconButton> </IconButton>
@@ -212,6 +215,8 @@ export function DirectList({
const createDirectSelected = useDirectCreateSelected(); const createDirectSelected = useDirectCreateSelected();
const selectedRoomId = useSelectedRoom(); const selectedRoomId = useSelectedRoom();
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
const reorderTrigger = useRoomListReorder(mx, directs);
const sortedDirects = useMemo(() => { const sortedDirects = useMemo(() => {
const items = Array.from(directs).sort(factoryRoomIdByActivity(mx)); const items = Array.from(directs).sort(factoryRoomIdByActivity(mx));
@@ -219,7 +224,9 @@ export function DirectList({
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId); return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
} }
return items; return items;
}, [mx, directs, closedCategories, roomToUnread, selectedRoomId]); }, [mx, directs, closedCategories, roomToUnread, selectedRoomId, reorderTrigger]);
const animationRef = useListReorderAnimation(sortedDirects);
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: sortedDirects.length, count: sortedDirects.length,
@@ -273,6 +280,7 @@ export function DirectList({
</NavCategoryHeader> </NavCategoryHeader>
)} )}
<div <div
ref={animationRef}
style={{ style={{
position: 'relative', position: 'relative',
height: virtualizer.getTotalSize(), height: virtualizer.getTotalSize(),
@@ -285,7 +293,12 @@ export function DirectList({
const selected = selectedRoomId === roomId; const selected = selectedRoomId === roomId;
return ( return (
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}> <VirtualTile
virtualItem={vItem}
key={roomId}
ref={virtualizer.measureElement}
data-room-id={roomId}
>
<RoomNavItem <RoomNavItem
room={room} room={room}
selected={selected} selected={selected}

View File

@@ -6,9 +6,11 @@ import React, {
forwardRef, forwardRef,
useCallback, useCallback,
useState, useState,
useEffect,
useRef,
} from 'react'; } from 'react';
import { Box, Scroll, Text } from 'folds'; 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 { import {
Slate, Slate,
Editable, Editable,
@@ -23,6 +25,7 @@ import { RenderElement, RenderLeaf } from './Elements';
import { CustomElement } from './slate'; import { CustomElement } from './slate';
import * as css from './Editor.css'; import * as css from './Editor.css';
import { toggleKeyboardShortcut } from './keyboard'; import { toggleKeyboardShortcut } from './keyboard';
import { createCommandElement } from './utils';
const initialValue: CustomElement[] = [ const initialValue: CustomElement[] = [
{ {
@@ -52,8 +55,25 @@ const withVoid = (editor: Editor): Editor => {
return 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 => { export const useEditor = (): Editor => {
const [editor] = useState(() => withInline(withVoid(withReact(withHistory(createEditor()))))); const [editor] = useState(() =>
withCommandAutoConvert(withInline(withVoid(withReact(withHistory(createEditor())))))
);
return editor; return editor;
}; };
@@ -97,6 +117,49 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
const renderLeaf = useCallback((props: RenderLeafProps) => <RenderLeaf {...props} />, []); 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( const handleKeydown: KeyboardEventHandler = useCallback(
(evt) => { (evt) => {
onKeyDown?.(evt); onKeyDown?.(evt);
@@ -106,6 +169,48 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
[editor, onKeyDown] [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( const renderPlaceholder = useCallback(
({ attributes, children }: RenderPlaceholderProps) => ( ({ attributes, children }: RenderPlaceholderProps) => (
<span {...attributes} className={css.EditorPlaceholderContainer}> <span {...attributes} className={css.EditorPlaceholderContainer}>
@@ -135,6 +240,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
size="300" size="300"
visibility="Hover" visibility="Hover"
hideTrack hideTrack
ref={editableRef}
> >
<Editable <Editable
data-editable-name={editableName} data-editable-name={editableName}
@@ -143,6 +249,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
renderPlaceholder={renderPlaceholder} renderPlaceholder={renderPlaceholder}
renderElement={renderElement} renderElement={renderElement}
renderLeaf={renderLeaf} renderLeaf={renderLeaf}
decorate={decorate}
onKeyDown={handleKeydown} onKeyDown={handleKeydown}
onKeyUp={onKeyUp} onKeyUp={onKeyUp}
onPaste={onPaste} onPaste={onPaste}

View File

@@ -269,6 +269,13 @@ export function RenderLeaf({ attributes, leaf, children }: RenderLeafProps) {
{child} {child}
</span> </span>
); );
if (leaf.pendingCommand)
child = (
<span className={css.Command({ active: true })} {...attributes}>
<InlineChromiumBugfix />
{child}
</span>
);
if (child !== children) return child; if (child !== children) return child;

View File

@@ -1,6 +1,6 @@
export * from './autocomplete'; export * from './autocomplete';
export * from './utils'; export * from './utils';
export * from './Editor'; export { CustomEditor, useEditor, setCommandValidator } from './Editor';
export * from './Elements'; export * from './Elements';
export * from './keyboard'; export * from './keyboard';
export * from './output'; export * from './output';

View File

@@ -18,6 +18,7 @@ export type FormattedText = Text & {
strikeThrough?: boolean; strikeThrough?: boolean;
code?: boolean; code?: boolean;
spoiler?: boolean; spoiler?: boolean;
pendingCommand?: boolean;
}; };
export type LinkElement = { export type LinkElement = {

View File

@@ -21,7 +21,7 @@ import { useEmojiGroupIcons } from './useEmojiGroupIcons';
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard'; import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
import { useRelevantImagePacks } from '../../hooks/useImagePacks'; import { useRelevantImagePacks } from '../../hooks/useImagePacks';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../hooks/useRecentEmoji'; import { useEmojiUsage } from '../../hooks/useEmojiUsage';
import { isUserId, mxcUrlToHttp } from '../../utils/matrix'; import { isUserId, mxcUrlToHttp } from '../../utils/matrix';
import { editableActiveElement, targetFromEvent } from '../../utils/dom'; import { editableActiveElement, targetFromEvent } from '../../utils/dom';
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch'; import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
@@ -53,6 +53,7 @@ import {
import { EmojiBoardTab, EmojiType } from './types'; import { EmojiBoardTab, EmojiType } from './types';
import { VirtualTile } from '../virtualizer'; import { VirtualTile } from '../virtualizer';
const MOST_USED_GROUP_ID = 'most_used_group';
const RECENT_GROUP_ID = 'recent_group'; const RECENT_GROUP_ID = 'recent_group';
const SEARCH_GROUP_ID = 'search_group'; const SEARCH_GROUP_ID = 'search_group';
@@ -72,19 +73,29 @@ const useGroups = (
imagePacks: ImagePack[] imagePacks: ImagePack[]
): [EmojiGroupItem[], StickerGroupItem[]] => { ): [EmojiGroupItem[], StickerGroupItem[]] => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const { getTopEmojis } = useEmojiUsage();
const recentEmojis = useRecentEmoji(mx, 21);
const labels = useEmojiGroupLabels(); const labels = useEmojiGroupLabels();
const emojiGroupItems = useMemo(() => { const emojiGroupItems = useMemo(() => {
const g: EmojiGroupItem[] = []; const g: EmojiGroupItem[] = [];
if (tab !== EmojiBoardTab.Emoji) return g; if (tab !== EmojiBoardTab.Emoji) return g;
g.push({ // Add most-used emojis
id: RECENT_GROUP_ID, const topShortcodes = getTopEmojis(21);
name: 'Recent', if (topShortcodes.length > 0) {
items: recentEmojis, 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) => { imagePacks.forEach((pack) => {
let label = pack.meta.name; let label = pack.meta.name;
@@ -108,7 +119,7 @@ const useGroups = (
}); });
return g; return g;
}, [mx, recentEmojis, labels, imagePacks, tab]); }, [mx, labels, imagePacks, tab, getTopEmojis]);
const stickerGroupItems = useMemo(() => { const stickerGroupItems = useMemo(() => {
const g: StickerGroupItem[] = []; const g: StickerGroupItem[] = [];
@@ -187,10 +198,10 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
<Sidebar> <Sidebar>
<SidebarStack> <SidebarStack>
<GroupIcon <GroupIcon
active={activeGroupId === RECENT_GROUP_ID} active={activeGroupId === MOST_USED_GROUP_ID}
id={RECENT_GROUP_ID} id={MOST_USED_GROUP_ID}
label="Recent" label="Most Used"
icon={Icons.RecentClock} icon={Icons.Heart}
onClick={handleScrollToGroup} onClick={handleScrollToGroup}
/> />
</SidebarStack> </SidebarStack>
@@ -378,13 +389,30 @@ export function EmojiBoard({
addToRecentEmoji = true, addToRecentEmoji = true,
}: EmojiBoardProps) { }: EmojiBoardProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const { trackEmojiUsage, getMostUsedEmoji } = useEmojiUsage();
const emojiTab = tab === EmojiBoardTab.Emoji; const emojiTab = tab === EmojiBoardTab.Emoji;
const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker; 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( const previewAtom = useMemo(
() => createPreviewDataAtom(emojiTab ? DefaultEmojiPreview : undefined), () => createPreviewDataAtom(defaultPreview),
[emojiTab] [defaultPreview]
); );
const activeGroupIdAtom = useMemo(() => atom<string | undefined>(undefined), []); const activeGroupIdAtom = useMemo(() => atom<string | undefined>(undefined), []);
const setActiveGroupId = useSetAtom(activeGroupIdAtom); const setActiveGroupId = useSetAtom(activeGroupIdAtom);
@@ -439,6 +467,10 @@ export function EmojiBoard({
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode); onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
if (!evt.altKey && !evt.shiftKey && addToRecentEmoji) { if (!evt.altKey && !evt.shiftKey && addToRecentEmoji) {
addRecentEmoji(mx, emojiInfo.data); 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) { if (emojiInfo.type === EmojiType.CustomEmoji) {

View File

@@ -1,4 +1,5 @@
import React, { VideoHTMLAttributes, forwardRef, useRef, useState, useEffect } from 'react'; import React, { VideoHTMLAttributes, forwardRef, useRef, useState, useEffect } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Box, IconButton, Icon, Icons, Tooltip, TooltipProvider, Text } from 'folds'; import { Box, IconButton, Icon, Icons, Tooltip, TooltipProvider, Text } from 'folds';
import * as css from './media.css'; import * as css from './media.css';
@@ -6,16 +7,14 @@ import * as videoCss from './videoControls.css';
type VideoProps = VideoHTMLAttributes<HTMLVideoElement> & { type VideoProps = VideoHTMLAttributes<HTMLVideoElement> & {
disableControls?: boolean; disableControls?: boolean;
onExpand?: () => void;
}; };
export const Video = forwardRef<HTMLVideoElement, VideoProps>( export const Video = forwardRef<HTMLVideoElement, VideoProps>(
({ className, disableControls, ...props }, ref) => { ({ className, disableControls, onExpand, ...props }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const [showControls, setShowControls] = useState(false); const [showControls, setShowControls] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
// Combine refs
useEffect(() => { useEffect(() => {
if (typeof ref === 'function') { if (typeof ref === 'function') {
ref(videoRef.current); ref(videoRef.current);
@@ -24,98 +23,61 @@ export const Video = forwardRef<HTMLVideoElement, VideoProps>(
} }
}, [ref]); }, [ref]);
// Track fullscreen state
useEffect(() => { useEffect(() => {
const handleFullscreenChange = () => { if (!onExpand) return;
setIsFullscreen(!!document.fullscreenElement); const video = videoRef.current;
if (!video) return;
const handleFullscreenRequest = (evt: Event) => {
evt.preventDefault();
evt.stopImmediatePropagation();
onExpand();
}; };
document.addEventListener('fullscreenchange', handleFullscreenChange); video.addEventListener('webkitbeginfullscreen', handleFullscreenRequest);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); return () => {
}, []); video.removeEventListener('webkitbeginfullscreen', handleFullscreenRequest);
};
const handleFullscreen = async () => { }, [onExpand]);
try {
if (!document.fullscreenElement && containerRef.current) {
await containerRef.current.requestFullscreen();
} else if (document.fullscreenElement) {
await document.exitFullscreen();
}
} catch (error) {
console.error('Error toggling fullscreen:', error);
}
};
const handlePopOut = () => {
const videoSrc = videoRef.current?.src;
const videoTitle = props.title || 'Video';
if (videoSrc) {
const popoutWindow = window.open(
'',
'_blank',
'width=800,height=600,menubar=no,toolbar=no,location=no,status=no'
);
if (popoutWindow) {
popoutWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>${videoTitle}</title>
<style>
body {
margin: 0;
padding: 0;
background: #000;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
video {
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<video controls autoplay src="${videoSrc}">
Your browser does not support the video tag.
</video>
</body>
</html>
`);
popoutWindow.document.close();
}
}
};
if (disableControls) { if (disableControls) {
return ( return (
// eslint-disable-next-line jsx-a11y/media-has-caption // eslint-disable-next-line jsx-a11y/media-has-caption
<video className={classNames(css.Video, className)} {...props} ref={videoRef} /> <video
className={classNames(css.Video, className)}
{...props}
ref={videoRef}
loop={props.autoPlay}
muted={props.autoPlay}
playsInline
/>
); );
} }
return ( return (
<Box <Box
ref={containerRef}
className={videoCss.VideoContainer} className={videoCss.VideoContainer}
onMouseEnter={() => setShowControls(true)} onMouseEnter={() => setShowControls(true)}
onMouseLeave={() => setShowControls(false)} onMouseLeave={() => setShowControls(false)}
> >
{/* eslint-disable-next-line jsx-a11y/media-has-caption */} {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video className={classNames(css.Video, className)} {...props} ref={videoRef} /> <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}
/>
{showControls && ( {onExpand && (
<Box className={videoCss.VideoControlsOverlay} gap="200"> <Box className={classNames(videoCss.VideoControlsOverlay, !showControls && videoCss.VideoControlsOverlayHidden)} gap="200">
<TooltipProvider <TooltipProvider
tooltip={ tooltip={
<Tooltip variant="Secondary"> <Tooltip variant="Secondary">
<Text size="T200">{isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}</Text> <Text size="T200">Expand</Text>
</Tooltip> </Tooltip>
} }
position="Bottom" position="Bottom"
@@ -127,36 +89,10 @@ export const Video = forwardRef<HTMLVideoElement, VideoProps>(
variant="Secondary" variant="Secondary"
size="300" size="300"
radii="300" radii="300"
onClick={handleFullscreen} onClick={onExpand}
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'} aria-label="Expand video"
> >
<Icon <Icon size="50" src={Icons.Plus} />
size="200"
src={isFullscreen ? Icons.Minus : Icons.Plus}
/>
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
tooltip={
<Tooltip variant="Secondary">
<Text size="T200">Pop Out</Text>
</Tooltip>
}
position="Bottom"
align="Center"
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="Secondary"
size="300"
radii="300"
onClick={handlePopOut}
aria-label="Open in new window"
>
<Icon size="200" src={Icons.External} />
</IconButton> </IconButton>
)} )}
</TooltipProvider> </TooltipProvider>

View File

@@ -1,20 +1,24 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { DefaultReset } from 'folds'; import { DefaultReset, toRem } from 'folds';
export const Image = style([ export const Image = style([
DefaultReset, DefaultReset,
{ {
objectFit: 'cover', display: 'block',
width: '100%', maxWidth: '100%',
height: '100%', maxHeight: '100%',
borderRadius: 'inherit',
imageRendering: 'auto',
backfaceVisibility: 'hidden',
transform: 'translateZ(0)',
}, },
]); ]);
export const Video = style([ export const Video = style([
DefaultReset, DefaultReset,
{ {
objectFit: 'contain', display: 'block',
width: '100%', maxWidth: '100%',
height: '100%', maxHeight: '100%',
}, },
]); ]);

View File

@@ -24,5 +24,12 @@ export const VideoControlsOverlay = style([
gap: config.space.S200, gap: config.space.S200,
zIndex: 10, zIndex: 10,
pointerEvents: 'auto', pointerEvents: 'auto',
opacity: 1,
transition: 'opacity 0.15s ease',
}, },
]); ]);
export const VideoControlsOverlayHidden = style({
opacity: 0,
pointerEvents: 'none',
});

View File

@@ -80,7 +80,10 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
const { body, formatted_body: customBody } = content; const { body, formatted_body: customBody } = content;
if (typeof body !== 'string') return <BrokenContent />; 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 urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
@@ -93,7 +96,7 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
> >
{renderBody({ {renderBody({
body: trimmedBody, body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined, customBody: trimmedCustomBody,
})} })}
{edited && <MessageEditedContent />} {edited && <MessageEditedContent />}
</MessageTextBody> </MessageTextBody>
@@ -119,7 +122,10 @@ export function MEmote({
const { body, formatted_body: customBody } = content; const { body, formatted_body: customBody } = content;
if (typeof body !== 'string') return <BrokenContent />; 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 urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
@@ -133,7 +139,7 @@ export function MEmote({
<b>{`${displayName} `}</b> <b>{`${displayName} `}</b>
{renderBody({ {renderBody({
body: trimmedBody, body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined, customBody: trimmedCustomBody,
})} })}
{edited && <MessageEditedContent />} {edited && <MessageEditedContent />}
</MessageTextBody> </MessageTextBody>
@@ -152,7 +158,10 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
const { body, formatted_body: customBody } = content; const { body, formatted_body: customBody } = content;
if (typeof body !== 'string') return <BrokenContent />; 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 urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
@@ -165,7 +174,7 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
> >
{renderBody({ {renderBody({
body: trimmedBody, body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined, customBody: trimmedCustomBody,
})} })}
{edited && <MessageEditedContent />} {edited && <MessageEditedContent />}
</MessageTextBody> </MessageTextBody>
@@ -195,15 +204,10 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
if (typeof mxcUrl !== 'string') { if (typeof mxcUrl !== 'string') {
return <BrokenContent />; return <BrokenContent />;
} }
const height = scaleYDimension(imgInfo?.w || 400, 400, imgInfo?.h || 400);
return ( return (
<Attachment outlined={outlined}> <Attachment outlined={outlined} transparent>
<AttachmentBox <AttachmentBox>
style={{
height: toRem(height < 48 ? 48 : height),
}}
>
{renderImageContent({ {renderImageContent({
body: content.body || 'Image', body: content.body || 'Image',
info: imgInfo, info: imgInfo,
@@ -245,12 +249,10 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
return <BrokenContent />; return <BrokenContent />;
} }
const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400);
const filename = content.filename ?? content.body ?? 'Video'; const filename = content.filename ?? content.body ?? 'Video';
return ( return (
<Attachment outlined={outlined}> <Attachment outlined={outlined} transparent>
<AttachmentHeader> <AttachmentHeader>
<FileHeader <FileHeader
body={filename} body={filename}
@@ -265,11 +267,7 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
} }
/> />
</AttachmentHeader> </AttachmentHeader>
<AttachmentBox <AttachmentBox>
style={{
height: toRem(height < 48 ? 48 : height),
}}
>
{renderVideoContent({ {renderVideoContent({
body: content.body || 'Video', body: content.body || 'Video',
info: videoInfo, info: videoInfo,

View File

@@ -32,7 +32,9 @@ export const Reply = style({
export const ReplyContent = style({ export const ReplyContent = style({
opacity: config.opacity.P300, opacity: config.opacity.P300,
overflow: 'clip',
overflowY: 'clip',
lineHeight: '1.5',
selectors: { selectors: {
[`${Reply}:hover &`]: { [`${Reply}:hover &`]: {
opacity: config.opacity.P500, opacity: config.opacity.P500,

View File

@@ -8,8 +8,7 @@ export const Attachment = recipe({
color: color.SurfaceVariant.OnContainer, color: color.SurfaceVariant.OnContainer,
borderRadius: config.radii.R400, borderRadius: config.radii.R400,
overflow: 'hidden', overflow: 'hidden',
maxWidth: '100%', maxWidth: toRem(400),
width: toRem(400),
}, },
variants: { variants: {
outlined: { outlined: {
@@ -17,6 +16,11 @@ export const Attachment = recipe({
boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`, 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([ export const AttachmentBox = style([
DefaultReset, DefaultReset,
{ {
maxWidth: '100%', maxWidth: toRem(400),
maxHeight: toRem(600), maxHeight: toRem(400),
width: toRem(400),
overflow: 'hidden', overflow: 'hidden',
}, },
]); ]);

View File

@@ -4,11 +4,11 @@ import classNames from 'classnames';
import * as css from './Attachment.css'; import * as css from './Attachment.css';
export const Attachment = as<'div', css.AttachmentVariants>( export const Attachment = as<'div', css.AttachmentVariants>(
({ className, outlined, ...props }, ref) => ( ({ className, outlined, transparent, ...props }, ref) => (
<Box <Box
display="InlineFlex" display="InlineFlex"
direction="Column" direction="Column"
className={classNames(css.Attachment({ outlined }), className)} className={classNames(css.Attachment({ outlined, transparent }), className)}
{...props} {...props}
ref={ref} ref={ref}
/> />

View File

@@ -6,6 +6,10 @@ import {
Chip, Chip,
Icon, Icon,
Icons, Icons,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
Spinner, Spinner,
Text, Text,
Tooltip, Tooltip,
@@ -14,6 +18,7 @@ import {
} from 'folds'; } from 'folds';
import classNames from 'classnames'; import classNames from 'classnames';
import { BlurhashCanvas } from 'react-blurhash'; import { BlurhashCanvas } from 'react-blurhash';
import FocusTrap from 'focus-trap-react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { import {
IThumbnailContent, IThumbnailContent,
@@ -33,7 +38,14 @@ import {
import { getCurrentAccessToken } from '../../../utils/auth'; import { getCurrentAccessToken } from '../../../utils/auth';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { validBlurHash } from '../../../utils/blurHash'; 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 = { type RenderVideoProps = {
title: string; title: string;
src: string; src: string;
@@ -41,6 +53,7 @@ type RenderVideoProps = {
onError: () => void; onError: () => void;
autoPlay: boolean; autoPlay: boolean;
controls: boolean; controls: boolean;
onExpand?: () => void;
}; };
type VideoContentProps = { type VideoContentProps = {
body: string; body: string;
@@ -52,6 +65,7 @@ type VideoContentProps = {
markedAsSpoiler?: boolean; markedAsSpoiler?: boolean;
spoilerReason?: string; spoilerReason?: string;
renderThumbnail?: () => ReactNode; renderThumbnail?: () => ReactNode;
renderViewer?: (props: RenderViewerProps) => ReactNode;
renderVideo: (props: RenderVideoProps) => ReactNode; renderVideo: (props: RenderVideoProps) => ReactNode;
}; };
export const VideoContent = as<'div', VideoContentProps>( export const VideoContent = as<'div', VideoContentProps>(
@@ -67,6 +81,7 @@ export const VideoContent = as<'div', VideoContentProps>(
markedAsSpoiler, markedAsSpoiler,
spoilerReason, spoilerReason,
renderThumbnail, renderThumbnail,
renderViewer,
renderVideo, renderVideo,
...props ...props
}, },
@@ -78,6 +93,7 @@ export const VideoContent = as<'div', VideoContentProps>(
const [load, setLoad] = useState(false); const [load, setLoad] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [viewer, setViewer] = useState(false);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false); const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
const [srcState, loadSrc] = useAsyncCallback( const [srcState, loadSrc] = useAsyncCallback(
@@ -113,6 +129,28 @@ export const VideoContent = as<'div', VideoContentProps>(
return ( return (
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}> <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 && ( {typeof blurHash === 'string' && !load && (
<BlurhashCanvas <BlurhashCanvas
style={{ width: '100%', height: '100%' }} style={{ width: '100%', height: '100%' }}
@@ -154,6 +192,7 @@ export const VideoContent = as<'div', VideoContentProps>(
onError: handleError, onError: handleError,
autoPlay: true, autoPlay: true,
controls: true, controls: true,
onExpand: renderViewer ? () => setViewer(true) : undefined,
})} })}
</Box> </Box>
)} )}

View File

@@ -5,19 +5,24 @@ export const RelativeBase = style([
DefaultReset, DefaultReset,
{ {
position: 'relative', position: 'relative',
width: '100%', display: 'flex',
height: '100%', maxWidth: '100%',
maxHeight: '100%',
borderRadius: '7px',
overflow: 'hidden',
}, },
]); ]);
export const AbsoluteContainer = style([ export const AbsoluteContainer = style([
DefaultReset, DefaultReset,
{ {
position: 'absolute', display: 'flex',
top: 0, alignItems: 'center',
left: 0, justifyContent: 'center',
width: '100%', maxWidth: '100%',
height: '100%', maxHeight: '100%',
borderRadius: 'inherit',
overflow: 'hidden',
}, },
]); ]);

View File

@@ -188,8 +188,10 @@ export const BubbleLeftArrow = style({
export const Username = style({ export const Username = style({
overflow: 'hidden', overflow: 'hidden',
overflowY: 'clip',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
lineHeight: '1.5',
// Allow text selection while keeping button clickable // Allow text selection while keeping button clickable
userSelect: 'text', userSelect: 'text',
WebkitUserSelect: 'text', WebkitUserSelect: 'text',
@@ -210,6 +212,9 @@ export const UsernameBold = style({
export const MessageTextBody = recipe({ export const MessageTextBody = recipe({
base: { base: {
wordBreak: 'break-word', wordBreak: 'break-word',
overflow: 'clip',
overflowY: 'clip',
lineHeight: '1.5',
}, },
variants: { variants: {
preWrap: { preWrap: {

View File

@@ -0,0 +1,22 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, toRem } from 'folds';
export const GiphyEmbed = style([
DefaultReset,
{
width: toRem(480),
maxWidth: '100%',
display: 'block',
},
]);
export const GiphyVideo = style([
DefaultReset,
{
width: '100%',
height: 'auto',
border: 'none',
display: 'block',
backgroundColor: 'transparent',
},
]);

View File

@@ -0,0 +1,64 @@
import React, { useEffect, useState } from 'react';
import { as } from 'folds';
import * as css from './GiphyEmbed.css';
const GIPHY_URL_PATTERNS = [
/(?:https?:\/\/)?(?:www\.)?giphy\.com\/gifs\/(?:[\w-]+-)([a-zA-Z0-9]+)(?:\?|$)/,
/(?:https?:\/\/)?(?:www\.)?giphy\.com\/embed\/([a-zA-Z0-9]+)(?:\?|$)/,
/(?:https?:\/\/)?(?:media\.)?giphy\.com\/media\/([a-zA-Z0-9]+)/,
];
export function isGiphyUrl(url: string): boolean {
return GIPHY_URL_PATTERNS.some((pattern) => pattern.test(url));
}
export function extractGiphyId(url: string): string | null {
let gifId: string | null = null;
GIPHY_URL_PATTERNS.some((pattern) => {
const match = url.match(pattern);
if (match) {
const [, id] = match;
if (id) {
gifId = id;
return true;
}
}
return false;
});
return gifId;
}
type GiphyEmbedProps = {
url: string;
};
export const GiphyEmbed = as<'div', GiphyEmbedProps>(({ url, ...props }, ref) => {
const gifId = extractGiphyId(url);
const [mediaUrl, setMediaUrl] = useState<string | null>(null);
useEffect(() => {
if (!gifId) return;
// Try to get the direct media URL from Giphy
// Format: https://i.giphy.com/media/{ID}/giphy.mp4 or giphy.gif
const mp4Url = `https://i.giphy.com/media/${gifId}/giphy.mp4`;
setMediaUrl(mp4Url);
}, [gifId]);
if (!gifId || !mediaUrl) {
return null;
}
return (
<div className={css.GiphyEmbed} {...props} ref={ref}>
<video
className={css.GiphyVideo}
src={mediaUrl}
autoPlay
loop
muted
playsInline
/>
</div>
);
});

View File

@@ -2,3 +2,4 @@ export * from './UrlPreview';
export * from './UrlPreviewCard'; export * from './UrlPreviewCard';
export * from './YouTubeEmbed'; export * from './YouTubeEmbed';
export * from './TikTokEmbed'; export * from './TikTokEmbed';
export * from './GiphyEmbed';

View File

@@ -37,6 +37,13 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
const bannerUrl = useOtherUserBanner(userId, avatarMxc); // Now returns blob URL directly const bannerUrl = useOtherUserBanner(userId, avatarMxc); // Now returns blob URL directly
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc); const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
console.log('[UserHero]', {
userId,
avatarMxc,
bannerUrl,
profileStyle,
});
return ( return (
<Box <Box
direction="Column" direction="Column"
@@ -125,6 +132,13 @@ export function UserHeroName({ displayName, userId, avatarMxc }: UserHeroNamePro
const username = getMxIdLocalPart(userId); const username = getMxIdLocalPart(userId);
const userColor = useOtherUserColor(userId, avatarMxc); const userColor = useOtherUserColor(userId, avatarMxc);
console.log('[UserHeroName]', {
userId,
avatarMxc,
userColor,
fallbackColor: colorMXID(userId),
});
return ( return (
<Box grow="Yes" direction="Column" gap="100"> <Box grow="Yes" direction="Column" gap="100">
<Box alignItems="Baseline" gap="200" wrap="Wrap"> <Box alignItems="Baseline" gap="200" wrap="Wrap">

View File

@@ -26,7 +26,8 @@ import { DirectCreateSearchParams } from '../../pages/paths';
import { getContrastingTextColor, stripAlphaFromColor, getTextShadowColor } from '../../utils/common'; import { getContrastingTextColor, stripAlphaFromColor, getTextShadowColor } from '../../utils/common';
import classNames from 'classnames'; import classNames from 'classnames';
import { BreakWord, LineClamp3 } from '../../styles/Text.css'; import { BreakWord, LineClamp3 } from '../../styles/Text.css';
import colorMXID from '../../../util/colorMXID'; import colorMXID, { getColorMXIDValue } from '../../../util/colorMXID';
import { useOtherUserColor } from '../../hooks/useUserColor';
type UserRoomProfileProps = { type UserRoomProfileProps = {
userId: string; userId: string;
@@ -64,6 +65,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
const presence = useUserPresence(userId); const presence = useUserPresence(userId);
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc); const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
const userColor = useOtherUserColor(userId, avatarMxc);
// Build gradient CSS if configured // Build gradient CSS if configured
const gradientStyle = profileStyle.gradient const gradientStyle = profileStyle.gradient
@@ -87,9 +89,25 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
(canKickUser && membership === Membership.Join) || (canKickUser && membership === Membership.Join) ||
(canBanUser && membership !== Membership.Ban); (canBanUser && membership !== Membership.Ban);
const profileColor = profileStyle.avatarBorderColor ? stripAlphaFromColor(profileStyle.avatarBorderColor) : colorMXID(userId); // Use userColor for text, or fall back to colorMXID
const profileColor = userColor ? userColor : getColorMXIDValue(userId);
const profileTextShadow = `0 1px 4px ${getTextShadowColor(profileColor)}`; const profileTextShadow = `0 1px 4px ${getTextShadowColor(profileColor)}`;
const pillBgColor = profileStyle.avatarBorderColor ? stripAlphaFromColor(profileStyle.avatarBorderColor) : undefined;
// Use avatarBorderColor for pill background only if it has visible alpha (not fully transparent)
const hasVisibleBorder = profileStyle.avatarBorderColor && !profileStyle.avatarBorderColor.endsWith('00');
const pillBgColor = hasVisibleBorder ? stripAlphaFromColor(profileStyle.avatarBorderColor!) : undefined;
console.log('[UserRoomProfile]', {
userId,
avatarMxc,
userColor,
profileStyle,
profileColor,
profileTextShadow,
hasVisibleBorder,
pillBgColor,
fallbackColorMXID: getColorMXIDValue(userId),
});
return ( return (
<Box direction="Column"> <Box direction="Column">

View File

@@ -0,0 +1,40 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config } from 'folds';
export const VideoViewer = style([
DefaultReset,
{
height: '100%',
},
]);
export const VideoViewerHeader = style([
DefaultReset,
{
paddingLeft: config.space.S200,
paddingRight: config.space.S200,
borderBottomWidth: config.borderWidth.B300,
flexShrink: 0,
gap: config.space.S200,
},
]);
export const VideoViewerContent = style([
DefaultReset,
{
backgroundColor: color.Background.Container,
color: color.Background.OnContainer,
overflow: 'hidden',
},
]);
export const VideoViewerVideo = style([
DefaultReset,
{
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
backgroundColor: color.Surface.Container,
},
]);

View File

@@ -0,0 +1,78 @@
import React from 'react';
import FileSaver from 'file-saver';
import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
import * as css from './VideoViewer.css';
import { downloadMedia } from '../../utils/matrix';
import { getCurrentAccessToken } from '../../utils/auth';
export type VideoViewerProps = {
alt: string;
src: string;
requestClose: () => void;
};
export const VideoViewer = as<'div', VideoViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => { const handleDownload = async () => {
try {
const fileContent = await downloadMedia(src, getCurrentAccessToken());
FileSaver.saveAs(fileContent, alt);
} catch (error) {
console.warn('[VideoViewer] Failed to download media:', error);
try {
const response = await fetch(src);
if (response.ok) {
const blob = await response.blob();
FileSaver.saveAs(blob, alt);
}
} catch {
window.open(src, '_blank');
}
}
};
return (
<Box
className={classNames(css.VideoViewer, className)}
direction="Column"
{...props}
ref={ref}
>
<Header className={css.VideoViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>
{alt}
</Text>
</Box>
<Box shrink="No" alignItems="Center" gap="200">
<Chip
variant="Primary"
onClick={handleDownload}
radii="300"
before={<Icon size="50" src={Icons.Download} />}
>
<Text size="B300">Download</Text>
</Chip>
</Box>
</Header>
<Box
grow="Yes"
className={css.VideoViewerContent}
justifyContent="Center"
alignItems="Center"
>
<video
className={css.VideoViewerVideo}
src={src}
title={alt}
controls
autoPlay
/>
</Box>
</Box>
);
}
);

View File

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

View File

@@ -0,0 +1,316 @@
import { useAtomValue } from 'jotai';
import React, { ChangeEventHandler, useCallback, useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import FocusTrap from 'focus-trap-react';
import {
Avatar,
Box,
Button,
Header,
Icon,
IconButton,
Icons,
Input,
MenuItem,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Text,
config,
} from 'folds';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
import { allRoomsAtom } from '../state/room-list/roomList';
import { mDirectAtom } from '../state/mDirectList';
import { useDirects, useRooms } from '../state/hooks/roomList';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../utils/room';
import { RoomAvatar, RoomIcon } from '../components/room-avatar';
import { nameInitials } from '../utils/common';
import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
import { factoryRoomIdByActivity } from '../utils/sort';
import {
SearchItemStrGetter,
useAsyncSearch,
UseAsyncSearchOptions,
} from '../hooks/useAsyncSearch';
import { VirtualTile } from '../components/virtualizer';
import { AndroidSharePayload } from '../utils/androidShare';
import { stopPropagation } from '../utils/keyboard';
const SEARCH_OPTS: UseAsyncSearchOptions = {
limit: 200,
matchOptions: {
contain: true,
},
};
type ShareRoomPickerProps = {
/** The pending share payload to display a preview of. */
share: AndroidSharePayload;
/** Called when the user picks a room. */
onPick: (roomId: string) => void;
/** Called when the user dismisses without picking. */
onDismiss: () => void;
};
/**
* Full-screen modal that lets the user pick a room to send
* an incoming Android share intent into.
*/
export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const scrollRef = useRef<HTMLDivElement>(null);
const [confirmRoomId, setConfirmRoomId] = useState<string | null>(null);
const mDirects = useAtomValue(mDirectAtom);
const allRoomsSet = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allRoomsSet);
const rooms = useRooms(mx, allRoomsAtom, mDirects);
const directs = useDirects(mx, allRoomsAtom, mDirects);
const allItems = useMemo(
() => [...rooms, ...directs].sort(factoryRoomIdByActivity(mx)),
[mx, rooms, directs]
);
const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
(rId) => getRoom(rId)?.name ?? rId,
[getRoom]
);
const [searchResult, searchRoom, resetSearch] = useAsyncSearch(
allItems,
getRoomNameStr,
SEARCH_OPTS
);
const items = searchResult ? searchResult.items : allItems;
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 48,
overscan: 8,
});
const vItems = virtualizer.getVirtualItems();
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const value = evt.currentTarget.value.trim();
if (!value) {
resetSearch();
return;
}
searchRoom(value);
};
const handleRoomClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
const roomId = evt.currentTarget.getAttribute('data-room-id');
if (roomId) {
setConfirmRoomId(roomId);
}
};
const handleConfirm = useCallback(() => {
if (confirmRoomId) {
onPick(confirmRoomId);
}
}, [confirmRoomId, onPick]);
const handleCancelConfirm = useCallback(() => {
setConfirmRoomId(null);
}, []);
const previewText = share.text?.slice(0, 80) ?? share.subject?.slice(0, 80);
const fileCount = share.files.length;
return (
<>
<Overlay open>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: onDismiss,
escapeDeactivates: stopPropagation,
}}
>
<Box
grow="Yes"
direction="Column"
style={{
position: 'fixed',
inset: 0,
zIndex: 999,
backgroundColor: '#1a1d23',
paddingTop: '60px',
}}
>
<Header
size="500"
style={{ padding: config.space.S400 }}
>
<Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0, maxWidth: '80%' }}>
<Text size="H4">Share to Room</Text>
{(previewText || fileCount > 0) && (
<Text size="T200" priority="300" style={{ opacity: 0.7, wordBreak: 'break-word', whiteSpace: 'normal' }}>
{previewText
? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
: `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
</Text>
)}
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={onDismiss}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Header>
<Box
style={{
padding: config.space.S200,
paddingTop: config.space.S300,
}}
>
<Input
onChange={handleSearchChange}
before={<Icon size="200" src={Icons.Search} />}
placeholder="Search rooms…"
size="400"
variant="Background"
outlined
autoFocus
style={{ width: '100%' }}
/>
</Box>
<Scroll ref={scrollRef} size="300" hideTrack style={{ flex: 1, minHeight: 0 }}>
<Box style={{ padding: config.space.S300 }} direction="Column">
{vItems.length === 0 && (
<Box
style={{ padding: `${config.space.S700} 0` }}
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="100"
>
<Text size="H6" align="Center">
{searchResult ? 'No Match Found' : 'No Rooms'}
</Text>
<Text size="T200" align="Center" priority="300">
{searchResult
? `No rooms found for "${searchResult.query}".`
: 'You have no rooms to share into.'}
</Text>
</Box>
)}
<Box style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
{vItems.map((vItem) => {
const roomId = items[vItem.index];
const room = getRoom(roomId);
if (!room) return null;
const isDm = mDirects.has(roomId);
const avatarSrc = isDm
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingBottom: config.space.S100 }}
ref={virtualizer.measureElement}
key={roomId}
>
<MenuItem
data-room-id={roomId}
onClick={handleRoomClick}
variant="Surface"
size="400"
radii="400"
before={
<Avatar size="300" radii={isDm ? '400' : '300'}>
{avatarSrc ? (
<RoomAvatar
roomId={roomId}
src={avatarSrc}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
) : (
<RoomIcon room={room} size="200" filled />
)}
</Avatar>
}
>
<Box grow="Yes" direction="Column">
<Text size="B400" truncate>
{room.name}
</Text>
</Box>
</MenuItem>
</VirtualTile>
);
})}
</Box>
</Box>
</Scroll>
</Box>
</FocusTrap>
</Overlay>
{confirmRoomId && (() => {
const room = getRoom(confirmRoomId);
const roomName = room?.name ?? confirmRoomId;
const isDm = mDirects.has(confirmRoomId);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleCancelConfirm,
escapeDeactivates: stopPropagation,
}}
>
<Modal style={{ maxWidth: '280px' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S300 }}>
<Box direction="Column" gap="100">
<Text size="H4">Share to {isDm ? 'Person' : 'Room'}?</Text>
<Text size="T300" priority="300">
Send to <strong>{roomName}</strong>?
</Text>
</Box>
<Box gap="200" justifyContent="End">
<Button
onClick={handleCancelConfirm}
variant="Secondary"
size="400"
>
<Text size="B400">Cancel</Text>
</Button>
<Button
onClick={handleConfirm}
variant="Primary"
size="400"
>
<Text size="B400">Share</Text>
</Button>
</Box>
</Box>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
})()}
</>
);
}

View File

@@ -2,7 +2,6 @@ import React, { ReactNode } from 'react';
import { CallProvider, useCallService } from './useCall'; import { CallProvider, useCallService } from './useCall';
import { CallOverlay } from './CallOverlay'; import { CallOverlay } from './CallOverlay';
import { IncomingCallNotification } from './IncomingCallNotification'; import { IncomingCallNotification } from './IncomingCallNotification';
import { useClientConfig } from '../../hooks/useClientConfig';
interface CallProviderWrapperProps { interface CallProviderWrapperProps {
children: ReactNode; children: ReactNode;
@@ -13,13 +12,7 @@ interface CallProviderWrapperProps {
* Also renders the CallOverlay for active call controls and monitors incoming calls * Also renders the CallOverlay for active call controls and monitors incoming calls
*/ */
export function CallProviderWrapper({ children }: CallProviderWrapperProps) { export function CallProviderWrapper({ children }: CallProviderWrapperProps) {
const clientConfig = useClientConfig(); const callContextValue = useCallService();
const livekitServiceUrl = clientConfig.calling?.livekitServiceUrl;
// eslint-disable-next-line no-console
console.log('CallProviderWrapper - config calling:', clientConfig.calling, 'livekitUrl:', livekitServiceUrl);
const callContextValue = useCallService({ livekitServiceUrl });
return ( return (
<CallProvider value={callContextValue}> <CallProvider value={callContextValue}>

View File

@@ -22,7 +22,15 @@ import {
LiveKitJWTResponse, LiveKitJWTResponse,
CallMemberEventContent, CallMemberEventContent,
} from './types'; } from './types';
import { fetchLiveKitJWT, fetchWellKnownWithRTC, getLiveKitFocus, requestOpenIdToken } from './useRtcConfig'; import {
fetchLiveKitJWT,
fetchWellKnownWithRTC,
getLiveKitFocus,
requestOpenIdToken,
fetchLiveKitJWTFromServers,
getLiveKitHomeserverPriority,
} from './useRtcConfig';
import { getActiveCallMembers } from './useRoomCallMembers';
import { getAudioSettings, SCREEN_SHARE_RESOLUTIONS, SCREEN_SHARE_BITRATES } from '../settings/audio/Audio'; import { getAudioSettings, SCREEN_SHARE_RESOLUTIONS, SCREEN_SHARE_BITRATES } from '../settings/audio/Audio';
import { getCallSounds, CallSoundType } from './CallSounds'; import { getCallSounds, CallSoundType } from './CallSounds';
@@ -174,6 +182,25 @@ export class CallService {
): Promise<void> { ): Promise<void> {
const stateKey = this.config.userId; const stateKey = this.config.userId;
// Check if user has permission to send this state event
const room = this.matrixClient.getRoom(roomId);
if (room) {
const powerLevelsEvent = room.currentState.getStateEvents('m.room.power_levels', '');
const powerLevelsContent = powerLevelsEvent?.getContent() || {};
const myPower = powerLevelsContent.users?.[this.config.userId] ?? powerLevelsContent.users_default ?? 0;
const requiredPower = powerLevelsContent.events?.[CALL_MEMBER_EVENT_TYPE] ?? powerLevelsContent.state_default ?? 50;
if (myPower < requiredPower) {
console.warn(
`Cannot send call member event: insufficient permissions. ` +
`User power level (${myPower}) < required power level (${requiredPower}). ` +
`Call will work locally but room members won't see your call status.`
);
return;
}
}
if (active) { if (active) {
const content: CallMemberEventContent = { const content: CallMemberEventContent = {
'm.calls': [ 'm.calls': [
@@ -293,39 +320,59 @@ export class CallService {
}); });
try { try {
// Step 1: Get an OpenID token from the Matrix homeserver // Step 1: Get room and determine which homeservers to try
const openIdToken = await requestOpenIdToken(
this.config.homeserverBaseUrl,
this.config.userId,
this.config.accessToken
);
// Step 2: Determine LiveKit service URL (check room-level config first)
let livekitServiceUrl = this.config.livekitServiceUrl;
// Check if room has a custom LiveKit config
const room = this.matrixClient.getRoom(roomId); const room = this.matrixClient.getRoom(roomId);
if (room) { if (!room) {
const livekitConfigEvent = room.currentState.getStateEvents('im.paarrot.room.livekit_config', ''); throw new Error(`Room ${roomId} not found`);
if (livekitConfigEvent) {
const roomLivekitUrl = livekitConfigEvent.getContent()?.service_url;
if (roomLivekitUrl) {
console.log('Using room-level LiveKit URL:', roomLivekitUrl);
livekitServiceUrl = roomLivekitUrl;
}
}
} }
// Step 3: Exchange OpenID token for LiveKit JWT // Check if there's already an active call
console.log('Requesting LiveKit JWT for Matrix room:', roomId); const activeCallMembers = getActiveCallMembers(room);
this.livekitJwt = await fetchLiveKitJWT( const isJoiningExistingCall = activeCallMembers.length > 0;
livekitServiceUrl,
if (isJoiningExistingCall) {
console.log(`Joining existing call with ${activeCallMembers.length} participant(s)`);
} else {
console.log('Starting new call');
}
const homeserverPriority = getLiveKitHomeserverPriority(
room,
this.config.homeserverBaseUrl,
this.config.userId
);
console.log('Trying homeservers in order:', homeserverPriority);
// Step 2: Try fetching LiveKit JWT from homeservers in priority order
const result = await fetchLiveKitJWTFromServers(
homeserverPriority,
this.config.homeserverBaseUrl,
roomId, roomId,
this.config.userId, this.config.userId,
this.config.deviceId, this.config.deviceId,
openIdToken this.config.accessToken
); );
if (!result) {
throw new Error('Failed to get LiveKit JWT from any homeserver');
}
this.livekitJwt = result.jwt;
// Track if using remote homeserver
const userHomeserverDomain = this.config.homeserverBaseUrl
.replace('https://', '')
.replace('http://', '');
const usedHomeserverDomain = result.homeserver
.replace('https://', '')
.replace('http://', '');
if (usedHomeserverDomain !== userHomeserverDomain) {
this.activeCall!.livekitHomeserver = result.homeserver;
console.log(`Using remote homeserver for LiveKit: ${result.homeserver}`);
}
console.log('Got LiveKit JWT, connecting to:', this.livekitJwt.url); console.log('Got LiveKit JWT, connecting to:', this.livekitJwt.url);
console.log('LiveKit JWT response:', JSON.stringify(this.livekitJwt)); console.log('LiveKit JWT response:', JSON.stringify(this.livekitJwt));
@@ -934,6 +981,22 @@ export class CallService {
return this.livekitRoom; return this.livekitRoom;
} }
/**
* Checks if the current call is using a remote homeserver for LiveKit
* @returns true if using a different homeserver than the user's
*/
isUsingRemoteHomeserver(): boolean {
return !!this.activeCall?.livekitHomeserver;
}
/**
* Gets the homeserver being used for LiveKit (if remote)
* @returns The remote homeserver URL, or undefined if using user's homeserver
*/
getLiveKitHomeserver(): string | undefined {
return this.activeCall?.livekitHomeserver;
}
/** /**
* Starts screen sharing with configurable resolution and bitrate * Starts screen sharing with configurable resolution and bitrate
* @returns true if screen share started successfully * @returns true if screen share started successfully

View File

@@ -102,6 +102,7 @@ export function IncomingCallNotification() {
const { activeCall, startCall } = useCall(); const { activeCall, startCall } = useCall();
const [incomingCall, setIncomingCall] = useState<IncomingCall | null>(null); const [incomingCall, setIncomingCall] = useState<IncomingCall | null>(null);
const [declinedRooms, setDeclinedRooms] = useState<Set<string>>(new Set()); const [declinedRooms, setDeclinedRooms] = useState<Set<string>>(new Set());
const [seenCalls, setSeenCalls] = useState<Map<string, number>>(new Map());
/** /**
* Checks all rooms for incoming calls * Checks all rooms for incoming calls
@@ -118,15 +119,25 @@ export function IncomingCallNotification() {
// Check all joined rooms for calls // Check all joined rooms for calls
const rooms = mx.getRooms(); const rooms = mx.getRooms();
const now = Date.now();
const MAX_CALL_AGE = 10000; // 10 seconds
// Find the first room with an incoming call // Find the first room with an incoming call
const incomingRoom = rooms.find((room) => { const incomingRoom = rooms.find((room) => {
// Only monitor DMs and small groups // Only monitor 1-1 DMs (exactly 2 members)
if (!isDirectOrSmallGroup(room)) return false; if (room.getJoinedMemberCount() !== 2) return false;
// Skip rooms we've declined // Skip rooms we've declined
if (declinedRooms.has(room.roomId)) return false; if (declinedRooms.has(room.roomId)) return false;
const { members } = getOtherCallMembersWithTime(room, myUserId); const { members, earliestTime } = getOtherCallMembersWithTime(room, myUserId);
return members.length > 0; if (members.length === 0) return false;
// Skip if we've already seen this exact call session
const lastSeenTime = seenCalls.get(room.roomId);
if (lastSeenTime && lastSeenTime === earliestTime) return false;
// Only show if the call is fresh (less than 10 seconds old)
const callAge = now - earliestTime;
return callAge < MAX_CALL_AGE;
}); });
if (incomingRoom) { if (incomingRoom) {
@@ -140,28 +151,33 @@ export function IncomingCallNotification() {
} else { } else {
setIncomingCall(null); setIncomingCall(null);
} }
}, [mx, activeCall, declinedRooms]); }, [mx, activeCall, declinedRooms, seenCalls]);
/** /**
* Handles state events for call member changes * Handles state events for call member changes
*/ */
const handleStateEvent = useCallback((event: MatrixEvent) => { const handleStateEvent = useCallback((event: MatrixEvent) => {
if (event.getType() === CALL_MEMBER_EVENT_TYPE) { if (event.getType() === CALL_MEMBER_EVENT_TYPE) {
// Clear declined status if call ended in that room // Clear declined status and seen calls if call ended in that room
const roomId = event.getRoomId(); const roomId = event.getRoomId();
if (roomId && declinedRooms.has(roomId)) { if (roomId) {
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
if (room) { if (room) {
const myUserId = mx.getUserId(); const myUserId = mx.getUserId();
if (myUserId) { if (myUserId) {
const members = getOtherCallMembers(room, myUserId); const members = getOtherCallMembers(room, myUserId);
if (members.length === 0) { if (members.length === 0) {
// Call ended, remove from declined // Call ended, clear tracking
setDeclinedRooms((prev) => { setDeclinedRooms((prev) => {
const newSet = new Set(prev); const newSet = new Set(prev);
newSet.delete(roomId); newSet.delete(roomId);
return newSet; return newSet;
}); });
setSeenCalls((prev) => {
const newMap = new Map(prev);
newMap.delete(roomId);
return newMap;
});
} }
} }
} }
@@ -183,9 +199,12 @@ export function IncomingCallNotification() {
}, [mx, handleStateEvent, checkForIncomingCalls]); }, [mx, handleStateEvent, checkForIncomingCalls]);
// Play/stop dialing sound based on incoming call state // Play/stop dialing sound based on incoming call state
// Only play sound if call started less than 15 seconds ago // Mark call as seen when displayed
useEffect(() => { useEffect(() => {
if (incomingCall) { if (incomingCall) {
// Mark this call as seen so we don't notify again
setSeenCalls((prev) => new Map(prev).set(incomingCall.roomId, incomingCall.startTime));
const callAge = Date.now() - incomingCall.startTime; const callAge = Date.now() - incomingCall.startTime;
const MAX_CALL_AGE_FOR_SOUND = 15000; // 15 seconds const MAX_CALL_AGE_FOR_SOUND = 15000; // 15 seconds
@@ -212,6 +231,7 @@ export function IncomingCallNotification() {
if (!incomingCall) return; if (!incomingCall) return;
getCallSounds().stopDialingLoop(); getCallSounds().stopDialingLoop();
setSeenCalls((prev) => new Map(prev).set(incomingCall.roomId, incomingCall.startTime));
try { try {
await startCall(incomingCall.roomId, CallType.Voice); await startCall(incomingCall.roomId, CallType.Voice);
} catch (error) { } catch (error) {
@@ -226,6 +246,7 @@ export function IncomingCallNotification() {
if (!incomingCall) return; if (!incomingCall) return;
getCallSounds().stopDialingLoop(); getCallSounds().stopDialingLoop();
setSeenCalls((prev) => new Map(prev).set(incomingCall.roomId, incomingCall.startTime));
setDeclinedRooms((prev) => new Set(prev).add(incomingCall.roomId)); setDeclinedRooms((prev) => new Set(prev).add(incomingCall.roomId));
setIncomingCall(null); setIncomingCall(null);
}, [incomingCall]); }, [incomingCall]);
@@ -237,6 +258,7 @@ export function IncomingCallNotification() {
const room = mx.getRoom(incomingCall.roomId); const room = mx.getRoom(incomingCall.roomId);
const firstCaller = incomingCall.callerIds[0]; const firstCaller = incomingCall.callerIds[0];
const isGroupCall = room ? room.getJoinedMemberCount() > 2 : false;
/** /**
* Gets the display name for a user * Gets the display name for a user
@@ -287,10 +309,12 @@ export function IncomingCallNotification() {
</span> </span>
)} )}
</Text> </Text>
<Text size="T200" className={css.RoomName}> {!isGroupCall && (
<Icon size="100" src={Icons.Phone} /> <Text size="T200" className={css.RoomName}>
<span>Calling from {incomingCall.roomName}</span> <Icon size="100" src={Icons.Phone} />
</Text> <span>Calling from {incomingCall.roomName}</span>
</Text>
)}
</Box> </Box>
</div> </div>

View File

@@ -119,6 +119,8 @@ export interface ActiveCall {
isDeafened: boolean; isDeafened: boolean;
isVideoEnabled: boolean; isVideoEnabled: boolean;
isScreenSharing: boolean; isScreenSharing: boolean;
/** The homeserver being used for LiveKit JWT (if different from user's homeserver) */
livekitHomeserver?: string;
} }
/** /**
@@ -150,7 +152,8 @@ export interface CallEvent {
* Call service configuration * Call service configuration
*/ */
export interface CallServiceConfig { export interface CallServiceConfig {
livekitServiceUrl: string; /** @deprecated No longer used - JWT fetched dynamically from multiple homeservers */
livekitServiceUrl?: string;
homeserverBaseUrl: string; homeserverBaseUrl: string;
accessToken: string; accessToken: string;
userId: string; userId: string;

View File

@@ -2,7 +2,7 @@ import { createContext, useContext, useState, useCallback, useEffect, useMemo }
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { CallService } from './CallService'; import { CallService } from './CallService';
import { ActiveCall, CallEvent, CallEventType, CallState, CallType } from './types'; import { ActiveCall, CallEvent, CallEventType, CallState, CallType } from './types';
import { fetchWellKnownWithRTC, getLiveKitFocus } from './useRtcConfig'; import { fetchWellKnownWithRTC, getLiveKitFocus, getLiveKitHomeserverPriority } from './useRtcConfig';
interface CallContextValue { interface CallContextValue {
callService: CallService | null; callService: CallService | null;
@@ -55,24 +55,17 @@ export function useCall(): CallContextValue {
export const CallProvider = CallContext.Provider; export const CallProvider = CallContext.Provider;
interface UseCallServiceOptions {
livekitServiceUrl?: string;
}
/** /**
* Hook to initialize and manage the call service * Hook to initialize and manage the call service
* Should be used at the app level to provide call context * Should be used at the app level to provide call context
* @param options - Optional configuration including livekitServiceUrl from client config
*/ */
export function useCallService(options?: UseCallServiceOptions): CallContextValue { export function useCallService(): CallContextValue {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [callService, setCallService] = useState<CallService | null>(null); const [callService, setCallService] = useState<CallService | null>(null);
const [activeCall, setActiveCall] = useState<ActiveCall | null>(null); const [activeCall, setActiveCall] = useState<ActiveCall | null>(null);
const [callSupported, setCallSupported] = useState(false); const [callSupported, setCallSupported] = useState(false);
const [callSupportLoading, setCallSupportLoading] = useState(true); const [callSupportLoading, setCallSupportLoading] = useState(true);
const configuredLivekitUrl = options?.livekitServiceUrl;
useEffect(() => { useEffect(() => {
let unsubscribe: (() => void) | undefined; let unsubscribe: (() => void) | undefined;
let service: CallService | undefined; let service: CallService | undefined;
@@ -89,35 +82,12 @@ export function useCallService(options?: UseCallServiceOptions): CallContextValu
} }
try { try {
let livekitServiceUrl: string | undefined = configuredLivekitUrl; // Initialize CallService without checking user's homeserver
// Call support will be determined per-room based on available homeservers
// If no configured URL, try to get from well-known console.log('Call service init - Initializing (call support determined per-room)');
if (!livekitServiceUrl) {
const wellKnown = await fetchWellKnownWithRTC(baseUrl);
// eslint-disable-next-line no-console
console.log('Call service init - well-known:', wellKnown);
const livekitFocus = getLiveKitFocus(wellKnown);
// eslint-disable-next-line no-console
console.log('Call service init - LiveKit focus:', livekitFocus);
livekitServiceUrl = livekitFocus?.livekit_service_url;
} else {
// eslint-disable-next-line no-console
console.log('Call service init - Using configured LiveKit URL:', livekitServiceUrl);
}
// If still no URL, log warning but allow service to initialize
// Individual rooms may have their own LiveKit configs
if (!livekitServiceUrl) {
// eslint-disable-next-line no-console
console.log('Call service init - No global LiveKit URL found');
console.log('Call service init - Will check for room-level configs when joining calls');
// Use a placeholder URL - room-level config will override this
livekitServiceUrl = 'https://placeholder.invalid/livekit';
}
service = new CallService( service = new CallService(
{ {
livekitServiceUrl,
homeserverBaseUrl: baseUrl, homeserverBaseUrl: baseUrl,
accessToken, accessToken,
userId, userId,
@@ -128,10 +98,10 @@ export function useCallService(options?: UseCallServiceOptions): CallContextValu
setCallService(service); setCallService(service);
setGlobalCallService(service); // Make available globally for Paarrot API setGlobalCallService(service); // Make available globally for Paarrot API
setCallSupported(true); setCallSupported(true); // Global flag - actual support checked per-room
setCallSupportLoading(false); setCallSupportLoading(false);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('Call service init - Success! Calls are supported'); console.log('Call service init - Success!');
unsubscribe = service.on((event: CallEvent) => { unsubscribe = service.on((event: CallEvent) => {
// Update state for any event that might change the call state // Update state for any event that might change the call state
@@ -173,7 +143,7 @@ export function useCallService(options?: UseCallServiceOptions): CallContextValu
service?.dispose(); service?.dispose();
setGlobalCallService(null); // Clear global reference setGlobalCallService(null); // Clear global reference
}; };
}, [mx, configuredLivekitUrl]); }, [mx]);
const startCall = useCallback( const startCall = useCallback(
async (roomId: string, callType: CallType) => { async (roomId: string, callType: CallType) => {
@@ -261,10 +231,90 @@ export function useCallService(options?: UseCallServiceOptions): CallContextValu
* Hook to check if a call is active in a specific room * Hook to check if a call is active in a specific room
*/ */
export function useRoomCall(roomId: string) { export function useRoomCall(roomId: string) {
const { activeCall, startCall, endCall, callSupported, callSupportLoading } = useCall(); const { activeCall, startCall, endCall, callSupported: globalCallSupported, callSupportLoading: globalCallSupportLoading, callService } = useCall();
const mx = useMatrixClient();
const [roomCallSupported, setRoomCallSupported] = useState(false);
const [roomCallSupportLoading, setRoomCallSupportLoading] = useState(true);
// Check if calls are supported for this specific room by checking all priority homeservers
useEffect(() => {
const checkRoomCallSupport = async () => {
setRoomCallSupportLoading(true);
const room = mx.getRoom(roomId);
if (!room) {
setRoomCallSupported(false);
setRoomCallSupportLoading(false);
return;
}
// Get homeserver priority list for this room
const homeserverPriority = getLiveKitHomeserverPriority(
room,
mx.getHomeserverUrl(),
mx.getSafeUserId()
);
console.log(`Checking call support for room ${roomId}, homeserver priority:`, homeserverPriority);
// Check each homeserver in priority order
for (const homeserver of homeserverPriority) {
try {
console.log(`Fetching well-known from ${homeserver}...`);
const wellKnown = await fetchWellKnownWithRTC(homeserver);
console.log(`Well-known response from ${homeserver}:`, wellKnown);
const livekitFocus = getLiveKitFocus(wellKnown);
console.log(`LiveKit focus from ${homeserver}:`, livekitFocus);
if (livekitFocus) {
console.log(`Call support found for room ${roomId} on homeserver: ${homeserver}`);
setRoomCallSupported(true);
setRoomCallSupportLoading(false);
return;
}
} catch (error) {
console.warn(`Failed to check call support on ${homeserver}:`, error);
// Continue to next homeserver
}
}
// No homeserver supports calls
console.log(`No call support found for room ${roomId}`);
setRoomCallSupported(false);
setRoomCallSupportLoading(false);
};
checkRoomCallSupport();
}, [roomId, mx]);
const isInCall = activeCall?.roomId === roomId; const isInCall = activeCall?.roomId === roomId;
const callState = isInCall ? activeCall.state : CallState.Idle; const callState = isInCall ? activeCall.state : CallState.Idle;
const isUsingRemoteHomeserver = isInCall ? callService?.isUsingRemoteHomeserver() ?? false : false;
// Check if a remote homeserver WOULD be used (pre-call check)
const wouldUseRemoteHomeserver = useMemo(() => {
if (isInCall) return isUsingRemoteHomeserver;
const room = mx.getRoom(roomId);
if (!room) return false;
const homeserverPriority = getLiveKitHomeserverPriority(
room,
mx.getHomeserverUrl(),
mx.getSafeUserId()
);
// Check if first priority is different from user's homeserver
const userHomeserverDomain = mx.getHomeserverUrl()
.replace('https://', '')
.replace('http://', '');
const firstPriorityDomain = homeserverPriority[0]
?.replace('https://', '')
.replace('http://', '');
return firstPriorityDomain !== userHomeserverDomain;
}, [isInCall, isUsingRemoteHomeserver, mx, roomId]);
const startVoiceCall = useCallback(() => startCall(roomId, CallType.Voice), [roomId, startCall]); const startVoiceCall = useCallback(() => startCall(roomId, CallType.Voice), [roomId, startCall]);
const startVideoCall = useCallback(() => startCall(roomId, CallType.Video), [roomId, startCall]); const startVideoCall = useCallback(() => startCall(roomId, CallType.Video), [roomId, startCall]);
@@ -273,10 +323,11 @@ export function useRoomCall(roomId: string) {
isInCall, isInCall,
callState, callState,
activeCall: isInCall ? activeCall : null, activeCall: isInCall ? activeCall : null,
isUsingRemoteHomeserver: wouldUseRemoteHomeserver,
startVoiceCall, startVoiceCall,
startVideoCall, startVideoCall,
endCall, endCall,
callSupported, callSupported: roomCallSupported,
callSupportLoading, callSupportLoading: roomCallSupportLoading,
}; };
} }

View File

@@ -19,7 +19,7 @@ export interface CallMember {
* @param room - The Matrix room to check * @param room - The Matrix room to check
* @returns Array of active call members * @returns Array of active call members
*/ */
function getActiveCallMembers(room: Room): CallMember[] { export function getActiveCallMembers(room: Room): CallMember[] {
const members: CallMember[] = []; const members: CallMember[] = [];
const now = Date.now(); const now = Date.now();

View File

@@ -1,6 +1,109 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { LiveKitFocus, LiveKitJWTResponse, OpenIdToken, WellKnownWithRTC } from './types'; import { LiveKitFocus, LiveKitJWTResponse, OpenIdToken, WellKnownWithRTC } from './types';
import { getMxIdServer } from '../../utils/matrix';
import { guessDmRoomUserId } from '../../utils/matrix';
import { getActiveCallMembers } from './useRoomCallMembers';
/**
* Get room server from room ID (format: !roomid:server.com)
*/
function getRoomServer(roomId: string): string | undefined {
const server = getMxIdServer(roomId);
console.log(`[getRoomServer] Room ID: ${roomId} -> Server: ${server}`);
return server;
}
/**
* Determine which homeservers to try for LiveKit JWT, in priority order
* Prioritizes servers where active call members are located when joining existing calls
* @param room - Room to start/join call in
* @param userHomeserver - User's homeserver
* @param myUserId - Current user's Matrix ID
* @returns Array of homeserver base URLs to try in order
*/
export function getLiveKitHomeserverPriority(
room: Room,
userHomeserver: string,
myUserId: string
): string[] {
const servers: string[] = [];
const memberCount = room.getMembers().length;
const isDM = memberCount <= 2;
console.log(`[getLiveKitHomeserverPriority] Room: ${room.roomId}`);
console.log(`[getLiveKitHomeserverPriority] Member count: ${memberCount}, isDM: ${isDM}`);
console.log(`[getLiveKitHomeserverPriority] User homeserver: ${userHomeserver}`);
// Check for active call members
const activeCallMembers = getActiveCallMembers(room);
const hasActiveCall = activeCallMembers.length > 0;
console.log(`[getLiveKitHomeserverPriority] Active call members: ${activeCallMembers.length}, hasActiveCall: ${hasActiveCall}`);
if (hasActiveCall) {
// Joining existing call - prioritize room's homeserver (where call is hosted)
const roomServer = getRoomServer(room.roomId);
console.log(`[getLiveKitHomeserverPriority] Room server (active call): ${roomServer}`);
if (roomServer) {
servers.push(`https://${roomServer}`);
}
// Add user's homeserver as fallback
const userDomain = userHomeserver.replace('https://', '').replace('http://', '');
if (!roomServer || roomServer !== userDomain) {
servers.push(userHomeserver);
}
// For DMs, also try other member's homeserver
if (isDM) {
const otherUserId = guessDmRoomUserId(room, myUserId);
if (otherUserId && otherUserId !== myUserId) {
const otherServer = getMxIdServer(otherUserId);
if (otherServer && otherServer !== userDomain && otherServer !== roomServer) {
servers.push(`https://${otherServer}`);
}
}
}
} else {
// Starting new call - use original priority logic
if (isDM) {
console.log(`[getLiveKitHomeserverPriority] DM path - user homeserver first`);
// For DMs: user's homeserver first, then other member's homeserver
servers.push(userHomeserver);
const otherUserId = guessDmRoomUserId(room, myUserId);
console.log(`[getLiveKitHomeserverPriority] Other user ID: ${otherUserId}`);
if (otherUserId && otherUserId !== myUserId) {
const otherServer = getMxIdServer(otherUserId);
console.log(`[getLiveKitHomeserverPriority] Other user server: ${otherServer}`);
const userDomain = userHomeserver.replace('https://', '').replace('http://', '');
if (otherServer && otherServer !== userDomain) {
servers.push(`https://${otherServer}`);
}
}
} else {
console.log(`[getLiveKitHomeserverPriority] Channel path - room homeserver first`);
// For channels: room's homeserver first, then user's homeserver
const roomServer = getRoomServer(room.roomId);
console.log(`[getLiveKitHomeserverPriority] Room server from ID: ${roomServer}`);
if (roomServer) {
servers.push(`https://${roomServer}`);
}
// Fallback to user's homeserver if different
const userDomain = userHomeserver.replace('https://', '').replace('http://', '');
console.log(`[getLiveKitHomeserverPriority] User domain: ${userDomain}, room server: ${roomServer}`);
if (!roomServer || roomServer !== userDomain) {
servers.push(userHomeserver);
}
}
}
console.log(`[getLiveKitHomeserverPriority] Final server list:`, servers);
return servers;
}
/** /**
* Fetches the LiveKit focus configuration from well-known data * Fetches the LiveKit focus configuration from well-known data
@@ -9,11 +112,16 @@ import { LiveKitFocus, LiveKitJWTResponse, OpenIdToken, WellKnownWithRTC } from
*/ */
export function getLiveKitFocus(wellKnown: WellKnownWithRTC): LiveKitFocus | undefined { export function getLiveKitFocus(wellKnown: WellKnownWithRTC): LiveKitFocus | undefined {
const rtcFoci = wellKnown['org.matrix.msc4143.rtc_foci']; const rtcFoci = wellKnown['org.matrix.msc4143.rtc_foci'];
console.log(`[getLiveKitFocus] rtcFoci from well-known:`, rtcFoci);
if (!rtcFoci || !Array.isArray(rtcFoci)) { if (!rtcFoci || !Array.isArray(rtcFoci)) {
console.log(`[getLiveKitFocus] No rtc_foci found or not an array`);
return undefined; return undefined;
} }
return rtcFoci.find((focus): focus is LiveKitFocus => focus.type === 'livekit'); const livekitFocus = rtcFoci.find((focus): focus is LiveKitFocus => focus.type === 'livekit');
console.log(`[getLiveKitFocus] Found LiveKit focus:`, livekitFocus);
return livekitFocus;
} }
/** /**
@@ -22,14 +130,28 @@ export function getLiveKitFocus(wellKnown: WellKnownWithRTC): LiveKitFocus | und
* @returns The well-known response with RTC configuration * @returns The well-known response with RTC configuration
*/ */
export async function fetchWellKnownWithRTC(baseUrl: string): Promise<WellKnownWithRTC> { export async function fetchWellKnownWithRTC(baseUrl: string): Promise<WellKnownWithRTC> {
const wellKnownUrl = `${baseUrl}/.well-known/matrix/client`; // In development mode (localhost), use CORS proxy to avoid CORS issues
const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
let wellKnownUrl: string;
if (isDev) {
// Extract hostname from baseUrl for proxy
const hostname = baseUrl.replace('https://', '').replace('http://', '').replace(/\/$/, '');
wellKnownUrl = `/_matrix-cors-proxy/${hostname}/.well-known/matrix/client`;
} else {
wellKnownUrl = `${baseUrl}/.well-known/matrix/client`;
}
console.log(`[fetchWellKnownWithRTC] Fetching from: ${wellKnownUrl}`);
const response = await fetch(wellKnownUrl); const response = await fetch(wellKnownUrl);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch well-known: ${response.status}`); throw new Error(`Failed to fetch well-known: ${response.status}`);
} }
return response.json(); const data = await response.json();
console.log(`[fetchWellKnownWithRTC] Response data:`, data);
return data;
} }
/** /**
@@ -45,7 +167,16 @@ export async function requestOpenIdToken(
userId: string, userId: string,
accessToken: string accessToken: string
): Promise<OpenIdToken> { ): Promise<OpenIdToken> {
const url = `${homeserverBaseUrl}/_matrix/client/v3/user/${encodeURIComponent(userId)}/openid/request_token`; // In development mode (localhost), use CORS proxy to avoid CORS issues
const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
let url: string;
if (isDev) {
const hostname = homeserverBaseUrl.replace('https://', '').replace('http://', '').replace(/\/$/, '');
url = `/_matrix-cors-proxy/${hostname}/_matrix/client/v3/user/${encodeURIComponent(userId)}/openid/request_token`;
} else {
url = `${homeserverBaseUrl}/_matrix/client/v3/user/${encodeURIComponent(userId)}/openid/request_token`;
}
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
@@ -66,7 +197,7 @@ export async function requestOpenIdToken(
/** /**
* Fetches a LiveKit JWT for joining a call using OpenID token authentication * Fetches a LiveKit JWT for joining a call using OpenID token authentication
* @param livekitServiceUrl - The LiveKit service URL from well-known or config * @param livekitServiceUrl - The LiveKit service URL from Matrix homeserver well-known
* @param roomId - The Matrix room ID to join * @param roomId - The Matrix room ID to join
* @param userId - The Matrix user ID * @param userId - The Matrix user ID
* @param deviceId - The Matrix device ID * @param deviceId - The Matrix device ID
@@ -88,7 +219,21 @@ export async function fetchLiveKitJWT(
// to create the LiveKit room name. // to create the LiveKit room name.
const slotId = 'm.call'; const slotId = 'm.call';
const response = await fetch(livekitServiceUrl, { // In development mode (localhost), use CORS proxy to avoid CORS issues
const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
let url: string;
if (isDev) {
// Extract hostname and path from livekitServiceUrl
const urlObj = new URL(livekitServiceUrl);
const hostname = urlObj.hostname;
const pathname = urlObj.pathname;
url = `/_matrix-cors-proxy/${hostname}${pathname}`;
} else {
url = livekitServiceUrl;
}
const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -113,6 +258,62 @@ export async function fetchLiveKitJWT(
return response.json(); return response.json();
} }
/**
* Try fetching LiveKit JWT from multiple homeservers in priority order
* @param homeservers - Array of homeserver base URLs to check for LiveKit config
* @param userHomeserver - The user's own homeserver URL (for OpenID token)
* @param roomId - The Matrix room ID
* @param userId - The Matrix user ID
* @param deviceId - The Matrix device ID
* @param accessToken - The Matrix access token
* @returns LiveKit JWT response and the homeserver that provided it, or null
*/
export async function fetchLiveKitJWTFromServers(
homeservers: string[],
userHomeserver: string,
roomId: string,
userId: string,
deviceId: string,
accessToken: string
): Promise<{ jwt: LiveKitJWTResponse; homeserver: string } | null> {
for (const homeserver of homeservers) {
try {
console.log(`Trying LiveKit JWT from homeserver: ${homeserver}`);
// Fetch well-known from this homeserver
const wellKnown = await fetchWellKnownWithRTC(homeserver);
const livekitFocus = getLiveKitFocus(wellKnown);
if (!livekitFocus) {
console.log(`No LiveKit config on ${homeserver}, trying next...`);
continue;
}
// Always get OpenID token from user's own homeserver
// (Can't use access token on remote homeserver)
const openIdToken = await requestOpenIdToken(userHomeserver, userId, accessToken);
// Exchange for LiveKit JWT
const jwt = await fetchLiveKitJWT(
livekitFocus.livekit_service_url,
roomId,
userId,
deviceId,
openIdToken
);
console.log(`Successfully got LiveKit JWT from ${homeserver}`);
return { jwt, homeserver };
} catch (error) {
console.warn(`Failed to get LiveKit JWT from ${homeserver}:`, error);
// Continue to next homeserver
}
}
console.error('Failed to get LiveKit JWT from any homeserver');
return null;
}
/** /**
* Hook to get RTC configuration functions * Hook to get RTC configuration functions
* Provides utilities for fetching well-known data and LiveKit JWTs * Provides utilities for fetching well-known data and LiveKit JWTs

View File

@@ -1,156 +0,0 @@
import React, { FormEventHandler, useCallback, useState } from 'react';
import {
Box,
Button,
color,
Input,
Spinner,
Text,
} from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useRoom } from '../../../hooks/useRoom';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { StateEvent } from '../../../../types/matrix/room';
interface LivekitConfigContent {
service_url?: string;
}
/**
* Room-specific LiveKit configuration component.
* Allows room admins to override the LiveKit service URL for calls in this room.
*/
export function LivekitConfig() {
const mx = useMatrixClient();
const room = useRoom();
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const stateEvent = useStateEvent(room, StateEvent.RoomLivekitConfig);
const currentConfig = stateEvent?.getContent<LivekitConfigContent>();
const currentUrl = currentConfig?.service_url || '';
const [urlValue, setUrlValue] = useState(currentUrl);
const [urlError, setUrlError] = useState<string>();
const [saveState, saveConfig] = useAsyncCallback(
useCallback(
async (serviceUrl: string) => {
const content: LivekitConfigContent = serviceUrl
? { service_url: serviceUrl }
: {};
await mx.sendStateEvent(room.roomId, StateEvent.RoomLivekitConfig as any, content, '');
},
[mx, room]
)
);
const canEdit = permissions.stateEvent(StateEvent.RoomLivekitConfig, mx.getUserId()!);
const handleSave: FormEventHandler<HTMLFormElement> = async (evt) => {
evt.preventDefault();
const serviceUrl = urlValue.trim();
// Basic URL validation
if (serviceUrl && !serviceUrl.startsWith('http://') && !serviceUrl.startsWith('https://')) {
setUrlError('URL must start with http:// or https://');
return;
}
setUrlError(undefined);
await saveConfig(serviceUrl);
};
const handleClear = async () => {
setUrlValue('');
setUrlError(undefined);
await saveConfig('');
};
const hasChanges = urlValue !== currentUrl;
const isSaving = saveState.status === AsyncStatus.Loading;
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="LiveKit Service URL"
description="Override the LiveKit JWT service endpoint for Matrix RTC calls in this room. Leave empty to use the server default or user preference."
/>
<Box as="form" onSubmit={handleSave} direction="Column" gap="200" style={{ padding: '0 16px' }}>
<Input
value={urlValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setUrlValue(e.target.value);
setUrlError(undefined);
}}
placeholder="https://example.com/livekit/jwt"
size="300"
variant="Secondary"
radii="300"
disabled={!canEdit || isSaving}
style={{ width: '100%' }}
/>
{urlError && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{urlError}
</Text>
)}
{saveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
Failed to save: {saveState.error instanceof Error ? saveState.error.message : 'Unknown error'}
</Text>
)}
{saveState.status === AsyncStatus.Success && !hasChanges && (
<Text size="T200" style={{ color: color.Success.Main }}>
Configuration saved
</Text>
)}
<Box gap="200" alignItems="Center">
<Button
type="submit"
size="300"
variant="Primary"
fill="Solid"
radii="300"
disabled={!canEdit || isSaving || !hasChanges}
before={isSaving && <Spinner size="100" variant="Primary" fill="Solid" />}
>
<Text size="B300">Save</Text>
</Button>
{currentUrl && (
<Button
type="button"
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={!canEdit || isSaving}
onClick={handleClear}
>
<Text size="B300">Clear</Text>
</Button>
)}
{!canEdit && (
<Text size="T200" priority="300">
You don't have permission to change this setting
</Text>
)}
</Box>
</Box>
</SequenceCard>
);
}

View File

@@ -1,5 +1,4 @@
export * from './EmbedFilters'; export * from './EmbedFilters';
export * from './LivekitConfig';
export * from './RoomAddress'; export * from './RoomAddress';
export * from './RoomEncryption'; export * from './RoomEncryption';
export * from './RoomHistoryVisibility'; export * from './RoomHistoryVisibility';

View File

@@ -9,7 +9,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { ErrorCode } from '../../cs-errorcode'; import { ErrorCode } from '../../cs-errorcode';
import { millisecondsToMinutes } from '../../utils/common'; import { millisecondsToMinutes } from '../../utils/common';
import { createRoomEncryptionState } from '../../components/create-room'; import { createRoomEncryptionState, createRoomPowerLevelsState } from '../../components/create-room';
import { useAlive } from '../../hooks/useAlive'; import { useAlive } from '../../hooks/useAlive';
import { getDirectRoomPath } from '../../pages/pathUtils'; import { getDirectRoomPath } from '../../pages/pathUtils';
@@ -29,6 +29,7 @@ export function CreateChat({ defaultUserId }: CreateChatProps) {
async (userId, encrypted) => { async (userId, encrypted) => {
const initialState: ICreateRoomStateEvent[] = []; const initialState: ICreateRoomStateEvent[] = [];
initialState.push(createRoomPowerLevelsState());
if (encrypted) initialState.push(createRoomEncryptionState()); if (encrypted) initialState.push(createRoomEncryptionState());
const result = await mx.createRoom({ const result = await mx.createRoom({

View File

@@ -1,6 +1,7 @@
import React, { MouseEventHandler, forwardRef, useState, useMemo } from 'react'; import React, { MouseEventHandler, forwardRef, useState, useMemo } from 'react';
import { Room, EventTimeline } from 'matrix-js-sdk'; import { Room, EventTimeline } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useNavigate } from 'react-router-dom';
import { import {
Avatar, Avatar,
Box, Box,
@@ -44,7 +45,7 @@ import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../util
import { getViaServers } from '../../plugins/via-servers'; import { getViaServers } from '../../plugins/via-servers';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom, ScrollToLatestBehavior } from '../../state/settings';
import { useOpenRoomSettings } from '../../state/hooks/roomSettings'; import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
import { useSpaceOptionally } from '../../hooks/useSpace'; import { useSpaceOptionally } from '../../hooks/useSpace';
import { import {
@@ -56,7 +57,7 @@ import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt'; import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { CallParticipantsIndicator } from './CallParticipantsIndicator'; import { CallParticipantsIndicator } from './CallParticipantsIndicator';
import { useCall } from '../call/useCall'; import { useCall, useRoomCall } from '../call/useCall';
import { CallType } from '../call/types'; import { CallType } from '../call/types';
import { roomToParentsAtom } from '../../state/room/roomToParents'; import { roomToParentsAtom } from '../../state/room/roomToParents';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
@@ -75,7 +76,8 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const powerLevels = usePowerLevels(room); const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room); const creators = useRoomCreators(room);
const { startCall, endCall, activeCall, callSupported } = useCall(); const { startCall: startCallGlobal, endCall, activeCall } = useCall();
const { callSupported } = useRoomCall(room.roomId);
const markAsRead = useMarkAsRead(mx); const markAsRead = useMarkAsRead(mx);
const permissions = useRoomPermissions(creators, powerLevels); const permissions = useRoomPermissions(creators, powerLevels);
@@ -119,7 +121,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
if (activeCall) { if (activeCall) {
await endCall(); await endCall();
} }
await startCall(room.roomId, CallType.Voice); await startCallGlobal(room.roomId, CallType.Voice);
requestClose(); requestClose();
} catch (error) { } catch (error) {
console.error('Failed to join voice call:', error); console.error('Failed to join voice call:', error);
@@ -293,6 +295,8 @@ export function RoomNavItem({
}: RoomNavItemProps) { }: RoomNavItemProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const navigate = useNavigate();
const [scrollToLatestBehavior] = useSetting(settingsAtom, 'scrollToLatestBehavior');
const [hover, setHover] = useState(false); const [hover, setHover] = useState(false);
const { hoverProps } = useHover({ onHoverChange: setHover }); const { hoverProps } = useHover({ onHoverChange: setHover });
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover }); const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
@@ -364,6 +368,20 @@ export function RoomNavItem({
setMenuAnchor(evt.currentTarget.getBoundingClientRect()); setMenuAnchor(evt.currentTarget.getBoundingClientRect());
}; };
const handleRoomClick: MouseEventHandler<HTMLAnchorElement> = (evt) => {
if (selected) {
evt.preventDefault();
const shouldScroll =
scrollToLatestBehavior === ScrollToLatestBehavior.Always ||
(scrollToLatestBehavior === ScrollToLatestBehavior.OnNewMessage && unread !== undefined);
if (shouldScroll) {
navigate(linkPath, { replace: true, state: { scrollToLatest: Date.now() } });
}
}
};
const optionsVisible = hover || !!menuAnchor; const optionsVisible = hover || !!menuAnchor;
return ( return (
@@ -379,7 +397,7 @@ export function RoomNavItem({
{...hoverProps} {...hoverProps}
{...focusWithinProps} {...focusWithinProps}
> >
<NavLink to={linkPath}> <NavLink to={linkPath} onClick={handleRoomClick}>
<NavItemContent> <NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap={shouldShowIcon ? "200" : "100"}> <Box as="span" grow="Yes" alignItems="Center" gap={shouldShowIcon ? "200" : "100"}>
{shouldShowIcon && ( {shouldShowIcon && (

View File

@@ -5,7 +5,6 @@ import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useRoom } from '../../../hooks/useRoom'; import { useRoom } from '../../../hooks/useRoom';
import { import {
EmbedFilters, EmbedFilters,
LivekitConfig,
RoomProfile, RoomProfile,
RoomEncryption, RoomEncryption,
RoomHistoryVisibility, RoomHistoryVisibility,
@@ -59,10 +58,6 @@ export function General({ requestClose }: GeneralProps) {
<Text size="L400">Link Previews</Text> <Text size="L400">Link Previews</Text>
<EmbedFilters /> <EmbedFilters />
</Box> </Box>
<Box direction="Column" gap="100">
<Text size="L400">Voice & Video</Text>
<LivekitConfig />
</Box>
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Addresses</Text> <Text size="L400">Addresses</Text>
<RoomPublishedAddresses permissions={permissions} /> <RoomPublishedAddresses permissions={permissions} />

View File

@@ -3,6 +3,7 @@ import { Editor } from 'slate';
import { Box, config, MenuItem, Text } from 'folds'; import { Box, config, MenuItem, Text } from 'folds';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { Command, useCommands } from '../../hooks/useCommands'; import { Command, useCommands } from '../../hooks/useCommands';
import { pluginRegistry } from '../settings/plugins/PluginAPI';
import { import {
AutocompleteMenu, AutocompleteMenu,
AutocompleteQuery, AutocompleteQuery,
@@ -38,7 +39,13 @@ export function CommandAutocomplete({
}: CommandAutocompleteProps) { }: CommandAutocompleteProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const commands = useCommands(mx, room); const commands = useCommands(mx, room);
const commandNames = useMemo(() => Object.keys(commands) as Command[], [commands]);
// Merge built-in commands with plugin commands
const commandNames = useMemo(() => {
const builtInCommands = Object.keys(commands) as Command[];
const pluginCommands = pluginRegistry.getCommands().map(cmd => cmd.name);
return [...builtInCommands, ...pluginCommands];
}, [commands]);
const [result, search, resetSearch] = useAsyncSearch( const [result, search, resetSearch] = useAsyncSearch(
commandNames, commandNames,
@@ -79,33 +86,40 @@ export function CommandAutocomplete({
} }
requestClose={requestClose} requestClose={requestClose}
> >
{autoCompleteNames.map((commandName) => ( {autoCompleteNames.map((commandName) => {
<MenuItem // Get description from built-in commands or plugin commands
key={commandName} const builtInCmd = commands[commandName as Command];
as="button" const pluginCmd = pluginRegistry.getCommands().find(cmd => cmd.name === commandName);
radii="300" const description = builtInCmd?.description || pluginCmd?.command.description || '';
style={{ height: 'unset' }}
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) => return (
onTabPress(evt, () => handleAutocomplete(commandName)) <MenuItem
} key={commandName}
onClick={() => handleAutocomplete(commandName)} as="button"
> radii="300"
<Box style={{ height: 'unset' }}
style={{ padding: `${config.space.S300} 0` }} onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
grow="Yes" onTabPress(evt, () => handleAutocomplete(commandName))
direction="Column" }
gap="100" onClick={() => handleAutocomplete(commandName)}
justifyContent="SpaceBetween"
> >
<Text style={{ flexGrow: 1 }} size="B400" truncate> <Box
{`/${commandName}`} style={{ padding: `${config.space.S300} 0` }}
</Text> grow="Yes"
<Text truncate priority="300" size="T200"> direction="Column"
{commands[commandName].description} gap="100"
</Text> justifyContent="SpaceBetween"
</Box> >
</MenuItem> <Text style={{ flexGrow: 1 }} size="B400" truncate>
))} {`/${commandName}`}
</Text>
<Text truncate priority="300" size="T200">
{description}
</Text>
</Box>
</MenuItem>
);
})}
</AutocompleteMenu> </AutocompleteMenu>
); );
} }

View File

@@ -25,6 +25,7 @@ import {
PopOut, PopOut,
Scroll, Scroll,
Text, Text,
color,
config, config,
toRem, toRem,
} from 'folds'; } from 'folds';
@@ -53,6 +54,7 @@ import {
getBeginCommand, getBeginCommand,
trimCommand, trimCommand,
getMentions, getMentions,
setCommandValidator,
} from '../../components/editor'; } from '../../components/editor';
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board'; import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
@@ -102,6 +104,8 @@ import {
import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room'; import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room';
import { CommandAutocomplete } from './CommandAutocomplete'; import { CommandAutocomplete } from './CommandAutocomplete';
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands'; import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
import { pluginRegistry } from '../settings/plugins/PluginAPI';
import { PluginButtonSlot } from '../settings/plugins/PluginButtonSlot';
import { mobileOrTablet } from '../../utils/user-agent'; import { mobileOrTablet } from '../../utils/user-agent';
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
import { ReplyLayout, ThreadIndicator } from '../../components/message'; import { ReplyLayout, ThreadIndicator } from '../../components/message';
@@ -121,6 +125,17 @@ import { useOtherUserColor } from '../../hooks/useUserColor';
import { getMemberAvatarMxc } from '../../utils/room'; import { getMemberAvatarMxc } from '../../utils/room';
import { convertEmoticons, convertEmoticonsInHtml } from '../../utils/emoticonConverter'; import { convertEmoticons, convertEmoticonsInHtml } from '../../utils/emoticonConverter';
/**
* Creates a UUID used to group image events into one carousel.
*/
const createCarouselUuid = (): string => {
if (typeof globalThis.crypto?.randomUUID === 'function') {
return globalThis.crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
interface RoomInputProps { interface RoomInputProps {
editor: Editor; editor: Editor;
fileDropContainerRef: RefObject<HTMLElement>; fileDropContainerRef: RefObject<HTMLElement>;
@@ -185,9 +200,22 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [autocompleteQuery, setAutocompleteQuery] = const [autocompleteQuery, setAutocompleteQuery] =
useState<AutocompleteQuery<AutocompletePrefix>>(); useState<AutocompleteQuery<AutocompletePrefix>>();
const [commandHint, setCommandHint] = useState<{
command: string;
description: string;
args?: string[];
}>();
const sendTypingStatus = useTypingStatusUpdater(mx, roomId); const sendTypingStatus = useTypingStatusUpdater(mx, roomId);
useEffect(() => {
setCommandValidator((commandName: string) => {
const isBuiltInCommand = commands[commandName as Command];
const isPluginCommand = pluginRegistry.getCommands().some(cmd => cmd.name === commandName);
return !!(isBuiltInCommand || isPluginCommand);
});
}, [commands]);
const handleFiles = useCallback( const handleFiles = useCallback(
async (files: File[]) => { async (files: File[]) => {
setUploadBoard(true); setUploadBoard(true);
@@ -288,12 +316,34 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}; };
const handleSendUpload = async (uploads: UploadSuccess[]) => { const handleSendUpload = async (uploads: UploadSuccess[]) => {
const imageUploads = uploads.filter((upload) => {
const fileItem = selectedFiles.find((f) => f.file === upload.file);
return !!fileItem && fileItem.file.type.startsWith('image');
});
const carouselUuid = imageUploads.length > 1 ? createCarouselUuid() : undefined;
const imageUploadIndexByFile = new Map(
imageUploads.map((upload, index) => [upload.file, index] as const)
);
const contentsPromises = uploads.map(async (upload) => { const contentsPromises = uploads.map(async (upload) => {
const fileItem = selectedFiles.find((f) => f.file === upload.file); const fileItem = selectedFiles.find((f) => f.file === upload.file);
if (!fileItem) throw new Error('Broken upload'); if (!fileItem) throw new Error('Broken upload');
if (fileItem.file.type.startsWith('image')) { if (fileItem.file.type.startsWith('image')) {
return getImageMsgContent(mx, fileItem, upload.mxc); const imageIndex = imageUploadIndexByFile.get(upload.file);
return getImageMsgContent(
mx,
fileItem,
upload.mxc,
carouselUuid !== undefined && imageIndex !== undefined
? {
uuid: carouselUuid,
index: imageIndex,
total: imageUploads.length,
}
: undefined
);
} }
if (fileItem.file.type.startsWith('video')) { if (fileItem.file.type.startsWith('video')) {
return getVideoMsgContent(mx, fileItem, upload.mxc); return getVideoMsgContent(mx, fileItem, upload.mxc);
@@ -308,10 +358,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
contents.forEach((content) => mx.sendMessage(roomId, content as any)); contents.forEach((content) => mx.sendMessage(roomId, content as any));
}; };
const submit = useCallback(() => { const submit = useCallback(async () => {
uploadBoardHandlers.current?.handleSend(); uploadBoardHandlers.current?.handleSend();
const commandName = getBeginCommand(editor); let commandName = getBeginCommand(editor);
let plainText = toPlainText(editor.children, isMarkdown).trim(); let plainText = toPlainText(editor.children, isMarkdown).trim();
let customHtml = trimCustomHtml( let customHtml = trimCustomHtml(
toMatrixCustomHTML(editor.children, { toMatrixCustomHTML(editor.children, {
@@ -322,6 +372,21 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
); );
let msgType = MsgType.Text; let msgType = MsgType.Text;
// If no CommandElement, check if text starts with a slash command
if (!commandName && plainText.startsWith('/')) {
const match = plainText.match(/^\/(\w+)(\s|$)/);
if (match) {
const potentialCommand = match[1];
// Check if it's a valid command (built-in or plugin)
const isBuiltInCommand = commands[potentialCommand as Command];
const isPluginCommand = pluginRegistry.getCommands().some(cmd => cmd.name === potentialCommand);
if (isBuiltInCommand || isPluginCommand) {
commandName = potentialCommand;
}
}
}
if (commandName) { if (commandName) {
plainText = trimCommand(commandName, plainText); plainText = trimCommand(commandName, plainText);
customHtml = trimCommand(commandName, customHtml); customHtml = trimCommand(commandName, customHtml);
@@ -340,20 +405,43 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
plainText = `${UNFLIP} ${plainText}`; plainText = `${UNFLIP} ${plainText}`;
customHtml = `${UNFLIP} ${customHtml}`; customHtml = `${UNFLIP} ${customHtml}`;
} else if (commandName) { } else if (commandName) {
// Check built-in commands first
const commandContent = commands[commandName as Command]; const commandContent = commands[commandName as Command];
if (commandContent) { if (commandContent) {
commandContent.exe(plainText); commandContent.exe(plainText);
resetEditor(editor);
resetEditorHistory(editor);
setCommandHint(undefined);
sendTypingStatus(false);
return;
}
// Check plugin commands
try {
const result = await pluginRegistry.executeCommand(commandName, plainText, roomId);
if (result) {
// If command returns text, send it as a message
const content: IContent = {
msgtype: MsgType.Text,
body: result,
};
mx.sendMessage(roomId, content as any);
}
} catch (err) {
console.error(`[RoomInput] Plugin command /${commandName} failed:`, err);
// Show error to user (could integrate with notification system)
} }
resetEditor(editor); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
setCommandHint(undefined);
sendTypingStatus(false); sendTypingStatus(false);
return; return;
} }
if (plainText === '') return; let body = plainText.trim();
let formattedBody = customHtml.trim().replace(/(<br\s*\/?>\s*)+$/gi, '');
let body = plainText; if (body === '') return;
let formattedBody = customHtml;
// Apply emoticon conversion if enabled // Apply emoticon conversion if enabled
if (autoConvertEmoticons) { if (autoConvertEmoticons) {
@@ -398,10 +486,32 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
'm.in_reply_to': { event_id: threadRootId }, 'm.in_reply_to': { event_id: threadRootId },
}; };
} }
// Process message through plugin interceptors
try {
const messageContext = {
content: content.body,
roomId,
eventType: 'm.room.message',
formatted: content.formatted_body,
metadata: {},
};
const processedContext = await pluginRegistry.processBeforeSend(messageContext);
// Update content with intercepted values
content.body = processedContext.content;
if (processedContext.formatted) {
content.formatted_body = processedContext.formatted;
}
} catch (err) {
console.error('[RoomInput] Plugin interceptor error:', err);
}
mx.sendMessage(roomId, content as any); mx.sendMessage(roomId, content as any);
resetEditor(editor); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
setReplyDraft(undefined); setReplyDraft(undefined);
setCommandHint(undefined);
sendTypingStatus(false); sendTypingStatus(false);
}, [mx, roomId, threadRootId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, autoConvertEmoticons]); }, [mx, roomId, threadRootId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, autoConvertEmoticons]);
@@ -420,10 +530,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
setAutocompleteQuery(undefined); setAutocompleteQuery(undefined);
return; return;
} }
if (commandHint) {
setCommandHint(undefined);
return;
}
setReplyDraft(undefined); setReplyDraft(undefined);
} }
}, },
[submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing] [submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing, commandHint]
); );
const handleKeyUp: KeyboardEventHandler = useCallback( const handleKeyUp: KeyboardEventHandler = useCallback(
@@ -442,8 +556,35 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES) ? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
: undefined; : undefined;
setAutocompleteQuery(query); setAutocompleteQuery(query);
// Check if user is typing a command and show hint
const plainText = toPlainText(editor.children, false).trim();
const commandMatch = plainText.match(/^\/(\w+)(\s|$)/);
if (commandMatch) {
const commandName = commandMatch[1];
const builtInCmd = commands[commandName as Command];
const pluginCmd = pluginRegistry.getCommands().find(cmd => cmd.name === commandName);
if (builtInCmd) {
setCommandHint({
command: commandName,
description: builtInCmd.description,
});
} else if (pluginCmd) {
setCommandHint({
command: commandName,
description: pluginCmd.command.description || '',
args: pluginCmd.command.args,
});
} else {
setCommandHint(undefined);
}
} else {
setCommandHint(undefined);
}
}, },
[editor, sendTypingStatus, hideActivity] [editor, sendTypingStatus, hideActivity, commands]
); );
const handleCloseAutocomplete = useCallback(() => { const handleCloseAutocomplete = useCallback(() => {
@@ -554,7 +695,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
requestClose={handleCloseAutocomplete} requestClose={handleCloseAutocomplete}
/> />
)} )}
{autocompleteQuery?.prefix === AutocompletePrefix.Command && ( {autocompleteQuery?.prefix === AutocompletePrefix.Command && !commandHint && (
<CommandAutocomplete <CommandAutocomplete
room={room} room={room}
editor={editor} editor={editor}
@@ -571,53 +712,85 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
onKeyUp={handleKeyUp} onKeyUp={handleKeyUp}
onPaste={handlePaste} onPaste={handlePaste}
top={ top={
replyDraft && ( <>
<div> {commandHint && (
<Box <Box
alignItems="Center" style={{
gap="300" padding: `${config.space.S200} ${config.space.S300}`,
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }} backgroundColor: color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderBottom: 'none',
borderTopLeftRadius: config.radii.R400,
borderTopRightRadius: config.radii.R400,
marginBottom: config.space.S100,
}}
direction="Column"
gap="100"
> >
<IconButton <Text size="T200" priority="400">
onClick={() => setReplyDraft(undefined)} <b>/{commandHint.command}</b>
variant="SurfaceVariant" {commandHint.args && commandHint.args.length > 0 && (
size="300" <span style={{ opacity: 0.6 }}> {commandHint.args.map(arg => `[${arg}]`).join(' ')}</span>
radii="300" )}
> </Text>
<Icon src={Icons.Cross} size="50" /> {commandHint.description && (
</IconButton> <Text size="T200" priority="300" style={{ opacity: 0.7 }}>
<Box direction="Row" gap="200" alignItems="Center"> {commandHint.description}
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />} </Text>
<ReplyLayout )}
userColor={replyUsernameColor}
username={
<Text size="T300" truncate>
<b>
{getMemberDisplayName(room, replyDraft.userId) ??
getMxIdLocalPart(replyDraft.userId) ??
replyDraft.userId}
</b>
</Text>
}
>
<Text size="T300" truncate>
{trimReplyFromBody(replyDraft.body)}
</Text>
</ReplyLayout>
</Box>
</Box> </Box>
</div> )}
) {replyDraft && (
<div>
<Box
alignItems="Center"
gap="300"
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
<IconButton
onClick={() => setReplyDraft(undefined)}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.Cross} size="50" />
</IconButton>
<Box direction="Row" gap="200" alignItems="Center">
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
<ReplyLayout
userColor={replyUsernameColor}
username={
<Text size="T300" truncate>
<b>
{getMemberDisplayName(room, replyDraft.userId) ??
getMxIdLocalPart(replyDraft.userId) ??
replyDraft.userId}
</b>
</Text>
}
>
<Text size="T300" truncate>
{trimReplyFromBody(replyDraft.body)}
</Text>
</ReplyLayout>
</Box>
</Box>
</div>
)}
</>
} }
before={ before={
<IconButton <>
onClick={() => pickFile('*')} <IconButton
variant="SurfaceVariant" onClick={() => pickFile('*')}
size="300" variant="SurfaceVariant"
radii="300" size="300"
> radii="300"
<Icon src={Icons.PlusCircle} /> >
</IconButton> <Icon src={Icons.PlusCircle} />
</IconButton>
<PluginButtonSlot location="composer-actions" />
</>
} }
after={ after={
<> <>
@@ -696,6 +869,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</PopOut> </PopOut>
)} )}
</UseStateProvider> </UseStateProvider>
<PluginButtonSlot location="text-composer-toolbar" />
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300"> <IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.Send} /> <Icon src={Icons.Send} />
</IconButton> </IconButton>

View File

@@ -1,5 +1,6 @@
import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
import { DefaultReset, config } from 'folds'; import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const TimelineFloat = recipe({ export const TimelineFloat = recipe({
base: [ base: [
@@ -28,3 +29,81 @@ export const TimelineFloat = recipe({
}); });
export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>; export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>;
export const CarouselScroller = style([
DefaultReset,
{
display: 'flex',
gap: config.space.S200,
overflowX: 'auto',
overflowY: 'hidden',
paddingBottom: config.space.S100,
width: '100%',
cursor: 'grab',
userSelect: 'none',
},
]);
export const CarouselScrollerDragging = style([
DefaultReset,
{
cursor: 'grabbing',
},
]);
export const CarouselItem = style([
DefaultReset,
{
flex: '0 0 auto',
maxWidth: '100%',
},
]);
export const CarouselEdgeGradient = recipe({
base: [
DefaultReset,
{
position: 'absolute',
top: 0,
height: '100%',
width: toRem(16),
pointerEvents: 'none',
zIndex: 1,
},
],
variants: {
position: {
Left: {
left: 0,
background: `linear-gradient(to right, ${color.Surface.Container}, rgba(116, 116, 116, 0))`,
},
Right: {
right: 0,
background: `linear-gradient(to left, ${color.Surface.Container}, rgba(116, 116, 116, 0))`,
},
},
},
});
export const CarouselScrollBtn = recipe({
base: [
DefaultReset,
{
position: 'absolute',
top: '50%',
zIndex: 2,
},
],
variants: {
position: {
Left: {
left: 0,
transform: 'translate(-25%, -50%)',
},
Right: {
right: 0,
transform: 'translate(25%, -50%)',
},
},
},
});

View File

@@ -19,6 +19,7 @@ import {
IContent, IContent,
MatrixClient, MatrixClient,
MatrixEvent, MatrixEvent,
MsgType,
Room, Room,
RoomEvent, RoomEvent,
RoomEventHandlerMap, RoomEventHandlerMap,
@@ -35,6 +36,7 @@ import {
Chip, Chip,
ContainerColor, ContainerColor,
Icon, Icon,
IconButton,
Icons, Icons,
Line, Line,
Scroll, Scroll,
@@ -47,11 +49,12 @@ import {
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { Opts as LinkifyOpts } from 'linkifyjs'; import { Opts as LinkifyOpts } from 'linkifyjs';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix'; import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator'; import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
import { useAlive } from '../../hooks/useAlive'; import { useAlive } from '../../hooks/useAlive';
import { editableActiveElement, scrollToBottom } from '../../utils/dom'; import { autoScrollToBottom, editableActiveElement } from '../../utils/dom';
import { import {
DefaultPlaceholder, DefaultPlaceholder,
CompactPlaceholder, CompactPlaceholder,
@@ -61,6 +64,7 @@ import {
Time, Time,
MessageNotDecryptedContent, MessageNotDecryptedContent,
RedactedContent, RedactedContent,
MImage,
MSticker, MSticker,
ImageContent, ImageContent,
EventContent, EventContent,
@@ -84,7 +88,7 @@ import {
reactionOrEditEvent, reactionOrEditEvent,
} from '../../utils/room'; } from '../../utils/room';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { MessageLayout, settingsAtom } from '../../state/settings'; import { MessageLayout, ScrollToLatestBehavior, settingsAtom } from '../../state/settings';
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer'; import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
import { Reactions, Message, Event, EncryptedContent } from './message'; import { Reactions, Message, Event, EncryptedContent } from './message';
import { useMemberEventParser } from '../../hooks/useMemberEventParser'; import { useMemberEventParser } from '../../hooks/useMemberEventParser';
@@ -104,6 +108,12 @@ import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { activeThreadIdAtomFamily } from '../../state/activeThread'; import { activeThreadIdAtomFamily } from '../../state/activeThread';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room'; import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
import {
IImageContent,
PAARROT_CAROUSEL_INDEX_PROPERTY_NAME,
PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME,
PAARROT_CAROUSEL_UUID_PROPERTY_NAME,
} from '../../../types/matrix/common';
import { useKeyDown } from '../../hooks/useKeyDown'; import { useKeyDown } from '../../hooks/useKeyDown';
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange'; import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
import { RenderMessageContent } from '../../components/RenderMessageContent'; import { RenderMessageContent } from '../../components/RenderMessageContent';
@@ -145,6 +155,55 @@ interface CallEventInfo {
mEvent: MatrixEvent; mEvent: MatrixEvent;
} }
interface CarouselEventMetadata {
uuid: string;
index: number;
total: number;
content: IImageContent;
}
interface CarouselRenderableEvent {
mEvent: MatrixEvent;
mEventId: string;
item: number;
content: IImageContent;
index: number;
}
/**
* Extracts carousel metadata from an m.image room message.
*/
const getCarouselEventMetadata = (mEvent: MatrixEvent): CarouselEventMetadata | undefined => {
if (mEvent.getType() !== MessageEvent.RoomMessage || mEvent.isRedacted()) {
return undefined;
}
const content = mEvent.getContent() as IImageContent;
const uuid = content[PAARROT_CAROUSEL_UUID_PROPERTY_NAME];
const index = content[PAARROT_CAROUSEL_INDEX_PROPERTY_NAME];
const total = content[PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME];
if (content.msgtype !== MsgType.Image) {
return undefined;
}
if (typeof uuid !== 'string') {
return undefined;
}
if (typeof index !== 'number' || !Number.isInteger(index) || index < 0) {
return undefined;
}
if (typeof total !== 'number' || !Number.isInteger(total) || total < 1) {
return undefined;
}
return {
uuid,
index,
total,
content,
};
};
const TimelineFloat = as<'div', css.TimelineFloatVariants>( const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => ( ({ position, className, ...props }, ref) => (
<Box <Box
@@ -168,6 +227,198 @@ const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>(
) )
); );
type CarouselScrollerProps = {
children: React.ReactNode;
};
const CarouselScroller = ({ children }: CarouselScrollerProps) => {
const scrollRef = useRef<HTMLDivElement>(null);
const backAnchorRef = useRef<HTMLDivElement>(null);
const frontAnchorRef = useRef<HTMLDivElement>(null);
const [backVisible, setBackVisible] = useState(true);
const [frontVisible, setFrontVisible] = useState(true);
const dragStateRef = useRef({
startX: 0,
startScrollLeft: 0,
dragging: false,
moved: false,
suppressClick: false,
});
const [isDragging, setIsDragging] = useState(false);
const intersectionObserver = useIntersectionObserver(
useCallback((entries) => {
const backAnchor = backAnchorRef.current;
const frontAnchor = frontAnchorRef.current;
const backEntry = backAnchor && getIntersectionObserverEntry(backAnchor, entries);
const frontEntry = frontAnchor && getIntersectionObserverEntry(frontAnchor, entries);
if (backEntry) {
setBackVisible(backEntry.isIntersecting);
}
if (frontEntry) {
setFrontVisible(frontEntry.isIntersecting);
}
}, []),
useCallback(
() => ({
root: scrollRef.current,
rootMargin: '10px',
}),
[]
)
);
useEffect(() => {
const backAnchor = backAnchorRef.current;
const frontAnchor = frontAnchorRef.current;
if (backAnchor) intersectionObserver?.observe(backAnchor);
if (frontAnchor) intersectionObserver?.observe(frontAnchor);
return () => {
if (backAnchor) intersectionObserver?.unobserve(backAnchor);
if (frontAnchor) intersectionObserver?.unobserve(frontAnchor);
};
}, [intersectionObserver]);
const handleMouseDown = (evt: React.MouseEvent<HTMLDivElement>) => {
if (evt.button !== 0) return;
const scroll = scrollRef.current;
if (!scroll) return;
const dragState = dragStateRef.current;
dragState.startX = evt.clientX;
dragState.startScrollLeft = scroll.scrollLeft;
dragState.dragging = true;
dragState.moved = false;
dragState.suppressClick = false;
setIsDragging(true);
};
const handleMouseMove = (evt: React.MouseEvent<HTMLDivElement>) => {
const dragState = dragStateRef.current;
if (!dragState.dragging) {
return;
}
evt.preventDefault();
const scroll = scrollRef.current;
if (!scroll) return;
const deltaX = evt.clientX - dragState.startX;
if (Math.abs(deltaX) > 5) {
dragState.moved = true;
}
scroll.scrollLeft = dragState.startScrollLeft - deltaX;
};
const endDrag = () => {
const dragState = dragStateRef.current;
if (!dragState.dragging) return;
if (dragState.moved) {
dragState.suppressClick = true;
}
dragState.dragging = false;
setIsDragging(false);
};
const handleClickCapture = (evt: React.MouseEvent<HTMLDivElement>) => {
const dragState = dragStateRef.current;
if (!dragState.suppressClick) {
return;
}
dragState.suppressClick = false;
evt.preventDefault();
evt.stopPropagation();
};
const handleScrollFront = () => {
const scroll = scrollRef.current;
if (!scroll) return;
scroll.scrollTo({
left: scroll.scrollLeft + scroll.offsetWidth / 1.3,
behavior: 'smooth',
});
};
const handleScrollBack = () => {
const scroll = scrollRef.current;
if (!scroll) return;
scroll.scrollTo({
left: scroll.scrollLeft - scroll.offsetWidth / 1.3,
behavior: 'smooth',
});
};
return (
<Box style={{ position: 'relative' }}>
<Box
ref={scrollRef}
className={classNames(css.CarouselScroller, isDragging && css.CarouselScrollerDragging)}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={endDrag}
onMouseLeave={endDrag}
onClickCapture={handleClickCapture}
>
<div ref={backAnchorRef} />
{children}
<div ref={frontAnchorRef} />
</Box>
{!backVisible && (
<>
<div className={css.CarouselEdgeGradient({ position: 'Left' })} />
<IconButton
className={css.CarouselScrollBtn({ position: 'Left' })}
variant="Secondary"
radii="Pill"
size="300"
outlined
onClick={handleScrollBack}
>
<Icon size="300" src={Icons.ArrowLeft} />
</IconButton>
</>
)}
{!frontVisible && (
<>
<div className={css.CarouselEdgeGradient({ position: 'Right' })} />
<IconButton
className={css.CarouselScrollBtn({ position: 'Right' })}
variant="Primary"
radii="Pill"
size="300"
outlined
onClick={handleScrollFront}
>
<Icon size="300" src={Icons.ArrowRight} />
</IconButton>
</>
)}
</Box>
);
};
const getCarouselItemWidth = (content: IImageContent): string => {
const width = content.info?.w;
const height = content.info?.h;
if (!width || !height || height <= 0) {
return 'min(24rem, 72vw)';
}
const aspectRatio = width / height;
const baseHeightRem = 22;
const computedWidthRem = Math.max(11, Math.min(24, baseHeightRem * aspectRatio));
return `min(${computedWidthRem}rem, 72vw)`;
};
export const getLiveTimeline = (room: Room): EventTimeline => export const getLiveTimeline = (room: Room): EventTimeline =>
room.getUnfilteredTimelineSet().getLiveTimeline(); room.getUnfilteredTimelineSet().getLiveTimeline();
@@ -473,6 +724,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const [scrollToLatestBehavior] = useSetting(settingsAtom, 'scrollToLatestBehavior');
// Track consecutive call member events for collapsing // Track consecutive call member events for collapsing
const pendingCallEventsRef = useRef<CallEventInfo[]>([]); const pendingCallEventsRef = useRef<CallEventInfo[]>([]);
@@ -511,10 +763,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const openUserRoomProfile = useOpenUserRoomProfile(); const openUserRoomProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally(); const space = useSpaceOptionally();
const markAsRead = useMarkAsRead(mx); const markAsRead = useMarkAsRead(mx);
const location = useLocation();
const scrollToLatest = (location.state as { scrollToLatest?: boolean } | null)?.scrollToLatest;
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents); const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true)); const [unreadInfo, setUnreadInfo] = useState(() =>
getRoomUnreadInfo(room, scrollToLatestBehavior !== ScrollToLatestBehavior.Always)
);
const readUptoEventIdRef = useRef<string>(); const readUptoEventIdRef = useRef<string>();
if (unreadInfo) { if (unreadInfo) {
readUptoEventIdRef.current = unreadInfo.readUptoEventId; readUptoEventIdRef.current = unreadInfo.readUptoEventId;
@@ -525,10 +781,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const atBottomRef = useRef(atBottom); const atBottomRef = useRef(atBottom);
atBottomRef.current = atBottom; atBottomRef.current = atBottom;
const [latestUnreadMessage, setLatestUnreadMessage] = useState<{
sender: string;
content: string;
} | null>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const timelineContentRef = useRef<HTMLDivElement>(null);
const timelineContentHeightRef = useRef(0);
const scrollToBottomRef = useRef({ const scrollToBottomRef = useRef({
count: 0, count: 0,
smooth: true, smooth: true,
force: false,
}); });
const [focusItem, setFocusItem] = useState< const [focusItem, setFocusItem] = useState<
@@ -628,6 +892,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
setTimeline(getInitialTimeline(room)); setTimeline(getInitialTimeline(room));
scrollToBottomRef.current.count += 1; scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false; scrollToBottomRef.current.smooth = false;
scrollToBottomRef.current.force = true;
}, [alive, room]) }, [alive, room])
); );
@@ -635,38 +900,50 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
room, room,
useCallback( useCallback(
(mEvt: MatrixEvent) => { (mEvt: MatrixEvent) => {
// if user is at bottom of timeline const isOwnMessage = mEvt.getSender() === mx.getUserId();
// keep paginating timeline and conditionally mark as read const shouldStickToBottom = atBottomRef.current || isOwnMessage;
// otherwise we update timeline without paginating
// so timeline can be updated with evt like: edits, reactions etc // Always update timeline with new message
if (atBottomRef.current) { setTimeline((ct) => ({
if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) { ...ct,
// Check if the document is in focus (user is actively viewing the app), range: {
// and either there are no unread messages or the latest message is from the current user. start: ct.range.start + 1,
// If either condition is met, trigger the markAsRead function to send a read receipt. end: ct.range.end + 1,
requestAnimationFrame(() => markAsRead(mEvt.getRoomId()!, hideActivity)); },
}));
// Handle own messages and focus
if (isOwnMessage) {
setLatestUnreadMessage(null);
if (document.hasFocus() && (!unreadInfo)) {
const roomId = mEvt.getRoomId();
if (roomId) {
requestAnimationFrame(() => markAsRead(roomId, hideActivity));
}
} }
} else if (!document.hasFocus() && !unreadInfo) {
if (!document.hasFocus() && !unreadInfo) { // Show unread notification for other users' messages when unfocused
setUnreadInfo(getRoomUnreadInfo(room));
}
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = true;
setTimeline((ct) => ({
...ct,
range: {
start: ct.range.start + 1,
end: ct.range.end + 1,
},
}));
return;
}
setTimeline((ct) => ({ ...ct }));
if (!unreadInfo) {
setUnreadInfo(getRoomUnreadInfo(room)); setUnreadInfo(getRoomUnreadInfo(room));
} }
// Preserve the pre-update bottom state so a newly added message keeps the view pinned.
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = !isOwnMessage;
scrollToBottomRef.current.force = shouldStickToBottom;
// Show unread message preview for incoming messages when not at bottom
if (!isOwnMessage && !atBottomRef.current) {
const senderName =
getMemberDisplayName(room, mEvt.getSender() ?? '') ??
getMxIdLocalPart(mEvt.getSender() ?? '') ??
'Unknown';
const content = mEvt.getContent();
const messageText = content.body ?? content.msgtype ?? 'New message';
setLatestUnreadMessage({
sender: senderName,
content: messageText.length > 100 ? `${messageText.substring(0, 100)}...` : messageText,
});
}
}, },
[mx, room, unreadInfo, hideActivity, markAsRead] [mx, room, unreadInfo, hideActivity, markAsRead]
) )
@@ -727,13 +1004,35 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (!editorBaseEntry || !scrollElement) return; if (!editorBaseEntry || !scrollElement) return;
if (atBottomRef.current) { if (atBottomRef.current) {
scrollToBottom(scrollElement); autoScrollToBottom(scrollElement, false, true);
} }
}; };
}, [getScrollElement, roomInputRef]), }, [getScrollElement, roomInputRef]),
useCallback(() => roomInputRef.current, [roomInputRef]) useCallback(() => roomInputRef.current, [roomInputRef])
); );
useResizeObserver(
useCallback((entries) => {
const timelineContent = timelineContentRef.current;
const scrollElement = getScrollElement();
if (!timelineContent || !scrollElement) return;
const timelineEntry = getResizeObserverEntry(timelineContent, entries);
if (!timelineEntry) return;
const nextHeight = timelineEntry.contentRect.height;
const previousHeight = timelineContentHeightRef.current;
timelineContentHeightRef.current = nextHeight;
if (previousHeight === 0 || nextHeight <= previousHeight || !atBottomRef.current) {
return;
}
autoScrollToBottom(scrollElement, false, true);
}, [getScrollElement]),
useCallback(() => timelineContentRef.current, [])
);
const tryAutoMarkAsRead = useCallback(() => { const tryAutoMarkAsRead = useCallback(() => {
const readUptoEventId = readUptoEventIdRef.current; const readUptoEventId = readUptoEventIdRef.current;
if (!readUptoEventId) { if (!readUptoEventId) {
@@ -749,7 +1048,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const debounceSetAtBottom = useDebounce( const debounceSetAtBottom = useDebounce(
useCallback((entry: IntersectionObserverEntry) => { useCallback((entry: IntersectionObserverEntry) => {
if (!entry.isIntersecting) setAtBottom(false); if (!entry.isIntersecting) {
setAtBottom(false);
}
}, []), }, []),
{ wait: 1000 } { wait: 1000 }
); );
@@ -762,6 +1063,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (targetEntry) debounceSetAtBottom(targetEntry); if (targetEntry) debounceSetAtBottom(targetEntry);
if (targetEntry?.isIntersecting && atLiveEndRef.current) { if (targetEntry?.isIntersecting && atLiveEndRef.current) {
setAtBottom(true); setAtBottom(true);
setLatestUnreadMessage(null);
if (document.hasFocus()) { if (document.hasFocus()) {
tryAutoMarkAsRead(); tryAutoMarkAsRead();
} }
@@ -831,11 +1133,23 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
} }
}, [eventId, loadEventTimeline]); }, [eventId, loadEventTimeline]);
// Scroll to latest when clicking on already-selected room
useEffect(() => {
if (scrollToLatest) {
setLatestUnreadMessage(null);
setUnreadInfo(undefined);
setTimeline(getInitialTimeline(room));
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false;
scrollToBottomRef.current.force = true;
}
}, [scrollToLatest, room]);
// Scroll to bottom on initial timeline load // Scroll to bottom on initial timeline load
useLayoutEffect(() => { useLayoutEffect(() => {
const scrollEl = scrollRef.current; const scrollEl = scrollRef.current;
if (scrollEl) { if (scrollEl) {
scrollToBottom(scrollEl); autoScrollToBottom(scrollEl, false, true);
} }
}, []); }, []);
@@ -882,8 +1196,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
useLayoutEffect(() => { useLayoutEffect(() => {
if (scrollToBottomCount > 0) { if (scrollToBottomCount > 0) {
const scrollEl = scrollRef.current; const scrollEl = scrollRef.current;
if (scrollEl) if (scrollEl) {
scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant'); autoScrollToBottom(
scrollEl,
scrollToBottomRef.current.smooth,
scrollToBottomRef.current.force
);
}
} }
}, [scrollToBottomCount]); }, [scrollToBottomCount]);
@@ -936,9 +1255,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (eventId) { if (eventId) {
navigateRoom(room.roomId, undefined, { replace: true }); navigateRoom(room.roomId, undefined, { replace: true });
} }
setLatestUnreadMessage(null);
setTimeline(getInitialTimeline(room)); setTimeline(getInitialTimeline(room));
scrollToBottomRef.current.count += 1; scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false; scrollToBottomRef.current.smooth = false;
scrollToBottomRef.current.force = true;
}; };
const handleJumpToUnread = () => { const handleJumpToUnread = () => {
@@ -1677,6 +1998,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
let isPrevRendered = false; let isPrevRendered = false;
let newDivider = false; let newDivider = false;
let dayDivider = false; let dayDivider = false;
const groupedCarouselEventIds = new Set<string>();
// Clear pending call events at the start of each render // Clear pending call events at the start of each render
pendingCallEventsRef.current = []; pendingCallEventsRef.current = [];
@@ -1798,6 +2120,110 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
); );
}; };
const renderImageCarouselMessage = (
carouselEvents: CarouselRenderableEvent[],
mEventId: string,
mEvent: MatrixEvent,
item: number,
timelineSet: EventTimelineSet,
collapse: boolean
): React.ReactNode => {
const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
const hasReactions = reactions && reactions.length > 0;
const { replyEventId, threadRootId } = mEvent;
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const orderedCarouselEvents = [...carouselEvents].sort((a, b) => a.index - b.index);
return (
<Message
key={`carousel-${mEventId}`}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
messageSpacing={messageSpacing}
messageLayout={messageLayout}
collapse={collapse}
highlight={highlighted}
edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
onEditId={handleEdit}
reply={
replyEventId && (
<Reply
room={room}
timelineSet={timelineSet}
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenReply}
onOpenThread={handleOpenThread}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
/>
)
}
reactions={
reactionRelations && (
<Reactions
style={{ marginTop: config.space.S200 }}
room={room}
relations={reactionRelations}
mEventId={mEventId}
canSendReaction={canSendReaction}
onReactionToggle={handleReactionToggle}
/>
)
}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
memberPowerTag={getMemberPowerTag(senderId)}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
>
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
) : (
<CarouselScroller>
{orderedCarouselEvents.map((carouselEvent) => (
<Box
key={carouselEvent.mEventId}
className={css.CarouselItem}
style={{ width: getCarouselItemWidth(carouselEvent.content) }}
>
<MImage
content={carouselEvent.content}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
outlined={messageLayout === MessageLayout.Bubble}
/>
</Box>
))}
</CarouselScroller>
)}
</Message>
);
};
const eventRenderer = (item: number, index: number, allItems: number[]) => { const eventRenderer = (item: number, index: number, allItems: number[]) => {
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item); const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
if (!eventTimeline) return null; if (!eventTimeline) return null;
@@ -1831,17 +2257,77 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
prevEvent.getType() === mEvent.getType() && prevEvent.getType() === mEvent.getType() &&
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2; minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
const rawEventJSX = reactionOrEditEvent(mEvent) if (groupedCarouselEventIds.has(mEventId)) {
? null prevEvent = mEvent;
: renderMatrixEvent( isPrevRendered = true;
mEvent.getType(), return null;
typeof mEvent.getStateKey() === 'string', }
mEventId,
mEvent, const getEventByItem = (
item, timelineItem: number
timelineSet, ): { event: MatrixEvent; eventId: string } | undefined => {
collapsed const [tm, bIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, timelineItem);
); if (!tm) return undefined;
const event = getTimelineEvent(tm, getTimelineRelativeIndex(timelineItem, bIndex));
const eventId = event?.getId();
if (!event || !eventId) return undefined;
return { event, eventId };
};
const carouselMetadata = getCarouselEventMetadata(mEvent);
const carouselEvents: CarouselRenderableEvent[] = [];
if (carouselMetadata && eventSender) {
carouselEvents.push({
mEvent,
mEventId,
item,
content: carouselMetadata.content,
index: carouselMetadata.index,
});
for (let nextIndex = index + 1; nextIndex < allItems.length; nextIndex += 1) {
const nextEvt = getEventByItem(allItems[nextIndex]);
if (!nextEvt) break;
const nextSender = nextEvt.event.getSender();
const nextMeta = getCarouselEventMetadata(nextEvt.event);
if (!nextMeta || nextSender !== eventSender || nextMeta.uuid !== carouselMetadata.uuid) {
break;
}
groupedCarouselEventIds.add(nextEvt.eventId);
carouselEvents.push({
mEvent: nextEvt.event,
mEventId: nextEvt.eventId,
item: allItems[nextIndex],
content: nextMeta.content,
index: nextMeta.index,
});
if (carouselEvents.length >= carouselMetadata.total) {
break;
}
}
}
let rawEventJSX: React.ReactNode = null;
if (!reactionOrEditEvent(mEvent)) {
rawEventJSX =
carouselEvents.length > 1
? renderImageCarouselMessage(carouselEvents, mEventId, mEvent, item, timelineSet, collapsed)
: renderMatrixEvent(
mEvent.getType(),
typeof mEvent.getStateKey() === 'string',
mEventId,
mEvent,
item,
timelineSet,
collapsed
);
}
// Check if this is a call event that should be grouped // Check if this is a call event that should be grouped
const isCallEventPending = rawEventJSX === ('CALL_EVENT_PENDING' as unknown as React.ReactNode); const isCallEventPending = rawEventJSX === ('CALL_EVENT_PENDING' as unknown as React.ReactNode);
@@ -1949,6 +2435,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
)} )}
<Scroll ref={scrollRef} visibility="Hover"> <Scroll ref={scrollRef} visibility="Hover">
<Box <Box
ref={timelineContentRef}
direction="Column" direction="Column"
justifyContent="End" justifyContent="End"
style={{ minHeight: '100%', padding: `${config.space.S600} 0` }} style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
@@ -2036,15 +2523,36 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</Scroll> </Scroll>
{!atBottom && ( {!atBottom && (
<TimelineFloat position="Bottom"> <TimelineFloat position="Bottom">
<Chip <Box direction="Column" gap="200" alignItems="Center">
variant="SurfaceVariant" {latestUnreadMessage && (
radii="Pill" <Box
outlined style={{
before={<Icon size="50" src={Icons.ArrowBottom} />} backgroundColor: color.Surface.Container,
onClick={handleJumpToLatest} padding: `${config.space.S200} ${config.space.S300}`,
> borderRadius: config.radii.R400,
<Text size="L400">Jump to Latest</Text> maxWidth: toRem(400),
</Chip> border: `1px solid ${color.Surface.ContainerLine}`,
}}
>
<Text size="T300" priority="300" style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
<b>{latestUnreadMessage.sender}:</b> {latestUnreadMessage.content}
</Text>
</Box>
)}
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.ArrowBottom} />}
onClick={handleJumpToLatest}
>
<Text size="L400">Jump to Latest</Text>
</Chip>
</Box>
</TimelineFloat> </TimelineFloat>
)} )}
</Box> </Box>

View File

@@ -88,7 +88,13 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
if (portalContainer && portalContainer.children.length > 0) { if (portalContainer && portalContainer.children.length > 0) {
return; return;
} }
if (shouldFocusMessageField(evt) || isKeyHotkey('mod+v', evt)) { if (shouldFocusMessageField(evt)) {
evt.preventDefault();
ReactEditor.focus(editor);
if (evt.key.length === 1) {
editor.insertText(evt.key);
}
} else if (isKeyHotkey('mod+v', evt)) {
ReactEditor.focus(editor); ReactEditor.focus(editor);
} }
}, },

View File

@@ -55,6 +55,7 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { getMatrixToRoom } from '../../plugins/matrix-to'; import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getViaServers } from '../../plugins/via-servers'; import { getViaServers } from '../../plugins/via-servers';
import { PluginButtonSlot } from '../settings/plugins/PluginButtonSlot';
import { BackRouteHandler } from '../../components/BackRouteHandler'; import { BackRouteHandler } from '../../components/BackRouteHandler';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents'; import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
@@ -229,6 +230,10 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
</UseStateProvider> </UseStateProvider>
</Box> </Box>
<Line variant="Surface" size="300" /> <Line variant="Surface" size="300" />
<Box direction="Row" gap="100" style={{ padding: config.space.S100 }}>
<PluginButtonSlot location="room-menu" />
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}> <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<UseStateProvider initial={false}> <UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => ( {(promptLeave, setPromptLeave) => (
@@ -381,6 +386,7 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) {
isInCall, isInCall,
callState, callState,
activeCall, activeCall,
isUsingRemoteHomeserver,
startVoiceCall, startVoiceCall,
startVideoCall, startVideoCall,
endCall, endCall,
@@ -433,7 +439,10 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) {
offset={4} offset={4}
tooltip={ tooltip={
<Tooltip> <Tooltip>
<Text>{isInCall ? 'End Call' : 'Voice Call'}</Text> <Text>
{isInCall ? 'End Call' : 'Voice Call'}
{isUsingRemoteHomeserver && !isInCall && ' (Remote Server)'}
</Text>
</Tooltip> </Tooltip>
} }
> >
@@ -456,7 +465,10 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) {
offset={4} offset={4}
tooltip={ tooltip={
<Tooltip> <Tooltip>
<Text>{isInCall ? 'End Call' : 'Video Call'}</Text> <Text>
{isInCall ? 'End Call' : 'Video Call'}
{isUsingRemoteHomeserver && !isInCall && ' (Remote Server)'}
</Text>
</Tooltip> </Tooltip>
} }
> >
@@ -706,6 +718,7 @@ export function RoomViewHeader() {
</IconButton> </IconButton>
)} )}
</TooltipProvider> </TooltipProvider>
<PluginButtonSlot location="room-header" />
<TooltipProvider <TooltipProvider
position="Bottom" position="Bottom"
align="End" align="End"

View File

@@ -24,4 +24,7 @@ export const RoomViewTyping = style([
]); ]);
export const TypingText = style({ export const TypingText = style({
flexGrow: 1, flexGrow: 1,
overflow: 'clip',
overflowY: 'clip',
lineHeight: '1.5',
}); });

View File

@@ -31,8 +31,11 @@ import {
import { HTMLReactParserOptions } from 'html-react-parser'; import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts as LinkifyOpts } from 'linkifyjs'; import { Opts as LinkifyOpts } from 'linkifyjs';
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
import { isKeyHotkey } from 'is-hotkey';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useKeyDown } from '../../hooks/useKeyDown';
import { editableActiveElement } from '../../utils/dom';
import { import {
useEditor, useEditor,
createMentionElement, createMentionElement,
@@ -93,13 +96,44 @@ import {
} from '../../utils/matrix'; } from '../../utils/matrix';
import { GetContentCallback, MessageEvent } from '../../../types/matrix/room'; import { GetContentCallback, MessageEvent } from '../../../types/matrix/room';
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time'; import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
import { scrollToBottom } from '../../utils/dom'; import { autoScrollToBottom, scrollElementIntoView } from '../../utils/dom';
type ThreadViewProps = { type ThreadViewProps = {
room: Room; room: Room;
threadRootId: string; threadRootId: string;
}; };
const FN_KEYS_REGEX = /^F\d+$/;
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
const { code } = evt;
if (evt.metaKey || evt.altKey || evt.ctrlKey) {
return false;
}
if (FN_KEYS_REGEX.test(code)) return false;
if (
code.startsWith('OS') ||
code.startsWith('Meta') ||
code.startsWith('Shift') ||
code.startsWith('Alt') ||
code.startsWith('Control') ||
code.startsWith('Arrow') ||
code.startsWith('Page') ||
code.startsWith('End') ||
code.startsWith('Home') ||
code === 'Tab' ||
code === 'Space' ||
code === 'Enter' ||
code === 'NumLock' ||
code === 'ScrollLock'
) {
return false;
}
return true;
};
/** /**
* Renders a thread inline, replacing the main room timeline. * Renders a thread inline, replacing the main room timeline.
* Structured as direct flex siblings of `Page` to match the RoomView layout contract. * Structured as direct flex siblings of `Page` to match the RoomView layout contract.
@@ -285,6 +319,29 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
const inputRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLDivElement>(null);
const editor = useEditor(); const editor = useEditor();
useKeyDown(
window,
useCallback(
(evt) => {
if (editableActiveElement()) return;
const portalContainer = document.getElementById('portalContainer');
if (portalContainer && portalContainer.children.length > 0) {
return;
}
if (shouldFocusMessageField(evt)) {
evt.preventDefault();
ReactEditor.focus(editor);
if (evt.key.length === 1) {
editor.insertText(evt.key);
}
} else if (isKeyHotkey('mod+v', evt)) {
ReactEditor.focus(editor);
}
},
[editor]
)
);
// Subscribe to thread and room timeline events. // Subscribe to thread and room timeline events.
useEffect(() => { useEffect(() => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@@ -349,7 +406,7 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
// Scroll to bottom on mount and when new events arrive. // Scroll to bottom on mount and when new events arrive.
useLayoutEffect(() => { useLayoutEffect(() => {
const el = scrollRef.current; const el = scrollRef.current;
if (el) scrollToBottom(el); if (el) autoScrollToBottom(el);
}, [events.length]); }, [events.length]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
@@ -424,7 +481,7 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
const el = scrollRef.current?.querySelector( const el = scrollRef.current?.querySelector(
`[data-message-id="${targetId}"]` `[data-message-id="${targetId}"]`
) as HTMLElement | null; ) as HTMLElement | null;
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); if (el) scrollElementIntoView(el, { behavior: 'smooth', block: 'center' });
}, []); }, []);
const handleReactionToggle = useCallback( const handleReactionToggle = useCallback(

View File

@@ -62,6 +62,8 @@ import {
import { MessageLayout, MessageSpacing } from '../../../state/settings'; import { MessageLayout, MessageSpacing } from '../../../state/settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import { useEmojiUsage } from '../../../hooks/useEmojiUsage';
import { emojis } from '../../../plugins/emoji';
import * as css from './styles.css'; import * as css from './styles.css';
import { EventReaders } from '../../../components/event-readers'; import { EventReaders } from '../../../components/event-readers';
import { TextViewer } from '../../../components/text-viewer'; import { TextViewer } from '../../../components/text-viewer';
@@ -75,6 +77,7 @@ import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to'; import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
import { getViaServers } from '../../../plugins/via-servers'; import { getViaServers } from '../../../plugins/via-servers';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { PluginButtonSlot } from '../../../features/settings/plugins/PluginButtonSlot';
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents'; import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room'; import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
import { PowerIcon } from '../../../components/power'; import { PowerIcon } from '../../../components/power';
@@ -90,10 +93,13 @@ type MessageQuickReactionsProps = {
}; };
export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>( export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
({ onReaction, ...props }, ref) => { ({ onReaction, ...props }, ref) => {
const mx = useMatrixClient(); const { getTopEmojis } = useEmojiUsage();
const recentEmojis = useRecentEmoji(mx, 4); const topShortcodes = getTopEmojis(4);
const mostUsedEmojis = topShortcodes
.map((shortcode) => emojis.find((e) => e.shortcode === shortcode))
.filter((e): e is IEmoji => e !== undefined);
if (recentEmojis.length === 0) return <span />; if (mostUsedEmojis.length === 0) return <span />;
return ( return (
<> <>
<Box <Box
@@ -104,7 +110,7 @@ export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
{...props} {...props}
ref={ref} ref={ref}
> >
{recentEmojis.map((emoji) => ( {mostUsedEmojis.map((emoji) => (
<IconButton <IconButton
key={emoji.unicode} key={emoji.unicode}
className={css.MessageQuickReaction} className={css.MessageQuickReaction}
@@ -1070,6 +1076,7 @@ export const Message = as<'div', MessageProps>(
<Icon src={Icons.Delete} size="100" /> <Icon src={Icons.Delete} size="100" />
</IconButton> </IconButton>
)} )}
<PluginButtonSlot location="message-actions" />
<PopOut <PopOut
anchor={menuAnchor} anchor={menuAnchor}
position="Bottom" position="Bottom"
@@ -1333,6 +1340,7 @@ export const Event = as<'div', EventProps>(
<div className={css.MessageOptionsBase}> <div className={css.MessageOptionsBase}>
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant"> <Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
<Box gap="100"> <Box gap="100">
<PluginButtonSlot location="message-actions" />
<PopOut <PopOut
anchor={menuAnchor} anchor={menuAnchor}
position="Bottom" position="Bottom"

View File

@@ -4,6 +4,9 @@ import {
IThumbnailContent, IThumbnailContent,
MATRIX_BLUR_HASH_PROPERTY_NAME, MATRIX_BLUR_HASH_PROPERTY_NAME,
MATRIX_SPOILER_PROPERTY_NAME, MATRIX_SPOILER_PROPERTY_NAME,
PAARROT_CAROUSEL_INDEX_PROPERTY_NAME,
PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME,
PAARROT_CAROUSEL_UUID_PROPERTY_NAME,
} from '../../../types/matrix/common'; } from '../../../types/matrix/common';
import { import {
getImageFileUrl, getImageFileUrl,
@@ -18,6 +21,12 @@ import { TUploadItem } from '../../state/room/roomInputDrafts';
import { encodeBlurHash } from '../../utils/blurHash'; import { encodeBlurHash } from '../../utils/blurHash';
import { scaleYDimension } from '../../utils/common'; import { scaleYDimension } from '../../utils/common';
type CarouselMetadata = {
uuid: string;
index: number;
total: number;
};
const generateThumbnailContent = async ( const generateThumbnailContent = async (
mx: MatrixClient, mx: MatrixClient,
img: HTMLImageElement | HTMLVideoElement, img: HTMLImageElement | HTMLVideoElement,
@@ -46,7 +55,8 @@ const generateThumbnailContent = async (
export const getImageMsgContent = async ( export const getImageMsgContent = async (
mx: MatrixClient, mx: MatrixClient,
item: TUploadItem, item: TUploadItem,
mxc: string mxc: string,
carouselMetadata?: CarouselMetadata
): Promise<IContent> => { ): Promise<IContent> => {
const { file, originalFile, encInfo, metadata } = item; const { file, originalFile, encInfo, metadata } = item;
const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile))); const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
@@ -74,6 +84,11 @@ export const getImageMsgContent = async (
} else { } else {
content.url = mxc; content.url = mxc;
} }
if (carouselMetadata) {
content[PAARROT_CAROUSEL_UUID_PROPERTY_NAME] = carouselMetadata.uuid;
content[PAARROT_CAROUSEL_INDEX_PROPERTY_NAME] = carouselMetadata.index;
content[PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME] = carouselMetadata.total;
}
return content; return content;
}; };

View File

@@ -128,6 +128,10 @@ function PinnedMessage({
}, [room, eventId, mx]) }, [room, eventId, mx])
); );
const sender = pinnedEvent?.getSender();
const senderAvatarMxc = sender ? getMemberAvatarMxc(room, sender) : undefined;
const customUserColor = useOtherUserColor(sender ?? '', senderAvatarMxc);
const handleOpenClick: MouseEventHandler = (evt) => { const handleOpenClick: MouseEventHandler = (evt) => {
evt.stopPropagation(); evt.stopPropagation();
const evtId = evt.currentTarget.getAttribute('data-event-id'); const evtId = evt.currentTarget.getAttribute('data-event-id');
@@ -175,14 +179,9 @@ function PinnedMessage({
</Box> </Box>
); );
const sender = pinnedEvent.getSender()!; const displayName = getMemberDisplayName(room, sender!) ?? getMxIdLocalPart(sender!) ?? sender!;
const displayName = getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender;
const senderAvatarMxc = getMemberAvatarMxc(room, sender);
const getContent = (() => pinnedEvent.getContent()) as GetContentCallback; const getContent = (() => pinnedEvent.getContent()) as GetContentCallback;
// Get custom user color from avatar metadata
const customUserColor = useOtherUserColor(sender, senderAvatarMxc);
const memberPowerTag = getMemberPowerTag(sender); const memberPowerTag = getMemberPowerTag(sender);
const tagColor = memberPowerTag?.color const tagColor = memberPowerTag?.color
? accessibleTagColors?.get(memberPowerTag.color) ? accessibleTagColors?.get(memberPowerTag.color)

View File

@@ -31,6 +31,7 @@ import { EmojisStickers } from './emojis-stickers';
import { DeveloperTools } from './developer-tools'; import { DeveloperTools } from './developer-tools';
import { About } from './about'; import { About } from './about';
import { Audio } from './audio'; import { Audio } from './audio';
import { Plugins } from './plugins';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { LogoutDialog } from '../../components/LogoutDialog'; import { LogoutDialog } from '../../components/LogoutDialog';
@@ -42,6 +43,7 @@ export enum SettingsPages {
AudioPage, AudioPage,
DevicesPage, DevicesPage,
EmojisStickersPage, EmojisStickersPage,
PluginsPage,
DeveloperToolsPage, DeveloperToolsPage,
AboutPage, AboutPage,
} }
@@ -85,6 +87,11 @@ const useSettingsMenuItems = (): SettingsMenuItem[] =>
name: 'Emojis & Stickers', name: 'Emojis & Stickers',
icon: Icons.Smile, icon: Icons.Smile,
}, },
{
page: SettingsPages.PluginsPage,
name: 'Plugins',
icon: Icons.Download,
},
{ {
page: SettingsPages.DeveloperToolsPage, page: SettingsPages.DeveloperToolsPage,
name: 'Developer Tools', name: 'Developer Tools',
@@ -235,6 +242,9 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
{activePage === SettingsPages.EmojisStickersPage && ( {activePage === SettingsPages.EmojisStickersPage && (
<EmojisStickers requestClose={handlePageRequestClose} /> <EmojisStickers requestClose={handlePageRequestClose} />
)} )}
{activePage === SettingsPages.PluginsPage && (
<Plugins requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.DeveloperToolsPage && ( {activePage === SettingsPages.DeveloperToolsPage && (
<DeveloperTools requestClose={handlePageRequestClose} /> <DeveloperTools requestClose={handlePageRequestClose} />
)} )}

View File

@@ -60,7 +60,7 @@ export function About({ requestClose }: AboutProps) {
<Box gap="200" wrap="Wrap"> <Box gap="200" wrap="Wrap">
<Button <Button
as="a" as="a"
href="https://synbox.ruv.wtf:8418/litruv/cinny-desktop" href="https://github.com/Paarrot/Paarrot-Desktop"
rel="noreferrer noopener" rel="noreferrer noopener"
target="_blank" target="_blank"
variant="Secondary" variant="Secondary"

View File

@@ -55,7 +55,7 @@ import { useUserProfileStyle } from '../../../hooks/useUserProfileStyle';
import { useUserPresence } from '../../../hooks/useUserPresence'; import { useUserPresence } from '../../../hooks/useUserPresence';
import { AvatarPresence, PresenceBadge } from '../../../components/presence'; import { AvatarPresence, PresenceBadge } from '../../../components/presence';
import { BreakWord, LineClamp3 } from '../../../styles/Text.css'; import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
import colorMXID from '../../../../util/colorMXID'; import colorMXID, { getColorMXIDValue } from '../../../../util/colorMXID';
import { import {
extractMetadataFromImage, extractMetadataFromImage,
embedMetadataInImage, embedMetadataInImage,
@@ -574,7 +574,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
: profileStyle.avatarBorderColor; : profileStyle.avatarBorderColor;
const previewPillBgColor = previewBorderColor ? stripAlphaFromColor(previewBorderColor) : undefined; const previewPillBgColor = previewBorderColor ? stripAlphaFromColor(previewBorderColor) : undefined;
const previewProfileColor = previewBorderColor ? stripAlphaFromColor(previewBorderColor) : (userColor || colorMXID(userId)); const previewProfileColor = userColor || getColorMXIDValue(userId);
const previewTextShadow = `0 1px 4px ${getTextShadowColor(previewProfileColor)}`; const previewTextShadow = `0 1px 4px ${getTextShadowColor(previewProfileColor)}`;
@@ -612,7 +612,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
cursor: 'pointer', cursor: 'pointer',
border: 'none', border: 'none',
padding: 0, padding: 0,
backgroundColor: bannerBlobUrl ? 'transparent' : colorMXID(userId), backgroundColor: bannerBlobUrl ? 'transparent' : (userColor || colorMXID(userId)),
filter: bannerBlobUrl || authenticatedCoverUrl ? 'none' : 'brightness(50%)', filter: bannerBlobUrl || authenticatedCoverUrl ? 'none' : 'brightness(50%)',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',

View File

@@ -1,8 +1,9 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
import React, { useEffect, useState, MouseEventHandler } from 'react'; import React, { useCallback, useEffect, useRef, useState, MouseEventHandler } from 'react';
import { import {
Box, Box,
Button, Button,
color,
config, config,
Header, Header,
Icon, Icon,
@@ -450,8 +451,15 @@ export function Audio({ requestClose }: AudioProps) {
const [speakers, setSpeakers] = useState<AudioDevice[]>([]); const [speakers, setSpeakers] = useState<AudioDevice[]>([]);
const [settings, setSettings] = useState<AudioSettings>(loadAudioSettings); const [settings, setSettings] = useState<AudioSettings>(loadAudioSettings);
const [permissionStatus, setPermissionStatus] = useState<'granted' | 'denied' | 'prompt'>('prompt'); const [permissionStatus, setPermissionStatus] = useState<'granted' | 'denied' | 'prompt'>('prompt');
const [micTestState, setMicTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle');
const [micLevel, setMicLevel] = useState(0);
const [speakerTestState, setSpeakerTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle');
const [micMonitoring, setMicMonitoring] = useState(false);
const micAnimFrameRef = useRef<number | null>(null);
const micMonitorContextRef = useRef<AudioContext | null>(null);
const micMonitorStreamRef = useRef<MediaStream | null>(null);
const micMonitorGainRef = useRef<GainNode | null>(null);
const [showRemoteCursor, setShowRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor'); const [showRemoteCursor, setShowRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor');
const [livekitServiceUrl, setLivekitServiceUrl] = useSetting(settingsAtom, 'livekitServiceUrl');
useEffect(() => { useEffect(() => {
const loadDevices = async () => { const loadDevices = async () => {
@@ -555,58 +563,168 @@ export function Audio({ requestClose }: AudioProps) {
saveAudioSettings(newSettings); saveAudioSettings(newSettings);
}; };
const handleTestMicrophone = async () => { const handleTestMicrophone = useCallback(async () => {
if (micTestState === 'testing') return;
setMicTestState('testing');
setMicLevel(0);
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ const stream = await navigator.mediaDevices.getUserMedia({
audio: settings.microphoneId ? { deviceId: { exact: settings.microphoneId } } : true, audio: settings.microphoneId ? { deviceId: { exact: settings.microphoneId } } : true,
}); });
// Create audio context to visualize
const audioContext = new AudioContext(); const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser(); const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
const dataArray = new Uint8Array(analyser.frequencyBinCount);
const microphone = audioContext.createMediaStreamSource(stream); const microphone = audioContext.createMediaStreamSource(stream);
microphone.connect(analyser); microphone.connect(analyser);
// Quick test - just log that it works const tick = () => {
console.log('🎤 Microphone test: Audio stream acquired'); analyser.getByteFrequencyData(dataArray);
const avg = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
// Stop after a short delay setMicLevel(Math.min(100, Math.round((avg / 255) * 100 * 3)));
micAnimFrameRef.current = requestAnimationFrame(tick);
};
micAnimFrameRef.current = requestAnimationFrame(tick);
setTimeout(() => { setTimeout(() => {
if (micAnimFrameRef.current !== null) {
cancelAnimationFrame(micAnimFrameRef.current);
micAnimFrameRef.current = null;
}
stream.getTracks().forEach((track) => track.stop()); stream.getTracks().forEach((track) => track.stop());
audioContext.close(); audioContext.close();
console.log('🎤 Microphone test: Complete'); setMicLevel(0);
}, 2000); setMicTestState('done');
setTimeout(() => setMicTestState('idle'), 2000);
}, 3000);
} catch (e) { } catch (e) {
console.error('Microphone test failed:', e); console.error('Microphone test failed:', e);
setMicTestState('error');
setTimeout(() => setMicTestState('idle'), 2000);
} }
}; }, [micTestState, settings.microphoneId]);
const handleTestSpeaker = async () => { const handleTestSpeaker = useCallback(async () => {
if (speakerTestState === 'testing') return;
setSpeakerTestState('testing');
try { try {
// Create a simple test tone
const audioContext = new AudioContext(); const audioContext = new AudioContext();
const oscillator = audioContext.createOscillator(); const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain(); const gainNode = audioContext.createGain();
oscillator.connect(gainNode); oscillator.connect(gainNode);
gainNode.connect(audioContext.destination); gainNode.connect(audioContext.destination);
oscillator.frequency.value = 440;
oscillator.frequency.value = 440; // A4 note gainNode.gain.setValueAtTime(0, audioContext.currentTime);
gainNode.gain.value = 0.1; // Low volume gainNode.gain.linearRampToValueAtTime(0.15, audioContext.currentTime + 0.1);
gainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + 1.0);
oscillator.start(); oscillator.start();
console.log('🔊 Speaker test: Playing tone'); oscillator.stop(audioContext.currentTime + 1.0);
// Stop after a short delay
setTimeout(() => { setTimeout(() => {
oscillator.stop();
audioContext.close(); audioContext.close();
console.log('🔊 Speaker test: Complete'); setSpeakerTestState('done');
}, 1000); setTimeout(() => setSpeakerTestState('idle'), 2000);
}, 1200);
} catch (e) { } catch (e) {
console.error('Speaker test failed:', e); console.error('Speaker test failed:', e);
setSpeakerTestState('error');
setTimeout(() => setSpeakerTestState('idle'), 2000);
} }
}; }, [speakerTestState]);
const handleMicMonitoringToggle = useCallback(async () => {
if (micMonitoring) {
// Stop monitoring
if (micAnimFrameRef.current !== null) {
cancelAnimationFrame(micAnimFrameRef.current);
micAnimFrameRef.current = null;
}
if (micMonitorStreamRef.current) {
micMonitorStreamRef.current.getTracks().forEach((track) => track.stop());
micMonitorStreamRef.current = null;
}
if (micMonitorContextRef.current) {
micMonitorContextRef.current.close();
micMonitorContextRef.current = null;
}
micMonitorGainRef.current = null;
setMicLevel(0);
setMicMonitoring(false);
} else {
// Start monitoring
try {
const constraints: MediaStreamConstraints = {
audio: settings.microphoneId
? {
deviceId: { exact: settings.microphoneId },
noiseSuppression: settings.noiseSuppression,
echoCancellation: settings.echoCancellation,
autoGainControl: settings.autoGainControl,
}
: {
noiseSuppression: settings.noiseSuppression,
echoCancellation: settings.echoCancellation,
autoGainControl: settings.autoGainControl,
},
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
const dataArray = new Uint8Array(analyser.frequencyBinCount);
const microphone = audioContext.createMediaStreamSource(stream);
const gainNode = audioContext.createGain();
// Connect microphone to analyser for level monitoring
microphone.connect(analyser);
// Connect microphone to speakers for playback
microphone.connect(gainNode);
gainNode.connect(audioContext.destination);
gainNode.gain.value = 1.0; // Full volume playback
micMonitorContextRef.current = audioContext;
micMonitorStreamRef.current = stream;
micMonitorGainRef.current = gainNode;
const tick = () => {
analyser.getByteFrequencyData(dataArray);
const avg = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
setMicLevel(Math.min(100, Math.round((avg / 255) * 100 * 3)));
micAnimFrameRef.current = requestAnimationFrame(tick);
};
micAnimFrameRef.current = requestAnimationFrame(tick);
setMicMonitoring(true);
} catch (e) {
console.error('Microphone monitoring failed:', e);
}
}
}, [micMonitoring, settings.microphoneId, settings.noiseSuppression, settings.echoCancellation, settings.autoGainControl]);
// Cleanup monitoring on unmount or when settings change
useEffect(() => {
return () => {
if (micAnimFrameRef.current !== null) {
cancelAnimationFrame(micAnimFrameRef.current);
}
if (micMonitorStreamRef.current) {
micMonitorStreamRef.current.getTracks().forEach((track) => track.stop());
}
if (micMonitorContextRef.current) {
micMonitorContextRef.current.close();
}
};
}, []);
// Restart monitoring when mic settings change
useEffect(() => {
if (micMonitoring) {
handleMicMonitoringToggle(); // Stop
setTimeout(() => handleMicMonitoringToggle(), 100); // Restart
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings.microphoneId, settings.noiseSuppression, settings.echoCancellation, settings.autoGainControl]);
return ( return (
<Page> <Page>
@@ -666,15 +784,39 @@ export function Audio({ requestClose }: AudioProps) {
selectedId={settings.microphoneId} selectedId={settings.microphoneId}
onSelect={handleMicrophoneSelect} onSelect={handleMicrophoneSelect}
/> />
<Button <Box direction="Column" gap="100" alignItems="End">
size="300" <Box gap="200" alignItems="Center">
variant="Secondary" <Button
fill="Soft" size="300"
radii="300" variant={micTestState === 'error' ? 'Critical' : micTestState === 'done' ? 'Success' : 'Secondary'}
onClick={handleTestMicrophone} fill="Soft"
> radii="300"
<Text size="T300">Test</Text> onClick={handleTestMicrophone}
</Button> disabled={micTestState === 'testing' || micMonitoring}
>
<Text size="T300">
{micTestState === 'testing' ? 'Testing...' : micTestState === 'done' ? 'Done ✓' : micTestState === 'error' ? 'Failed' : 'Test'}
</Text>
</Button>
<Button
size="300"
variant={micMonitoring ? 'Primary' : 'Secondary'}
fill={micMonitoring ? 'Solid' : 'Soft'}
radii="300"
onClick={handleMicMonitoringToggle}
disabled={micTestState === 'testing'}
>
<Text size="T300">
{micMonitoring ? 'Stop' : 'Monitor'}
</Text>
</Button>
</Box>
{(micTestState === 'testing' || micMonitoring) && (
<Box style={{ width: toRem(160), height: toRem(6), background: color.Surface.Container, borderRadius: toRem(3), overflow: 'hidden' }}>
<Box style={{ width: `${micLevel}%`, height: '100%', background: micMonitoring ? color.Success.Main : color.Primary.Main, transition: 'width 0.05s ease' }} />
</Box>
)}
</Box>
</Box> </Box>
} }
/> />
@@ -703,12 +845,15 @@ export function Audio({ requestClose }: AudioProps) {
/> />
<Button <Button
size="300" size="300"
variant="Secondary" variant={speakerTestState === 'error' ? 'Critical' : speakerTestState === 'done' ? 'Success' : 'Secondary'}
fill="Soft" fill="Soft"
radii="300" radii="300"
onClick={handleTestSpeaker} onClick={handleTestSpeaker}
disabled={speakerTestState === 'testing'}
> >
<Text size="T300">Test</Text> <Text size="T300">
{speakerTestState === 'testing' ? 'Playing...' : speakerTestState === 'done' ? 'Done ✓' : speakerTestState === 'error' ? 'Failed' : 'Test'}
</Text>
</Button> </Button>
</Box> </Box>
} }
@@ -815,33 +960,6 @@ export function Audio({ requestClose }: AudioProps) {
/> />
</SequenceCard> </SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<Header size="400">
<Box gap="200" grow="Yes">
<Text size="H4">LiveKit Configuration</Text>
</Box>
</Header>
<SettingTile
title="LiveKit Service URL"
description="Custom LiveKit JWT service endpoint for Matrix RTC calls. Leave empty to use the default configured server."
after={
<Input
size="300"
variant="Background"
value={livekitServiceUrl ?? ''}
onChange={(e) => setLivekitServiceUrl(e.target.value || undefined)}
placeholder="https://example.com/livekit/jwt"
style={{ minWidth: toRem(300) }}
/>
}
/>
</SequenceCard>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="SurfaceVariant"

View File

@@ -34,7 +34,7 @@ import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../../components/page'; import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { useSetting } from '../../../state/hooks/settings'; import { useSetting } from '../../../state/hooks/settings';
import { DateFormat, EmojiStyle, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings'; import { DateFormat, EmojiStyle, MessageLayout, MessageSpacing, ScrollToLatestBehavior, settingsAtom } from '../../../state/settings';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { KeySymbol } from '../../../utils/key-symbol'; import { KeySymbol } from '../../../utils/key-symbol';
import { isMacOS } from '../../../utils/user-agent'; import { isMacOS } from '../../../utils/user-agent';
@@ -984,6 +984,83 @@ function SelectMessageSpacing() {
); );
} }
function SelectScrollToLatestBehavior() {
const [menuCords, setMenuCords] = useState<RectCords>();
const [scrollToLatestBehavior, setScrollToLatestBehavior] = useSetting(
settingsAtom,
'scrollToLatestBehavior'
);
const behaviors = [
{ value: ScrollToLatestBehavior.Always, label: 'Always' },
{ value: ScrollToLatestBehavior.OnNewMessage, label: 'On New Message' },
{ value: ScrollToLatestBehavior.Never, label: 'Never' },
];
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (behavior: ScrollToLatestBehavior) => {
setScrollToLatestBehavior(behavior);
setMenuCords(undefined);
};
const currentLabel = behaviors.find((b) => b.value === scrollToLatestBehavior)?.label ?? 'Always';
return (
<>
<Button
size="300"
variant="Secondary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleMenu}
>
<Text size="T300">{currentLabel}</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{behaviors.map((item) => (
<MenuItem
key={item.value}
size="300"
variant={scrollToLatestBehavior === item.value ? 'Primary' : 'Surface'}
radii="300"
onClick={() => handleSelect(item.value)}
>
<Text size="T300">{item.label}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
function Messages() { function Messages() {
const [legacyUsernameColor, setLegacyUsernameColor] = useSetting( const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
settingsAtom, settingsAtom,
@@ -1011,6 +1088,12 @@ function Messages() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Message Spacing" after={<SelectMessageSpacing />} /> <SettingTile title="Message Spacing" after={<SelectMessageSpacing />} />
</SequenceCard> </SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Scroll to Latest on Room Reselect"
after={<SelectScrollToLatestBehavior />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile <SettingTile
title="Legacy Username Color" title="Legacy Username Color"

View File

@@ -1,4 +1,4 @@
import React, { useCallback } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text, Switch, Button, color, Spinner } from 'folds'; import { Box, Text, Switch, Button, color, Spinner } from 'folds';
import { IPusherRequest } from 'matrix-js-sdk'; import { IPusherRequest } from 'matrix-js-sdk';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
@@ -10,6 +10,13 @@ import { getNotificationState, usePermissionState } from '../../../hooks/usePerm
import { useEmailNotifications } from '../../../hooks/useEmailNotifications'; import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { isCapacitorNative, requestSystemNotificationPermission } from '../../../utils/tauri';
import {
isBackgroundSyncSupported,
getBackgroundSyncStatus,
requestResetPushRegistration,
triggerBackgroundSyncPing,
} from '../../../utils/backgroundSync';
function EmailNotification() { function EmailNotification() {
const mx = useMatrixClient(); const mx = useMatrixClient();
@@ -86,14 +93,15 @@ function EmailNotification() {
export function SystemNotification() { export function SystemNotification() {
const notifPermission = usePermissionState('notifications', getNotificationState()); const notifPermission = usePermissionState('notifications', getNotificationState());
const capacitorNative = isCapacitorNative();
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications'); const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
const [isNotificationSounds, setIsNotificationSounds] = useSetting( const [isNotificationSounds, setIsNotificationSounds] = useSetting(
settingsAtom, settingsAtom,
'isNotificationSounds' 'isNotificationSounds'
); );
const requestNotificationPermission = () => { const requestNotificationPermission = async () => {
window.Notification.requestPermission(); await requestSystemNotificationPermission();
}; };
return ( return (
@@ -125,7 +133,7 @@ export function SystemNotification() {
</Button> </Button>
) : ( ) : (
<Switch <Switch
disabled={notifPermission !== 'granted'} disabled={!capacitorNative && notifPermission !== 'granted'}
value={showNotifications} value={showNotifications}
onChange={setShowNotifications} onChange={setShowNotifications}
/> />
@@ -145,6 +153,7 @@ export function SystemNotification() {
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />} after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
/> />
</SequenceCard> </SequenceCard>
{isBackgroundSyncSupported() && <AndroidPushNotifications />}
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="SurfaceVariant"
@@ -156,3 +165,126 @@ export function SystemNotification() {
</Box> </Box>
); );
} }
type PushStatus = {
registered: boolean;
distributor: string;
endpoint: string;
};
/** Android-only section showing UnifiedPush registration status and controls. */
function AndroidPushNotifications() {
const [status, setStatus] = useState<PushStatus | undefined>(undefined);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
setLoading(true);
const s = await getBackgroundSyncStatus();
setStatus(
s
? {
registered: s.registered,
distributor: s.distributor || '',
endpoint: s.endpoint || '',
}
: undefined
);
setLoading(false);
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const [resetState, reset] = useAsyncCallback(
useCallback(async () => {
await requestResetPushRegistration();
await refresh();
}, [refresh])
);
const [pingState, ping] = useAsyncCallback(
useCallback(async () => {
await triggerBackgroundSyncPing('manual-test');
}, [])
);
const isBusy =
loading ||
resetState.status === AsyncStatus.Loading ||
pingState.status === AsyncStatus.Loading;
return (
<Box direction="Column" gap="100">
<Text size="L400">Android Push (UnifiedPush)</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Background Notifications"
description={
loading ? (
'Loading status…'
) : status === undefined ? (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
Failed to read push status.
</Text>
) : status.registered ? (
<>
<Text as="span" size="T200">
{`Distributor: ${status.distributor || 'unknown'}`}
</Text>
</>
) : (
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
Not registered. Tap &quot;Reset&quot; to choose a distributor app.
</Text>
)
}
after={loading ? <Spinner variant="Secondary" /> : undefined}
/>
<SettingTile
title="Change Distributor"
description="Re-open the UnifiedPush distributor selection dialog."
after={
<Button
size="300"
radii="300"
variant="Secondary"
disabled={isBusy}
onClick={() => void reset()}
>
{resetState.status === AsyncStatus.Loading ? (
<Spinner variant="Secondary" size="200" />
) : (
<Text size="B300">Reset</Text>
)}
</Button>
}
/>
<SettingTile
title="Test Notification"
description="Trigger a test push ping to verify the pipeline works."
after={
<Button
size="300"
radii="300"
variant="Secondary"
disabled={isBusy}
onClick={() => void ping()}
>
{pingState.status === AsyncStatus.Loading ? (
<Spinner variant="Secondary" size="200" />
) : (
<Text size="B300">Send Test</Text>
)}
</Button>
}
/>
</SequenceCard>
</Box>
);
}

View File

@@ -0,0 +1,612 @@
# Plugin Development Guide
## Plugin Structure
Each plugin must be a directory containing at least:
- `index.js` - Main entry point
- `plugin-metadata.json` - Plugin metadata
### Example Directory Structure
```
my-plugin/
├── index.js
├── plugin-metadata.json
└── assets/
└── icon.png
```
## Plugin Metadata
The `plugin-metadata.json` file contains information about your plugin:
```json
{
"name": "My Cool Plugin",
"version": "1.0.0",
"description": "Does cool things",
"author": "Your Name",
"homepage": "https://github.com/yourusername/my-plugin",
"thumbnail": "https://example.com/thumbnail.png",
"tags": ["utility", "ui"]
}
```
## Plugin Entry Point (index.js)
Your `index.js` must export a default plugin object with an `onLoad` function:
```javascript
// index.js
module.exports = {
name: "My Plugin",
version: "1.0.0",
dependencies: {
"paarrot.utils": "^1.0.0" // Optional: depend on other plugins
},
/**
* Called when the plugin is loaded
* @param {PluginContext} ctx - The plugin context
*/
onLoad: async (ctx) => {
ctx.log('Plugin loaded!');
// Your plugin code here
},
/**
* Called when the plugin is unloaded (optional)
*/
onUnload: async () => {
ctx.log('Plugin unloaded!');
// Cleanup resources here
},
/**
* Exports for other plugins to use (optional)
*/
exports: {
myFunction: () => { /* ... */ }
}
};
```
## Plugin Context API
The `ctx` object provided to your plugin contains:
### 🎯 Commands API
Register slash commands with argument parsing:
```javascript
// Simple command
ctx.commands.register({
name: "shrug",
run: () => "¯\\_(ツ)_/¯"
});
// Command with args
ctx.commands.register({
name: "echo",
args: ["text"],
run: ({ text }) => text
});
// Advanced command
ctx.commands.register({
name: "greet",
description: "Greet someone",
args: ["name", "greeting"],
run: ({ name, greeting }) => {
return `${greeting || 'Hello'}, ${name || 'stranger'}!`;
}
});
// Execute commands programmatically
ctx.commands.execute("shrug", {});
// Unregister a command
ctx.commands.unregister("mycommand");
```
### 💬 Messages API
Intercept and modify messages:
```javascript
// Before send interceptor
ctx.messages.onBeforeSend((msg) => {
if (msg.content === "brb") {
msg.content = "be right back!";
}
// Add prefix
msg.content = `[Bot] ${msg.content}`;
// Access metadata
msg.metadata = { processed: true };
});
// Receive interceptor
ctx.messages.onReceive((msg) => {
if (msg.content.includes("@bot")) {
ctx.log('Bot was mentioned!');
}
});
```
MessageContext interface:
```typescript
interface MessageContext {
content: string;
roomId: string;
eventType: string;
formatted?: string;
metadata?: Record<string, any>;
}
```
### 🎨 UI API
Register custom renderers:
```javascript
ctx.ui.registerRenderer("message", (msg, defaultRenderer) => {
if (msg.type === "image") {
return `<img src="${msg.url}" style="border-radius:12px"/>`;
}
// Fall back to default
return defaultRenderer?.();
});
// Unregister renderer
ctx.ui.unregisterRenderer("message");
```
### ⚙️ Settings API
Define plugin settings with UI:
```javascript
ctx.settings.define({
theme: {
type: "select",
label: "Theme",
description: "Choose your theme",
options: [
{ value: "dark", label: "Dark" },
{ value: "light", label: "Light" }
],
default: "dark"
},
spamFilter: {
type: "boolean",
label: "Spam Filter",
description: "Enable spam filtering",
default: true
},
prefix: {
type: "string",
label: "Command Prefix",
default: "!"
},
maxMessages: {
type: "number",
label: "Max Messages",
default: 100
},
accentColor: {
type: "color",
label: "Accent Color",
default: "#6366f1"
}
});
// Get/set settings
const theme = ctx.settings.get('theme');
ctx.settings.set('theme', 'light');
```
### 🪝 Matrix Events API
Listen to raw Matrix events:
```javascript
ctx.matrix.on("m.room.message", (event) => {
const content = event.getContent();
ctx.log('Message received:', content.body);
});
ctx.matrix.on("Room.timeline", (event) => {
if (event.getType() === 'm.room.member') {
ctx.log('Member event:', event.getSender());
}
});
// Remove listener
ctx.matrix.off("m.room.message", handler);
```
Common events:
- `"m.room.message"` - Room messages
- `"m.room.member"` - Membership changes
- `"Room.timeline"` - Timeline events
- `"sync"` - Sync state changes
- `"RoomState.events"` - State events
### ⏱️ Timers API
Run background tasks:
```javascript
// Set interval
const intervalId = ctx.timers.setInterval(() => {
ctx.log('Running every 10 seconds');
// Auto-cleanup bots, sync data, etc.
}, 10000);
// Set timeout
const timeoutId = ctx.timers.setTimeout(() => {
ctx.log('Running once after 5 seconds');
}, 5000);
// Clear timers
ctx.timers.clearInterval(intervalId);
ctx.timers.clearTimeout(timeoutId);
// Timers are auto-cleaned on plugin unload
```
### 📣 Notifications API
Show notifications to users:
```javascript
// Simple notification
ctx.notify("Plugin loaded!");
// Advanced notification
ctx.notify({
title: "New Message",
body: "You have a new message from Steve",
type: "info", // info, success, error, warning
duration: 5000,
actions: [
{
label: "View",
action: () => {
// Handle action
}
}
]
});
```
### 📝 Logging API
Per-plugin logging:
```javascript
ctx.log('Info message', { data: 123 });
ctx.warn('Warning message');
ctx.error('Error message', error);
// Logs are visible in plugin logs panel
// Format: [Plugin pluginId] message
```
### 🔗 Require API
Use other plugins:
```javascript
// Require another plugin's exports
const utils = ctx.require("paarrot.utils");
utils.formatDate(new Date());
// Check if plugin exists
try {
const plugin = ctx.require("optional.plugin");
} catch (err) {
ctx.warn('Optional plugin not available');
}
```
### 🔌 Matrix Client
Direct access to Matrix.js SDK:
```javascript
const mx = ctx.matrixClient;
// Get user info
const userId = mx.getUserId();
const user = mx.getUser(userId);
// Get rooms
const rooms = mx.getRooms();
const room = mx.getRoom(roomId);
// Send messages
await mx.sendTextMessage(roomId, "Hello!");
// Full Matrix.js SDK available
```
### ⚛️ React
Access to React:
```javascript
const { useState, useEffect } = ctx.React;
// Create custom UI components
const MyComponent = () => {
const [count, setCount] = useState(0);
return ctx.React.createElement('button', {
onClick: () => setCount(count + 1)
}, `Count: ${count}`);
};
```
## Complete Example
See [`example-plugin/index.js`](../../../../example-plugin/index.js) for a full-featured example.
```javascript
module.exports = {
name: "Awesome Plugin",
version: "1.0.0",
onLoad: async (ctx) => {
// Register commands
ctx.commands.register({
name: "shrug",
run: () => "¯\\_(ツ)_/¯"
});
// Message interceptors
ctx.messages.onBeforeSend((msg) => {
if (msg.content === "brb") {
msg.content = "be right back!";
}
});
// Settings
ctx.settings.define({
enabled: { type: "boolean", default: true }
});
// Matrix events
ctx.matrix.on("m.room.message", (event) => {
ctx.log('Message:', event.getContent().body);
});
// Background task
ctx.timers.setInterval(() => {
ctx.log('Checking for updates...');
}, 60000);
ctx.notify("Plugin loaded!");
},
onUnload: async () => {
// Cleanup happens automatically
},
exports: {
version: "1.0.0"
}
};
```
## Best Practices
### 1. Error Handling
Always wrap your code in try-catch blocks:
```javascript
onLoad: async (ctx) => {
try {
// Your code
} catch (error) {
ctx.error('Failed to load:', error);
}
}
```
### 2. Cleanup Resources
Timers and Matrix handlers are auto-cleaned, but clean up other resources:
```javascript
onUnload: async () => {
// Close connections, save state, etc.
}
```
### 3. Performance
Don't block the main thread:
```javascript
// Bad
for (let i = 0; i < 1000000; i++) { /* ... */ }
// Good
ctx.timers.setTimeout(() => {
// Heavy work in background
}, 0);
```
### 4. Settings Best Practices
Provide defaults and descriptions:
```javascript
ctx.settings.define({
maxRetries: {
type: "number",
label: "Max Retries",
description: "Number of retry attempts",
default: 3
}
});
```
### 5. Logging
Use ctx.log() instead of console.log():
```javascript
ctx.log('Info'); // Shows in plugin logs panel
ctx.warn('Warning'); // Highlighted as warning
ctx.error('Error'); // Highlighted as error
```
## Advanced Patterns
### Plugin Dependencies
Require and use other plugins:
```javascript
onLoad: async (ctx) => {
try {
const utils = ctx.require("paarrot.utils");
const formatted = utils.formatDate(new Date());
ctx.log('Using utils:', formatted);
} catch (err) {
ctx.warn('Utils plugin not available, using fallback');
}
}
```
### Conditional Features
Enable features based on settings:
```javascript
const autoReply = ctx.settings.get('autoReply');
if (autoReply) {
ctx.matrix.on("m.room.message", (event) => {
// Auto-reply logic
});
}
```
### State Management
Store plugin state in settings:
```javascript
// Save state
ctx.settings.set('lastRun', Date.now());
ctx.settings.set('messageCount', count);
// Load state
const lastRun = ctx.settings.get('lastRun');
```
### Command Aliases
Create multiple commands for the same function:
```javascript
const shrugFn = () => "¯\\_(ツ)_/¯";
ctx.commands.register({ name: "shrug", run: shrugFn });
ctx.commands.register({ name: "dunno", run: shrugFn });
```
### Hot Reload Support
Make your plugin hot-reload friendly:
```javascript
let messageHandler = null;
onLoad: async (ctx) => {
messageHandler = (event) => { /* ... */ };
ctx.matrix.on("m.room.message", messageHandler);
},
onUnload: async () => {
if (messageHandler) {
// Cleanup happens automatically but explicit is good
}
}
```
## Security Considerations
1. **Never store passwords or tokens in plaintext**
2. **Validate all user input**
3. **Don't execute arbitrary code from messages**
4. **Use HTTPS for external requests**
5. **Respect user privacy - don't log sensitive data**
## Packaging
To package your plugin:
1. Create a ZIP file containing your plugin directory
2. The ZIP should contain the plugin folder at the root:
```
my-plugin.zip
└── my-plugin/
├── index.js
├── plugin-metadata.json
└── assets/
```
3. Upload to a URL accessible by users
4. Add your plugin to the Plugin Directory repository
## Plugin Directory
To list your plugin in the marketplace:
1. Fork the [Paarrot Plugin Directory](https://github.com/Paarrot/Plugin-Directory)
2. Add your plugin metadata to `plugins/index.json`
3. Create a plugin JSON file in `plugins/your-plugin.json`
4. Submit a pull request
## Debugging
### Browser Console
Check the browser console (F12) for plugin logs:
- `[PluginLoader]` - Plugin loading messages
- `[Plugin yourplugin]` - Your plugin's logs
### Plugin Logs Panel
View per-plugin logs in Settings → Plugins → Logs (coming soon)
### Common Issues
**Plugin won't load:**
- Check `index.js` exists
- Verify `module.exports` is correct
- Look for JavaScript syntax errors
**Command not working:**
- Ensure command is registered in onLoad
- Check command name doesn't conflict
- Verify run function returns a value
**Settings not saving:**
- Use ctx.settings.set() not localStorage
- Check setting key matches defined schema
## Testing
Test your plugin locally:
1. Copy plugin folder to `%APPDATA%/paarrot/plugins/` (Windows) or `~/.config/Paarrot/plugins/` (Linux)
2. Restart Paarrot
3. Enable plugin in Settings → Plugins
4. Check console for errors
5. Test all features
## Resources
- [Matrix.js SDK Docs](https://matrix-org.github.io/matrix-js-sdk/)
- [Paarrot Plugin Examples](../../../../example-plugin/)
- [Plugin Directory](https://github.com/Paarrot/Plugin-Directory)
- [Report Issues](https://github.com/Paarrot/cinny-desktop/issues)

View File

@@ -0,0 +1,145 @@
/**
* Re-exports the full @paarrot/plugin-manager API and creates the shared
* singleton PluginRegistry instance for this application.
*
* All other modules in this folder should import from here rather than
* directly from the package so the singleton is guaranteed to be shared.
*/
export {
PluginMarketplaceClient,
PluginMarketplaceManager,
PluginRegistry,
createPluginContext,
generateThemeCSS,
MemoryStorage,
} from '@paarrot/plugin-manager';
export type {
IPluginStorage,
IPluginEventClient,
PluginMarketplaceClientOptions,
PluginMarketplaceHostAdapter,
PluginMarketplaceInstalledRecord,
PluginMarketplaceManagerOptions,
PluginRegistryOptions,
PluginContextOptions,
PluginMetadata,
InstalledPlugin,
PluginIndex,
ThemeColorGroup,
ThemePaletteGroup,
PluginThemeColors,
PluginTheme,
CommandArg,
PluginCommand,
MessageInterceptor,
MessageContext,
CustomRenderer,
SettingDefinition,
SettingsSchema,
NotificationOptions,
PluginContext,
PluginSettingsSection,
UILocation,
UIButtonDefinition,
UIButtonPosition,
Plugin,
PluginLogEntry,
} from '@paarrot/plugin-manager';
export { PluginTab } from '@paarrot/plugin-manager';
import {
PluginMarketplaceClient,
PluginMarketplaceManager,
PluginRegistry,
} from '@paarrot/plugin-manager';
const PLUGIN_INDEX_URL =
import.meta.env.VITE_PLUGIN_INDEX_URL ||
`https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/index.json?cache_burst=${(Math.random()*256).toString()}`;
const PLUGIN_BASE_URL =
import.meta.env.VITE_PLUGIN_BASE_URL ||
'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/';
function injectThemeStyle(styleId: string, css: string): void {
let el = document.getElementById(styleId) as HTMLStyleElement | null;
if (!el) {
el = document.createElement('style');
el.id = styleId;
document.head.appendChild(el);
}
el.textContent = css;
}
function removeThemeStyle(styleId: string): void {
document.getElementById(styleId)?.remove();
}
/** Shared singleton registry for the entire application. */
export const pluginRegistry = new PluginRegistry({
storage: localStorage,
onThemeRegistered: (themeId, _className, css) =>
injectThemeStyle(`plugin-theme-${themeId}`, css),
onThemeUnregistered: (themeId) => removeThemeStyle(`plugin-theme-${themeId}`),
});
/** Shared marketplace client for remote plugin directory reads. */
export const pluginMarketplaceClient = new PluginMarketplaceClient({
indexUrl: PLUGIN_INDEX_URL,
baseUrl: PLUGIN_BASE_URL,
});
/**
* Shared marketplace manager for install state, directory fetch, and enable flags.
*/
export const pluginMarketplaceManager = new PluginMarketplaceManager({
client: pluginMarketplaceClient,
storage: localStorage,
registry: pluginRegistry,
host: {
listInstalledPlugins: async () => {
if (!window.electron?.plugins) {
return [];
}
const result = await window.electron.plugins.list();
if (!result.success || !result.data) {
throw new Error(result.error ?? 'Failed to list installed plugins');
}
return result.data.map((plugin) => ({
id: plugin.id,
name: plugin.name,
version: plugin.version,
description: plugin.description,
author: plugin.author,
repository: plugin.repository,
thumbnail: plugin.thumbnail,
homepage: plugin.homepage,
tags: plugin.tags,
installedDate: plugin.installedDate,
}));
},
installPlugin: async (plugin) => {
if (!window.electron?.plugins) {
return;
}
const result = await window.electron.plugins.download(plugin.id, plugin.downloadUrl, plugin.name);
if (!result.success) {
throw new Error(result.error ?? 'Failed to install plugin');
}
},
uninstallPlugin: async (pluginId) => {
if (!window.electron?.plugins) {
return;
}
const result = await window.electron.plugins.uninstall(pluginId);
if (!result.success) {
throw new Error(result.error ?? 'Failed to uninstall plugin');
}
},
},
});

View File

@@ -0,0 +1,71 @@
import React, { useSyncExternalStore } from 'react';
import { IconButton } from 'folds';
import { UILocation } from '@paarrot/plugin-manager';
import { pluginRegistry } from './PluginAPI';
// ---------------------------------------------------------------------------
// Module-level subscription store — works correctly with React 18 concurrent
// rendering, lazy-mounted components (nav panels, route changes), and popup
// menus that mount fresh after plugins have already loaded.
// ---------------------------------------------------------------------------
type Listener = () => void;
const listeners = new Set<Listener>();
let storeVersion = 0;
export function subscribeToButtons(callback: Listener): () => void {
listeners.add(callback);
return () => listeners.delete(callback);
}
export function getStoreVersion(): number {
return storeVersion;
}
/**
* Notify all subscribed slots that the button registry has changed.
* Call this after plugins load or unload.
*/
export function dispatchPluginButtonsChanged(): void {
storeVersion += 1;
for (const listener of listeners) {
listener();
}
}
interface PluginButtonSlotProps {
/** The UI location to render buttons for. */
location: UILocation;
}
/**
* Renders all plugin-registered buttons for the given UI location.
* Correctly handles components that mount before OR after plugins load,
* including popup menus, navigation panels, and concurrent React renders.
*/
export function PluginButtonSlot({ location }: PluginButtonSlotProps): React.ReactElement | null {
useSyncExternalStore(subscribeToButtons, getStoreVersion);
const buttons = pluginRegistry.getButtonsForLocation(location);
if (buttons.length === 0) return null;
return (
<>
{buttons.map((button) => (
<IconButton
key={button.id}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label={button.label}
title={button.label}
onClick={() => button.onClick?.()}
>
<span aria-hidden style={{ fontSize: '1.1em', lineHeight: 1 }}>
{button.icon ?? '🔌'}
</span>
</IconButton>
))}
</>
);
}

View File

@@ -0,0 +1,188 @@
import React, { useEffect } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { Plugin, createPluginContext } from '@paarrot/plugin-manager';
import { pluginMarketplaceManager, pluginRegistry } from './PluginAPI';
import { sendNotification } from '../../../utils/tauri';
import { dispatchPluginButtonsChanged } from './PluginButtonSlot';
interface PluginLoaderProps {
matrixClient: MatrixClient;
children: React.ReactNode;
}
/**
* Component that loads and initializes enabled plugins.
* Should be placed high in the component tree after MatrixClient is available.
*/
export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
console.log('[PluginLoader] Component rendering, matrixClient:', !!matrixClient);
useEffect(() => {
const loadPlugins = async () => {
try {
console.log('[PluginLoader] Loading plugins...');
console.log('[PluginLoader] window.electron:', !!window.electron);
console.log('[PluginLoader] window.electron.plugins:', !!window.electron?.plugins);
if (!window.electron?.plugins) {
console.log('[PluginLoader] Running in web mode, plugins not supported');
return;
}
const pluginsToLoad = await pluginMarketplaceManager.listEnabledPlugins();
console.log('[PluginLoader] Plugins to load:', pluginsToLoad.length);
console.log('[PluginLoader] Plugin list:', pluginsToLoad.map(p => p.id));
for (const installedPlugin of pluginsToLoad) {
try {
console.log(`[PluginLoader] Loading plugin: ${installedPlugin.id}`);
const pluginModule = await loadPluginModule(installedPlugin.id);
const plugin = normalisePlugin(pluginModule);
if (!plugin || typeof plugin !== 'object') {
console.error(
`[PluginLoader] Plugin ${installedPlugin.id} does not export a valid plugin object`
);
continue;
}
if (typeof plugin.onLoad !== 'function') {
console.error(
`[PluginLoader] Plugin ${installedPlugin.id} does not have an onLoad function`
);
continue;
}
const context = createPluginContext(
{
pluginId: installedPlugin.id,
eventClient: matrixClient as any,
onNotify: async (opts) => {
await sendNotification({
title: opts.title,
body: opts.body,
path: undefined,
});
},
},
pluginRegistry
);
const compatContext = Object.assign(context as Record<string, unknown>, {
matrixClient,
matrix: {
on: (eventType: string, handler: (...args: unknown[]) => void) =>
matrixClient.on(eventType as any, handler as any),
off: (eventType: string, handler: (...args: unknown[]) => void) =>
matrixClient.off(eventType as any, handler as any),
},
React,
});
console.log('[PluginLoader] compatContext.React:', !!compatContext.React);
console.log('[PluginLoader] compatContext.ui:', !!compatContext.ui);
console.log('[PluginLoader] About to register plugin and call onLoad...');
pluginRegistry.registerPlugin(installedPlugin.id, plugin, context);
await plugin.onLoad(compatContext as any);
console.log(`[PluginLoader] Successfully loaded plugin: ${installedPlugin.id}`);
dispatchPluginButtonsChanged();
} catch (error) {
console.error(`[PluginLoader] Failed to load plugin ${installedPlugin.id}:`, error);
pluginRegistry.addLog(installedPlugin.id, 'error', ['Failed to load:', error]);
}
}
} catch (error) {
console.error('[PluginLoader] Error loading plugins:', error);
}
};
loadPlugins();
return () => {
console.log('[PluginLoader] Cleaning up plugins');
pluginRegistry.clear();
dispatchPluginButtonsChanged();
};
}, [matrixClient]);
return <>{children}</>;
}
/**
* Reads and evaluates a plugin's JS module from the Electron filesystem.
* Supports both CommonJS and ESM-style exports.
*/
async function loadPluginModule(pluginId: string): Promise<any> {
if (!window.electron?.plugins) {
throw new Error('Plugins are only supported in the desktop app');
}
const response = await window.electron.plugins.readPluginCode(pluginId);
if (!response.success || !response.data) {
throw new Error(response.error ?? 'Failed to read plugin code');
}
const pluginExports: any = {};
const mod = { exports: pluginExports };
try {
// eslint-disable-next-line no-new-func
const pluginFunction = new Function('module', 'exports', response.data);
pluginFunction(mod, pluginExports);
} catch (error) {
if (!(error instanceof SyntaxError)) {
throw error;
}
const dataUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(response.data)}`;
const importedModule = await import(/* @vite-ignore */ dataUrl);
return importedModule;
}
return mod.exports;
}
/**
* Normalizes raw plugin exports to current plugin interface.
* Supports native `onLoad` and legacy `activate` / `deactivate` plugins.
*/
function normalisePlugin(raw: Record<string, unknown>): Plugin {
if (typeof raw.onLoad === 'function') {
return raw as unknown as Plugin;
}
if (typeof raw.activate === 'function') {
const activate = raw.activate as (ctx: unknown) => void | Promise<void>;
const deactivate = raw.deactivate as ((ctx?: unknown) => void | Promise<void>) | undefined;
return {
name: typeof raw.name === 'string' ? raw.name : undefined,
version: typeof raw.version === 'string' ? raw.version : undefined,
onLoad: async (ctx) => {
const legacyCtx = Object.assign(Object.create(ctx as object), {
registerHook: (name: string) =>
ctx.log(`[compat] registerHook("${name}") - not supported`),
runHook: (name: string) => {
ctx.log(`[compat] runHook("${name}")`);
return Promise.resolve([]);
},
getConfig: (key: string) => ctx.settings.get(key),
on: (event: string, handler: (...args: unknown[]) => void) =>
ctx.events.on(event, handler),
});
await activate(legacyCtx);
},
onUnload: deactivate
? async () => {
await deactivate();
}
: undefined,
};
}
return raw as unknown as Plugin;
}

View File

@@ -0,0 +1,48 @@
import React, { useSyncExternalStore } from 'react';
import { Avatar, Box, Text } from 'folds';
import { UILocation } from '@paarrot/plugin-manager';
import { NavItem, NavButton, NavItemContent } from '../../../components/nav';
import { pluginRegistry } from './PluginAPI';
import { subscribeToButtons, getStoreVersion } from './PluginButtonSlot';
interface PluginNavSlotProps {
/** The UI location to render nav list entries for. */
location: UILocation;
}
/**
* Renders plugin buttons as `NavItem` list entries, matching the style of
* built-in sidebar items like "Create Room" and "Message Search".
* Intended for locations that live inside the channel / room list, not toolbars.
*/
export function PluginNavSlot({ location }: PluginNavSlotProps): React.ReactElement | null {
useSyncExternalStore(subscribeToButtons, getStoreVersion);
const buttons = pluginRegistry.getButtonsForLocation(location);
if (buttons.length === 0) return null;
return (
<>
{buttons.map((button) => (
<NavItem key={button.id} variant="Background" radii="400">
<NavButton onClick={() => button.onClick?.()}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<span aria-hidden style={{ fontSize: '1em', lineHeight: 1 }}>
{button.icon ?? '🔌'}
</span>
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
{button.label}
</Text>
</Box>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
))}
</>
);
}

View File

@@ -0,0 +1,49 @@
import React, { useSyncExternalStore } from 'react';
import { UILocation } from '@paarrot/plugin-manager';
import {
SidebarItem,
SidebarItemTooltip,
SidebarAvatar,
} from '../../../components/sidebar';
import { pluginRegistry } from './PluginAPI';
import { subscribeToButtons, getStoreVersion } from './PluginButtonSlot';
interface PluginSidebarSlotProps {
/** The UI location to render sidebar icon entries for. */
location: UILocation;
}
/**
* Renders plugin buttons as `SidebarItem` entries, matching the style of
* built-in sidebar tabs like SearchTab, InboxTab, and SettingsTab.
* Intended for the `sidebar-actions` location in the main left sidebar.
*/
export function PluginSidebarSlot({ location }: PluginSidebarSlotProps): React.ReactElement | null {
useSyncExternalStore(subscribeToButtons, getStoreVersion);
const buttons = pluginRegistry.getButtonsForLocation(location);
if (buttons.length === 0) return null;
return (
<>
{buttons.map((button) => (
<SidebarItem key={button.id}>
<SidebarItemTooltip tooltip={button.label}>
{(triggerRef) => (
<SidebarAvatar
as="button"
ref={triggerRef}
onClick={() => button.onClick?.()}
aria-label={button.label}
>
<span aria-hidden style={{ fontSize: '1.2em', lineHeight: 1 }}>
{button.icon ?? '🔌'}
</span>
</SidebarAvatar>
)}
</SidebarItemTooltip>
</SidebarItem>
))}
</>
);
}

View File

@@ -0,0 +1,690 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
Avatar,
Badge,
Box,
Button,
color,
config,
Header,
Icon,
IconButton,
Icons,
Input,
Menu,
MenuItem,
PopOut,
RectCords,
Scroll,
Spinner,
Switch,
Text,
toRem,
} from 'folds';
import { HexColorPicker } from 'react-colorful';
import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { PluginTab, PluginMetadata, InstalledPlugin } from './types';
import { SequenceCard } from '../../../components/sequence-card';
import { SettingTile } from '../../../components/setting-tile';
import { SequenceCardStyle } from '../styles.css';
import { pluginMarketplaceManager, pluginRegistry, SettingDefinition } from './PluginAPI';
import { stopPropagation } from '../../../utils/keyboard';
/**
* Renders settings UI for a plugin based on its settings schema
*/
function PluginSettingsRenderer({ pluginId }: { pluginId: string }) {
const schema = pluginRegistry.getPluginSettingsSchema(pluginId);
const [, forceUpdate] = useState({});
if (!schema || Object.keys(schema).length === 0) {
return (
<Box padding="300">
<Text size="T300" priority="300">No settings available for this plugin.</Text>
</Box>
);
}
const handleChange = (key: string, value: any) => {
pluginRegistry.setPluginSetting(pluginId, key, value);
forceUpdate({});
};
return (
<Box direction="Column" gap="300" style={{ padding: config.space.S400 }}>
{Object.entries(schema).map(([key, def]) => (
<SettingRenderer
key={key}
settingKey={key}
definition={def}
value={pluginRegistry.getPluginSetting(pluginId, key)}
onChange={(value) => handleChange(key, value)}
/>
))}
</Box>
);
}
/**
* Renders a single setting based on its type
*/
function SettingRenderer({
settingKey,
definition,
value,
onChange,
}: {
settingKey: string;
definition: SettingDefinition;
value: any;
onChange: (value: any) => void;
}) {
const [colorMenuCords, setColorMenuCords] = useState<RectCords>();
const [selectMenuCords, setSelectMenuCords] = useState<RectCords>();
const label = definition.label || settingKey;
const description = definition.description;
// Boolean - Switch
if (definition.type === 'boolean') {
return (
<SettingTile
title={label}
description={description}
after={<Switch value={value ?? definition.default ?? false} onChange={onChange} />}
/>
);
}
// String - Input
if (definition.type === 'string') {
return (
<SettingTile title={label} description={description}>
<Input
size="400"
variant="Background"
value={value ?? definition.default ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={definition.default}
/>
</SettingTile>
);
}
// Number - Input
if (definition.type === 'number') {
return (
<SettingTile title={label} description={description}>
<Input
size="400"
variant="Background"
type="number"
value={value ?? definition.default ?? 0}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
/>
</SettingTile>
);
}
// Select - Dropdown
if (definition.type === 'select' && definition.options) {
const currentValue = value ?? definition.default ?? definition.options[0]?.value;
const selectedOption = definition.options.find(opt => opt.value === currentValue);
return (
<SettingTile title={label} description={description}>
<Button
size="300"
variant="Primary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={(evt) => setSelectMenuCords(evt.currentTarget.getBoundingClientRect())}
>
<Text size="T300">{selectedOption?.label || currentValue}</Text>
</Button>
<PopOut
anchor={selectMenuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setSelectMenuCords(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu>
{definition.options.map((option) => (
<MenuItem
key={option.value}
size="300"
after={currentValue === option.value ? <Icon size="100" src={Icons.Check} /> : undefined}
radii="300"
onClick={() => {
onChange(option.value);
setSelectMenuCords(undefined);
}}
>
<Text size="T300">{option.label}</Text>
</MenuItem>
))}
</Menu>
</FocusTrap>
}
/>
</SettingTile>
);
}
// Color - Color Picker
if (definition.type === 'color') {
const currentColor = value ?? definition.default ?? '#000000';
return (
<SettingTile title={label} description={description}>
<Button
size="400"
variant="Primary"
outlined
fill="Soft"
radii="300"
before={
<Box
style={{
width: toRem(20),
height: toRem(20),
borderRadius: config.radii.R300,
backgroundColor: currentColor,
border: `1px solid ${color.Surface.ContainerLine}`,
}}
/>
}
onClick={(evt) => setColorMenuCords(evt.currentTarget.getBoundingClientRect())}
>
<Text size="T300">{currentColor}</Text>
</Button>
<PopOut
anchor={colorMenuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setColorMenuCords(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Box style={{ padding: config.space.S300 }}>
<HexColorPicker color={currentColor} onChange={onChange} />
</Box>
</FocusTrap>
}
/>
</SettingTile>
);
}
return null;
}
type PluginsProps = {
requestClose: () => void;
};
export function Plugins({ requestClose }: PluginsProps) {
const [activeTab, setActiveTab] = useState<PluginTab>(PluginTab.Marketplace);
const [marketplacePlugins, setMarketplacePlugins] = useState<PluginMetadata[]>([]);
const [installedPlugins, setInstalledPlugins] = useState<InstalledPlugin[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [expandedSettings, setExpandedSettings] = useState<string | null>(null);
const refreshInstalledPlugins = useCallback(async () => {
try {
setInstalledPlugins(await pluginMarketplaceManager.listInstalledPlugins());
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to list installed plugins');
console.error('Failed to list installed plugins:', err);
}
}, []);
const fetchMarketplacePlugins = useCallback(async () => {
setLoading(true);
setError(null);
try {
setMarketplacePlugins(await pluginMarketplaceManager.fetchMarketplacePlugins());
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch plugins');
console.error('Error fetching marketplace plugins:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (activeTab === PluginTab.Marketplace) {
fetchMarketplacePlugins();
}
}, [activeTab, fetchMarketplacePlugins]);
useEffect(() => {
refreshInstalledPlugins();
}, [refreshInstalledPlugins]);
const handleInstall = useCallback(
async (plugin: PluginMetadata) => {
try {
setLoading(true);
setError(null);
await pluginMarketplaceManager.installPlugin(plugin);
await refreshInstalledPlugins();
} catch (err) {
console.error('Failed to install plugin:', err);
setError(err instanceof Error ? err.message : 'Failed to install plugin');
} finally {
setLoading(false);
}
},
[refreshInstalledPlugins]
);
const handleUninstall = useCallback(
async (pluginId: string) => {
try {
setError(null);
await pluginMarketplaceManager.uninstallPlugin(pluginId);
await refreshInstalledPlugins();
} catch (err) {
console.error('Failed to uninstall plugin:', err);
setError(err instanceof Error ? err.message : 'Failed to uninstall plugin');
}
},
[refreshInstalledPlugins]
);
const handleToggleEnabled = useCallback(
async (pluginId: string) => {
const plugin = installedPlugins.find((p) => p.id === pluginId);
if (!plugin) return;
const newEnabledState = !plugin.enabled;
try {
setError(null);
await pluginMarketplaceManager.setPluginEnabled(pluginId, newEnabledState);
await refreshInstalledPlugins();
if (newEnabledState) {
console.log(`[Plugins] Plugin ${pluginId} enabled. Restart app to load it.`);
}
} catch (err) {
console.error(`Failed to toggle plugin ${pluginId}:`, err);
setError(err instanceof Error ? err.message : 'Failed to update plugin state');
}
},
[installedPlugins, refreshInstalledPlugins]
);
const isPluginInstalled = useCallback(
(pluginId: string) => installedPlugins.some((p) => p.id === pluginId),
[installedPlugins]
);
const fuzzySearch = (query: string, text: string): boolean => {
if (!query) return true;
const searchLower = query.toLowerCase();
const textLower = text.toLowerCase();
return textLower.includes(searchLower);
};
const PluginAvatar = ({ thumbnail, name }: { thumbnail: string; name: string }) => {
const [imageError, setImageError] = useState(false);
return (
<Avatar size="500" radii="400">
{!imageError ? (
<img
src={thumbnail}
alt={name}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
borderRadius: 'inherit',
}}
onError={() => setImageError(true)}
/>
) : (
<Box
justifyContent="Center"
alignItems="Center"
style={{
width: '100%',
height: '100%',
background: `linear-gradient(135deg, ${color.Secondary.Container} 0%, ${color.Primary.Container} 100%)`,
borderRadius: 'inherit',
}}
>
<Text size="H3" priority="300" style={{ fontWeight: 600 }}>
{name.charAt(0).toUpperCase()}
</Text>
</Box>
)}
</Avatar>
);
};
const filteredMarketplacePlugins = useMemo(() => {
if (!searchQuery) return marketplacePlugins;
return marketplacePlugins.filter((plugin) =>
fuzzySearch(searchQuery, plugin.name) ||
fuzzySearch(searchQuery, plugin.description) ||
fuzzySearch(searchQuery, plugin.author) ||
plugin.tags.some((tag) => fuzzySearch(searchQuery, tag))
);
}, [marketplacePlugins, searchQuery]);
const filteredInstalledPlugins = useMemo(() => {
if (!searchQuery) return installedPlugins;
return installedPlugins.filter((plugin) =>
fuzzySearch(searchQuery, plugin.name) ||
fuzzySearch(searchQuery, plugin.description) ||
fuzzySearch(searchQuery, plugin.author)
);
}, [installedPlugins, searchQuery]);
return (
<Page>
<PageHeader>
<Box grow="Yes" alignItems="Center" gap="200">
<Header size="H3" className="truncate">
Plugins
</Header>
</Box>
<Box shrink="No" gap="200">
<Button variant="Background" size="300" onClick={requestClose}>
<Text size="B300">Close</Text>
</Button>
</Box>
</PageHeader>
<Box shrink="No" style={{ padding: `0 ${config.space.S400}` }} direction="Column" gap="300">
<Box gap="200">
<Badge
style={{ cursor: 'pointer' }}
as="button"
variant="Secondary"
fill={activeTab === PluginTab.Marketplace ? 'Solid' : 'None'}
size="500"
onClick={() => setActiveTab(PluginTab.Marketplace)}
>
<Text as="span" size="L400">
Marketplace
</Text>
</Badge>
<Badge
style={{ cursor: 'pointer' }}
as="button"
variant="Secondary"
fill={activeTab === PluginTab.Installed ? 'Solid' : 'None'}
size="500"
onClick={() => setActiveTab(PluginTab.Installed)}
>
<Text as="span" size="L400">
Installed
</Text>
</Badge>
</Box>
<Input
variant="Background"
size="400"
placeholder="Search plugins..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
before={<Icon size="100" src={Icons.Search} />}
after={
searchQuery && (
<IconButton
size="300"
variant="SurfaceLow"
radii="300"
onClick={() => setSearchQuery('')}
>
<Icon size="50" src={Icons.Cross} filled />
</IconButton>
)
}
/>
</Box>
<Box grow="Yes" direction="Column">
<Scroll variant="Background" visibility="Hover" hideTrack size="300">
<PageContent>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
{activeTab === PluginTab.Marketplace && (
<Box direction="Column" gap="400">
{loading && (
<Box justifyContent="Center" alignItems="Center" style={{ padding: toRem(32) }}>
<Spinner variant="Secondary" size="400" />
</Box>
)}
{error && (
<Box direction="Column" gap="200" style={{ padding: toRem(16) }}>
<Text size="T400" priority="300">
Error: {error}
</Text>
<Button onClick={fetchMarketplacePlugins} size="300" variant="Primary">
<Text size="B300">Retry</Text>
</Button>
</Box>
)}
{!loading && !error && filteredMarketplacePlugins.length === 0 && (
<Box justifyContent="Center" style={{ padding: toRem(32) }}>
<Text size="T400" priority="300">
{searchQuery ? 'No plugins found matching your search' : 'No plugins available'}
</Text>
</Box>
)}
{!loading &&
!error &&
filteredMarketplacePlugins.map((plugin) => (
<Box
key={plugin.id}
style={{
padding: config.space.S500,
background: color.Surface.Container,
borderRadius: config.radii.R400,
border: `1px solid ${color.Surface.ContainerLine}`,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<Box gap="500" alignItems="Start">
<Box shrink="No">
<PluginAvatar thumbnail={plugin.thumbnail} name={plugin.name} />
</Box>
<Box grow="Yes" direction="Column" gap="200">
<Box direction="Column" gap="100">
<Text size="H5">{plugin.name}</Text>
<Box gap="200" alignItems="Center">
<Text size="T200" priority="400">
by {plugin.author}
</Text>
<Text size="T200" priority="400"></Text>
<Text size="T200" priority="400">
v{plugin.version}
</Text>
</Box>
</Box>
<Text size="T300" priority="300">
{plugin.description}
</Text>
{plugin.tags.length > 0 && (
<Box gap="100" wrap="Wrap">
{plugin.tags.map((tag) => (
<Badge key={tag} size="300" variant="Secondary" fill="Soft">
<Text size="L400">{tag}</Text>
</Badge>
))}
</Box>
)}
</Box>
<Box shrink="No">
<Button
variant={isPluginInstalled(plugin.id) ? 'Success' : 'Primary'}
size="400"
onClick={() => handleInstall(plugin)}
disabled={isPluginInstalled(plugin.id)}
>
<Text size="B400">
{isPluginInstalled(plugin.id) ? 'Installed' : 'Install'}
</Text>
</Button>
</Box>
</Box>
</Box>
))}
</Box>
)}
{activeTab === PluginTab.Installed && (
<Box direction="Column" gap="400">
{filteredInstalledPlugins.length === 0 && (
<Box justifyContent="Center" style={{ padding: toRem(32) }}>
<Text size="T400" priority="300">
{searchQuery ? 'No installed plugins found matching your search' : 'No plugins installed'}
</Text>
</Box>
)}
{filteredInstalledPlugins.map((plugin) => {
const hasSettings = pluginRegistry.getPluginSettingsSchema(plugin.id) &&
Object.keys(pluginRegistry.getPluginSettingsSchema(plugin.id)!).length > 0;
const settingsExpanded = expandedSettings === plugin.id;
return (
<Box
key={plugin.id}
direction="Column"
style={{
background: color.Surface.Container,
borderRadius: config.radii.R400,
border: `1px solid ${color.Surface.ContainerLine}`,
transition: 'all 0.2s ease',
}}
>
<Box
style={{
padding: config.space.S500,
}}
onMouseEnter={(e) => {
e.currentTarget.parentElement!.style.transform = 'translateY(-2px)';
e.currentTarget.parentElement!.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.parentElement!.style.transform = 'translateY(0)';
e.currentTarget.parentElement!.style.boxShadow = 'none';
}}
>
<Box gap="500" alignItems="Start">
<Box shrink="No">
<PluginAvatar thumbnail={plugin.thumbnail} name={plugin.name} />
</Box>
<Box grow="Yes" direction="Column" gap="200">
<Box direction="Column" gap="100">
<Text size="H5">{plugin.name}</Text>
<Box gap="200" alignItems="Center">
<Text size="T200" priority="400">
by {plugin.author}
</Text>
<Text size="T200" priority="400"></Text>
<Text size="T200" priority="400">
v{plugin.version}
</Text>
</Box>
</Box>
<Text size="T300" priority="300">
{plugin.description}
</Text>
<Box gap="100" wrap="Wrap">
<Badge
size="300"
variant={plugin.enabled ? 'Success' : 'Secondary'}
fill="Soft"
>
<Text size="L400">{plugin.enabled ? 'Enabled' : 'Disabled'}</Text>
</Badge>
</Box>
</Box>
<Box shrink="No" direction="Column" gap="200">
{hasSettings && (
<Button
variant="Secondary"
fill="Soft"
size="400"
before={<Icon size="200" src={Icons.Setting} />}
after={<Icon size="200" src={settingsExpanded ? Icons.ChevronTop : Icons.ChevronBottom} />}
onClick={() => setExpandedSettings(settingsExpanded ? null : plugin.id)}
>
<Text size="B400">Settings</Text>
</Button>
)}
<Button
variant="Secondary"
size="400"
onClick={() => handleToggleEnabled(plugin.id)}
>
<Text size="B400">{plugin.enabled ? 'Disable' : 'Enable'}</Text>
</Button>
<Button
variant="Critical"
fill="None"
size="400"
onClick={() => handleUninstall(plugin.id)}
>
<Text size="B400">Uninstall</Text>
</Button>
</Box>
</Box>
</Box>
{hasSettings && settingsExpanded && (
<Box
direction="Column"
style={{
borderTop: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.ContainerLowest,
}}
>
<PluginSettingsRenderer pluginId={plugin.id} />
</Box>
)}
</Box>
);
})}
</Box>
)}
</SequenceCard>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View File

@@ -0,0 +1,4 @@
export * from './Plugins';
export * from './types';
export * from './PluginLoader';
export * from './PluginAPI';

View File

@@ -0,0 +1,2 @@
export type { InstalledPlugin, PluginIndex, PluginMetadata } from '@paarrot/plugin-manager';
export { PluginTab } from '@paarrot/plugin-manager';

View File

@@ -29,7 +29,9 @@ export const useAuthenticatedMediaUrl = (
// Check if this is an authenticated media URL // Check if this is an authenticated media URL
const isAuthenticatedMediaUrl = const isAuthenticatedMediaUrl =
src.includes('/_matrix/client/v1/media/download') || src.includes('/_matrix/client/v1/media/download') ||
src.includes('/_matrix/client/v1/media/thumbnail'); src.includes('/_matrix/client/v1/media/thumbnail') ||
(src.includes('/_matrix/media/') &&
(src.includes('/download/') || src.includes('/thumbnail/')));
if (!isAuthenticatedMediaUrl) { if (!isAuthenticatedMediaUrl) {
setBlobUrl(src); setBlobUrl(src);
@@ -99,7 +101,9 @@ export const useAuthenticatedMediaFetch = () => {
async (src: string): Promise<string> => { async (src: string): Promise<string> => {
const isAuthenticatedMediaUrl = const isAuthenticatedMediaUrl =
src.includes('/_matrix/client/v1/media/download') || src.includes('/_matrix/client/v1/media/download') ||
src.includes('/_matrix/client/v1/media/thumbnail'); src.includes('/_matrix/client/v1/media/thumbnail') ||
(src.includes('/_matrix/media/') &&
(src.includes('/download/') || src.includes('/thumbnail/')));
if (!isAuthenticatedMediaUrl) { if (!isAuthenticatedMediaUrl) {
return src; return src;

View File

@@ -5,10 +5,6 @@ export type HashRouterConfig = {
basename?: string; basename?: string;
}; };
export type CallingConfig = {
livekitServiceUrl?: string;
};
export type ClientConfig = { export type ClientConfig = {
defaultHomeserver?: number; defaultHomeserver?: number;
homeserverList?: string[]; homeserverList?: string[];
@@ -21,8 +17,6 @@ export type ClientConfig = {
servers?: string[]; servers?: string[];
}; };
calling?: CallingConfig;
hashRouter?: HashRouterConfig; hashRouter?: HashRouterConfig;
}; };

View File

@@ -26,7 +26,7 @@ import { useRoomNavigate } from './useRoomNavigate';
import { Membership, StateEvent } from '../../types/matrix/room'; import { Membership, StateEvent } from '../../types/matrix/room';
import { getStateEvent } from '../utils/room'; import { getStateEvent } from '../utils/room';
import { splitWithSpace } from '../utils/common'; import { splitWithSpace } from '../utils/common';
import { createRoomEncryptionState } from '../components/create-room'; import { createRoomEncryptionState, createRoomPowerLevelsState } from '../components/create-room';
export const SHRUG = '¯\\_(ツ)_/¯'; export const SHRUG = '¯\\_(ツ)_/¯';
export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻'; export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻';
@@ -218,7 +218,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
invite: userIds, invite: userIds,
visibility: Visibility.Private, visibility: Visibility.Private,
preset: Preset.TrustedPrivateChat, preset: Preset.TrustedPrivateChat,
initial_state: [createRoomEncryptionState()], initial_state: [createRoomPowerLevelsState(), createRoomEncryptionState()],
}); });
addRoomIdToMDirect(mx, result.room_id, userIds[0]); addRoomIdToMDirect(mx, result.room_id, userIds[0]);
navigateRoom(result.room_id); navigateRoom(result.room_id);

View File

@@ -0,0 +1,104 @@
import { useCallback, useMemo } from 'react';
import { useMatrixClient } from './useMatrixClient';
import { useAccountData } from './useAccountData';
const PAARROT_EMOJI_USAGE_EVENT = 'paarrot.favemojis';
const MAX_TRACKED_EMOJIS = 50;
export type EmojiUsageData = {
[shortcode: string]: {
count: number;
lastUsed: number;
};
};
/**
* Hook for tracking emoji usage and storing favorites in Matrix account data
* Stores emoji usage stats under paarrot.favemojis in user's account data
*/
export function useEmojiUsage() {
const mx = useMatrixClient();
const event = useAccountData(PAARROT_EMOJI_USAGE_EVENT);
const emojiUsage = useMemo((): EmojiUsageData => {
const content = event?.getContent<EmojiUsageData>();
return content || {};
}, [event]);
/**
* Track emoji usage (max 1 per message as per spec)
* @param shortcode - Emoji shortcode (e.g., 'thumbsup', 'smile')
*/
const trackEmojiUsage = useCallback(
async (shortcode: string) => {
const now = Date.now();
const currentUsage = { ...emojiUsage };
// Update or create emoji entry
currentUsage[shortcode] = {
count: (currentUsage[shortcode]?.count || 0) + 1,
lastUsed: now,
};
// Keep only top MAX_TRACKED_EMOJIS by usage count
const entries = Object.entries(currentUsage);
if (entries.length > MAX_TRACKED_EMOJIS) {
entries.sort((a, b) => b[1].count - a[1].count);
const topEmojis = entries.slice(0, MAX_TRACKED_EMOJIS);
const newUsage: EmojiUsageData = {};
topEmojis.forEach(([key, value]) => {
newUsage[key] = value;
});
await mx.setAccountData(PAARROT_EMOJI_USAGE_EVENT, newUsage);
} else {
await mx.setAccountData(PAARROT_EMOJI_USAGE_EVENT, currentUsage);
}
},
[mx, emojiUsage]
);
/**
* Get most used emoji shortcode
* @returns Shortcode of most used emoji, or undefined if none tracked
*/
const getMostUsedEmoji = useCallback((): string | undefined => {
const entries = Object.entries(emojiUsage);
if (entries.length === 0) return undefined;
// Sort by count descending, then by lastUsed descending
entries.sort((a, b) => {
if (b[1].count !== a[1].count) {
return b[1].count - a[1].count;
}
return b[1].lastUsed - a[1].lastUsed;
});
return entries[0][0];
}, [emojiUsage]);
/**
* Get top N most used emojis
* @param count - Number of top emojis to return
* @returns Array of shortcodes sorted by usage
*/
const getTopEmojis = useCallback(
(count: number = 10): string[] => {
const entries = Object.entries(emojiUsage);
entries.sort((a, b) => {
if (b[1].count !== a[1].count) {
return b[1].count - a[1].count;
}
return b[1].lastUsed - a[1].lastUsed;
});
return entries.slice(0, count).map(([shortcode]) => shortcode);
},
[emojiUsage]
);
return {
emojiUsage,
trackEmojiUsage,
getMostUsedEmoji,
getTopEmojis,
};
}

View File

@@ -0,0 +1,110 @@
import { useEffect, useRef, useState } from 'react';
import { MatrixClient, MatrixEvent, Room, RoomEvent, IRoomTimelineData } from 'matrix-js-sdk';
/**
* Hook that tracks timeline events across all rooms to trigger re-sorting of room lists.
* Returns a counter that increments whenever a message is sent or received in any room.
* This can be used as a dependency in useMemo to trigger re-sorting of room lists.
*
* @param mx - The Matrix client instance
* @param roomIds - Optional array of room IDs to track. If not provided, tracks all rooms.
* @returns A number that increments on each timeline event
*/
export const useRoomListReorder = (mx: MatrixClient, roomIds?: string[]): number => {
const [updateCounter, setUpdateCounter] = useState(0);
useEffect(() => {
const handleTimelineEvent = (
mEvent: MatrixEvent,
room: Room | undefined,
toStartOfTimeline: boolean | undefined,
removed: boolean,
data: IRoomTimelineData
) => {
if (!room || !data.liveEvent) return;
if (toStartOfTimeline) return;
if (roomIds && !roomIds.includes(room.roomId)) return;
setUpdateCounter((prev) => prev + 1);
};
mx.on(RoomEvent.Timeline, handleTimelineEvent);
return () => {
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
};
}, [mx, roomIds]);
return updateCounter;
};
/**
* Hook that implements FLIP (First, Last, Invert, Play) animation for list reordering.
* Tracks element positions before and after reorder, then animates the difference.
*
* @param items - Array of item IDs in their current order
* @returns Ref callback to attach to the list container
*/
export const useListReorderAnimation = <T extends string>(
items: T[]
): React.RefCallback<HTMLElement> => {
const positionsRef = useRef<Map<T, number>>(new Map());
const containerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!containerRef.current) return;
const currentPositions = new Map<T, number>();
const elements = new Map<T, HTMLElement>();
items.forEach((itemId) => {
const element = containerRef.current!.querySelector(`[data-room-id="${itemId}"]`) as HTMLElement;
if (element) {
currentPositions.set(itemId, element.offsetTop);
elements.set(itemId, element);
}
});
if (positionsRef.current.size === 0) {
positionsRef.current = currentPositions;
return;
}
const animations: Array<{ element: HTMLElement; deltaY: number }> = [];
items.forEach((itemId) => {
const element = elements.get(itemId);
const oldPos = positionsRef.current.get(itemId);
const newPos = currentPositions.get(itemId);
if (element && oldPos !== undefined && newPos !== undefined) {
const deltaY = oldPos - newPos;
if (Math.abs(deltaY) > 1) {
animations.push({ element, deltaY });
}
}
});
if (animations.length > 0) {
animations.forEach(({ element, deltaY }) => {
element.style.transform = `translateY(${deltaY}px)`;
element.style.transition = 'none';
});
requestAnimationFrame(() => {
animations.forEach(({ element }) => {
element.style.transition = 'transform 300ms cubic-bezier(0.4, 0, 0.2, 1)';
element.style.transform = '';
});
});
}
positionsRef.current = currentPositions;
}, [items]);
return (container: HTMLElement | null) => {
containerRef.current = container;
};
};

View File

@@ -1,9 +1,10 @@
import { lightTheme } from 'folds'; import { lightTheme } from 'folds';
import { createContext, useContext, useEffect, useMemo, useState } from 'react'; import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { onDarkFontWeight, onLightFontWeight } from '../../config.css'; import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
import { butterTheme, darkTheme, discordTheme, discordDarkerTheme, mochaTheme, silverTheme, twilightTheme } from '../../colors.css'; import { butterTheme, catppuccinMochaTheme, darkTheme, discordTheme, discordDarkerTheme, mochaTheme, silverTheme, twilightTheme } from '../../colors.css';
import { settingsAtom } from '../state/settings'; import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings'; import { useSetting } from '../state/hooks/settings';
import { pluginRegistry } from '../features/settings/plugins/PluginAPI';
export enum ThemeKind { export enum ThemeKind {
Light = 'light', Light = 'light',
@@ -57,16 +58,58 @@ export const MochaTheme: Theme = {
kind: ThemeKind.Dark, kind: ThemeKind.Dark,
classNames: ['mocha-theme', mochaTheme, onDarkFontWeight, 'prism-dark'], classNames: ['mocha-theme', mochaTheme, onDarkFontWeight, 'prism-dark'],
}; };
export const CatppuccinMochaTheme: Theme = {
export const useThemes = (): Theme[] => { id: 'catppuccin-mocha-theme',
const themes: Theme[] = useMemo(() => [LightTheme, SilverTheme, DarkTheme, ButterTheme, DiscordTheme, DiscordDarkerTheme, TwilightTheme, MochaTheme], []); kind: ThemeKind.Dark,
classNames: ['catppuccin-mocha-theme', catppuccinMochaTheme, onDarkFontWeight, 'prism-dark'],
return themes;
}; };
export const useThemeNames = (): Record<string, string> => export const useThemes = (): Theme[] => {
useMemo( const [pluginThemesUpdate, setPluginThemesUpdate] = useState(0);
() => ({
// Force update when plugins change (this is a simple approach)
useEffect(() => {
const interval = setInterval(() => {
setPluginThemesUpdate(prev => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
const builtInThemes: Theme[] = useMemo(() => [
LightTheme, SilverTheme, DarkTheme, ButterTheme,
DiscordTheme, DiscordDarkerTheme, TwilightTheme,
MochaTheme, CatppuccinMochaTheme
], []);
// Convert plugin themes to Theme format
const pluginThemes: Theme[] = useMemo(() => {
const themes = pluginRegistry.getPluginThemes();
return themes.map(pluginTheme => ({
id: pluginTheme.id,
kind: pluginTheme.kind === 'dark' ? ThemeKind.Dark : ThemeKind.Light,
classNames: [
pluginTheme.className,
pluginTheme.kind === 'dark' ? onDarkFontWeight : onLightFontWeight,
pluginTheme.kind === 'dark' ? 'prism-dark' : 'prism-light'
]
}));
}, [pluginThemesUpdate]);
return [...builtInThemes, ...pluginThemes];
};
export const useThemeNames = (): Record<string, string> => {
const [pluginThemesUpdate, setPluginThemesUpdate] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setPluginThemesUpdate(prev => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
return useMemo(() => {
const builtInNames = {
[LightTheme.id]: 'Light', [LightTheme.id]: 'Light',
[SilverTheme.id]: 'Silver', [SilverTheme.id]: 'Silver',
[DarkTheme.id]: 'Dark', [DarkTheme.id]: 'Dark',
@@ -75,9 +118,18 @@ export const useThemeNames = (): Record<string, string> =>
[DiscordDarkerTheme.id]: 'Discord Darker', [DiscordDarkerTheme.id]: 'Discord Darker',
[TwilightTheme.id]: 'Twilight', [TwilightTheme.id]: 'Twilight',
[MochaTheme.id]: 'Mocha', [MochaTheme.id]: 'Mocha',
}), [CatppuccinMochaTheme.id]: 'Catppuccin Mocha',
[] };
);
const pluginThemes = pluginRegistry.getPluginThemes();
const pluginNames = pluginThemes.reduce((acc, theme) => {
acc[theme.id] = theme.name;
return acc;
}, {} as Record<string, string>);
return { ...builtInNames, ...pluginNames };
}, [pluginThemesUpdate]);
};
export const useSystemThemeKind = (): ThemeKind => { export const useSystemThemeKind = (): ThemeKind => {
const darkModeQueryList = useMemo(() => window.matchMedia('(prefers-color-scheme: dark)'), []); const darkModeQueryList = useMemo(() => window.matchMedia('(prefers-color-scheme: dark)'), []);

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