84 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
0659f3c1b0 feat: implement sync retry mechanism in ClientRoot with timeout handling 2026-04-07 16:44:21 +10:00
f64ce6168d refactor: update sound file handling to resolve base URL dynamically 2026-04-03 21:14:00 +11:00
fe193bb062 feat: enhance sound playback in InviteNotifications and MessageNotifications with logging and error handling 2026-04-03 20:47:16 +11:00
0ddf05746a fix: update sound source mappings to use local file paths 2026-04-03 16:48:25 +11:00
6ca8e59b0b refactor: streamline UpdateNotification component by removing redundant code 2026-04-03 16:35:43 +11:00
6e1df928ad feat: enhance sound playback in InviteNotifications and MessageNotifications for Electron support 2026-04-02 22:40:48 +11:00
b3495bc102 feat: update sound source mappings to use resolved Vite asset URLs 2026-03-26 10:23:41 +11:00
05e5ac71b7 feat: enhance UpdateNotification component with update checking and download functionality 2026-03-25 06:56:12 +11:00
9d744ff3cc fix: correct comment formatting in VideoFrameThumbnail component 2026-03-25 06:44:25 +11:00
da3983d5c4 feat: add Mocha theme with custom styles and transitions 2026-03-25 06:20:47 +11:00
9a00375568 feat: Implement view transitions for smoother navigation
- Added `useViewTransitions` hook to enable view transitions in the app.
- Created `useNavigateWithTransition` for navigation with transitions.
- Integrated view transitions in `ClientRoot`, `HomeTab`, `InboxTab`, and `SettingsTab`.
- Introduced `AnimatedOutlet` and `RouteTransition` components for route-based animations.
- Enhanced CSS for transitions and added a new `twilightTheme`.
- Updated Vite configuration to serve sound and font assets.
- Added support for reaction notifications in the room utility.
- Created `DirectList` and related components for direct messaging functionality.
2026-03-25 06:14:11 +11:00
a8eb404ff3 feat: add TikTok embed support 2026-03-24 00:14:53 +11:00
4f1179d5cd YT Fixes 2026-03-23 22:13:32 +11:00
75a9b4ca1e feat: implement centralized access token management for media downloads and user authentication 2026-03-23 21:53:27 +11:00
f76fee23bf feat: prevent duplicate initialization of Paarrot API and update server port 2026-03-23 20:34:14 +11:00
5eb937ab3e feat: add handling for sub-room creation by setting parent-child relationships and copying join rules 2026-03-15 23:44:53 +11:00
27713572b5 feat: filter to only include joined sub-rooms in Home and Space components 2026-03-15 21:52:58 +11:00
61086efbcf feat: remove unjoined sub-room handling from Home and Space components 2026-03-15 21:51:20 +11:00
a65898e74f feat: enhance RoomNavItem to conditionally display avatar and adjust icon visibility 2026-03-15 19:49:25 +11:00
12286f57f7 feat: enhance room navigation with sub-room indicators and styling adjustments 2026-03-15 19:23:22 +11:00
c980439bc9 feat: add video title fetching from YouTube oEmbed API in YouTubeEmbed component 2026-03-15 17:24:54 +11:00
3cb4237f59 feat: enhance token retrieval for service worker with multi-account support 2026-03-15 17:17:07 +11:00
c7bd376292 feat: Implement sub-room functionality
- Added support for sub-rooms in the SpaceHierarchy component, allowing rooms to have nested child rooms.
- Introduced UnjoinedSubRoomItem component to display unjoined sub-rooms with a join button.
- Created SubRooms settings page for managing sub-rooms, including adding and removing sub-rooms.
- Updated RoomNavItem to include options for creating sub-rooms.
- Enhanced room fetching logic to filter out sub-rooms from the main room list.
- Added hooks for managing sub-rooms state and permissions.
- Updated relevant state management to handle sub-room creation and association with parent rooms.
2026-03-15 16:25:53 +11:00
a0c0068997 feat: implement auto-fix for one-time key conflicts in crypto database 2026-03-13 01:47:56 +11:00
c07cf58086 feat: implement fallback to unauthenticated requests for media fetching on 401 errors 2026-03-13 01:19:58 +11:00
4756bfdc57 feat: add thread preview functionality to RoomNavItem component 2026-03-13 00:54:12 +11:00
ced29a6571 feat: add thread functionality to room features
- Enhance RoomInput to support sending messages as replies in threads by adding `threadRootId` prop.
- Update RoomTimeline to manage active thread state and handle opening threads.
- Implement ThreadView component to display thread messages and input.
- Create HomeThreadsCategory to list threads user has participated in.
- Introduce activeThreadIdAtomFamily to manage active thread state across rooms.
2026-03-13 00:44:33 +11:00
37f4297972 feat: add avatar border color customization and improve text contrast across user profile components 2026-03-12 23:08:05 +11:00
67f3ae4d34 feat: add border color customization for user profile chips and improve text contrast 2026-03-12 22:18:05 +11:00
834de012b4 feat: enhance user profile customization with avatar border color and gradient options
- Added support for setting avatar border color and gradient in user profile settings.
- Introduced new hooks for managing user profile styles and fetching metadata from avatars.
- Implemented an AngleSelector component for selecting gradient direction.
- Updated RoomNavItem to display parent space information for direct messages.
- Improved caching mechanism for user banners and profile styles.
- Refactored image metadata handling to include new profile style attributes.
2026-03-12 20:50:00 +11:00
fc30d81f8f feat: Enhance user color and banner management
- Updated `useUserColor` hook to support fetching and embedding user color from avatar metadata with authentication.
- Introduced `useUserBanner` hook for managing user banners stored in avatar metadata, including fetching and embedding functionality.
- Added `fetchAndExtractMetadata` utility to retrieve both color and banner from images.
- Implemented `CustomStatusDialog` component for users to set and clear custom status messages.
- Modified `SettingsTab` to include custom status functionality and improved user experience.
- Enhanced image metadata utilities to support banner extraction and embedding in PNG format.
- Updated sidebar settings to handle user profile changes more gracefully.
2026-03-12 10:58:38 +11:00
2c2560f5a2 fix: add base href to improve relative URL resolution 2026-03-12 01:26:45 +11:00
7cf6b4e6ae multiuser 2026-03-12 01:08:16 +11:00
30a7725769 feat: enhance image components with draggable attribute and context menu handling 2026-03-08 21:53:32 +11:00
b185e0b129 feat: implement sidebar docked call panel with participant management and sync status handling 2026-02-28 00:54:55 +11:00
29c409fad8 feat: implement useMarkAsRead hook for improved unread state management and update notification handling 2026-02-28 00:10:35 +11:00
c890cce37a fix: update notification icon handling to use default avatar if unavailable 2026-02-27 22:56:40 +11:00
b400c4d6c3 feat: enhance message notification handling with improved navigation and notification logic 2026-02-27 22:45:14 +11:00
f8af51b75c feat: implement video controls and pop-out functionality in Video component 2026-02-27 22:12:47 +11:00
216 changed files with 19644 additions and 2455 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

@@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<base href="./" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover" />
<title>Paarrot</title>
@@ -17,7 +18,7 @@
/>
<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:description"
@@ -85,7 +86,7 @@
href="./public/res/apple/apple-touch-icon-180x180.png"
/>
</head>
<body id="appBody">
<body id="appBody" style="background-color: #262626;">
<script>
window.global ||= window;
</script>

25
package-lock.json generated
View File

@@ -12,7 +12,10 @@
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@capacitor/core": "8.3.4",
"@capacitor/local-notifications": "8.2.0",
"@fontsource/inter": "4.5.14",
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
"@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.2.0",
@@ -1668,6 +1671,24 @@
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@capacitor/core": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.4.tgz",
"integrity": "sha512-CqRQCkb6HXxcx/N7s+hHTN6ef2CmamFiRMITwm4qB840ph56mS42bzUgn6tKCP+RZjdDweiRHj9ytDDeN6jFag==",
"license": "MIT",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/@capacitor/local-notifications": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.2.0.tgz",
"integrity": "sha512-fvLY0w2w4MiX+DD4+Wv4DOwOLdzKZsMDwAcRv/Juudd+QbKbn69s6cM3xVqPwAiDqfnqsY4/S8xtQD6M73wY2A==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@emotion/hash": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
@@ -2333,6 +2354,10 @@
"node": ">= 8"
}
},
"node_modules/@paarrot/plugin-manager": {
"version": "1.0.0",
"resolved": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git#6c389381df3966568a40c39d8661f9a4300f7819"
},
"node_modules/@react-aria/breadcrumbs": {
"version": "3.5.20",
"resolved": "https://registry.npmjs.org/@react-aria/breadcrumbs/-/breadcrumbs-3.5.20.tgz",

View File

@@ -23,7 +23,10 @@
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@capacitor/core": "8.3.4",
"@capacitor/local-notifications": "8.2.0",
"@fontsource/inter": "4.5.14",
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
"@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.2.0",

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

@@ -0,0 +1,189 @@
import React, { useRef, useCallback, useState } from 'react';
import { Box, Text, toRem, color } from 'folds';
/**
* Props for the AngleSelector component
*/
export interface AngleSelectorProps {
/** Current angle in degrees (0-360) */
value: number;
/** Callback when angle changes */
onChange: (angle: number) => void;
/** Size of the selector in pixels */
size?: number;
/** Whether the component is disabled */
disabled?: boolean;
}
/**
* A circular angle selector similar to Photoshop's drop shadow angle control.
* Click or drag around the circle to set an angle in degrees.
*/
export function AngleSelector({
value,
onChange,
size = 80,
disabled = false,
}: AngleSelectorProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
/**
* Calculate angle from mouse/touch position relative to center
*/
const calculateAngle = useCallback(
(clientX: number, clientY: number) => {
if (!containerRef.current) return value;
const rect = containerRef.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const deltaX = clientX - centerX;
const deltaY = clientY - centerY;
// Calculate angle in radians, then convert to degrees
// atan2 returns angle from -PI to PI, where 0 is pointing right
// We adjust so 0deg is pointing up (like CSS gradients)
let angle = Math.atan2(deltaX, -deltaY) * (180 / Math.PI);
// Normalize to 0-360 range
if (angle < 0) angle += 360;
return Math.round(angle);
},
[value]
);
/**
* Handle mouse/touch start
*/
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (disabled) return;
e.preventDefault();
setIsDragging(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
const newAngle = calculateAngle(e.clientX, e.clientY);
onChange(newAngle);
},
[disabled, calculateAngle, onChange]
);
/**
* Handle mouse/touch move
*/
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!isDragging || disabled) return;
const newAngle = calculateAngle(e.clientX, e.clientY);
onChange(newAngle);
},
[isDragging, disabled, calculateAngle, onChange]
);
/**
* Handle mouse/touch end
*/
const handlePointerUp = useCallback(() => {
setIsDragging(false);
}, []);
// Calculate line endpoint position
const lineLength = (size / 2) - 8;
const radians = ((value - 90) * Math.PI) / 180;
const lineX = Math.cos(radians) * lineLength;
const lineY = Math.sin(radians) * lineLength;
return (
<Box direction="Column" gap="100" alignItems="Center">
<Box
ref={containerRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
style={{
width: toRem(size),
height: toRem(size),
borderRadius: '50%',
border: `${toRem(2)} solid ${color.Surface.ContainerLine}`,
backgroundColor: color.Surface.Container,
position: 'relative',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
touchAction: 'none',
userSelect: 'none',
}}
>
{/* Center dot */}
<Box
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: toRem(6),
height: toRem(6),
borderRadius: '50%',
backgroundColor: color.Secondary.Main,
}}
/>
{/* Direction line */}
<Box
style={{
position: 'absolute',
top: '50%',
left: '50%',
width: toRem(2),
height: toRem(lineLength),
backgroundColor: color.Primary.Main,
transformOrigin: 'center top',
transform: `translate(-50%, 0) rotate(${value}deg)`,
borderRadius: toRem(1),
}}
/>
{/* Angle indicator dot at end of line */}
<Box
style={{
position: 'absolute',
top: '50%',
left: '50%',
width: toRem(10),
height: toRem(10),
borderRadius: '50%',
backgroundColor: color.Primary.Main,
transform: `translate(calc(-50% + ${lineX}px), calc(-50% + ${lineY}px))`,
boxShadow: `0 0 ${toRem(4)} ${color.Primary.Main}`,
}}
/>
{/* Cardinal direction markers */}
{[0, 90, 180, 270].map((deg) => {
const markerRadians = ((deg - 90) * Math.PI) / 180;
const markerDist = (size / 2) - 3;
const markerX = Math.cos(markerRadians) * markerDist;
const markerY = Math.sin(markerRadians) * markerDist;
return (
<Box
key={deg}
style={{
position: 'absolute',
top: '50%',
left: '50%',
width: toRem(4),
height: toRem(4),
borderRadius: '50%',
backgroundColor: color.Surface.OnContainer,
opacity: 0.3,
transform: `translate(calc(-50% + ${markerX}px), calc(-50% + ${markerY}px))`,
}}
/>
);
})}
</Box>
<Text size="T200">{value}°</Text>
</Box>
);
}
export default AngleSelector;

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { Outlet, useLocation } from 'react-router-dom';
/**
* Wrapper for Outlet that adds route-based animation
* Forces remount on route change by using location as key
*/
export function AnimatedOutlet() {
const location = useLocation();
return (
<div
key={location.pathname}
data-route-transition="true"
style={{
flex: 1,
minWidth: 0,
minHeight: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Outlet />
</div>
);
}

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 { Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
import { Modal, Overlay, OverlayBackdrop, OverlayCenter, PopOutContainerProvider } from 'folds';
import { stopPropagation } from '../utils/keyboard';
type Modal500Props = {
@@ -8,23 +8,8 @@ type Modal500Props = {
children: ReactNode;
};
export function Modal500({ requestClose, children }: Modal500Props) {
const handleClickOutside = (e: MouseEvent | TouchEvent | FocusEvent) => {
const target = e.target as HTMLElement;
// 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;
};
const [modalEl, setModalEl] = useState<HTMLDivElement | null>(null);
const modalRef = useCallback((el: HTMLDivElement | null) => setModalEl(el), []);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
@@ -32,13 +17,15 @@ export function Modal500({ requestClose, children }: Modal500Props) {
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: handleClickOutside,
clickOutsideDeactivates: true,
onDeactivate: requestClose,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="500" variant="Background">
{children}
<Modal ref={modalRef} size="500" variant="Background">
<PopOutContainerProvider value={modalEl ?? undefined}>
{children}
</PopOutContainerProvider>
</Modal>
</FocusTrap>
</OverlayCenter>

View File

@@ -23,15 +23,18 @@ import {
ThumbnailContent,
UnsupportedContent,
VideoContent,
VideoFrameThumbnail,
} from './message';
import { UrlPreviewCard, UrlPreviewHolder, YouTubeEmbed, isYouTubeUrl } from './url-preview';
import { UrlPreviewCard, UrlPreviewHolder, YouTubeEmbed, isYouTubeUrl, TikTokEmbed, isTikTokUrl, GiphyEmbed, isGiphyUrl } from './url-preview';
import { Image, MediaControl, Video } from './media';
import { ImageViewer } from './image-viewer';
import { VideoViewer } from './video-viewer';
import { PdfViewer } from './Pdf-viewer';
import { TextViewer } from './text-viewer';
import { testMatrixTo } from '../plugins/matrix-to';
import { IImageContent } from '../../types/matrix/common';
import { filterDisabledUrls } from '../hooks/useRoomEmbedFilters';
import { pluginRegistry } from '../features/settings/plugins/PluginAPI';
type RenderMessageContentProps = {
displayName: string;
@@ -66,16 +69,26 @@ export function RenderMessageContent({
filteredUrls = filterDisabledUrls(filteredUrls, disabledEmbedPatterns ?? []);
if (filteredUrls.length === 0) return undefined;
// Separate YouTube URLs from other URLs
// Separate special URLs from generic ones
const youtubeUrls = filteredUrls.filter(isYouTubeUrl);
const otherUrls = filteredUrls.filter((url) => !isYouTubeUrl(url));
const tiktokUrls = filteredUrls.filter(isTikTokUrl);
const giphyUrls = filteredUrls.filter(isGiphyUrl);
const otherUrls = filteredUrls.filter((url) => !isYouTubeUrl(url) && !isTikTokUrl(url) && !isGiphyUrl(url));
return (
<>
{/* Render YouTube embeds (ad-free via Piped) */}
{/* Render YouTube embeds (ad-free via yt-dlp) */}
{youtubeUrls.map((url) => (
<YouTubeEmbed key={url} url={url} />
))}
{/* Render TikTok embeds */}
{tiktokUrls.map((url) => (
<TikTokEmbed key={url} url={url} />
))}
{/* Render Giphy embeds */}
{giphyUrls.map((url) => (
<GiphyEmbed key={url} url={url} />
))}
{/* Render standard URL previews for other links */}
{otherUrls.length > 0 && (
<UrlPreviewHolder>
@@ -146,6 +159,24 @@ export function RenderMessageContent({
</>
);
// Check for custom renderer from plugins BEFORE standard type handling
// so plugins can intercept any msgtype including custom ones.
const customRendererEarly = pluginRegistry.getRenderer('message');
if (customRendererEarly) {
try {
const messageDataEarly = {
msgtype: msgType,
...getContent(),
};
const customContentEarly = customRendererEarly(messageDataEarly, () => null);
if (customContentEarly) {
return customContentEarly as React.ReactElement;
}
} catch (error) {
console.error('[RenderMessageContent] Custom renderer error (early):', error);
}
}
if (msgType === MsgType.Text) {
return (
<MText
@@ -227,26 +258,46 @@ export function RenderMessageContent({
<MVideo
content={getContent()}
renderAsFile={renderFile}
renderVideoContent={({ body, info, ...props }) => (
<VideoContent
body={body}
info={info}
{...props}
renderThumbnail={
mediaAutoLoad
? () => (
<ThumbnailContent
info={info}
renderImage={(src) => (
<Image alt={body} title={body} src={src} loading="lazy" />
)}
/>
)
: undefined
}
renderVideo={(p) => <Video {...p} />}
/>
)}
renderVideoContent={({ body, info, mimeType, url, encInfo, ...props }) => {
const hasValidThumbnail =
(typeof info.thumbnail_file?.url === 'string' || typeof info.thumbnail_url === 'string') &&
typeof info.thumbnail_info?.mimetype === 'string';
return (
<VideoContent
body={body}
info={info}
mimeType={mimeType}
url={url}
encInfo={encInfo}
autoPlay={mediaAutoLoad}
{...props}
renderThumbnail={
mediaAutoLoad
? () => hasValidThumbnail ? (
<ThumbnailContent
info={info}
renderImage={(src) => (
<Image alt={body} title={body} src={src} loading="lazy" />
)}
/>
) : (
<VideoFrameThumbnail
mimeType={mimeType}
url={url}
encInfo={encInfo}
renderImage={(src) => (
<Image alt={body} title={body} src={src} loading="lazy" />
)}
/>
)
: undefined
}
renderViewer={(p) => <VideoViewer {...p} />}
renderVideo={(p) => <Video {...p} />}
/>
);
}}
outlined={outlineAttachment}
/>
{renderCaption()}
@@ -282,5 +333,36 @@ export function RenderMessageContent({
return <MBadEncrypted />;
}
// Check for custom renderer from plugins FIRST
const customRenderer = pluginRegistry.getRenderer('message');
console.log('[RenderMessageContent] customRenderer function:', typeof customRenderer);
if (customRenderer) {
try {
const messageData = {
msgtype: msgType,
...getContent(),
};
console.log('[RenderMessageContent] Calling customRenderer with msgtype:', msgType);
console.log('[RenderMessageContent] messageData:', messageData);
const customContent = customRenderer(
messageData,
() => {
console.log('[RenderMessageContent] defaultRenderer called');
return null;
}
);
console.log('[RenderMessageContent] customRenderer returned:', customContent);
console.log('[RenderMessageContent] customContent type:', typeof customContent);
if (customContent) {
console.log('[RenderMessageContent] Returning custom content');
return customContent as React.ReactElement;
}
} catch (error) {
console.error('[RenderMessageContent] Custom renderer error:', error);
}
} else {
console.log('[RenderMessageContent] No custom renderer registered');
}
return <UnsupportedContent />;
}

View File

@@ -0,0 +1,104 @@
import React, { useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { css } from '@vanilla-extract/css';
const transitionContainer = css({
position: 'relative',
width: '100%',
height: '100%',
overflow: 'hidden',
});
const transitionContent = css({
width: '100%',
height: '100%',
});
const animateIn = css({
animation: 'routeFadeSlideIn 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
'@keyframes': {
routeFadeSlideIn: {
from: {
opacity: 0,
transform: 'translateX(12px)',
},
to: {
opacity: 1,
transform: 'translateX(0)',
},
},
},
});
const animateOut = css({
animation: 'routeFadeSlideOut 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards',
'@keyframes': {
routeFadeSlideOut: {
from: {
opacity: 1,
transform: 'translateX(0)',
},
to: {
opacity: 0,
transform: 'translateX(-12px)',
},
},
},
});
interface RouteTransitionProps {
children: React.ReactNode;
}
/**
* Wraps content in a transition animation that triggers on route change
*/
export function RouteTransition({ children }: RouteTransitionProps) {
const location = useLocation();
const [displayLocation, setDisplayLocation] = useState(location);
const [transitionStage, setTransitionStage] = useState<'entering' | 'exiting' | 'idle'>('idle');
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (location.pathname !== displayLocation.pathname) {
// Start exit animation
setTransitionStage('exiting');
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Wait for exit animation, then update location and enter
timeoutRef.current = setTimeout(() => {
setDisplayLocation(location);
setTransitionStage('entering');
// Reset to idle after enter animation
timeoutRef.current = setTimeout(() => {
setTransitionStage('idle');
}, 250);
}, 200);
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [location, displayLocation]);
const className = transitionStage === 'entering'
? `${transitionContent} ${animateIn}`
: transitionStage === 'exiting'
? `${transitionContent} ${animateOut}`
: transitionContent;
return (
<div className={transitionContainer}>
<div className={className} key={displayLocation.pathname}>
{children}
</div>
</div>
);
}

View File

@@ -31,11 +31,12 @@ export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
const authMetadata = promiseFulfilledResult(result[2]);
let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
try {
validatedAuthMetadata = validateAuthMetadata(authMetadata);
} catch (e) {
// Silently ignore OIDC configuration errors when server doesn't support it
// This is expected for most Matrix servers
if (authMetadata) {
try {
validatedAuthMetadata = validateAuthMetadata(authMetadata);
} catch (e) {
// Server returned auth metadata but it failed validation — ignore silently.
}
}
return {

View File

@@ -0,0 +1,244 @@
import React, { useEffect, useState } from 'react';
import { Avatar, Box, Icon, Icons, MenuItem, Text, config } from 'folds';
import { useAtomValue } from 'jotai';
import { sessionsAtom, Session } from '../../state/sessions';
import { setLocalStorageItem } from '../../state/utils/atomWithLocalStorage';
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useUserProfile } from '../../hooks/useUserProfile';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { UserAvatar } from '../user-avatar';
type AccountSwitcherProps = {
currentUserId?: string;
};
/**
* Loads and displays an avatar for a session that is not the active client.
* Fetches the profile directly via the homeserver REST API using stored credentials.
*/
function OtherSessionAvatar({ session }: { session: Session }) {
const [src, setSrc] = useState<string | undefined>();
useEffect(() => {
const load = async () => {
try {
const res = await fetch(
`${session.baseUrl}/_matrix/client/v3/profile/${encodeURIComponent(session.userId)}`,
{ headers: { Authorization: `Bearer ${session.accessToken}` } }
);
if (!res.ok) return;
const data = await res.json();
if (data.avatar_url) {
const match = (data.avatar_url as string).match(/^mxc:\/\/([^/]+)\/(.+)$/);
if (match) {
setSrc(
`${session.baseUrl}/_matrix/media/v3/thumbnail/${match[1]}/${match[2]}?width=32&height=32&method=crop`
);
}
}
} catch {
// ignore — avatar just won't show
}
};
load();
}, [session.userId, session.baseUrl, session.accessToken]);
return (
<Avatar size="300" style={{ width: '1.75rem', height: '1.75rem', minWidth: '1.75rem' }}>
<UserAvatar
userId={session.userId}
src={src}
alt={session.userId}
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
/>
</Avatar>
);
}
const deleteAllMatrixDatabases = async (): Promise<void> => {
console.log('[AccountSwitcher] Deleting all Matrix IndexedDB databases...');
// List of known Matrix database names/patterns to delete
const dbsToDelete = [
'matrix-js-sdk::matrix-sdk-crypto', // Shared crypto store (causes conflicts)
];
// Try to get all databases if the API is available
try {
if ('databases' in indexedDB) {
const allDbs = await (indexedDB as any).databases();
const matrixDbs = allDbs
.map((db: any) => db.name)
.filter((name: string) =>
name && (
name.startsWith('matrix-js-sdk') ||
name.includes('@@') ||
name.startsWith('sync@@') ||
name.startsWith('crypto@@')
)
);
dbsToDelete.push(...matrixDbs);
console.log('[AccountSwitcher] Found Matrix databases:', matrixDbs);
}
} catch (e) {
console.warn('[AccountSwitcher] Could not enumerate databases:', e);
}
// Delete each database with timeout
const deletePromises = dbsToDelete.map(async (dbName) => {
try {
const deletePromise = new Promise<void>((resolve, reject) => {
const request = indexedDB.deleteDatabase(dbName);
request.onsuccess = () => {
console.log(`[AccountSwitcher] ✅ Deleted database: ${dbName}`);
resolve();
};
request.onerror = () => {
console.warn(`[AccountSwitcher] ⚠️ Failed to delete: ${dbName}`, request.error);
resolve(); // Don't reject, just continue
};
request.onblocked = () => {
console.warn(`[AccountSwitcher] ⚠️ Delete blocked: ${dbName}`);
resolve(); // Don't reject, reload will handle it
};
});
// Add 1-second timeout per database
const timeoutPromise = new Promise<void>((resolve) => {
setTimeout(() => {
console.warn(`[AccountSwitcher] ⏱️ Delete timeout: ${dbName}`);
resolve();
}, 1000);
});
await Promise.race([deletePromise, timeoutPromise]);
} catch (error) {
console.warn(`[AccountSwitcher] Error deleting ${dbName}:`, error);
}
});
await Promise.all(deletePromises);
console.log('[AccountSwitcher] ✅ Database cleanup complete');
};
export function AccountSwitcher({ currentUserId }: AccountSwitcherProps) {
const sessions = useAtomValue(sessionsAtom);
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const currentProfile = useUserProfile(currentUserId ?? '');
const currentAvatarUrl = currentProfile.avatarUrl
? mxcUrlToHttp(mx, currentProfile.avatarUrl, useAuthentication, 32, 32, 'crop') ?? undefined
: undefined;
const handleSwitchAccount = async (session: Session) => {
if (session.userId === currentUserId) return;
console.log('[AccountSwitcher] Switching account from', currentUserId, 'to', session.userId);
// Stop the current client before switching
if (mx) {
console.log('[AccountSwitcher] Stopping current Matrix client...');
try {
mx.stopClient();
console.log('[AccountSwitcher] Matrix client stopped');
} catch (error) {
console.error('[AccountSwitcher] Error stopping client:', error);
}
}
// Set the new current session
setLocalStorageItem('currentSessionUserId', session.userId);
// Delete all Matrix databases to prevent conflicts
await deleteAllMatrixDatabases();
// Small delay to ensure cleanup is complete
await new Promise(resolve => setTimeout(resolve, 100));
// Reload the page to initialize the new session
console.log('[AccountSwitcher] Reloading page...');
window.location.reload();
};
// If no current user, don't show anything
if (!currentUserId) {
return null;
}
// Filter out other sessions (not current)
const otherSessions = sessions.filter(s => s.userId !== currentUserId);
const hasMultipleAccounts = sessions.length > 1;
return (
<Box direction="Column" gap="200">
<Text size="L400" style={{ paddingInline: config.space.S200, paddingBlock: config.space.S100 }}>
{hasMultipleAccounts ? 'Switch Account' : 'Accounts'}
</Text>
{/* Show current account */}
{currentUserId && (
<MenuItem
key={currentUserId}
size="300"
radii="300"
variant="SurfaceVariant"
disabled
style={{ paddingInline: config.space.S200 }}
after={<Icon size="100" src={Icons.Check} filled />}
>
<Box gap="200" alignItems="Center">
<Avatar size="300" style={{ width: '1.75rem', height: '1.75rem', minWidth: '1.75rem' }}>
<UserAvatar
userId={currentUserId}
src={currentAvatarUrl}
alt={currentUserId}
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
/>
</Avatar>
<Box direction="Column" style={{ gap: '2px' }}>
<Text size="T300" truncate>
{currentUserId}
</Text>
{getMxIdServer(currentUserId) && (
<Text size="T200" priority="300" truncate>
{getMxIdServer(currentUserId)}
</Text>
)}
</Box>
</Box>
</MenuItem>
)}
{/* Show other accounts if any */}
{otherSessions.map((session) => {
const server = getMxIdServer(session.userId);
return (
<MenuItem
key={session.userId}
size="300"
radii="300"
onClick={() => handleSwitchAccount(session)}
variant="Surface"
style={{ paddingInline: config.space.S200 }}
>
<Box gap="200" alignItems="Center">
<OtherSessionAvatar session={session} />
<Box direction="Column" style={{ gap: '2px' }}>
<Text size="T300" truncate>
{session.userId}
</Text>
{server && (
<Text size="T200" priority="300" truncate>
{server}
</Text>
)}
</Box>
</Box>
</MenuItem>
);
})}
</Box>
);
}

View File

@@ -0,0 +1 @@
export { AccountSwitcher } from './AccountSwitcher';

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

View File

@@ -0,0 +1,180 @@
import React, { useState, useCallback, ChangeEventHandler } from 'react';
import {
Box,
Button,
config,
Dialog,
Input,
Text,
Overlay,
OverlayBackdrop,
OverlayCenter,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useUserPresence } from '../../hooks/useUserPresence';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
/**
* Props for the CustomStatusDialog component
*/
export interface CustomStatusDialogProps {
/** Whether the dialog is currently open */
open: boolean;
/** Callback function invoked when the dialog should close */
onClose: () => void;
}
/**
* CustomStatusDialog Component
*
* Provides a dialog interface for users to set or clear their custom status message.
* The status is stored in Matrix presence and will be visible to other users.
*
* @param props - Component props
* @returns The custom status dialog component
*/
export function CustomStatusDialog({ open, onClose }: CustomStatusDialogProps) {
const mx = useMatrixClient();
const userId = mx.getUserId();
const presence = useUserPresence(userId ?? '');
const [statusText, setStatusText] = useState(presence?.status || '');
/**
* Handles input changes for the status text field
* @param evt - The change event from the input
*/
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
setStatusText(evt.currentTarget.value);
};
/**
* Async callback to save the status to Matrix presence
*/
const [saveState, saveStatus] = useAsyncCallback(
useCallback(async () => {
await mx.setPresence({
presence: presence?.presence || 'online',
status_msg: statusText,
});
onClose();
}, [mx, presence?.presence, statusText, onClose])
);
/**
* Async callback to clear the status from Matrix presence
*/
const [clearState, clearStatus] = useAsyncCallback(
useCallback(async () => {
await mx.setPresence({
presence: presence?.presence || 'online',
status_msg: '',
});
setStatusText('');
onClose();
}, [mx, presence?.presence, onClose])
);
/**
* Handles form submission to save the custom status
* @param evt - The form submit event
*/
const handleSubmit = (evt: React.FormEvent) => {
evt.preventDefault();
saveStatus();
};
/**
* Handles the clear status button click
*/
const handleClear = () => {
clearStatus();
};
if (!open) return null;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onClose,
clickOutsideDeactivates: true,
}}
>
<Dialog>
<Box
as="form"
onSubmit={handleSubmit}
direction="Column"
gap="400"
style={{ padding: config.space.S400 }}
>
<Box direction="Column" gap="100">
<Text size="H4">Set Custom Status</Text>
<Text size="T300" priority="400">
Your custom status will be visible to other users
</Text>
</Box>
<Box direction="Column" gap="200">
<Input
value={statusText}
onChange={handleChange}
placeholder="What's on your mind?"
maxLength={100}
autoFocus
disabled={saveState.status === AsyncStatus.Loading || clearState.status === AsyncStatus.Loading}
/>
{statusText.length > 0 && (
<Text size="T200" priority="300">
{statusText.length}/100 characters
</Text>
)}
</Box>
{(saveState.status === AsyncStatus.Error || clearState.status === AsyncStatus.Error) && (
<Text size="T300" priority="400" style={{ color: 'var(--color-critical-main)' }}>
Failed to update status. Please try again.
</Text>
)}
<Box direction="Row" gap="200" justifyContent="End">
<Button
type="button"
variant="Secondary"
onClick={onClose}
disabled={saveState.status === AsyncStatus.Loading || clearState.status === AsyncStatus.Loading}
>
<Text size="B400">Cancel</Text>
</Button>
{presence?.status && (
<Button
type="button"
variant="Critical"
fill="None"
onClick={handleClear}
disabled={saveState.status === AsyncStatus.Loading || clearState.status === AsyncStatus.Loading}
>
<Text size="B400">Clear Status</Text>
</Button>
)}
<Button
type="submit"
variant="Primary"
disabled={saveState.status === AsyncStatus.Loading || clearState.status === AsyncStatus.Loading}
>
<Text size="B400">
{saveState.status === AsyncStatus.Loading ? 'Saving...' : 'Save'}
</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}

View File

@@ -0,0 +1 @@
export { CustomStatusDialog, type CustomStatusDialogProps } from './CustomStatusDialog';

View File

@@ -0,0 +1,317 @@
import React, { MouseEventHandler, forwardRef, useMemo, useState } from 'react';
import { useAtom, useAtomValue } from 'jotai';
import {
Avatar,
Box,
Button,
Icon,
IconButton,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
config,
toRem,
} from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual';
import FocusTrap from 'focus-trap-react';
import { useNavigate } from 'react-router-dom';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { factoryRoomIdByActivity } from '../../utils/sort';
import {
NavButton,
NavCategory,
NavCategoryHeader,
NavEmptyCenter,
NavEmptyLayout,
NavItem,
NavItemContent,
} from '../nav';
import { getDirectCreatePath, getDirectRoomPath } from '../../pages/pathUtils';
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { VirtualTile } from '../virtualizer';
import { RoomNavCategoryButton, RoomNavItem } from '../../features/room-nav';
import { makeNavCategoryId } from '../../state/closedNavCategories';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { useCategoryHandler } from '../../hooks/useCategoryHandler';
import { useDirectRooms } from '../../pages/client/direct/useDirectRooms';
import { useClosedNavCategoriesAtom } from '../../state/hooks/closedNavCategories';
import { useRoomsUnread } from '../../state/hooks/unread';
import { useMarkRoomsAsRead } from '../../hooks/useMarkAsRead';
import { stopPropagation } from '../../utils/keyboard';
import { PluginButtonSlot } from '../../features/settings/plugins/PluginButtonSlot';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import {
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
} from '../../hooks/useRoomsNotificationPreferences';
import { useDirectCreateSelected } from '../../hooks/router/useDirectSelected';
import { useRoomListReorder, useListReorderAnimation } from '../../hooks/useRoomListReorder';
/**
* Props for the DirectMenu component
*/
type DirectMenuProps = {
requestClose: () => void;
};
/**
* Menu component for Direct Messages category actions
*/
const DirectMenu = forwardRef<HTMLDivElement, DirectMenuProps>(({ requestClose }, ref) => {
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const orphanRooms = useDirectRooms();
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const markRoomsAsRead = useMarkRoomsAsRead(mx);
const handleMarkAsRead = () => {
if (!unread) return;
markRoomsAsRead(orphanRooms, hideActivity);
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
aria-disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
</Box>
</Menu>
);
});
/**
* Props for the DirectListHeader component
*/
type DirectListHeaderProps = {
title?: string;
};
/**
* Header component for the Direct Messages list with menu
*/
export function DirectListHeader({ title = 'Direct Messages' }: DirectListHeaderProps) {
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<>
<Box alignItems="Center" grow="Yes" gap="300">
<Box grow="Yes">
<Text size="H4" truncate>
{title}
</Text>
</Box>
<Box gap="100" alignItems="Center">
<PluginButtonSlot location="direct-messages" />
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
<Icon src={Icons.VerticalDots} size="200" />
</IconButton>
</Box>
</Box>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<DirectMenu requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
</>
);
}
/**
* Empty state component when there are no direct messages
*/
export function DirectListEmpty() {
const navigate = useNavigate();
return (
<NavEmptyCenter>
<NavEmptyLayout
icon={<Icon size="600" src={Icons.Mention} />}
title={
<Text size="H5" align="Center">
No Direct Messages
</Text>
}
content={
<Text size="T300" align="Center">
You do not have any direct messages yet.
</Text>
}
options={
<Button variant="Secondary" size="300" onClick={() => navigate(getDirectCreatePath())}>
<Text size="B300" truncate>
Direct Message
</Text>
</Button>
}
/>
</NavEmptyCenter>
);
}
const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct');
/**
* Props for the DirectList component
*/
export type DirectListProps = {
scrollRef: React.RefObject<HTMLDivElement>;
showCreateButton?: boolean;
showCategoryHeader?: boolean;
};
/**
* Reusable component that renders the virtualized list of direct message rooms.
* Can be embedded in any PageNavContent context.
*/
export function DirectList({
scrollRef,
showCreateButton = true,
showCategoryHeader = true,
}: DirectListProps) {
const mx = useMatrixClient();
const directs = useDirectRooms();
const notificationPreferences = useRoomsNotificationPreferencesContext();
const roomToUnread = useAtomValue(roomToUnreadAtom);
const navigate = useNavigate();
const createDirectSelected = useDirectCreateSelected();
const selectedRoomId = useSelectedRoom();
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
const reorderTrigger = useRoomListReorder(mx, directs);
const sortedDirects = useMemo(() => {
const items = Array.from(directs).sort(factoryRoomIdByActivity(mx));
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
}
return items;
}, [mx, directs, closedCategories, roomToUnread, selectedRoomId, reorderTrigger]);
const animationRef = useListReorderAnimation(sortedDirects);
const virtualizer = useVirtualizer({
count: sortedDirects.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 38,
overscan: 10,
});
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId)
);
const noRoomToDisplay = directs.length === 0;
if (noRoomToDisplay) {
return <DirectListEmpty />;
}
return (
<Box direction="Column" gap="300">
{showCreateButton && (
<NavCategory>
<NavItem variant="Background" radii="400" aria-selected={createDirectSelected}>
<NavButton onClick={() => navigate(getDirectCreatePath())}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Plus} size="100" />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Create Chat
</Text>
</Box>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
</NavCategory>
)}
<NavCategory>
{showCategoryHeader && (
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
data-category-id={DEFAULT_CATEGORY_ID}
onClick={handleCategoryClick}
>
Chats
</RoomNavCategoryButton>
</NavCategoryHeader>
)}
<div
ref={animationRef}
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const roomId = sortedDirects[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
return (
<VirtualTile
virtualItem={vItem}
key={roomId}
ref={virtualizer.measureElement}
data-room-id={roomId}
>
<RoomNavItem
room={room}
selected={selected}
showAvatar
direct
linkPath={getDirectRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
</Box>
);
}

View File

@@ -0,0 +1,2 @@
export { DirectList, DirectListHeader, DirectListEmpty } from './DirectList';
export type { DirectListProps } from './DirectList';

View File

@@ -6,9 +6,11 @@ import React, {
forwardRef,
useCallback,
useState,
useEffect,
useRef,
} from 'react';
import { Box, Scroll, Text } from 'folds';
import { Descendant, Editor, createEditor } from 'slate';
import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate';
import {
Slate,
Editable,
@@ -23,6 +25,7 @@ import { RenderElement, RenderLeaf } from './Elements';
import { CustomElement } from './slate';
import * as css from './Editor.css';
import { toggleKeyboardShortcut } from './keyboard';
import { createCommandElement } from './utils';
const initialValue: CustomElement[] = [
{
@@ -52,8 +55,25 @@ const withVoid = (editor: Editor): Editor => {
return editor;
};
type CommandValidatorFn = (commandName: string) => boolean;
let commandValidator: CommandValidatorFn | null = null;
export const setCommandValidator = (validator: CommandValidatorFn) => {
commandValidator = validator;
};
const withCommandAutoConvert = (editor: Editor): Editor => {
// Removed auto-conversion on space to prevent focus issues
// Commands are now highlighted visually via decorations
// and executed when Enter is pressed
return editor;
};
export const useEditor = (): Editor => {
const [editor] = useState(() => withInline(withVoid(withReact(withHistory(createEditor())))));
const [editor] = useState(() =>
withCommandAutoConvert(withInline(withVoid(withReact(withHistory(createEditor())))))
);
return editor;
};
@@ -97,6 +117,49 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
const renderLeaf = useCallback((props: RenderLeafProps) => <RenderLeaf {...props} />, []);
const decorate = useCallback(([node, path]: [any, number[]]) => {
const ranges: any[] = [];
// Only decorate text nodes in the first paragraph
if (
path.length === 2 &&
path[0] === 0 &&
path[1] === 0 &&
SlateText.isText(node)
) {
const firstChild = editor.children[0];
if (SlateElement.isElement(firstChild) && firstChild.type === BlockType.Paragraph) {
const [firstInline, secondInline] = firstChild.children;
// Don't decorate if we already have a CommandElement
if (SlateElement.isElement(secondInline) && secondInline.type === BlockType.Command) {
return ranges;
}
// Check if the text matches /command pattern
const text = node.text;
const match = text.match(/^(\s*\/\w+)/);
if (match && commandValidator) {
const commandText = match[1].trim();
const commandName = commandText.substring(1);
if (commandValidator(commandName)) {
// Create decoration for the command text
ranges.push({
anchor: { path, offset: 0 },
focus: { path, offset: match[1].length },
pendingCommand: true,
});
}
}
}
}
return ranges;
}, [editor]);
const handleKeydown: KeyboardEventHandler = useCallback(
(evt) => {
onKeyDown?.(evt);
@@ -106,6 +169,48 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
[editor, onKeyDown]
);
const handleBeforeInput = useCallback(
(event: Event) => {
const inputEvent = event as InputEvent;
// Handle autocorrect replacement that causes text duplication
if (inputEvent.inputType === 'insertReplacementText' ||
inputEvent.inputType === 'insertFromComposition') {
const { selection } = editor;
if (!selection) return;
// Get the data being inserted
const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain');
if (data) {
event.preventDefault();
// If there's selected text, delete it first
if (selection && !Range.isCollapsed(selection)) {
Transforms.delete(editor, { at: selection });
}
// Insert the replacement text
editor.insertText(data);
}
}
},
[editor]
);
const editableRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const editableElement = editableRef.current?.querySelector('[data-slate-editor="true"]');
if (!editableElement) return;
editableElement.addEventListener('beforeinput', handleBeforeInput, { capture: true });
return () => {
editableElement.removeEventListener('beforeinput', handleBeforeInput, { capture: true });
};
}, [handleBeforeInput]);
const renderPlaceholder = useCallback(
({ attributes, children }: RenderPlaceholderProps) => (
<span {...attributes} className={css.EditorPlaceholderContainer}>
@@ -135,6 +240,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
size="300"
visibility="Hover"
hideTrack
ref={editableRef}
>
<Editable
data-editable-name={editableName}
@@ -143,6 +249,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
renderPlaceholder={renderPlaceholder}
renderElement={renderElement}
renderLeaf={renderLeaf}
decorate={decorate}
onKeyDown={handleKeydown}
onKeyUp={onKeyUp}
onPaste={onPaste}

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan';
import { downloadMedia } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getCurrentAccessToken } from '../../utils/auth';
export type ImageViewerProps = {
alt: string;
@@ -22,8 +23,24 @@ export const ImageViewer = as<'div', ImageViewerProps>(
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
const handleDownload = async () => {
const fileContent = await downloadMedia(src, mx.getAccessToken());
FileSaver.saveAs(fileContent, alt);
try {
// Always use current session's token to avoid stale tokens during account switches
const fileContent = await downloadMedia(src, getCurrentAccessToken());
FileSaver.saveAs(fileContent, alt);
} catch (error) {
console.warn('[ImageViewer] Failed to download media:', error);
// Fallback: try to fetch via standard fetch as blob
try {
const response = await fetch(src);
if (response.ok) {
const blob = await response.blob();
FileSaver.saveAs(blob, alt);
}
} catch {
// If all else fails, open in new tab to let browser handle it
window.open(src, '_blank');
}
}
};
return (
@@ -90,6 +107,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
}}
src={src}
alt={alt}
draggable={false}
onMouseDown={onMouseDown}
/>
</Box>

View File

@@ -4,6 +4,12 @@ import * as css from './media.css';
export const Image = forwardRef<HTMLImageElement, ImgHTMLAttributes<HTMLImageElement>>(
({ className, alt, ...props }, ref) => (
<img className={classNames(css.Image, className)} alt={alt} {...props} ref={ref} />
<img
className={classNames(css.Image, className)}
alt={alt}
draggable={false}
{...props}
ref={ref}
/>
)
);

View File

@@ -1,10 +1,104 @@
import React, { VideoHTMLAttributes, forwardRef } from 'react';
import classNames from 'classnames';
import * as css from './media.css';
import React, { VideoHTMLAttributes, forwardRef, useRef, useState, useEffect } from 'react';
export const Video = forwardRef<HTMLVideoElement, VideoHTMLAttributes<HTMLVideoElement>>(
({ className, ...props }, ref) => (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video className={classNames(css.Video, className)} {...props} ref={ref} />
)
import classNames from 'classnames';
import { Box, IconButton, Icon, Icons, Tooltip, TooltipProvider, Text } from 'folds';
import * as css from './media.css';
import * as videoCss from './videoControls.css';
type VideoProps = VideoHTMLAttributes<HTMLVideoElement> & {
disableControls?: boolean;
onExpand?: () => void;
};
export const Video = forwardRef<HTMLVideoElement, VideoProps>(
({ className, disableControls, onExpand, ...props }, ref) => {
const videoRef = useRef<HTMLVideoElement>(null);
const [showControls, setShowControls] = useState(false);
useEffect(() => {
if (typeof ref === 'function') {
ref(videoRef.current);
} else if (ref) {
ref.current = videoRef.current;
}
}, [ref]);
useEffect(() => {
if (!onExpand) return;
const video = videoRef.current;
if (!video) return;
const handleFullscreenRequest = (evt: Event) => {
evt.preventDefault();
evt.stopImmediatePropagation();
onExpand();
};
video.addEventListener('webkitbeginfullscreen', handleFullscreenRequest);
return () => {
video.removeEventListener('webkitbeginfullscreen', handleFullscreenRequest);
};
}, [onExpand]);
if (disableControls) {
return (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video
className={classNames(css.Video, className)}
{...props}
ref={videoRef}
loop={props.autoPlay}
muted={props.autoPlay}
playsInline
/>
);
}
return (
<Box
className={videoCss.VideoContainer}
onMouseEnter={() => setShowControls(true)}
onMouseLeave={() => setShowControls(false)}
>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
className={classNames(css.Video, className)}
{...props}
ref={videoRef}
loop={props.autoPlay}
muted={props.autoPlay}
playsInline
controlsList={onExpand ? 'nofullscreen' : undefined}
onDoubleClick={onExpand ? (e) => { e.stopPropagation(); onExpand(); } : undefined}
/>
{onExpand && (
<Box className={classNames(videoCss.VideoControlsOverlay, !showControls && videoCss.VideoControlsOverlayHidden)} gap="200">
<TooltipProvider
tooltip={
<Tooltip variant="Secondary">
<Text size="T200">Expand</Text>
</Tooltip>
}
position="Bottom"
align="Center"
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="Secondary"
size="300"
radii="300"
onClick={onExpand}
aria-label="Expand video"
>
<Icon size="50" src={Icons.Plus} />
</IconButton>
)}
</TooltipProvider>
</Box>
)}
</Box>
);
}
);

View File

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

View File

@@ -0,0 +1,35 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, config } from 'folds';
export const VideoContainer = style([
DefaultReset,
{
position: 'relative',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
]);
export const VideoControlsOverlay = style([
DefaultReset,
{
position: 'absolute',
top: config.space.S200,
right: config.space.S200,
display: 'flex',
flexDirection: 'row',
gap: config.space.S200,
zIndex: 10,
pointerEvents: 'auto',
opacity: 1,
transition: 'opacity 0.15s ease',
},
]);
export const VideoControlsOverlayHidden = style({
opacity: 0,
pointerEvents: 'none',
});

View File

@@ -12,6 +12,7 @@ import {
downloadMedia,
mxcUrlToHttp,
} from '../../utils/matrix';
import { getCurrentAccessToken } from '../../utils/auth';
const badgeStyles = { maxWidth: toRem(100) };
@@ -28,7 +29,8 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
const [downloadState, download] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
const accessToken = mx.getAccessToken();
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
: await downloadMedia(mediaUrl, accessToken);

View File

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

View File

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

View File

@@ -58,6 +58,8 @@ type ReplyProps = {
replyEventId: string;
threadRootId?: string | undefined;
onClick?: MouseEventHandler | undefined;
/** Called when the thread indicator badge is clicked. Receives the thread root event ID. */
onOpenThread?: (threadRootId: string) => void;
getMemberPowerTag?: GetMemberPowerTag;
accessibleTagColors?: Map<string, string>;
legacyUsernameColor?: boolean;
@@ -71,6 +73,7 @@ export const Reply = as<'div', ReplyProps>(
replyEventId,
threadRootId,
onClick,
onOpenThread,
getMemberPowerTag,
accessibleTagColors,
legacyUsernameColor,
@@ -107,7 +110,11 @@ export const Reply = as<'div', ReplyProps>(
return (
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
{threadRootId && (
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
<ThreadIndicator
as="button"
data-event-id={threadRootId}
onClick={onOpenThread ? () => onOpenThread(threadRootId) : onClick}
/>
)}
<ReplyLayout
as="button"

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ import {
downloadMedia,
mxcUrlToHttp,
} from '../../../utils/matrix';
import { getCurrentAccessToken } from '../../../utils/auth';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
const PLAY_TIME_THROTTLE_OPS = {
@@ -55,7 +56,8 @@ export function AudioContent({
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
const accessToken = mx.getAccessToken();
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
: await downloadMedia(mediaUrl, accessToken);

View File

@@ -35,6 +35,7 @@ import {
mxcUrlToHttp,
} from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { getCurrentAccessToken } from '../../../utils/auth';
import { ModalWide } from '../../../styles/Modal.css';
const renderErrorButton = (retry: () => void, text: string) => (
@@ -87,7 +88,8 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
const [textState, loadText] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
const accessToken = mx.getAccessToken();
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
: await downloadMedia(mediaUrl, accessToken);
@@ -178,7 +180,8 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
const [pdfState, loadPdf] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
const accessToken = mx.getAccessToken();
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
: await downloadMedia(mediaUrl, accessToken);
@@ -256,7 +259,8 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
const [downloadState, download] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
const accessToken = mx.getAccessToken();
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
: await downloadMedia(mediaUrl, accessToken);

View File

@@ -31,24 +31,40 @@ import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../util
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
import { validBlurHash } from '../../../utils/blurHash';
import { getCurrentAccessToken } from '../../../utils/auth';
/**
* Fetches media with authentication headers and returns a blob URL.
* This is needed because service workers don't work reliably in Tauri/WebKit.
* Falls back to unauthenticated request if authenticated request fails with 401.
* If all attempts fail, returns the original URL to let the browser try directly.
*/
const fetchAuthenticatedMedia = async (
url: string,
accessToken: string | null
): Promise<string> => {
const response = await fetch(url, {
method: 'GET',
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
if (!response.ok) {
throw new Error(`Failed to fetch media: ${response.status}`);
try {
let response = await fetch(url, {
method: 'GET',
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
// If we got a 401 and we tried with auth, fallback to unauthenticated request
if (!response.ok && response.status === 401 && accessToken) {
console.warn('[ImageContent] Auth failed (401), attempting unauthenticated fallback for:', url);
response = await fetch(url, { method: 'GET' });
}
if (!response.ok) {
console.warn(`[ImageContent] Failed to fetch authenticated media (${response.status}), falling back to original URL`);
return url;
}
const blob = await response.blob();
return URL.createObjectURL(blob);
} catch (error) {
console.warn('[ImageContent] Error fetching authenticated media, falling back to original URL:', error);
return url;
}
const blob = await response.blob();
return URL.createObjectURL(blob);
};
/**
@@ -114,7 +130,8 @@ export const ImageContent = as<'div', ImageContentProps>(
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
const accessToken = mx.getAccessToken();
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
if (encInfo) {
const fileContent = await downloadEncryptedMedia(
mediaUrl,
@@ -164,7 +181,6 @@ export const ImageContent = as<'div', ImageContentProps>(
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
>
{renderViewer({
src: srcState.data,

View File

@@ -5,23 +5,39 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { decryptFile, downloadEncryptedMedia, isAuthenticatedMediaUrl, mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
import { getCurrentAccessToken } from '../../../utils/auth';
/**
* Fetches media with authentication headers and returns a blob URL.
* Falls back to unauthenticated request if authenticated request fails with 401.
* If all attempts fail, returns the original URL to let the browser try directly.
*/
const fetchAuthenticatedMedia = async (
url: string,
accessToken: string | null
): Promise<string> => {
const response = await fetch(url, {
method: 'GET',
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
if (!response.ok) {
throw new Error(`Failed to fetch media: ${response.status}`);
try {
let response = await fetch(url, {
method: 'GET',
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
// If we got a 401 and we tried with auth, fallback to unauthenticated request
if (!response.ok && response.status === 401 && accessToken) {
console.warn('[ThumbnailContent] Auth failed (401), attempting unauthenticated fallback for:', url);
response = await fetch(url, { method: 'GET' });
}
if (!response.ok) {
console.warn(`[ThumbnailContent] Failed to fetch authenticated media (${response.status}), falling back to original URL`);
return url;
}
const blob = await response.blob();
return URL.createObjectURL(blob);
} catch (error) {
console.warn('[ThumbnailContent] Error fetching authenticated media, falling back to original URL:', error);
return url;
}
const blob = await response.blob();
return URL.createObjectURL(blob);
};
export type ThumbnailContentProps = {
@@ -32,17 +48,18 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
// Check if we have valid thumbnail data
const thumbInfo = info.thumbnail_info;
const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url;
const hasValidThumbnail = typeof thumbMxcUrl === 'string' && typeof thumbInfo?.mimetype === 'string';
const [thumbSrcState, loadThumbSrc] = useAsyncCallback(
useCallback(async () => {
const thumbInfo = info.thumbnail_info;
const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url;
const encInfo = info.thumbnail_file;
if (typeof thumbMxcUrl !== 'string' || typeof thumbInfo?.mimetype !== 'string') {
throw new Error('Failed to load thumbnail');
}
const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication) ?? thumbMxcUrl;
const accessToken = mx.getAccessToken();
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
if (encInfo) {
const fileContent = await downloadEncryptedMedia(
mediaUrl,
@@ -58,12 +75,15 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
}
return mediaUrl;
}, [mx, info, useAuthentication])
}, [mx, thumbMxcUrl, thumbInfo, info.thumbnail_file, useAuthentication])
);
useEffect(() => {
loadThumbSrc();
}, [loadThumbSrc]);
// Only attempt to load thumbnail if we have valid data
if (hasValidThumbnail) {
loadThumbSrc();
}
}, [hasValidThumbnail, loadThumbSrc]);
return thumbSrcState.status === AsyncStatus.Success ? renderImage(thumbSrcState.data) : null;
}

View File

@@ -6,6 +6,10 @@ import {
Chip,
Icon,
Icons,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
Spinner,
Text,
Tooltip,
@@ -14,6 +18,7 @@ import {
} from 'folds';
import classNames from 'classnames';
import { BlurhashCanvas } from 'react-blurhash';
import FocusTrap from 'focus-trap-react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import {
IThumbnailContent,
@@ -30,9 +35,17 @@ import {
downloadMedia,
mxcUrlToHttp,
} from '../../../utils/matrix';
import { getCurrentAccessToken } from '../../../utils/auth';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { validBlurHash } from '../../../utils/blurHash';
import { ModalWide } from '../../../styles/Modal.css';
import { stopPropagation } from '../../../utils/keyboard';
type RenderViewerProps = {
src: string;
alt: string;
requestClose: () => void;
};
type RenderVideoProps = {
title: string;
src: string;
@@ -40,6 +53,7 @@ type RenderVideoProps = {
onError: () => void;
autoPlay: boolean;
controls: boolean;
onExpand?: () => void;
};
type VideoContentProps = {
body: string;
@@ -51,6 +65,7 @@ type VideoContentProps = {
markedAsSpoiler?: boolean;
spoilerReason?: string;
renderThumbnail?: () => ReactNode;
renderViewer?: (props: RenderViewerProps) => ReactNode;
renderVideo: (props: RenderVideoProps) => ReactNode;
};
export const VideoContent = as<'div', VideoContentProps>(
@@ -66,6 +81,7 @@ export const VideoContent = as<'div', VideoContentProps>(
markedAsSpoiler,
spoilerReason,
renderThumbnail,
renderViewer,
renderVideo,
...props
},
@@ -77,12 +93,14 @@ export const VideoContent = as<'div', VideoContentProps>(
const [load, setLoad] = useState(false);
const [error, setError] = useState(false);
const [viewer, setViewer] = useState(false);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
const accessToken = mx.getAccessToken();
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo), accessToken
@@ -111,6 +129,28 @@ export const VideoContent = as<'div', VideoContentProps>(
return (
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
{renderViewer && srcState.status === AsyncStatus.Success && (
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setViewer(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal className={ModalWide} size="500">
{renderViewer({
src: srcState.data,
alt: body,
requestClose: () => setViewer(false),
})}
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
{typeof blurHash === 'string' && !load && (
<BlurhashCanvas
style={{ width: '100%', height: '100%' }}
@@ -152,6 +192,7 @@ export const VideoContent = as<'div', VideoContentProps>(
onError: handleError,
autoPlay: true,
controls: true,
onExpand: renderViewer ? () => setViewer(true) : undefined,
})}
</Box>
)}

View File

@@ -0,0 +1,132 @@
import { ReactNode, useCallback, useEffect, useState } from 'react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { decryptFile, downloadEncryptedMedia, downloadMedia, mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { getCurrentAccessToken } from '../../../utils/auth';
/**
* Extracts the first frame from a video file and renders it as a thumbnail.
* This is used as a fallback when no thumbnail metadata is provided.
*/
export type VideoFrameThumbnailProps = {
mimeType: string;
url: string;
encInfo?: EncryptedAttachmentInfo;
renderImage: (src: string) => ReactNode;
};
export function VideoFrameThumbnail({
mimeType,
url,
encInfo,
renderImage,
}: VideoFrameThumbnailProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [thumbnailSrc, setThumbnailSrc] = useState<string | null>(null);
const [videoSrcState, loadVideoSrc] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
const accessToken = getCurrentAccessToken();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo), accessToken
)
: await downloadMedia(mediaUrl, accessToken);
return URL.createObjectURL(fileContent);
}, [mx, url, useAuthentication, mimeType, encInfo])
);
useEffect(() => {
loadVideoSrc();
}, [loadVideoSrc]);
useEffect(() => {
if (videoSrcState.status !== AsyncStatus.Success) return undefined;
const videoUrl = videoSrcState.status === AsyncStatus.Success ? videoSrcState.data : null;
if (!videoUrl) return undefined;
const video = document.createElement('video');
video.preload = 'metadata';
video.muted = true;
video.playsInline = true;
video.crossOrigin = 'anonymous';
video.src = videoUrl;
let isCleanedUp = false;
const handlers: {
loadedData?: () => void;
seeked?: () => void;
error?: () => void;
} = {};
const cleanup = () => {
if (isCleanedUp) return;
isCleanedUp = true;
if (handlers.loadedData) video.removeEventListener('loadeddata', handlers.loadedData);
if (handlers.seeked) video.removeEventListener('seeked', handlers.seeked);
if (handlers.error) video.removeEventListener('error', handlers.error);
video.src = '';
video.load();
};
handlers.loadedData = () => {
try {
video.currentTime = Math.min(0.1, video.duration / 2);
} catch {
cleanup();
}
};
handlers.seeked = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
cleanup();
return;
}
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
if (blob) {
const thumbnailUrl = URL.createObjectURL(blob);
setThumbnailSrc(thumbnailUrl);
}
cleanup();
}, 'image/jpeg', 0.85);
} catch {
cleanup();
}
};
handlers.error = () => {
cleanup();
};
video.addEventListener('loadeddata', handlers.loadedData);
video.addEventListener('seeked', handlers.seeked);
video.addEventListener('error', handlers.error);
return () => {
cleanup();
if (thumbnailSrc) {
URL.revokeObjectURL(thumbnailSrc);
}
URL.revokeObjectURL(videoUrl);
};
}, [videoSrcState, thumbnailSrc]);
return thumbnailSrc ? renderImage(thumbnailSrc) : null;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -126,3 +126,7 @@ export const NavItemContent = style({
export const NavItemOptions = style({
paddingRight: config.space.S200,
});
export const ThreadItem = style({
minHeight: toRem(24),
});

View File

@@ -4,6 +4,7 @@ import classNames from 'classnames';
import { ContainerColor } from '../../styles/ContainerColor.css';
import * as css from './style.css';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
type PageRootProps = {
nav: ReactNode;
@@ -39,6 +40,7 @@ export function PageNav({ size, children }: ClientDrawerLayoutProps & css.PageNa
>
<Box grow="Yes" direction="Column">
{children}
<SidebarDockedCallPanel />
</Box>
</Box>
);

View File

@@ -62,14 +62,15 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
type AvatarPresenceProps = {
badge: ReactNode;
variant?: ContainerColor;
badgeBackgroundColor?: string;
};
export const AvatarPresence = as<'div', AvatarPresenceProps>(
({ as: AsAvatarPresence, badge, variant = 'Surface', children, ...props }, ref) => (
({ as: AsAvatarPresence, badge, variant = 'Surface', badgeBackgroundColor, children, ...props }, ref) => (
<Box as={AsAvatarPresence} className={css.AvatarPresence} {...props} ref={ref}>
{badge && (
<div
className={css.AvatarPresenceBadge}
style={{ backgroundColor: color[variant].Container }}
style={{ backgroundColor: badgeBackgroundColor || color[variant].Container }}
>
{badge}
</div>

View File

@@ -75,3 +75,33 @@ export const TitleBarDragRegion = style({
WebkitAppRegion: 'drag',
color: color.Surface.OnContainer,
});
export const SyncStatusBadge = style({
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
padding: `${config.space.S100} ${config.space.S300}`,
borderRadius: config.radii.R300,
fontSize: '11px',
fontWeight: 500,
whiteSpace: 'nowrap',
zIndex: 1001,
pointerEvents: 'none',
WebkitAppRegion: 'no-drag',
});
export const SyncStatusSuccess = style({
backgroundColor: color.Success.Container,
color: color.Success.OnContainer,
});
export const SyncStatusWarning = style({
backgroundColor: color.Warning.Container,
color: color.Warning.OnContainer,
});
export const SyncStatusCritical = style({
backgroundColor: color.Critical.Container,
color: color.Critical.OnContainer,
});

View File

@@ -1,7 +1,7 @@
import React, { ReactNode, useMemo, useEffect, useState, useCallback, useRef } from 'react';
import { Box, Text } from 'folds';
import { invoke } from '@tauri-apps/api/core';
import { MatrixClient, RoomEvent, ClientEvent, MatrixEvent, Room, IPushRules } from 'matrix-js-sdk';
import { MatrixClient, RoomEvent, ClientEvent, MatrixEvent, Room, IPushRules, SyncState } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { useNavigate } from 'react-router-dom';
import { WindowControls } from './WindowControls';
@@ -17,6 +17,7 @@ import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreference
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { getNotificationMode, NotificationMode } from '../../hooks/useNotificationMode';
import { isRoomId } from '../../utils/matrix';
import { useSyncState } from '../../hooks/useSyncState';
type NewMessageNotification = {
id: string;
@@ -42,6 +43,28 @@ export function TitleBar({ mx, children }: TitleBarProps) {
const roomToParents = useAtomValue(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom);
// Sync status state
const [syncStateData, setSyncStateData] = useState<{
current: SyncState | null;
previous: SyncState | null | undefined;
}>({
current: null,
previous: undefined,
});
// Track sync state changes
useSyncState(
mx,
useCallback((current, previous) => {
setSyncStateData((s) => {
if (s.current === current && s.previous === previous) {
return s;
}
return { current, previous };
});
}, [])
);
// Notification-related state - only populated when mx is available
const [notificationData, setNotificationData] = useState<{
preferences: { mute: Set<string>; specialMessages: Set<string>; allMessages: Set<string> };
@@ -284,7 +307,6 @@ export function TitleBar({ mx, children }: TitleBarProps) {
// Ignore if it's the currently active room
if (room.roomId === roomId) {
console.log('[TitleBar] Skipping: active room');
return;
}
@@ -591,8 +613,38 @@ export function TitleBar({ mx, children }: TitleBarProps) {
return null;
}
// Determine sync status badge
let syncStatusBadge: ReactNode = null;
if (mx) {
if (
(syncStateData.current === SyncState.Prepared ||
syncStateData.current === SyncState.Syncing ||
syncStateData.current === SyncState.Catchup) &&
syncStateData.previous !== SyncState.Syncing
) {
syncStatusBadge = (
<div className={`${css.SyncStatusBadge} ${css.SyncStatusSuccess}`}>
Connecting...
</div>
);
} else if (syncStateData.current === SyncState.Reconnecting) {
syncStatusBadge = (
<div className={`${css.SyncStatusBadge} ${css.SyncStatusWarning}`}>
Reconnecting...
</div>
);
} else if (syncStateData.current === SyncState.Error) {
syncStatusBadge = (
<div className={`${css.SyncStatusBadge} ${css.SyncStatusCritical}`}>
Connection Lost
</div>
);
}
}
return (
<Box className={css.TitleBar} alignItems="Center" justifyContent="SpaceBetween">
{syncStatusBadge}
<Box
className={css.TitleBarDragRegion}
alignItems="Center"

View File

@@ -18,19 +18,19 @@ export function UnreadBadgeCenter({ children }: { children: ReactNode }) {
}
export function UnreadBadge({ highlight, count }: UnreadBadgeProps) {
if (count === 0) return null;
return (
<Badge
variant={highlight ? 'Success' : 'Secondary'}
size={count > 0 ? '400' : '200'}
size="400"
fill="Solid"
radii="Pill"
outlined={false}
>
{count > 0 && (
<Text as="span" size="L400">
{millify(count)}
</Text>
)}
<Text as="span" size="L400">
{millify(count)}
</Text>
</Badge>
);
}

View File

@@ -1,6 +1,14 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
export const CheckButtonContainer = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '32px',
width: '32px',
});
export const CheckButton = style({
all: 'unset',
display: 'flex',
@@ -11,13 +19,19 @@ export const CheckButton = style({
cursor: 'pointer',
color: color.Secondary.Main,
backgroundColor: 'transparent',
transition: 'background-color 0.15s',
transition: 'opacity 0.2s, background-color 0.15s',
height: '32px',
width: '32px',
opacity: 0.7,
opacity: 0,
WebkitAppRegion: 'no-drag',
flexShrink: 0,
selectors: {
'&[data-visible="true"]': {
opacity: 0.7,
},
},
':hover': {
backgroundColor: color.Surface.ContainerHover,
opacity: 1,
@@ -30,6 +44,7 @@ export const CheckButton = style({
':focus-visible': {
outline: `2px solid ${color.Secondary.Main}`,
outlineOffset: '2px',
opacity: 1,
},
});

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Box, IconButton, Spinner, Text, Menu, PopOut, Button, config } from 'folds';
import { Box, Spinner, Text, Menu, PopOut, Button, config } from 'folds';
import * as css from './UpdateNotification.css';
interface UpdateInfo {
@@ -21,6 +21,7 @@ export function UpdateNotification() {
const [downloadProgress, setDownloadProgress] = useState(0);
const [updateReady, setUpdateReady] = useState(false);
const [checking, setChecking] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number; width: number; height: number } | undefined>(undefined);
useEffect(() => {
@@ -118,29 +119,36 @@ export function UpdateNotification() {
// Show check button if no update status
if (!updateAvailable && !updateReady && !checking) {
return (
<button
className={css.CheckButton}
onClick={handleCheckForUpdates}
aria-label="Check for updates"
title="Check for updates"
type="button"
<div
className={css.CheckButtonContainer}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M8 2V10M8 10L5 7M8 10L11 7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 14H13"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
<button
className={css.CheckButton}
data-visible={isHovered}
onClick={handleCheckForUpdates}
aria-label="Check for updates"
title="Check for updates"
type="button"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M8 2V10M8 10L5 7M8 10L11 7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 14H13"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</div>
);
}

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

@@ -0,0 +1,116 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const TikTokEmbed = style([
DefaultReset,
{
width: toRem(360),
maxWidth: '100%',
backgroundColor: color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
overflow: 'hidden',
},
]);
export const TikTokEmbedHeader = style([
DefaultReset,
{
padding: `${config.space.S200} ${config.space.S300}`,
backgroundColor: color.SurfaceVariant.ContainerActive,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: config.space.S200,
},
]);
export const TikTokEmbedInfo = style([
DefaultReset,
{
display: 'flex',
flexDirection: 'column',
gap: config.space.S100,
flex: 1,
minWidth: 0,
},
]);
export const TikTokEmbedTitle = style({
color: color.Primary.Main,
textDecoration: 'none',
fontWeight: config.fontWeight.W500,
':hover': {
textDecoration: 'underline',
},
});
export const TikTokEmbedAuthor = style({
color: color.SurfaceVariant.OnContainer,
textDecoration: 'none',
opacity: 0.8,
':hover': {
textDecoration: 'underline',
opacity: 1,
},
});
export const TikTokEmbedLink = style({
color: color.Success.Main,
textDecoration: 'none',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
':hover': {
textDecoration: 'underline',
},
});
export const TikTokThumbnailContainer = style([
DefaultReset,
{
position: 'relative',
width: '100%',
height: toRem(550),
backgroundColor: '#000',
display: 'block',
},
]);
export const TikTokThumbnailButton = style([
DefaultReset,
{
position: 'relative',
width: '100%',
height: toRem(550),
border: 'none',
padding: 0,
cursor: 'pointer',
backgroundColor: '#000',
display: 'block',
overflow: 'hidden',
},
]);
export const TikTokThumbnail = style([
DefaultReset,
{
width: '100%',
height: '100%',
display: 'block',
objectFit: 'cover',
objectPosition: 'center',
},
]);
export const TikTokEmbedContainer = style([
DefaultReset,
{
width: '100%',
height: toRem(550),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#000',
},
]);

View File

@@ -0,0 +1,245 @@
import React, { MouseEvent, useEffect, useState } from 'react';
import { Box, Spinner, Text, as } from 'folds';
import * as css from './TikTokEmbed.css';
import { openExternalUrl } from '../../utils/tauri';
/**
* Regex patterns to match TikTok URLs
*/
const TIKTOK_URL_PATTERNS = [
// Standard tiktok.com/@username/video/VIDEO_ID
/(?:https?:\/\/)?(?:www\.)?tiktok\.com\/@[\w.-]+\/video\/(\d+)/i,
// Short vm.tiktok.com/VIDEO_CODE
/(?:https?:\/\/)?(?:www\.)?vm\.tiktok\.com\/([\w]+)/i,
// Short vt.tiktok.com/VIDEO_CODE
/(?:https?:\/\/)?(?:www\.)?vt\.tiktok\.com\/([\w]+)/i,
// Mobile m.tiktok.com
/(?:https?:\/\/)?(?:www\.)?m\.tiktok\.com\/v\/(\d+)/i,
];
/**
* TikTok oEmbed API base
*/
const TIKTOK_OEMBED_API = 'https://www.tiktok.com/oembed';
/**
* Click handler for external links - uses Tauri shell on desktop/mobile
*/
const handleExternalLinkClick = (e: MouseEvent<HTMLAnchorElement>) => {
const { href } = e.currentTarget;
if (href && (href.startsWith('http://') || href.startsWith('https://'))) {
e.preventDefault();
openExternalUrl(href);
}
};
/**
* Checks if a URL is a TikTok URL
* @param url The URL to check
* @returns True if the URL is a TikTok URL
*/
export function isTikTokUrl(url: string): boolean {
return TIKTOK_URL_PATTERNS.some((pattern) => pattern.test(url));
}
/**
* Fetches TikTok video metadata using oEmbed API
* @param url The TikTok video URL
* @returns Video metadata including title, author, and thumbnail
*/
async function fetchTikTokOEmbed(url: string): Promise<{
title: string;
authorName: string;
authorUrl: string;
thumbnailUrl: string;
embedHtml: string;
} | null> {
try {
const oembedUrl = `${TIKTOK_OEMBED_API}?url=${encodeURIComponent(url)}`;
const response = await fetch(oembedUrl);
if (!response.ok) return null;
const data = await response.json();
return {
title: data.title || 'TikTok Video',
authorName: data.author_name || 'Unknown',
authorUrl: data.author_url || url,
thumbnailUrl: data.thumbnail_url || '',
embedHtml: data.html || '',
};
} catch {
return null;
}
}
type TikTokEmbedState =
| { status: 'loading' }
| { status: 'loaded'; title: string; authorName: string; authorUrl: string; thumbnailUrl: string; embedHtml: string }
| { status: 'error' };
type TikTokEmbedProps = {
/** The original TikTok URL */
url: string;
};
/**
* TikTok embed component that shows video preview with metadata
*/
export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref) => {
const [state, setState] = useState<TikTokEmbedState>({ status: 'loading' });
const [showEmbed, setShowEmbed] = useState(false);
// Fetch video info on mount
useEffect(() => {
let cancelled = false;
fetchTikTokOEmbed(url).then((info) => {
if (cancelled) return;
if (info) {
setState({
status: 'loaded',
title: info.title,
authorName: info.authorName,
authorUrl: info.authorUrl,
thumbnailUrl: info.thumbnailUrl,
embedHtml: info.embedHtml,
});
} else {
setState({ status: 'error' });
}
});
return () => {
cancelled = true;
};
}, [url]);
// Loading state
if (state.status === 'loading') {
return (
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
<div className={css.TikTokEmbedHeader}>
<Text
as="a"
href={url}
target="_blank"
rel="noopener noreferrer"
size="T200"
className={css.TikTokEmbedLink}
onClick={handleExternalLinkClick}
truncate
>
TikTok - Loading...
</Text>
</div>
<Box
className={css.TikTokThumbnailContainer}
alignItems="Center"
justifyContent="Center"
style={{ background: '#000', display: 'flex' }}
>
<Spinner variant="Secondary" size="600" />
</Box>
</Box>
);
}
// Error state - show basic link
if (state.status === 'error') {
return (
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
<div className={css.TikTokEmbedHeader}>
<Text
as="a"
href={url}
target="_blank"
rel="noopener noreferrer"
size="T200"
className={css.TikTokEmbedLink}
onClick={handleExternalLinkClick}
truncate
>
TikTok Video
</Text>
</div>
</Box>
);
}
// Loaded state - show thumbnail or embed
if (!showEmbed) {
return (
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
<div className={css.TikTokEmbedHeader}>
<div className={css.TikTokEmbedInfo}>
<Text
as="a"
href={url}
target="_blank"
rel="noopener noreferrer"
size="T300"
className={css.TikTokEmbedTitle}
onClick={handleExternalLinkClick}
truncate
>
{state.title}
</Text>
<Text
as="a"
href={state.authorUrl}
target="_blank"
rel="noopener noreferrer"
size="T200"
className={css.TikTokEmbedAuthor}
onClick={handleExternalLinkClick}
truncate
>
{state.authorName}
</Text>
</div>
</div>
<button
type="button"
className={css.TikTokThumbnailButton}
onClick={() => setShowEmbed(true)}
aria-label="Load TikTok video"
>
{state.thumbnailUrl && (
<img
src={state.thumbnailUrl}
alt={state.title}
className={css.TikTokThumbnail}
crossOrigin="anonymous"
/>
)}
</button>
</Box>
);
}
// Show embed (using dangerouslySetInnerHTML for TikTok's oEmbed HTML)
return (
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
<div className={css.TikTokEmbedHeader}>
<Text
as="a"
href={url}
target="_blank"
rel="noopener noreferrer"
size="T200"
className={css.TikTokEmbedLink}
onClick={handleExternalLinkClick}
truncate
>
{state.title}
</Text>
</div>
<div
className={css.TikTokEmbedContainer}
dangerouslySetInnerHTML={{ __html: state.embedHtml }}
/>
</Box>
);
});

View File

@@ -12,6 +12,7 @@ import * as css from './UrlPreviewCard.css';
import { tryDecodeURIComponent } from '../../utils/dom';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
import { openExternalUrl } from '../../utils/tauri';
const linkStyles = { color: color.Success.Main };
@@ -27,6 +28,18 @@ const handleExternalLinkClick = (e: MouseEvent<HTMLAnchorElement>) => {
}
};
/**
* Preview image component with authentication support
*/
const AuthenticatedUrlPreviewImg = ({
src,
useAuthentication,
...props
}: React.ComponentProps<typeof UrlPreviewImg> & { useAuthentication: boolean }) => {
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
return <UrlPreviewImg src={authenticatedSrc} {...props} />;
};
export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
({ url, ts, ...props }, ref) => {
const mx = useMatrixClient();
@@ -36,10 +49,22 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
);
useEffect(() => {
loadPreview().catch(() => {
// Error is already handled by AsyncStatus.Error state
loadPreview().catch((error) => {
// Log preview errors for debugging - common causes:
// - 502/503: Matrix server's URL preview service is down/misconfigured
// - 404: URL preview endpoint not available on this server
// - Network errors: connectivity issues
if (error?.httpStatus === 502 || error?.httpStatus === 503) {
// Server's URL preview backend is down - this is a server-side issue
// eslint-disable-next-line no-console
console.warn('[UrlPreview] Server URL preview service unavailable (502/503):', url);
} else if (error?.httpStatus === 404) {
// Server doesn't support URL previews
// eslint-disable-next-line no-console
console.info('[UrlPreview] Server does not support URL previews:', url);
}
});
}, [loadPreview]);
}, [loadPreview, url]);
if (previewStatus.status === AsyncStatus.Error) return null;
@@ -57,8 +82,9 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
return (
<>
{imgUrl && (
<UrlPreviewImg
<AuthenticatedUrlPreviewImg
src={imgUrl}
useAuthentication={useAuthentication}
alt={prev['og:title']}
title={prev['og:title']}
style={isSvg ? { padding: '10px' } : undefined}

View File

@@ -1,4 +1,4 @@
import React, { MouseEvent, useCallback, useState } from 'react';
import React, { MouseEvent, useCallback, useEffect, useState } from 'react';
import { Box, Icon, Icons, Spinner, Text, as } from 'folds';
import * as css from './YouTubeEmbed.css';
import { getYouTubeStream, isYouTubeStreamingAvailable, openExternalUrl } from '../../utils/tauri';
@@ -21,8 +21,14 @@ const YOUTUBE_URL_PATTERNS = [
/**
* YouTube embed URL base - fallback when yt-dlp is not available
* Using youtube-nocookie.com to avoid Error 153 and embedding restrictions
*/
const YOUTUBE_EMBED_BASE = 'https://www.youtube.com/embed';
const YOUTUBE_EMBED_BASE = 'https://www.youtube-nocookie.com/embed';
/**
* YouTube oEmbed API base - used to fetch video metadata
*/
const YOUTUBE_OEMBED_API = 'https://www.youtube.com/oembed';
/**
* Get YouTube thumbnail URL for a video
@@ -33,6 +39,23 @@ function getYouTubeThumbnailUrl(videoId: string): string {
return `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
}
/**
* Fetch YouTube video title using oEmbed API
* @param url The YouTube video URL
* @returns The video title or null if fetch fails
*/
async function fetchYouTubeTitle(url: string): Promise<string | null> {
try {
const oembedUrl = `${YOUTUBE_OEMBED_API}?url=${encodeURIComponent(url)}&format=json`;
const response = await fetch(oembedUrl);
if (!response.ok) return null;
const data = await response.json();
return data.title || null;
} catch {
return null;
}
}
/**
* Checks if a URL is a YouTube URL
* @param url The URL to check
@@ -66,10 +89,15 @@ export function extractYouTubeVideoId(url: string): string | null {
/**
* Generates a YouTube embed URL for a video (fallback)
* @param videoId The YouTube video ID
* @returns The embed URL
* @returns The embed URL with parameters to avoid Error 153
*/
export function getYouTubeEmbedUrl(videoId: string): string {
return `${YOUTUBE_EMBED_BASE}/${videoId}?autoplay=1`;
// Use youtube-nocookie.com and add parameters to help with embedding
// autoplay=1 - start playing when loaded
// rel=0 - only show related videos from same channel
// modestbranding=1 - minimize YouTube branding
// enablejsapi=1 - enable JavaScript API for better compatibility
return `${YOUTUBE_EMBED_BASE}/${videoId}?autoplay=1&rel=0&modestbranding=1&enablejsapi=1`;
}
/**
@@ -101,6 +129,22 @@ type YouTubeEmbedProps = {
export const YouTubeEmbed = as<'div', YouTubeEmbedProps>(({ url, ...props }, ref) => {
const videoId = extractYouTubeVideoId(url);
const [playerState, setPlayerState] = useState<PlayerState>({ status: 'thumbnail' });
const [videoTitle, setVideoTitle] = useState<string | null>(null);
// Fetch video title on mount
useEffect(() => {
let cancelled = false;
fetchYouTubeTitle(url).then((title) => {
if (!cancelled) {
setVideoTitle(title);
}
});
return () => {
cancelled = true;
};
}, [url]);
const handlePlay = useCallback(async () => {
if (!isYouTubeStreamingAvailable()) {
@@ -141,11 +185,19 @@ export const YouTubeEmbed = as<'div', YouTubeEmbedProps>(({ url, ...props }, ref
</div>
);
// Determine what to show in the header based on video title availability
const getHeaderLabel = (suffix?: string) => {
if (videoTitle) {
return suffix ? `${videoTitle} ${suffix}` : videoTitle;
}
return suffix ? `YouTube ${suffix}` : 'YouTube';
};
// Thumbnail preview - click to play
if (playerState.status === 'thumbnail') {
return (
<Box shrink="No" className={css.YouTubeEmbed} direction="Column" {...props} ref={ref}>
{renderHeader('YouTube')}
{renderHeader(getHeaderLabel())}
<button
type="button"
className={css.YouTubeThumbnailButton}
@@ -169,7 +221,7 @@ export const YouTubeEmbed = as<'div', YouTubeEmbedProps>(({ url, ...props }, ref
if (playerState.status === 'loading') {
return (
<Box shrink="No" className={css.YouTubeEmbed} direction="Column" {...props} ref={ref}>
{renderHeader('YouTube - Loading...')}
{renderHeader(getHeaderLabel('- Loading...'))}
<Box
className={css.YouTubeEmbedIframe}
alignItems="Center"
@@ -204,13 +256,14 @@ export const YouTubeEmbed = as<'div', YouTubeEmbedProps>(({ url, ...props }, ref
const embedUrl = getYouTubeEmbedUrl(videoId);
return (
<Box shrink="No" className={css.YouTubeEmbed} direction="Column" {...props} ref={ref}>
{renderHeader('YouTube')}
{renderHeader(getHeaderLabel())}
<iframe
className={css.YouTubeEmbedIframe}
src={embedUrl}
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
referrerPolicy="strict-origin-when-cross-origin"
/>
</Box>
);

View File

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

View File

@@ -1,4 +1,4 @@
import { Chip, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import { Chip, color, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import React, { MouseEventHandler, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey';
@@ -14,8 +14,9 @@ import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
import { SpaceSettingsPage } from '../../state/spaceSettings';
import { RoomSettingsPage } from '../../state/roomSettings';
import { getContrastingTextColor } from '../../utils/common';
export function CreatorChip() {
export function CreatorChip({ borderColor }: { borderColor?: string }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = useRoom();
@@ -79,6 +80,7 @@ export function CreatorChip() {
>
<Chip
variant="Success"
fill={borderColor ? 'Soft' : 'None'}
outlined
radii="Pill"
before={
@@ -91,8 +93,14 @@ export function CreatorChip() {
after={tagIconSrc ? <PowerIcon size="50" iconSrc={tagIconSrc} /> : undefined}
onClick={open}
aria-pressed={!!cords}
style={borderColor ? {
backgroundColor: borderColor,
border: 'none',
color: getContrastingTextColor(borderColor),
opacity: 1
} : { backgroundColor: color.Surface.Container, opacity: 1 }}
>
<Text size="B300" truncate>
<Text size="B300" truncate style={borderColor ? { color: getContrastingTextColor(borderColor) } : undefined}>
{tag.name}
</Text>
</Chip>

View File

@@ -2,6 +2,7 @@ import {
Box,
Button,
Chip,
color,
config,
Dialog,
Header,
@@ -43,6 +44,7 @@ import { getPowerTagIconSrc, useGetMemberPowerTag } from '../../hooks/useMemberP
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
import { getContrastingTextColor } from '../../utils/common';
type SelfDemoteAlertProps = {
power: number;
@@ -144,7 +146,7 @@ function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps)
);
}
export function PowerChip({ userId }: { userId: string }) {
export function PowerChip({ userId, borderColor }: { userId: string; borderColor?: string }) {
const mx = useMatrixClient();
const room = useRoom();
const space = useSpaceOptionally();
@@ -318,6 +320,7 @@ export function PowerChip({ userId }: { userId: string }) {
>
<Chip
variant={error ? 'Critical' : 'SurfaceVariant'}
fill={borderColor ? 'Soft' : 'None'}
radii="Pill"
before={
cords ? (
@@ -332,8 +335,14 @@ export function PowerChip({ userId }: { userId: string }) {
after={tagIconSrc ? <PowerIcon size="50" iconSrc={tagIconSrc} /> : undefined}
onClick={open}
aria-pressed={!!cords}
style={borderColor ? {
backgroundColor: borderColor,
border: 'none',
color: getContrastingTextColor(borderColor),
opacity: 1
} : { backgroundColor: color.Surface.Container, opacity: 1 }}
>
<Text size="B300" truncate>
<Text size="B300" truncate style={borderColor ? { color: getContrastingTextColor(borderColor) } : undefined}>
{tag.name}
</Text>
</Chip>

View File

@@ -8,6 +8,7 @@ import {
PopOut,
Menu,
MenuItem,
color,
config,
Text,
Line,
@@ -42,8 +43,9 @@ import { useTimeoutToggle } from '../../hooks/useTimeoutToggle';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { CutoutCard } from '../cutout-card';
import { SettingTile } from '../setting-tile';
import { getContrastingTextColor } from '../../utils/common';
export function ServerChip({ server }: { server: string }) {
export function ServerChip({ server, borderColor }: { server: string; borderColor?: string }) {
const mx = useMatrixClient();
const myServer = getMxIdServer(mx.getSafeUserId());
const navigate = useNavigate();
@@ -124,6 +126,7 @@ export function ServerChip({ server }: { server: string }) {
>
<Chip
variant={myServer === server ? 'SurfaceVariant' : 'Warning'}
fill={borderColor ? 'Soft' : 'None'}
radii="Pill"
before={
cords ? (
@@ -134,8 +137,14 @@ export function ServerChip({ server }: { server: string }) {
}
onClick={open}
aria-pressed={!!cords}
style={borderColor ? {
backgroundColor: borderColor,
border: 'none',
color: getContrastingTextColor(borderColor),
opacity: 1
} : { backgroundColor: color.Surface.Container, opacity: 1 }}
>
<Text size="B300" truncate>
<Text size="B300" truncate style={borderColor ? { color: getContrastingTextColor(borderColor) } : undefined}>
{server}
</Text>
</Chip>
@@ -143,7 +152,7 @@ export function ServerChip({ server }: { server: string }) {
);
}
export function ShareChip({ userId }: { userId: string }) {
export function ShareChip({ userId, borderColor }: { userId: string; borderColor?: string }) {
const [cords, setCords] = useState<RectCords>();
const [copied, setCopied] = useTimeoutToggle();
@@ -206,6 +215,7 @@ export function ShareChip({ userId }: { userId: string }) {
>
<Chip
variant={copied ? 'Success' : 'SurfaceVariant'}
fill={borderColor ? 'Soft' : 'None'}
radii="Pill"
before={
cords ? (
@@ -216,8 +226,14 @@ export function ShareChip({ userId }: { userId: string }) {
}
onClick={open}
aria-pressed={!!cords}
style={borderColor ? {
backgroundColor: borderColor,
border: 'none',
color: getContrastingTextColor(borderColor),
opacity: 1
} : { backgroundColor: color.Surface.Container, opacity: 1 }}
>
<Text size="B300" truncate>
<Text size="B300" truncate style={borderColor ? { color: getContrastingTextColor(borderColor) } : undefined}>
Share
</Text>
</Chip>
@@ -231,7 +247,7 @@ type MutualRoomsData = {
directs: Room[];
};
export function MutualRoomsChip({ userId }: { userId: string }) {
export function MutualRoomsChip({ userId, borderColor }: { userId: string; borderColor?: string }) {
const mx = useMatrixClient();
const mutualRoomSupported = useMutualRoomsSupport();
const mutualRoomsState = useMutualRooms(userId);
@@ -402,6 +418,7 @@ export function MutualRoomsChip({ userId }: { userId: string }) {
>
<Chip
variant="SurfaceVariant"
fill={borderColor ? 'Soft' : 'None'}
radii="Pill"
before={mutualRoomsState.status === AsyncStatus.Loading && <Spinner size="50" />}
disabled={
@@ -409,8 +426,14 @@ export function MutualRoomsChip({ userId }: { userId: string }) {
}
onClick={open}
aria-pressed={!!cords}
style={borderColor ? {
backgroundColor: borderColor,
border: 'none',
color: getContrastingTextColor(borderColor),
opacity: 1
} : { backgroundColor: color.Surface.Container, opacity: 1 }}
>
<Text size="B300">
<Text size="B300" style={borderColor ? { color: getContrastingTextColor(borderColor) } : undefined}>
{mutualRoomsState.status === AsyncStatus.Success &&
`${mutualRoomsState.data.length} Mutual Rooms`}
{mutualRoomsState.status === AsyncStatus.Loading && 'Mutual Rooms'}
@@ -437,7 +460,7 @@ export function IgnoredUserAlert() {
);
}
export function OptionsChip({ userId }: { userId: string }) {
export function OptionsChip({ userId, borderColor }: { userId: string; borderColor?: string }) {
const mx = useMatrixClient();
const [cords, setCords] = useState<RectCords>();
@@ -503,11 +526,23 @@ export function OptionsChip({ userId }: { userId: string }) {
</FocusTrap>
}
>
<Chip variant="SurfaceVariant" radii="Pill" onClick={open} aria-pressed={!!cords}>
<Chip
variant="SurfaceVariant"
fill={borderColor ? 'Soft' : 'None'}
radii="Pill"
onClick={open}
aria-pressed={!!cords}
style={borderColor ? {
backgroundColor: borderColor,
border: 'none',
color: getContrastingTextColor(borderColor),
opacity: 1
} : { backgroundColor: color.Surface.Container, opacity: 1 }}
>
{ignoring ? (
<Spinner variant="Secondary" size="50" />
) : (
<Icon size="50" src={Icons.HorizontalDots} />
<Icon size="50" src={Icons.HorizontalDots} style={borderColor ? { color: getContrastingTextColor(borderColor) } : undefined} />
)}
</Chip>
</PopOut>

View File

@@ -9,6 +9,7 @@ import {
OverlayBackdrop,
OverlayCenter,
Text,
toRem,
} from 'folds';
import classNames from 'classnames';
import FocusTrap from 'focus-trap-react';
@@ -21,31 +22,53 @@ import { UserPresence } from '../../hooks/useUserPresence';
import { AvatarPresence, PresenceBadge } from '../presence';
import { ImageViewer } from '../image-viewer';
import { stopPropagation } from '../../utils/keyboard';
import { useOtherUserColor } from '../../hooks/useUserColor';
import { useOtherUserBanner } from '../../hooks/useUserBanner';
import { useOtherUserProfileStyle } from '../../hooks/useUserProfileStyle';
type UserHeroProps = {
userId: string;
avatarUrl?: string;
avatarMxc?: string;
presence?: UserPresence;
};
export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroProps) {
const [viewAvatar, setViewAvatar] = useState<string>();
const bannerUrl = useOtherUserBanner(userId, avatarMxc); // Now returns blob URL directly
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
console.log('[UserHero]', {
userId,
avatarMxc,
bannerUrl,
profileStyle,
});
return (
<Box direction="Column" className={css.UserHero}>
<Box
direction="Column"
className={css.UserHero}
>
<div
className={css.UserHeroCoverContainer}
style={{
backgroundColor: colorMXID(userId),
filter: avatarUrl ? undefined : 'brightness(50%)',
backgroundColor: bannerUrl ? undefined : colorMXID(userId),
filter: bannerUrl || avatarUrl ? undefined : 'brightness(50%)',
}}
>
{avatarUrl && (
<img className={css.UserHeroCover} src={avatarUrl} alt={userId} draggable="false" />
{bannerUrl ? (
<img className={css.UserHeroBanner} src={bannerUrl} alt={userId} draggable="false" />
) : (
avatarUrl && (
<img className={css.UserHeroCover} src={avatarUrl} alt={userId} draggable="false" />
)
)}
</div>
<div className={css.UserHeroAvatarContainer}>
<AvatarPresence
className={css.UserAvatarContainer}
style={profileStyle.avatarBorderColor ? { backgroundColor: profileStyle.avatarBorderColor } : undefined}
badgeBackgroundColor={profileStyle.avatarBorderColor}
badge={
presence && <PresenceBadge presence={presence.presence} status={presence.status} />
}
@@ -55,6 +78,14 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined}
className={css.UserHeroAvatar}
size="500"
style={{
width: toRem(72),
height: toRem(72),
...(profileStyle.avatarBorderColor ? {
outline: `${toRem(4)} solid ${profileStyle.avatarBorderColor}`,
outlineOffset: toRem(-1),
} : {}),
}}
>
<UserAvatar
className={css.UserHeroAvatarImg}
@@ -76,7 +107,7 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
escapeDeactivates: stopPropagation,
}}
>
<Modal size="500" onContextMenu={(evt: any) => evt.stopPropagation()}>
<Modal size="500" onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}>
<ImageViewer
src={viewAvatar}
alt={userId}
@@ -95,24 +126,39 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
type UserHeroNameProps = {
displayName?: string;
userId: string;
avatarMxc?: string;
};
export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
export function UserHeroName({ displayName, userId, avatarMxc }: UserHeroNameProps) {
const username = getMxIdLocalPart(userId);
const userColor = useOtherUserColor(userId, avatarMxc);
console.log('[UserHeroName]', {
userId,
avatarMxc,
userColor,
fallbackColor: colorMXID(userId),
});
return (
<Box grow="Yes" direction="Column" gap="0">
<Box grow="Yes" direction="Column" gap="100">
<Box alignItems="Baseline" gap="200" wrap="Wrap">
<Text
size="H4"
className={classNames(BreakWord, LineClamp3)}
title={displayName ?? username}
style={{ color: userColor || colorMXID(userId) }}
>
{displayName ?? username ?? userId}
</Text>
</Box>
<Box alignItems="Center" gap="100" wrap="Wrap">
<Text size="T200" className={classNames(BreakWord, LineClamp3)} title={username}>
@{username}
<Text
size="T200"
className={BreakWord}
title={userId}
style={{ color: userColor || colorMXID(userId) }}
>
{userId}
</Text>
</Box>
</Box>

View File

@@ -1,4 +1,4 @@
import { Box, Button, config, Icon, Icons, Text } from 'folds';
import { Box, Button, color, config, Icon, Icons, Text, toRem } from 'folds';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { UserHero, UserHeroName } from './UserHero';
@@ -9,6 +9,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { usePowerLevels } from '../../hooks/usePowerLevels';
import { useRoom } from '../../hooks/useRoom';
import { useUserPresence } from '../../hooks/useUserPresence';
import { useOtherUserProfileStyle } from '../../hooks/useUserProfileStyle';
import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips';
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { PowerChip } from './PowerChip';
@@ -22,6 +23,11 @@ import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
import { CreatorChip } from './CreatorChip';
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
import { DirectCreateSearchParams } from '../../pages/paths';
import { getContrastingTextColor, stripAlphaFromColor, getTextShadowColor } from '../../utils/common';
import classNames from 'classnames';
import { BreakWord, LineClamp3 } from '../../styles/Text.css';
import colorMXID, { getColorMXIDValue } from '../../../util/colorMXID';
import { useOtherUserColor } from '../../hooks/useUserColor';
type UserRoomProfileProps = {
userId: string;
@@ -58,6 +64,13 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
const presence = useUserPresence(userId);
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
const userColor = useOtherUserColor(userId, avatarMxc);
// Build gradient CSS if configured
const gradientStyle = profileStyle.gradient
? `linear-gradient(${profileStyle.gradient.direction}, ${profileStyle.gradient.startColor}, ${profileStyle.gradient.stopColor})`
: undefined;
const handleMessage = () => {
closeUserRoomProfile();
@@ -67,40 +80,126 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
navigate(withSearchParam(getDirectCreatePath(), directSearchParam));
};
const hasBottomContent =
ignored ||
(!!member && membership === Membership.Ban) ||
(!!member && membership === Membership.Leave && !!member.events.member && member.events.member.getSender() !== userId) ||
(!!member && membership === Membership.Invite) ||
(canInvite && membership === Membership.Leave) ||
(canKickUser && membership === Membership.Join) ||
(canBanUser && membership !== Membership.Ban);
// Use userColor for text, or fall back to colorMXID
const profileColor = userColor ? userColor : getColorMXIDValue(userId);
const profileTextShadow = `0 1px 4px ${getTextShadowColor(profileColor)}`;
// 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 (
<Box direction="Column">
<UserHero
userId={userId}
avatarUrl={avatarUrl}
avatarMxc={avatarMxc}
presence={presence && presence.lastActiveTs !== 0 ? presence : undefined}
/>
<Box direction="Column" gap="500" style={{ padding: config.space.S400 }}>
<Box direction="Column" gap="400">
<Box gap="400" alignItems="Start">
<UserHeroName displayName={displayName} userId={userId} />
{userId !== myUserId && (
<Box shrink="No">
<Button
size="300"
variant="Primary"
fill="Solid"
radii="300"
before={<Icon size="50" src={Icons.Message} filled />}
onClick={handleMessage}
>
<Text size="B300">Message</Text>
</Button>
</Box>
)}
</Box>
<Box alignItems="Center" gap="200" wrap="Wrap">
{server && <ServerChip server={server} />}
<ShareChip userId={userId} />
{creator ? <CreatorChip /> : <PowerChip userId={userId} />}
{userId !== myUserId && <MutualRoomsChip userId={userId} />}
{userId !== myUserId && <OptionsChip userId={userId} />}
<Box
direction="Column"
gap="200"
alignItems="Center"
style={{
padding: config.space.S400,
paddingTop: `calc(${config.space.S200} + ${toRem(36)})`,
marginTop: toRem(-36),
background: gradientStyle,
textAlign: 'center'
}}
>
{/* Display Name */}
<Text
size="H4"
className={classNames(BreakWord, LineClamp3)}
style={{ color: getMemberDisplayName(room, userId) !== userId ? profileColor : undefined, textShadow: profileTextShadow }}
>
{getMemberDisplayName(room, userId)}
</Text>
{/* Username */}
<Text
size="T200"
className={BreakWord}
style={{ color: profileColor, textShadow: profileTextShadow }}
>
{userId}
</Text>
{/* Status Pill */}
{presence?.status && (
<Box
style={{
backgroundColor: pillBgColor || color.Surface.Container,
border: pillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(6)} ${toRem(10)}`,
borderRadius: toRem(16),
maxWidth: toRem(250),
}}
>
<Text
size="T300"
className={BreakWord}
style={{
color: pillBgColor ? getContrastingTextColor(pillBgColor) : undefined
}}
>
{presence.status}
</Text>
</Box>
)}
{/* Chips Row */}
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
{server && <ServerChip server={server} borderColor={pillBgColor} />}
<ShareChip userId={userId} borderColor={pillBgColor} />
{creator ? <CreatorChip borderColor={pillBgColor} /> : <PowerChip userId={userId} borderColor={pillBgColor} />}
</Box>
{/* Message Button */}
{userId !== myUserId && (
<Button
size="300"
variant="Primary"
fill="Solid"
radii="300"
before={<Icon size="50" src={Icons.Message} filled />}
onClick={handleMessage}
>
<Text size="B300">Message</Text>
</Button>
)}
{/* Additional Chips if needed */}
{userId !== myUserId && (
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
<MutualRoomsChip userId={userId} borderColor={pillBgColor} />
<OptionsChip userId={userId} borderColor={pillBgColor} />
</Box>
)}
</Box>
{hasBottomContent && <Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
{ignored && <IgnoredUserAlert />}
{member && membership === Membership.Ban && (
<UserBanAlert
@@ -136,7 +235,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
canKick={canKickUser && membership === Membership.Join}
canBan={canBanUser && membership !== Membership.Ban}
/>
</Box>
</Box>}
</Box>
);
}

View File

@@ -15,7 +15,8 @@ export const UserHero = style({
});
export const UserHeroCoverContainer = style({
height: toRem(96),
position: 'relative',
height: toRem(140),
overflow: 'hidden',
});
export const UserHeroCover = style({
@@ -26,17 +27,32 @@ export const UserHeroCover = style({
transform: 'scale(2)',
});
export const UserHeroBanner = style({
height: '100%',
width: '100%',
objectFit: 'cover',
});
export const UserHeroAvatarContainer = style({
position: 'relative',
height: toRem(29),
});
export const UserAvatarContainer = style({
position: 'absolute',
left: config.space.S400,
left: '50%',
top: 0,
transform: 'translateY(-50%)',
transform: 'translate(-50%, -50%)',
backgroundColor: color.Surface.Container,
});
export const UserStatusBubble = style({
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
bottom: toRem(-8),
zIndex: 2,
});
export const UserHeroAvatar = style({
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
selectors: {

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

@@ -54,6 +54,53 @@ export const DockIndicator = style({
borderLeft: `2px dashed ${color.Primary.Main}`,
});
export const SidebarDockIndicator = style({
position: 'fixed',
left: toRem(68), // After space panel (68px)
bottom: 0,
width: toRem(256), // Room list panel width
height: toRem(120),
backgroundColor: color.Primary.Container,
opacity: 0.5,
zIndex: 9998,
pointerEvents: 'none',
borderTop: `2px dashed ${color.Primary.Main}`,
borderRight: `2px dashed ${color.Primary.Main}`,
});
export const SidebarDockedCallContainer = style({
display: 'flex',
flexDirection: 'column',
gap: config.space.S200,
padding: config.space.S200,
backgroundColor: color.Surface.Container,
borderTop: `1px solid ${color.Surface.ContainerLine}`,
marginTop: 'auto',
});
export const SidebarDockedCallHeader = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: config.space.S100,
color: color.Surface.OnContainer,
cursor: 'grab',
padding: config.space.S100,
borderRadius: config.radii.R200,
selectors: {
'&:hover': {
backgroundColor: color.Surface.ContainerHover,
},
},
});
export const SidebarDockedCallControls = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: config.space.S100,
});
export const DockedCallPanelLayout = style({
width: toRem(266),
height: '100%',

View File

@@ -28,10 +28,16 @@ interface DragPosition {
/** Threshold in pixels for docking detection */
const DOCK_THRESHOLD = 50;
/** Combined width of space panel (68px) + room list panel (256px) for dock detection */
const ROOM_LIST_EDGE = 324;
/** Dock zone type */
type DockZone = 'none' | 'right' | 'sidebar';
/** Custom hook for draggable functionality with viewport constraints and docking detection */
function useDraggable(
onDockRequest?: () => void,
onDockRight?: () => void,
onDockSidebar?: () => void,
onUndockRequest?: () => void,
isDocked?: boolean,
pendingDrag?: PendingDragState | null,
@@ -39,7 +45,7 @@ function useDraggable(
) {
const [position, setPosition] = useState<DragPosition>({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [isNearDockZone, setIsNearDockZone] = useState(false);
const [nearDockZone, setNearDockZone] = useState<DockZone>('none');
const dragRef = useRef<HTMLDivElement>(null);
const dragStartRef = useRef<{ x: number; y: number; startX: number; startY: number } | null>(null);
@@ -145,9 +151,20 @@ function useDraggable(
const handleMouseMove = (e: MouseEvent) => {
if (!dragStartRef.current || !dragRef.current) return;
// Check if near right edge for docking
// Check dock zones
const nearRightEdge = e.clientX > window.innerWidth - DOCK_THRESHOLD;
setIsNearDockZone(nearRightEdge && !isDocked);
const nearSidebarZone = e.clientX < ROOM_LIST_EDGE + DOCK_THRESHOLD &&
e.clientY > window.innerHeight - 150;
if (!isDocked) {
if (nearSidebarZone) {
setNearDockZone('sidebar');
} else if (nearRightEdge) {
setNearDockZone('right');
} else {
setNearDockZone('none');
}
}
// If docked and dragging away from edge, trigger undock
if (isDocked) {
@@ -191,12 +208,20 @@ function useDraggable(
};
const handleMouseUp = (e: MouseEvent) => {
// Check if should dock on mouse release
if (!isDocked && e.clientX > window.innerWidth - DOCK_THRESHOLD) {
onDockRequest?.();
// Check which dock zone to use
const nearRightEdge = e.clientX > window.innerWidth - DOCK_THRESHOLD;
const nearSidebarZone = e.clientX < ROOM_LIST_EDGE + DOCK_THRESHOLD &&
e.clientY > window.innerHeight - 150;
if (!isDocked) {
if (nearSidebarZone) {
onDockSidebar?.();
} else if (nearRightEdge) {
onDockRight?.();
}
}
setIsDragging(false);
setIsNearDockZone(false);
setNearDockZone('none');
dragStartRef.current = null;
};
@@ -207,9 +232,9 @@ function useDraggable(
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, position, isDocked, onDockRequest, onUndockRequest]);
}, [isDragging, position, isDocked, onDockRight, onDockSidebar, onUndockRequest]);
return { position, isDragging, isNearDockZone, handleMouseDown, dragRef };
return { position, isDragging, nearDockZone, handleMouseDown, dragRef };
}
/**
@@ -301,13 +326,19 @@ interface ScreenShareItemProps {
* Handles attaching the video stream/track properly
*/
export function ScreenShareItem({ share }: ScreenShareItemProps) {
const containerRef = useRef<HTMLDivElement>(null);
const videoContainerRef = useRef<HTMLDivElement>(null);
const [showControls, setShowControls] = useState(false);
const [isPoppedOut, setIsPoppedOut] = useState(false);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { handleMouseMove, handleMouseLeave } = useScreenShareCursor(share.participantId);
useEffect(() => {
const container = containerRef.current;
const container = videoContainerRef.current;
if (!container) return undefined;
// Always render video in main window (even when popped out - just hidden)
// This keeps the track alive
// Handle local screenshare (MediaStream)
if (share.isLocal && share.stream) {
const videoEl = document.createElement('video');
@@ -354,38 +385,307 @@ export function ScreenShareItem({ share }: ScreenShareItemProps) {
return undefined;
}, [share.isLocal, share.stream, share.track]);
/**
* Handles double-click to toggle fullscreen on the screenshare video element
*/
const handleDoubleClick = useCallback(async () => {
const container = containerRef.current;
if (!container) return;
const handlePopOut = useCallback(() => {
const popoutWindow = window.open(
'',
'_blank',
'width=1200,height=800,frame=false,titleBarStyle=hidden'
);
const videoEl = container.querySelector('video');
if (!videoEl) return;
if (!popoutWindow) return;
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await videoEl.requestFullscreen();
popoutWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>Screen Share</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
user-select: none;
position: relative;
}
#videoContainer {
width: 100%;
height: 100%;
}
#videoContainer video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
-webkit-app-region: drag;
cursor: move;
}
#closeButton {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
background: rgba(0, 0, 0, 0.7);
border: none;
border-radius: 8px;
color: #fff;
font-size: 20px;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
-webkit-app-region: no-drag;
transition: background 0.2s;
}
#closeButton:hover {
background: rgba(255, 0, 0, 0.8);
}
#closeButton.visible {
display: flex;
}
</style>
</head>
<body>
<div id="videoContainer"></div>
<button id="closeButton" title="Close">×</button>
<script>
let hideTimeout = null;
const closeButton = document.getElementById('closeButton');
function showCloseButton() {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
closeButton.classList.add('visible');
hideTimeout = setTimeout(() => {
closeButton.classList.remove('visible');
hideTimeout = null;
}, 2000);
}
// Show on any mouse movement
document.addEventListener('mousemove', showCloseButton, true);
// Keep visible when hovering over button
closeButton.addEventListener('mouseenter', () => {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
closeButton.classList.add('visible');
});
// Start hide timer when leaving button
closeButton.addEventListener('mouseleave', () => {
hideTimeout = setTimeout(() => {
closeButton.classList.remove('visible');
hideTimeout = null;
}, 2000);
});
// Close window on click
closeButton.addEventListener('click', () => {
window.close();
});
// Keep video playing - prevent pause from visibility changes or focus loss
function keepVideoPlaying() {
const video = document.querySelector('video');
if (video && video.paused && video.srcObject) {
video.play().catch(() => {});
}
}
// Check every 500ms if video got paused
setInterval(keepVideoPlaying, 500);
// Also resume on visibility change
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
keepVideoPlaying();
}
});
// Resume on window focus
window.addEventListener('focus', keepVideoPlaying);
</script>
</body>
</html>
`);
popoutWindow.document.close();
// Wait for document to be ready, attach video FIRST (while still attached to main), THEN hide main
setTimeout(() => {
const videoContainer = popoutWindow.document.getElementById('videoContainer');
if (!videoContainer) return;
// For local screenshare, create new video with the MediaStream
if (share.isLocal && share.stream) {
const videoEl = popoutWindow.document.createElement('video');
videoEl.autoplay = true;
videoEl.playsInline = true;
videoEl.muted = true;
videoEl.srcObject = share.stream;
videoContainer.appendChild(videoEl);
videoEl.play().catch(() => {});
// Now that pop-out has the stream, hide main window
setIsPoppedOut(true);
}
} catch (err) {
// Fullscreen may not be available
// For remote screenshare, use LiveKit track's attach method (creates fresh video)
if (!share.isLocal && share.track) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const track = share.track as any;
if (typeof track.attach === 'function') {
// track.attach() creates a brand new video element attached to the track
// Track is now attached to BOTH main window and pop-out
const videoEl = track.attach() as HTMLVideoElement;
videoEl.style.width = '100%';
videoEl.style.height = '100%';
videoEl.style.objectFit = 'contain';
videoEl.style.display = 'block';
videoEl.style.cursor = 'move';
// @ts-expect-error webkit property
videoEl.style.webkitAppRegion = 'drag';
videoContainer.appendChild(videoEl);
// Now that pop-out has the track, hide main window (which will detach from main)
setIsPoppedOut(true);
}
}
}, 100);
// Show preview again when pop-out window is closed
popoutWindow.addEventListener('beforeunload', () => {
// For remote tracks, detach from pop-out
if (!share.isLocal && share.track) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const track = share.track as any;
if (typeof track.detach === 'function') {
const videoContainer = popoutWindow.document.getElementById('videoContainer');
if (videoContainer) {
const videoEl = videoContainer.querySelector('video');
if (videoEl) {
track.detach(videoEl);
}
}
}
}
setIsPoppedOut(false);
});
}, [share.isLocal, share.stream, share.track]);
const handleMouseMoveWrapper = useCallback((e: React.MouseEvent) => {
// Clear any pending hide timeout
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
setShowControls(true);
handleMouseMove(e);
}, [handleMouseMove]);
const handleMouseLeaveWrapper = useCallback((e: React.MouseEvent) => {
// Delay hiding controls to prevent flickering
hideTimeoutRef.current = setTimeout(() => {
setShowControls(false);
}, 300);
handleMouseLeave(e);
}, [handleMouseLeave]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
};
}, []);
return (
<div className={css.ScreenShareContainer} style={{ position: 'relative' }}>
<div
className={css.ScreenShareContainer}
style={{
position: isPoppedOut ? 'absolute' : 'relative',
width: isPoppedOut ? '1px' : undefined,
height: isPoppedOut ? '1px' : undefined,
opacity: isPoppedOut ? 0 : 1,
pointerEvents: isPoppedOut ? 'none' : 'auto',
overflow: isPoppedOut ? 'hidden' : 'visible',
left: isPoppedOut ? '-9999px' : undefined,
}}
>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
ref={containerRef}
ref={videoContainerRef}
className={css.ScreenShareVideoContainer}
onDoubleClick={handleDoubleClick}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onMouseMove={handleMouseMoveWrapper}
onMouseLeave={handleMouseLeaveWrapper}
style={{ cursor: 'pointer' }}
/>
{/* Control buttons overlay */}
{showControls && (
<Box
style={{
position: 'absolute',
top: '12px',
right: '12px',
display: 'flex',
gap: '8px',
zIndex: 10,
pointerEvents: 'auto',
}}
onMouseEnter={() => {
// Keep controls visible when hovering over them
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
setShowControls(true);
}}
onMouseLeave={() => {
// Hide controls after a delay when leaving the buttons
hideTimeoutRef.current = setTimeout(() => {
setShowControls(false);
}, 300);
}}
>
<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>
)}
</TooltipProvider>
</Box>
)}
<RemoteCursorOverlay shareId={share.participantId} roomId={share.roomId} />
</div>
);
@@ -412,6 +712,7 @@ export function CallOverlay() {
// Docking state
const [isDocked, setIsDocked] = useSetting(settingsAtom, 'isCallPanelDocked');
const [, setDockPosition] = useSetting(settingsAtom, 'callPanelDockPosition');
// Pending drag state from undocking
const [pendingDrag, setPendingDrag] = useAtom(pendingUndockDragAtom);
@@ -420,16 +721,23 @@ export function CallOverlay() {
setPendingDrag(null);
}, [setPendingDrag]);
const handleDockRequest = useCallback(() => {
const handleDockRight = useCallback(() => {
setDockPosition('right');
setIsDocked(true);
}, [setIsDocked]);
}, [setIsDocked, setDockPosition]);
const handleDockSidebar = useCallback(() => {
setDockPosition('sidebar');
setIsDocked(true);
}, [setIsDocked, setDockPosition]);
const handleUndockRequest = useCallback(() => {
setIsDocked(false);
}, [setIsDocked]);
const { position, isDragging, isNearDockZone, handleMouseDown, dragRef } = useDraggable(
handleDockRequest,
const { position, isDragging, nearDockZone, handleMouseDown, dragRef } = useDraggable(
handleDockRight,
handleDockSidebar,
handleUndockRequest,
isDocked,
pendingDrag,
@@ -538,6 +846,68 @@ export function CallOverlay() {
}
}, [isFullscreen]);
// Pop-out window handler
const handlePopOut = useCallback(() => {
if (!activeCall) return;
// Get room inside callback to avoid initialization order issues
const callRoom = mx.getRoom(activeCall.roomId);
const popoutWindow = window.open(
'',
'_blank',
'width=1200,height=800,menubar=no,toolbar=no,location=no,status=no'
);
if (popoutWindow && activeCall.remoteStreams.size > 0) {
const [firstStream] = activeCall.remoteStreams.values();
popoutWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>Video Call - ${callRoom?.name ?? 'Call'}</title>
<style>
body {
margin: 0;
padding: 0;
background: #000;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
overflow: hidden;
}
video {
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
object-fit: contain;
}
</style>
</head>
<body>
<video id="remoteVideo" autoplay playsinline></video>
<script>
const video = document.getElementById('remoteVideo');
// Stream will be set from parent window
</script>
</body>
</html>
`);
popoutWindow.document.close();
// Wait for window to load, then set the video stream
setTimeout(() => {
const videoEl = popoutWindow.document.getElementById('remoteVideo');
if (videoEl) {
videoEl.srcObject = firstStream;
}
}, 100);
}
}, [activeCall, mx]);
// PiP toggle handler
const handleTogglePip = useCallback(async () => {
const videoEl = remoteVideoRef.current;
@@ -710,8 +1080,9 @@ export function CallOverlay() {
return (
<>
{/* Dock indicator shown when dragging near the right edge */}
{isNearDockZone && !isDocked && <div className={css.DockIndicator} />}
{/* Dock indicators shown when dragging near dock zones */}
{nearDockZone === 'right' && !isDocked && <div className={css.DockIndicator} />}
{nearDockZone === 'sidebar' && !isDocked && <div className={css.SidebarDockIndicator} />}
<div
ref={setContainerRef}
className={containerClass}
@@ -778,6 +1149,17 @@ export function CallOverlay() {
</Text>
{isVideoCall && (
<>
<TooltipProvider
position="Top"
offset={4}
tooltip={<Tooltip><Text>Pop Out</Text></Tooltip>}
>
{(triggerRef) => (
<IconButton ref={triggerRef} onClick={handlePopOut} variant="Secondary" size="300">
<Icon size="200" src={Icons.External} />
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
position="Top"
offset={4}

View File

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

View File

@@ -22,7 +22,15 @@ import {
LiveKitJWTResponse,
CallMemberEventContent,
} 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 { getCallSounds, CallSoundType } from './CallSounds';
@@ -174,6 +182,25 @@ export class CallService {
): Promise<void> {
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) {
const content: CallMemberEventContent = {
'm.calls': [
@@ -293,39 +320,59 @@ export class CallService {
});
try {
// Step 1: Get an OpenID token from the Matrix homeserver
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
// Step 1: Get room and determine which homeservers to try
const room = this.matrixClient.getRoom(roomId);
if (room) {
const livekitConfigEvent = room.currentState.getStateEvents('im.paarrot.room.livekit_config', '');
if (livekitConfigEvent) {
const roomLivekitUrl = livekitConfigEvent.getContent()?.service_url;
if (roomLivekitUrl) {
console.log('Using room-level LiveKit URL:', roomLivekitUrl);
livekitServiceUrl = roomLivekitUrl;
}
}
if (!room) {
throw new Error(`Room ${roomId} not found`);
}
// Step 3: Exchange OpenID token for LiveKit JWT
console.log('Requesting LiveKit JWT for Matrix room:', roomId);
this.livekitJwt = await fetchLiveKitJWT(
livekitServiceUrl,
// Check if there's already an active call
const activeCallMembers = getActiveCallMembers(room);
const isJoiningExistingCall = activeCallMembers.length > 0;
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,
this.config.userId,
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('LiveKit JWT response:', JSON.stringify(this.livekitJwt));
@@ -934,6 +981,22 @@ export class CallService {
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
* @returns true if screen share started successfully

View File

@@ -1,11 +1,4 @@
/* eslint-disable no-console */
import MicMuteSound from '../../../../public/sound/Mic_Mute.ogg';
import MicUnmuteSound from '../../../../public/sound/Mic_Unmuted.ogg';
import SpeakersDeafenSound from '../../../../public/sound/Speakers_Deafen.ogg';
import SpeakersUndeafenSound from '../../../../public/sound/Speakers_Undeafen.ogg';
import CallJoinedSound from '../../../../public/sound/Call_Joined.ogg';
import CallDepartSound from '../../../../public/sound/Call_Depart.ogg';
import CallDialingSound from '../../../../public/sound/Call_Dialing.ogg';
/**
* Sound types that can be played during a call
@@ -20,19 +13,30 @@ export enum CallSoundType {
CallDialing = 'call_dialing',
}
/**
* Maps sound types to their audio sources
*/
const SOUND_SOURCES: Record<CallSoundType, string> = {
[CallSoundType.MicMute]: MicMuteSound,
[CallSoundType.MicUnmute]: MicUnmuteSound,
[CallSoundType.Deafen]: SpeakersDeafenSound,
[CallSoundType.Undeafen]: SpeakersUndeafenSound,
[CallSoundType.CallJoined]: CallJoinedSound,
[CallSoundType.CallDepart]: CallDepartSound,
[CallSoundType.CallDialing]: CallDialingSound,
const SOUND_FILES: Record<CallSoundType, string> = {
[CallSoundType.MicMute]: 'Mic_Mute.ogg',
[CallSoundType.MicUnmute]: 'Mic_Unmuted.ogg',
[CallSoundType.Deafen]: 'Speakers_Deafen.ogg',
[CallSoundType.Undeafen]: 'Speakers_Undeafen.ogg',
[CallSoundType.CallJoined]: 'Call_Joined.ogg',
[CallSoundType.CallDepart]: 'Call_Depart.ogg',
[CallSoundType.CallDialing]: 'Call_Dialing.ogg',
};
/**
* Resolves the base URL for sounds.
* In Electron production, sounds are in resources/sound/ as plain files.
* In dev or browser, sounds are served from public/sound/.
*/
async function resolveSoundBaseUrl(): Promise<string> {
const electronAudio = (window as any).electron?.audio;
if (electronAudio?.getSoundBaseUrl) {
const base: string | null = await electronAudio.getSoundBaseUrl();
if (base) return base;
}
return './sound';
}
/**
* Manages call-related sound effects.
* Handles playing local sounds and coordinating with remote participants.
@@ -45,15 +49,15 @@ export class CallSounds {
private isDeafened = false;
constructor() {
this.preloadSounds();
resolveSoundBaseUrl().then((base) => this.preloadSounds(base));
}
/**
* Preloads all call sounds for instant playback
*/
private preloadSounds(): void {
Object.entries(SOUND_SOURCES).forEach(([type, src]) => {
const audio = new Audio(src);
private preloadSounds(base: string): void {
Object.entries(SOUND_FILES).forEach(([type, file]) => {
const audio = new Audio(`${base}/${file}`);
audio.preload = 'auto';
this.audioElements.set(type as CallSoundType, audio);
});

View File

@@ -1,17 +1,17 @@
import React from 'react';
import { Box, Line } from 'folds';
import { useIsCallDocked } from './useDockedCall';
import { useIsCallDockedRight } from './useDockedCall';
import { DockedCallContent } from './DockedCallContent';
import * as css from './CallOverlay.css';
/**
* Component that renders the docked call panel in the layout flow
* Only renders when there's an active call AND docking is enabled
* Component that renders the docked call panel in the layout flow (right side)
* Only renders when there's an active call AND docking is enabled for right position
*/
export function DockedCallPanel() {
const isCallDocked = useIsCallDocked();
const isCallDockedRight = useIsCallDockedRight();
if (!isCallDocked) {
if (!isCallDockedRight) {
return null;
}

View File

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

View File

@@ -0,0 +1,299 @@
import React, { useEffect, useState } from 'react';
import { Box, Icon, IconButton, Icons, Scroll, Text, Tooltip, TooltipProvider, color } from 'folds';
import { useSetAtom } from 'jotai';
import { useCall } from './useCall';
import { useRoomCallMembers } from './useRoomCallMembers';
import { CallState } from './types';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { pendingUndockDragAtom } from './pendingDragState';
import { useIsCallDockedSidebar } from './useDockedCall';
import { getMemberDisplayName, getMemberAvatarMxc } from '../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { UserAvatar } from '../../components/user-avatar';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import * as css from './CallOverlay.css';
/**
* Formats call duration in MM:SS format
*/
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
const secsStr = secs < 10 ? `0${secs}` : `${secs}`;
return `${mins}:${secsStr}`;
}
/**
* Sidebar docked call panel - renders at bottom of room list
*/
export function SidebarDockedCallPanel() {
const isCallDockedSidebar = useIsCallDockedSidebar();
const { activeCall, endCall, toggleMute, toggleDeafen } = useCall();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const selectedRoomId = useSelectedRoom();
const { navigateRoom } = useRoomNavigate();
const [duration, setDuration] = useState(0);
const [showParticipants, setShowParticipants] = useState(true);
const [, setIsDocked] = useSetting(settingsAtom, 'isCallPanelDocked');
const setPendingDrag = useSetAtom(pendingUndockDragAtom);
const { callMembers } = useRoomCallMembers(activeCall?.roomId ?? '');
const myUserId = mx.getUserId() ?? '';
useEffect(() => {
if (!activeCall || activeCall.state !== CallState.Connected) {
setDuration(0);
return undefined;
}
const { startTime } = activeCall;
const updateDuration = () => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
setDuration(elapsed);
};
updateDuration();
const interval = setInterval(updateDuration, 1000);
return () => clearInterval(interval);
}, [activeCall]);
/**
* Checks if a participant is currently speaking
*/
const isParticipantSpeaking = (userId: string): boolean => {
if (!activeCall) return false;
if (activeCall.activeSpeakers.has(userId)) return true;
const member = callMembers.find((m) => m.userId === userId);
if (!member) return false;
return Array.from(activeCall.activeSpeakers).some((speakerId) => {
const underscoreIndex = speakerId.lastIndexOf('_');
const deviceId = underscoreIndex > 0 ? speakerId.substring(0, underscoreIndex) : speakerId;
return deviceId === member.deviceId;
});
};
/**
* Checks if a participant is currently muted
*/
const isParticipantMuted = (userId: string): boolean => {
if (!activeCall) return false;
if (userId === myUserId) return activeCall.isMuted;
const member = callMembers.find((m) => m.userId === userId);
if (!member) return false;
return Array.from(activeCall.mutedParticipants).some((mutedId) => {
const underscoreIndex = mutedId.lastIndexOf('_');
const deviceId = underscoreIndex > 0 ? mutedId.substring(0, underscoreIndex) : mutedId;
return deviceId === member.deviceId;
});
};
/**
* Handle mousedown on header to start drag-to-undock
*/
const handleHeaderMouseDown = async (e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('button')) return;
if (document.fullscreenElement) {
try {
await document.exitFullscreen();
} catch {
// Ignore
}
}
const header = e.currentTarget as HTMLElement;
const rect = header.getBoundingClientRect();
setPendingDrag({
startX: e.clientX,
startY: e.clientY,
offsetX: e.clientX - rect.left,
offsetY: e.clientY - rect.top,
timestamp: Date.now(),
});
setIsDocked(false);
};
if (!isCallDockedSidebar || !activeCall || activeCall.state === CallState.Idle || activeCall.state === CallState.Ended) {
return null;
}
const isConnecting = activeCall.state === CallState.Connecting;
const room = mx.getRoom(activeCall.roomId);
const allParticipants = callMembers.map((m) => m.userId);
const participantCount = allParticipants.length;
return (
<div className={css.SidebarDockedCallContainer}>
{/* Header - drag to undock */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className={css.SidebarDockedCallHeader}
onMouseDown={handleHeaderMouseDown}
>
<Box alignItems="Center" gap="200" style={{ minWidth: 0, flex: 1 }}>
<Icon size="100" src={Icons.Phone} />
<Text size="T300" truncate>
{isConnecting ? 'Connecting...' : room?.name ?? 'Call'}
</Text>
{selectedRoomId === activeCall.roomId && (
<Icon size="100" src={Icons.Check} style={{ opacity: 0.7 }} />
)}
</Box>
<Box alignItems="Center" gap="100">
<Text size="T200" style={{ opacity: 0.7 }}>
{formatDuration(duration)}
</Text>
{selectedRoomId !== activeCall.roomId && (
<TooltipProvider
position="Top"
offset={4}
tooltip={<Tooltip><Text>Go to Room</Text></Tooltip>}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={() => navigateRoom(activeCall.roomId)}
variant="Secondary"
size="300"
>
<Icon size="100" src={Icons.Link} />
</IconButton>
)}
</TooltipProvider>
)}
</Box>
</div>
{/* Participants */}
<div className={css.ParticipantsSection}>
<button
type="button"
className={css.ParticipantsHeader}
onClick={() => setShowParticipants(!showParticipants)}
>
<Icon size="100" src={Icons.User} style={{ opacity: 0.7 }} />
<Text size="T200" className={css.ParticipantsHeaderText}>
{participantCount} Participant{participantCount !== 1 ? 's' : ''}
</Text>
<Icon
size="100"
src={showParticipants ? Icons.ChevronTop : Icons.ChevronBottom}
style={{ opacity: 0.7 }}
/>
</button>
{showParticipants && room && (
<Scroll hideTrack visibility="Hover" style={{ maxHeight: '150px' }}>
<div className={css.ParticipantsList}>
{allParticipants.map((participantId) => {
const avatarMxc = getMemberAvatarMxc(room, participantId);
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 32, 32, 'crop') ?? undefined
: undefined;
const displayName = getMemberDisplayName(room, participantId)
?? getMxIdLocalPart(participantId)
?? participantId;
const isMe = participantId === myUserId;
const isMuted = isParticipantMuted(participantId);
const isSpeaking = isParticipantSpeaking(participantId);
return (
<div key={participantId} className={css.ParticipantItem}>
<div
className={`${css.ParticipantAvatarWrapper} ${isSpeaking ? css.ParticipantSpeaking : ''}`}
>
<UserAvatar
className={css.ParticipantAvatar}
userId={participantId}
src={avatarUrl}
alt={displayName}
renderFallback={() => (
<Text size="H6" style={{ color: color.Surface.Container }}>
{displayName.charAt(0).toUpperCase()}
</Text>
)}
/>
</div>
<Box direction="Column" style={{ minWidth: 0, flex: 1 }}>
<Text size="T200" truncate>
{displayName}
{isMe && <span className={css.YouBadge}> (You)</span>}
</Text>
</Box>
{isMuted && (
<Icon size="100" src={Icons.MicMute} style={{ opacity: 0.5 }} />
)}
</div>
);
})}
</div>
</Scroll>
)}
</div>
{/* Controls */}
<div className={css.SidebarDockedCallControls}>
<TooltipProvider
position="Top"
offset={4}
tooltip={<Tooltip><Text>{activeCall.isMuted ? 'Unmute' : 'Mute'}</Text></Tooltip>}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={toggleMute}
variant={activeCall.isMuted ? 'Critical' : 'Secondary'}
size="300"
>
<Icon size="200" src={activeCall.isMuted ? Icons.MicMute : Icons.Mic} />
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
position="Top"
offset={4}
tooltip={<Tooltip><Text>{activeCall.isDeafened ? 'Undeafen' : 'Deafen'}</Text></Tooltip>}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={toggleDeafen}
variant={activeCall.isDeafened ? 'Critical' : 'Secondary'}
size="300"
>
<Icon size="200" src={activeCall.isDeafened ? Icons.VolumeMute : Icons.VolumeHigh} />
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
position="Top"
offset={4}
tooltip={<Tooltip><Text>End Call</Text></Tooltip>}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={endCall}
variant="Critical"
size="300"
>
<Icon size="200" src={Icons.Phone} />
</IconButton>
)}
</TooltipProvider>
</div>
</div>
);
}

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