Compare commits
36 Commits
6853e04db0
...
carousel-s
| Author | SHA1 | Date | |
|---|---|---|---|
| a3caafaf20 | |||
| b9b0dce40b | |||
| b2fd65c8cb | |||
| 7ef9939d8a | |||
| 43bd6320b8 | |||
| aaf8089ba6 | |||
| 38a43a3a99 | |||
| 4fdedb87c8 | |||
| 5d4d518af8 | |||
| cad4852878 | |||
| e6af56a417 | |||
| ffec244113 | |||
| 52abffb4c9 | |||
| ef0aa2a847 | |||
| 7b69ed6df8 | |||
| 35ccdc0a22 | |||
| 643608f25d | |||
| 17f7a7eaf2 | |||
| 854f365715 | |||
| 114435a56d | |||
| e5e296ca98 | |||
| ec6fe77567 | |||
| d4cb0b7435 | |||
| 64e252da94 | |||
| 9a463facce | |||
| 7c824392b7 | |||
| e5f74ec13e | |||
| 8060d50a6f | |||
| 13a0c98d1d | |||
| 9161c4bde2 | |||
| a3dac17b7c | |||
| 5bc3a1a456 | |||
| 878e1561ea | |||
| 1ecb70135a | |||
| 9930a4bb99 | |||
| 572743f16d |
@@ -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
BIN
build_output.txt
Normal file
Binary file not shown.
12
capacitor.config.json
Normal file
12
capacitor.config.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"appId": "com.paarrot.app",
|
||||||
|
"appName": "Paarrot",
|
||||||
|
"webDir": "dist",
|
||||||
|
"bundledWebRuntime": false,
|
||||||
|
"plugins": {
|
||||||
|
"LocalNotifications": {
|
||||||
|
"smallIcon": "ic_stat_paarrot",
|
||||||
|
"iconColor": "#FF8A00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@
|
|||||||
],
|
],
|
||||||
"allowCustomHomeservers": true,
|
"allowCustomHomeservers": true,
|
||||||
|
|
||||||
|
"calling": {
|
||||||
|
"livekitServiceUrl": "https://b.ruv.wtf/matrix-rtc/livekit/jwt"
|
||||||
|
},
|
||||||
|
|
||||||
"featuredCommunities": {
|
"featuredCommunities": {
|
||||||
"openAsDefault": false,
|
"openAsDefault": false,
|
||||||
"spaces": [
|
"spaces": [
|
||||||
|
|||||||
172
docs/API-QUICKSTART.md
Normal file
172
docs/API-QUICKSTART.md
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
# Paarrot API Quick Start
|
||||||
|
|
||||||
|
A local HTTP API server for controlling Paarrot from external devices like Stream Deck, scripts, and automation tools.
|
||||||
|
|
||||||
|
## 🚀 Getting Started
|
||||||
|
|
||||||
|
The API server starts automatically when you run Paarrot. It listens on `http://127.0.0.1:33384`.
|
||||||
|
|
||||||
|
### Test the API
|
||||||
|
|
||||||
|
**Option 1: Using Postman**
|
||||||
|
|
||||||
|
Import the Postman collection for easy testing:
|
||||||
|
1. Open Postman
|
||||||
|
2. Click **Import** → **File**
|
||||||
|
3. Select `paarrot-api.postman_collection.json`
|
||||||
|
4. All endpoints will be ready to use!
|
||||||
|
|
||||||
|
The collection is automatically updated on git push.
|
||||||
|
|
||||||
|
**Option 2: Using the test script**
|
||||||
|
```bash
|
||||||
|
node test-api.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 3: Using curl**
|
||||||
|
```bash
|
||||||
|
./test-api.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 4: Manual curl commands**
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://127.0.0.1:33384/health
|
||||||
|
|
||||||
|
# Toggle mute
|
||||||
|
curl -X POST http://127.0.0.1:33384/mute/toggle
|
||||||
|
|
||||||
|
# Send message
|
||||||
|
curl -X POST http://127.0.0.1:33384/message/current \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"message": "Hello from API!"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Quick Reference
|
||||||
|
|
||||||
|
### Common Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/health` | GET | Check if API is running |
|
||||||
|
| `/status` | GET | Get app status (mute, deafen, etc.) |
|
||||||
|
| `/mute/toggle` | POST | Toggle microphone mute |
|
||||||
|
| `/deafen/toggle` | POST | Toggle deafen |
|
||||||
|
| `/channels` | GET | Get list of rooms |
|
||||||
|
| `/channel` | POST | Switch to a room |
|
||||||
|
| `/message/current` | POST | Send message to current room |
|
||||||
|
|
||||||
|
## 🎮 Stream Deck Integration
|
||||||
|
|
||||||
|
1. Install the "API Ninja" or "HTTP Request" plugin
|
||||||
|
2. Create buttons with these settings:
|
||||||
|
|
||||||
|
**Mute Toggle Button:**
|
||||||
|
- URL: `http://127.0.0.1:33384/mute/toggle`
|
||||||
|
- Method: POST
|
||||||
|
|
||||||
|
**Quick Message Button:**
|
||||||
|
- URL: `http://127.0.0.1:33384/message/current`
|
||||||
|
- Method: POST
|
||||||
|
- Body: `{"message": "BRB!"}`
|
||||||
|
|
||||||
|
## 🔧 Integration with Paarrot
|
||||||
|
|
||||||
|
To enable full functionality, you need to implement the action handlers in the Cinny frontend:
|
||||||
|
|
||||||
|
1. Import the API handler in your app initialization:
|
||||||
|
```typescript
|
||||||
|
import { initPaarrotAPI } from './app/paarrot-api';
|
||||||
|
|
||||||
|
// After Matrix client is initialized
|
||||||
|
initPaarrotAPI(matrixClient);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Implement the TODO items in `cinny/src/app/paarrot-api.ts`:
|
||||||
|
- Mute/unmute logic (WebRTC audio)
|
||||||
|
- Deafen logic (WebRTC audio output)
|
||||||
|
- Navigation to rooms (router integration)
|
||||||
|
- Get current room from URL/state
|
||||||
|
|
||||||
|
## 📖 Full Documentation
|
||||||
|
|
||||||
|
See [API.md](API.md) for complete API documentation including:
|
||||||
|
- All available endpoints
|
||||||
|
- Request/response formats
|
||||||
|
- Error handling
|
||||||
|
- Code examples in multiple languages
|
||||||
|
- Detailed integration guide
|
||||||
|
|
||||||
|
## 🛠️ Configuration
|
||||||
|
|
||||||
|
Edit `electron/api-server.js` to customize:
|
||||||
|
- Port number (default: 33384)
|
||||||
|
- Timeout duration (default: 10s)
|
||||||
|
- CORS settings
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
**API not responding:**
|
||||||
|
1. Check if Paarrot is running
|
||||||
|
2. Look for "Paarrot API server listening" in console logs
|
||||||
|
3. Verify port 33384 is not in use: `lsof -i :33384`
|
||||||
|
|
||||||
|
**Actions not working:**
|
||||||
|
1. Check browser console for errors
|
||||||
|
2. Ensure `initPaarrotAPI()` is called in your app
|
||||||
|
3. Implement the TODO items in `paarrot-api.ts`
|
||||||
|
|
||||||
|
**Port in use:**
|
||||||
|
Edit `electron/api-server.js` and change the port number in the constructor.
|
||||||
|
|
||||||
|
## 📦 Files
|
||||||
|
|
||||||
|
- `electron/api-server.js` - API server implementation
|
||||||
|
- `cinny/src/app/paarrot-api.ts` - Client-side handler (needs implementation)
|
||||||
|
- `test-api.js` - Node.js test script
|
||||||
|
- `test-api.sh` - Bash test script
|
||||||
|
- `API.md` - Full documentation
|
||||||
|
|
||||||
|
## 🔐 Security
|
||||||
|
|
||||||
|
- API only listens on localhost (127.0.0.1)
|
||||||
|
- Not accessible from network
|
||||||
|
- No authentication required (local only)
|
||||||
|
- CORS enabled for all origins (safe since localhost only)
|
||||||
|
|
||||||
|
## 💡 Examples
|
||||||
|
|
||||||
|
**Python Script:**
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
def toggle_mute():
|
||||||
|
r = requests.post('http://127.0.0.1:33384/mute/toggle')
|
||||||
|
print(r.json())
|
||||||
|
|
||||||
|
toggle_mute()
|
||||||
|
```
|
||||||
|
|
||||||
|
**JavaScript:**
|
||||||
|
```javascript
|
||||||
|
async function sendQuickMessage(msg) {
|
||||||
|
const res = await fetch('http://127.0.0.1:33384/message/current', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message: msg })
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Next Steps
|
||||||
|
|
||||||
|
1. Run Paarrot: `npm run dev`
|
||||||
|
2. Test the API: `node test-api.js`
|
||||||
|
3. Integrate the handler: Import and call `initPaarrotAPI()`
|
||||||
|
4. Implement the TODO items in `paarrot-api.ts`
|
||||||
|
5. Create Stream Deck buttons or automation scripts!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
For more details, see [API.md](API.md)
|
||||||
537
docs/API.md
Normal file
537
docs/API.md
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
# Paarrot API Server Documentation
|
||||||
|
|
||||||
|
The Paarrot API server provides a local HTTP API for controlling the Paarrot desktop app from external applications like Stream Deck, keyboard macros, scripts, and other automation tools.
|
||||||
|
|
||||||
|
## Server Details
|
||||||
|
|
||||||
|
- **Base URL**: `http://127.0.0.1:33384`
|
||||||
|
- **Protocol**: HTTP
|
||||||
|
- **Content-Type**: `application/json`
|
||||||
|
- **CORS**: Enabled for all origins
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Currently, no authentication is required. The API server only listens on localhost (127.0.0.1) for security.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
Check if the API server is running.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /health`
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"app": "Paarrot API",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Status
|
||||||
|
|
||||||
|
Get the current status of the app (mute, deafen, current room, etc.).
|
||||||
|
|
||||||
|
**Endpoint**: `GET /status`
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"muted": false,
|
||||||
|
"deafened": false,
|
||||||
|
"currentRoom": "!abc123:matrix.org",
|
||||||
|
"connected": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Mute/Unmute
|
||||||
|
|
||||||
|
Set the mute status.
|
||||||
|
|
||||||
|
**Endpoint**: `POST /mute`
|
||||||
|
|
||||||
|
**Request Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"muted": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"muted": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Toggle Mute
|
||||||
|
|
||||||
|
Toggle the current mute status.
|
||||||
|
|
||||||
|
**Endpoint**: `POST /mute/toggle`
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"muted": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Deafen/Undeafen
|
||||||
|
|
||||||
|
Set the deafen status (deafening also mutes).
|
||||||
|
|
||||||
|
**Endpoint**: `POST /deafen`
|
||||||
|
|
||||||
|
**Request Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"deafened": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"deafened": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Toggle Deafen
|
||||||
|
|
||||||
|
Toggle the current deafen status.
|
||||||
|
|
||||||
|
**Endpoint**: `POST /deafen/toggle`
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"deafened": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Change Channel/Room
|
||||||
|
|
||||||
|
Switch to a different Matrix room.
|
||||||
|
|
||||||
|
**Endpoint**: `POST /channel`
|
||||||
|
|
||||||
|
**Request Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"roomId": "!abc123:matrix.org"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"roomId": "!abc123:matrix.org",
|
||||||
|
"roomName": "General"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Channels/Rooms
|
||||||
|
|
||||||
|
Get a list of available rooms/channels.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /channels`
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"roomId": "!abc123:matrix.org",
|
||||||
|
"name": "General",
|
||||||
|
"server": "matrix.org",
|
||||||
|
"isDirect": false,
|
||||||
|
"avatar": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "!def456:matrix.org",
|
||||||
|
"name": "Random",
|
||||||
|
"server": "matrix.org",
|
||||||
|
"isDirect": false,
|
||||||
|
"avatar": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "!xyz789:example.com",
|
||||||
|
"name": "General",
|
||||||
|
"server": "example.com",
|
||||||
|
"isDirect": false,
|
||||||
|
"avatar": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: The `server` field helps distinguish between rooms with the same name on different servers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Send Message
|
||||||
|
|
||||||
|
Send a message to a specific room.
|
||||||
|
|
||||||
|
**Endpoint**: `POST /message`
|
||||||
|
|
||||||
|
**Request Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"roomId": "!abc123:matrix.org",
|
||||||
|
"message": "Hello from the API!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"eventId": "$abc123",
|
||||||
|
"roomId": "!abc123:matrix.org"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Send Message to Current Room
|
||||||
|
|
||||||
|
Send a message to the currently active room.
|
||||||
|
|
||||||
|
**Endpoint**: `POST /message/current`
|
||||||
|
|
||||||
|
**Request Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Hello from the API!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"eventId": "$abc123",
|
||||||
|
"roomId": "!abc123:matrix.org"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Current Room
|
||||||
|
|
||||||
|
Get information about the currently active room.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /room/current`
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"roomId": "!abc123:matrix.org",
|
||||||
|
"name": "General",
|
||||||
|
"server": "matrix.org",
|
||||||
|
"avatar": "mxc://matrix.org/abc123",
|
||||||
|
"isDirect": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
All endpoints return error responses in the following format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": "Error message here"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Common HTTP status codes:
|
||||||
|
- `200 OK` - Request successful
|
||||||
|
- `400 Bad Request` - Missing or invalid parameters
|
||||||
|
- `404 Not Found` - Endpoint not found
|
||||||
|
- `500 Internal Server Error` - Server error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example Usage
|
||||||
|
|
||||||
|
### cURL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Toggle mute
|
||||||
|
curl -X POST http://127.0.0.1:33384/mute/toggle
|
||||||
|
|
||||||
|
# Send message to current room
|
||||||
|
curl -X POST http://127.0.0.1:33384/message/current \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"message": "Hello!"}'
|
||||||
|
|
||||||
|
# Change channel
|
||||||
|
curl -X POST http://127.0.0.1:33384/channel \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"roomId": "!abc123:matrix.org"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript/Node.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const fetch = require('node-fetch');
|
||||||
|
|
||||||
|
// Toggle mute
|
||||||
|
async function toggleMute() {
|
||||||
|
const response = await fetch('http://127.0.0.1:33384/mute/toggle', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Muted:', data.data.muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send message
|
||||||
|
async function sendMessage(roomId, message) {
|
||||||
|
const response = await fetch('http://127.0.0.1:33384/message', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ roomId, message })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Message sent:', data.data.eventId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Toggle mute
|
||||||
|
def toggle_mute():
|
||||||
|
response = requests.post('http://127.0.0.1:33384/mute/toggle')
|
||||||
|
data = response.json()
|
||||||
|
print('Muted:', data['data']['muted'])
|
||||||
|
|
||||||
|
# Send message
|
||||||
|
def send_message(room_id, message):
|
||||||
|
response = requests.post('http://127.0.0.1:33384/message', json={
|
||||||
|
'roomId': room_id,
|
||||||
|
'message': message
|
||||||
|
})
|
||||||
|
data = response.json()
|
||||||
|
print('Message sent:', data['data']['eventId'])
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stream Deck Integration
|
||||||
|
|
||||||
|
To integrate with Stream Deck:
|
||||||
|
|
||||||
|
1. Install the "API Ninja" or "HTTP Request" plugin for Stream Deck
|
||||||
|
2. Create a button with the following configuration:
|
||||||
|
- **URL**: `http://127.0.0.1:33384/mute/toggle`
|
||||||
|
- **Method**: POST
|
||||||
|
- **Headers**: `Content-Type: application/json`
|
||||||
|
|
||||||
|
Example configurations:
|
||||||
|
|
||||||
|
**Mute Toggle Button**:
|
||||||
|
- URL: `http://127.0.0.1:33384/mute/toggle`
|
||||||
|
- Method: POST
|
||||||
|
|
||||||
|
**Deafen Toggle Button**:
|
||||||
|
- URL: `http://127.0.0.1:33384/deafen/toggle`
|
||||||
|
- Method: POST
|
||||||
|
|
||||||
|
**Send Quick Message**:
|
||||||
|
- URL: `http://127.0.0.1:33384/message/current`
|
||||||
|
- Method: POST
|
||||||
|
- Body: `{"message": "BRB!"}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client-Side Implementation
|
||||||
|
|
||||||
|
To enable the API functionality in the Paarrot app, you need to implement the action handlers in your Matrix client code. The API server sends actions via IPC to the renderer process, which should handle them and respond.
|
||||||
|
|
||||||
|
### Example Handler (TypeScript/React)
|
||||||
|
|
||||||
|
Add this to your app initialization:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Listen for API actions from the Electron API server
|
||||||
|
if (window.electron?.api?.onAction) {
|
||||||
|
window.electron.api.onAction(async (action: {
|
||||||
|
action: string;
|
||||||
|
params: any;
|
||||||
|
responseChannel: string;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
let result;
|
||||||
|
|
||||||
|
switch (action.action) {
|
||||||
|
case 'toggle-mute':
|
||||||
|
// Toggle microphone mute
|
||||||
|
result = await toggleMicrophone();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'set-mute':
|
||||||
|
// Set microphone mute state
|
||||||
|
result = await setMicrophone(action.params.muted);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'toggle-deafen':
|
||||||
|
// Toggle deafen (mute speakers)
|
||||||
|
result = await toggleDeafen();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'set-deafen':
|
||||||
|
// Set deafen state
|
||||||
|
result = await setDeafen(action.params.deafened);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'change-channel':
|
||||||
|
// Navigate to a different room
|
||||||
|
result = await changeRoom(action.params.roomId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'get-channels':
|
||||||
|
// Get list of rooms
|
||||||
|
result = await getRoomList();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'send-message':
|
||||||
|
// Send message to specific room
|
||||||
|
result = await sendMessage(action.params.roomId, action.params.message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'send-message-current':
|
||||||
|
// Send message to current room
|
||||||
|
result = await sendMessageToCurrent(action.params.message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'get-current-room':
|
||||||
|
// Get current room info
|
||||||
|
result = await getCurrentRoomInfo();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'get-status':
|
||||||
|
// Get current app status
|
||||||
|
result = await getAppStatus();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown action: ${action.action}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send success response back
|
||||||
|
window.electron.api.sendResponse(action.responseChannel, {
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Send error response back
|
||||||
|
window.electron.api.sendResponse(action.responseChannel, {
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The API server can be configured by modifying `electron/api-server.js`:
|
||||||
|
|
||||||
|
- **Default Port**: 33384 (can be changed in the constructor)
|
||||||
|
- **Bind Address**: 127.0.0.1 (localhost only for security)
|
||||||
|
- **Timeout**: 10 seconds for actions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
1. The API server only listens on localhost (127.0.0.1) and is not accessible from the network
|
||||||
|
2. No authentication is implemented - anyone with local access can control the app
|
||||||
|
3. Consider adding API key authentication if needed for your use case
|
||||||
|
4. CORS is enabled for all origins since it's localhost only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**API server not starting**:
|
||||||
|
- Check if port 33384 is already in use
|
||||||
|
- Check console logs for errors
|
||||||
|
- Verify Express is installed: `npm install express cors body-parser`
|
||||||
|
|
||||||
|
**Actions not working**:
|
||||||
|
- Ensure the client-side handler is implemented
|
||||||
|
- Check browser console for errors
|
||||||
|
- Verify the action names match between API server and client
|
||||||
|
|
||||||
|
**Timeout errors**:
|
||||||
|
- Actions must respond within 10 seconds
|
||||||
|
- Check if the Matrix client is initialized
|
||||||
|
- Verify the action handler is registered
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential features to add:
|
||||||
|
- WebSocket support for real-time events
|
||||||
|
- Authentication with API keys
|
||||||
|
- Voice channel controls (join/leave)
|
||||||
|
- User status updates
|
||||||
|
- Notification controls
|
||||||
|
- Screen sharing controls
|
||||||
157
docs/PLUGINS.md
Normal file
157
docs/PLUGINS.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# Paarrot Plugin System
|
||||||
|
|
||||||
|
The Paarrot desktop client includes a powerful plugin system that allows you to extend and customize your Matrix experience.
|
||||||
|
|
||||||
|
## What are Plugins?
|
||||||
|
|
||||||
|
Plugins are small JavaScript modules that run inside Paarrot and can:
|
||||||
|
- Add custom commands
|
||||||
|
- Modify the UI
|
||||||
|
- React to Matrix events
|
||||||
|
- Add new settings sections
|
||||||
|
- Enhance functionality
|
||||||
|
|
||||||
|
## Installing Plugins
|
||||||
|
|
||||||
|
### From the Plugin Marketplace
|
||||||
|
|
||||||
|
1. Open **Settings** → **Plugins**
|
||||||
|
2. Browse the **Marketplace** tab
|
||||||
|
3. Click **Install** on any plugin you want to add
|
||||||
|
4. The plugin will be downloaded and enabled automatically
|
||||||
|
|
||||||
|
### Manual Installation
|
||||||
|
|
||||||
|
1. Download a plugin ZIP file
|
||||||
|
2. Extract it to your plugins directory:
|
||||||
|
- **Windows**: `%APPDATA%\paarrot\plugins\`
|
||||||
|
- **Linux**: `~/.config/Paarrot/plugins/`
|
||||||
|
- **macOS**: `~/Library/Application Support/Paarrot/plugins/`
|
||||||
|
3. Restart Paarrot
|
||||||
|
4. Enable the plugin in **Settings** → **Plugins** → **Installed**
|
||||||
|
|
||||||
|
## Managing Plugins
|
||||||
|
|
||||||
|
### Viewing Installed Plugins
|
||||||
|
|
||||||
|
Go to **Settings** → **Plugins** → **Installed** to see all installed plugins.
|
||||||
|
|
||||||
|
### Enabling/Disabling Plugins
|
||||||
|
|
||||||
|
Click the toggle switch next to any plugin to enable or disable it. Disabled plugins won't run or use resources.
|
||||||
|
|
||||||
|
### Uninstalling Plugins
|
||||||
|
|
||||||
|
Click the **Uninstall** button next to any plugin to remove it completely.
|
||||||
|
|
||||||
|
### Searching Plugins
|
||||||
|
|
||||||
|
Use the search bar to filter plugins by name, description, author, or tags.
|
||||||
|
|
||||||
|
## Plugin Security
|
||||||
|
|
||||||
|
⚠️ **Important Security Information**
|
||||||
|
|
||||||
|
Plugins run with access to your Matrix client and can:
|
||||||
|
- Read your messages
|
||||||
|
- Send messages on your behalf
|
||||||
|
- Access your room list
|
||||||
|
- Modify app behavior
|
||||||
|
|
||||||
|
**Only install plugins from trusted sources!**
|
||||||
|
|
||||||
|
### Safety Tips
|
||||||
|
|
||||||
|
1. **Check the author**: Install plugins from known developers
|
||||||
|
2. **Read reviews**: Look for community feedback
|
||||||
|
3. **Review permissions**: Understand what the plugin can do
|
||||||
|
4. **Keep updated**: Update plugins regularly for security fixes
|
||||||
|
5. **Report issues**: If a plugin behaves suspiciously, report it immediately
|
||||||
|
|
||||||
|
## Developing Plugins
|
||||||
|
|
||||||
|
Want to create your own plugin? Check out the [Plugin API Reference](docs/PLUGIN_API.md).
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
1. Create a plugin directory with:
|
||||||
|
- `index.js` - Your plugin code
|
||||||
|
- `plugin-metadata.json` - Plugin information
|
||||||
|
|
||||||
|
2. Implement the plugin interface:
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
onLoad: async (context) => {
|
||||||
|
// Your plugin initialization
|
||||||
|
console.log('Plugin loaded!');
|
||||||
|
},
|
||||||
|
onUnload: async () => {
|
||||||
|
// Cleanup when disabled
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Test locally by copying to your plugins directory
|
||||||
|
|
||||||
|
4. Publish to the [Plugin Directory](https://github.com/Paarrot/Plugin-Directory)
|
||||||
|
|
||||||
|
## Plugin API
|
||||||
|
|
||||||
|
Plugins have access to:
|
||||||
|
- **Matrix Client**: Full matrix-js-sdk client instance
|
||||||
|
- **React**: For building UI components
|
||||||
|
- **Commands**: Register custom slash commands
|
||||||
|
- **UI Buttons**: Inject buttons into 11 locations across the app (nav lists, toolbars, headers, sidebar)
|
||||||
|
- **UI Renderers**: Custom message and content renderers
|
||||||
|
- **Settings**: Add custom settings sections per plugin
|
||||||
|
- **Themes**: Register custom themes that appear in the theme selector
|
||||||
|
- **Matrix Events**: Hook into raw Matrix events
|
||||||
|
- **Timers**: Background intervals and timeouts, auto-cleaned on unload
|
||||||
|
- **Notifications**: System notifications
|
||||||
|
|
||||||
|
See the [Plugin API Reference](docs/PLUGIN_API.md) and [Button Registration API](docs/PLUGIN_BUTTON_API.md) for complete documentation.
|
||||||
|
|
||||||
|
## Example Plugins
|
||||||
|
|
||||||
|
Check out the `example-plugin/` directory for a simple example, or the `plugins/example-showcase-plugin/` for a full demonstration of all 11 UI button locations.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Plugin Won't Load
|
||||||
|
|
||||||
|
1. Check the browser console for errors (F12 → Console)
|
||||||
|
2. Look for `[PluginLoader]` messages
|
||||||
|
3. Verify `index.js` exists in the plugin directory
|
||||||
|
4. Ensure `plugin-metadata.json` is valid JSON
|
||||||
|
|
||||||
|
### Plugin Crashes
|
||||||
|
|
||||||
|
1. Disable the plugin in Settings → Plugins
|
||||||
|
2. Check console for error stack traces
|
||||||
|
3. Report the issue to the plugin author
|
||||||
|
4. Try reinstalling the plugin
|
||||||
|
|
||||||
|
### Performance Issues
|
||||||
|
|
||||||
|
If Paarrot becomes slow:
|
||||||
|
1. Disable plugins one by one to identify the culprit
|
||||||
|
2. Check for memory leaks in the console
|
||||||
|
3. Contact the plugin author with details
|
||||||
|
|
||||||
|
## Plugin Directory
|
||||||
|
|
||||||
|
The official Paarrot Plugin Directory is maintained at:
|
||||||
|
https://github.com/Paarrot/Plugin-Directory
|
||||||
|
|
||||||
|
Submit your plugins there to make them available in the marketplace!
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
- **Plugin API Reference**: [docs/PLUGIN_API.md](docs/PLUGIN_API.md)
|
||||||
|
- **Button API Reference**: [docs/PLUGIN_BUTTON_API.md](docs/PLUGIN_BUTTON_API.md)
|
||||||
|
- **Issues**: https://github.com/Paarrot/cinny-desktop/issues
|
||||||
|
- **Community**: Join our Matrix room for plugin development help
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Plugins are independent works and have their own licenses. Check each plugin's metadata for license information.
|
||||||
1282
docs/PLUGIN_API.md
Normal file
1282
docs/PLUGIN_API.md
Normal file
File diff suppressed because it is too large
Load Diff
432
docs/PLUGIN_BUTTON_API.md
Normal file
432
docs/PLUGIN_BUTTON_API.md
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
# Plugin Button Registration API
|
||||||
|
|
||||||
|
An easy way to register plugin buttons in various UI locations with positioning and grouping support.
|
||||||
|
|
||||||
|
## Example Plugins
|
||||||
|
|
||||||
|
- **[example-button-plugin/](example-button-plugin/)** - Simple examples showing basic usage patterns
|
||||||
|
- **[example-showcase-plugin/](example-showcase-plugin/)** - Complete demonstration of ALL 11 locations ⭐
|
||||||
|
|
||||||
|
## UI Locations
|
||||||
|
|
||||||
|
Buttons render in two visual styles depending on location — **nav list rows** (icon + label, full width) or **icon buttons** (compact, toolbar/header style).
|
||||||
|
|
||||||
|
### Nav List Rows
|
||||||
|
|
||||||
|
These render as full-width list entries matching the style of built-in items like "Create Room" and "Message Search":
|
||||||
|
|
||||||
|
| Location | Where it appears |
|
||||||
|
|---|---|
|
||||||
|
| `channel-list` | Space channel list, below Lobby and Message Search |
|
||||||
|
| `home-section` | Home panel, inside the Rooms category above the room list |
|
||||||
|
| `direct-messages` | Direct Messages panel, below "Create Chat" and above the CHATS dropdown |
|
||||||
|
|
||||||
|
### Icon Buttons
|
||||||
|
|
||||||
|
These render as compact icon buttons inline with other toolbar/header controls:
|
||||||
|
|
||||||
|
| Location | Where it appears |
|
||||||
|
|---|---|
|
||||||
|
| `text-composer-toolbar` | Message composer toolbar (alongside emoji, sticker buttons) |
|
||||||
|
| `composer-actions` | Left side of the composer, beside the `+` attach button |
|
||||||
|
| `room-header` | Top room header bar, before the ⋮ menu button |
|
||||||
|
| `room-menu` | Room ⋮ dropdown menu |
|
||||||
|
| `message-actions` | Message hover action bar |
|
||||||
|
| `user-menu` | Right-click popup on the user avatar |
|
||||||
|
| `search-notification-section` | Notifications page header (right side) |
|
||||||
|
| `sidebar-actions` | Left sidebar — appears in **two** places: above the Explore Servers icon, and above the Search icon in the sticky bottom section |
|
||||||
|
|
||||||
|
> 💡 **Tip:** Install the [example-showcase-plugin](example-showcase-plugin/) to see exactly where each location appears in the UI!
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
### Simple Button
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
name: 'my-plugin',
|
||||||
|
version: '1.0.0',
|
||||||
|
|
||||||
|
onLoad: (context) => {
|
||||||
|
context.ui.registerButton({
|
||||||
|
id: 'my-button',
|
||||||
|
location: 'text-composer-toolbar',
|
||||||
|
label: 'My Action',
|
||||||
|
icon: '🎨',
|
||||||
|
onClick: () => {
|
||||||
|
context.log('Button clicked!');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnload: () => {
|
||||||
|
// Buttons are automatically cleaned up on plugin unload
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Positioning
|
||||||
|
|
||||||
|
### Before/After Positioning
|
||||||
|
|
||||||
|
Place your button before or after existing buttons:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
context.ui.registerButton({
|
||||||
|
id: 'my-button',
|
||||||
|
location: 'text-composer-toolbar',
|
||||||
|
label: 'My Action',
|
||||||
|
icon: '✨',
|
||||||
|
position: {
|
||||||
|
before: 'emoji-picker-button', // Place before emoji picker
|
||||||
|
// OR
|
||||||
|
after: 'sticker-button' // Place after sticker button
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Grouping Buttons
|
||||||
|
|
||||||
|
Group related buttons together:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
context.ui.registerButton({
|
||||||
|
id: 'action-1',
|
||||||
|
location: 'text-composer-toolbar',
|
||||||
|
label: 'Action 1',
|
||||||
|
icon: '1️⃣',
|
||||||
|
position: {
|
||||||
|
group: 'my-plugin-tools',
|
||||||
|
order: 1 // Lower numbers appear first
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
context.ui.registerButton({
|
||||||
|
id: 'action-2',
|
||||||
|
location: 'text-composer-toolbar',
|
||||||
|
label: 'Action 2',
|
||||||
|
icon: '2️⃣',
|
||||||
|
position: {
|
||||||
|
group: 'my-plugin-tools',
|
||||||
|
order: 2
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Combined Positioning and Grouping
|
||||||
|
|
||||||
|
Place a group before/after other elements:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
context.ui.registerButton({
|
||||||
|
id: 'tool-1',
|
||||||
|
location: 'text-composer-toolbar',
|
||||||
|
label: 'Tool 1',
|
||||||
|
icon: '🔧',
|
||||||
|
position: {
|
||||||
|
group: 'my-tools',
|
||||||
|
after: 'emoji-picker-button',
|
||||||
|
order: 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
context.ui.registerButton({
|
||||||
|
id: 'tool-2',
|
||||||
|
location: 'text-composer-toolbar',
|
||||||
|
label: 'Tool 2',
|
||||||
|
icon: '🔨',
|
||||||
|
position: {
|
||||||
|
group: 'my-tools',
|
||||||
|
order: 2
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
name: 'custom-tools',
|
||||||
|
version: '1.0.0',
|
||||||
|
|
||||||
|
onLoad: (context) => {
|
||||||
|
// Nav list row — appears below "Create Chat" in the DMs panel
|
||||||
|
context.ui.registerButton({
|
||||||
|
id: 'dm-favorites',
|
||||||
|
location: 'direct-messages',
|
||||||
|
label: 'Favourites',
|
||||||
|
icon: '⭐',
|
||||||
|
onClick: () => context.log('Favourites clicked!')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nav list row — appears in the space channel list
|
||||||
|
context.ui.registerButton({
|
||||||
|
id: 'quick-access',
|
||||||
|
location: 'channel-list',
|
||||||
|
label: 'Quick Access',
|
||||||
|
icon: '⚡',
|
||||||
|
onClick: () => context.log('Quick access clicked!')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Icon button — appears in the message composer toolbar
|
||||||
|
context.ui.registerButton({
|
||||||
|
id: 'format-bold',
|
||||||
|
location: 'text-composer-toolbar',
|
||||||
|
label: 'Bold',
|
||||||
|
icon: '𝐁',
|
||||||
|
position: {
|
||||||
|
group: 'custom-formatting',
|
||||||
|
order: 1
|
||||||
|
},
|
||||||
|
onClick: () => context.log('Bold clicked!')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Icon button — appears in the room header
|
||||||
|
context.ui.registerButton({
|
||||||
|
id: 'special-search',
|
||||||
|
location: 'room-header',
|
||||||
|
label: 'Special Search',
|
||||||
|
icon: '🔍',
|
||||||
|
position: { after: 'search-button' },
|
||||||
|
onClick: () => context.log('Special search clicked!')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sidebar icon — appears above Explore and above Search
|
||||||
|
context.ui.registerButton({
|
||||||
|
id: 'plugin-panel',
|
||||||
|
location: 'sidebar-actions',
|
||||||
|
label: 'Plugin Panel',
|
||||||
|
icon: '🧩',
|
||||||
|
onClick: () => context.log('Plugin panel clicked!')
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnload: () => {
|
||||||
|
// All registered buttons are automatically cleaned up
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Unregistering Buttons
|
||||||
|
|
||||||
|
Buttons are automatically unregistered when the plugin is unloaded. To manually unregister:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
context.ui.unregisterButton('my-button-id');
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { UIButtonDefinition, UIButtonPosition, UILocation } from '@paarrot/plugin-manager';
|
||||||
|
|
||||||
|
const button: UIButtonDefinition = {
|
||||||
|
id: 'my-button',
|
||||||
|
location: 'text-composer-toolbar',
|
||||||
|
label: 'My Action',
|
||||||
|
icon: '🎨',
|
||||||
|
position: {
|
||||||
|
group: 'my-tools',
|
||||||
|
after: 'emoji-picker',
|
||||||
|
order: 1
|
||||||
|
},
|
||||||
|
onClick: () => console.log('Clicked!')
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
### Simple Button
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
name: 'my-plugin',
|
||||||
|
version: '1.0.0',
|
||||||
|
|
||||||
|
onLoad: (context) => {
|
||||||
|
context.ui.registerButton({
|
||||||
|
id: 'my-button',
|
||||||
|
location: 'text-composer-toolbar',
|
||||||
|
label: 'My Action',
|
||||||
|
icon: '🎨',
|
||||||
|
onClick: () => {
|
||||||
|
context.log('Button clicked!');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnload: () => {
|
||||||
|
// Buttons are automatically cleaned up on plugin unload
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Positioning
|
||||||
|
|
||||||
|
### Before/After Positioning
|
||||||
|
|
||||||
|
Place your button before or after existing buttons:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
context.ui.registerButton({
|
||||||
|
id: 'my-button',
|
||||||
|
location: 'text-composer-toolbar',
|
||||||
|
label: 'My Action',
|
||||||
|
icon: '✨',
|
||||||
|
position: {
|
||||||
|
before: 'emoji-picker-button', // Place before emoji picker
|
||||||
|
// OR
|
||||||
|
after: 'sticker-button' // Place after sticker button
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Grouping Buttons
|
||||||
|
|
||||||
|
Group related buttons together:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
context.ui.registerButton({
|
||||||
|
id: 'action-1',
|
||||||
|
location: 'text-composer-toolbar',
|
||||||
|
label: 'Action 1',
|
||||||
|
icon: '1️⃣',
|
||||||
|
position: {
|
||||||
|
group: 'my-plugin-tools',
|
||||||
|
order: 1 // Lower numbers appear first
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
context.ui.registerButton({
|
||||||
|
id: 'action-2',
|
||||||
|
location: 'text-composer-toolbar',
|
||||||
|
label: 'Action 2',
|
||||||
|
icon: '2️⃣',
|
||||||
|
position: {
|
||||||
|
group: 'my-plugin-tools',
|
||||||
|
order: 2
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Combined Positioning and Grouping
|
||||||
|
|
||||||
|
Place a group before/after other elements:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
context.ui.registerButton({
|
||||||
|
id: 'tool-1',
|
||||||
|
location: 'text-composer-toolbar',
|
||||||
|
label: 'Tool 1',
|
||||||
|
icon: '🔧',
|
||||||
|
position: {
|
||||||
|
group: 'my-tools',
|
||||||
|
after: 'emoji-picker-button',
|
||||||
|
order: 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
context.ui.registerButton({
|
||||||
|
id: 'tool-2',
|
||||||
|
location: 'text-composer-toolbar',
|
||||||
|
label: 'Tool 2',
|
||||||
|
icon: '🔨',
|
||||||
|
position: {
|
||||||
|
group: 'my-tools',
|
||||||
|
order: 2
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
name: 'custom-tools',
|
||||||
|
version: '1.0.0',
|
||||||
|
|
||||||
|
onLoad: (context) => {
|
||||||
|
// Nav list row — appears below "Create Chat" in the DMs panel
|
||||||
|
context.ui.registerButton({
|
||||||
|
id: 'dm-favorites',
|
||||||
|
location: 'direct-messages',
|
||||||
|
label: 'Favourites',
|
||||||
|
icon: '⭐',
|
||||||
|
onClick: () => context.log('Favourites clicked!')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nav list row — appears in the space channel list
|
||||||
|
context.ui.registerButton({
|
||||||
|
id: 'quick-access',
|
||||||
|
location: 'channel-list',
|
||||||
|
label: 'Quick Access',
|
||||||
|
icon: '⚡',
|
||||||
|
onClick: () => context.log('Quick access clicked!')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Icon button — appears in the message composer toolbar
|
||||||
|
context.ui.registerButton({
|
||||||
|
id: 'format-bold',
|
||||||
|
location: 'text-composer-toolbar',
|
||||||
|
label: 'Bold',
|
||||||
|
icon: '𝐁',
|
||||||
|
position: {
|
||||||
|
group: 'custom-formatting',
|
||||||
|
order: 1
|
||||||
|
},
|
||||||
|
onClick: () => context.log('Bold clicked!')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Icon button — appears in the room header
|
||||||
|
context.ui.registerButton({
|
||||||
|
id: 'special-search',
|
||||||
|
location: 'room-header',
|
||||||
|
label: 'Special Search',
|
||||||
|
icon: '🔍',
|
||||||
|
position: { after: 'search-button' },
|
||||||
|
onClick: () => context.log('Special search clicked!')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sidebar icon — appears above Explore and above Search
|
||||||
|
context.ui.registerButton({
|
||||||
|
id: 'plugin-panel',
|
||||||
|
location: 'sidebar-actions',
|
||||||
|
label: 'Plugin Panel',
|
||||||
|
icon: '🧩',
|
||||||
|
onClick: () => context.log('Plugin panel clicked!')
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnload: () => {
|
||||||
|
// All registered buttons are automatically cleaned up
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Unregistering Buttons
|
||||||
|
|
||||||
|
Buttons are automatically unregistered when the plugin is unloaded. To manually unregister:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
context.ui.unregisterButton('my-button-id');
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { UIButtonDefinition, UIButtonPosition, UILocation } from '@paarrot/plugin-manager';
|
||||||
|
|
||||||
|
const button: UIButtonDefinition = {
|
||||||
|
id: 'my-button',
|
||||||
|
location: 'text-composer-toolbar',
|
||||||
|
label: 'My Action',
|
||||||
|
icon: '🎨',
|
||||||
|
position: {
|
||||||
|
group: 'my-tools',
|
||||||
|
after: 'emoji-picker',
|
||||||
|
order: 1
|
||||||
|
},
|
||||||
|
onClick: () => console.log('Clicked!')
|
||||||
|
};
|
||||||
|
```
|
||||||
207
docs/PLUGIN_SYSTEM_IMPLEMENTATION.md
Normal file
207
docs/PLUGIN_SYSTEM_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
# Plugin System Wishlist - Implementation Summary
|
||||||
|
|
||||||
|
All requested features have been implemented! 🎉
|
||||||
|
|
||||||
|
## ✅ Implemented Features
|
||||||
|
|
||||||
|
### 1. ✅ Enhanced Slash Commands
|
||||||
|
- **Simple commands**: `ctx.commands.register({ name: "shrug", run: () => "¯\\_(ツ)_/¯" })`
|
||||||
|
- **Commands with args**: `ctx.commands.register({ name: "echo", args: ["text"], run: ({ text }) => text })`
|
||||||
|
- Automatic argument parsing
|
||||||
|
- Execution API: `ctx.commands.execute(name, args)`
|
||||||
|
|
||||||
|
### 2. ✅ Message Interceptors
|
||||||
|
- **Before send**: `ctx.messages.onBeforeSend((msg) => { msg.content = "modified" })`
|
||||||
|
- **On receive**: `ctx.messages.onReceive((msg) => { /* process */ })`
|
||||||
|
- Full MessageContext with content, roomId, metadata
|
||||||
|
- Async support for interceptors
|
||||||
|
|
||||||
|
### 3. ✅ Custom Renderers & Button Registration
|
||||||
|
- **Register renderer**: `ctx.ui.registerRenderer("message", (msg, defaultRenderer) => { /* render */ })`
|
||||||
|
- **Unregister renderer**: `ctx.ui.unregisterRenderer("message")`
|
||||||
|
- **Register button**: `ctx.ui.registerButton({ id, location, label, icon, onClick })`
|
||||||
|
- **Unregister button**: `ctx.ui.unregisterButton(id)`
|
||||||
|
- 11 UI locations across nav lists, toolbars, headers, sidebar, and menus
|
||||||
|
- Nav list rows (icon + label) for `channel-list`, `home-section`, `direct-messages`
|
||||||
|
- Icon buttons for all other locations
|
||||||
|
- Positioning and grouping support (`before`, `after`, `group`, `order`)
|
||||||
|
- Auto-cleanup on plugin unload
|
||||||
|
- See [PLUGIN_BUTTON_API.md](PLUGIN_BUTTON_API.md) for full reference
|
||||||
|
|
||||||
|
### 4. ✅ Settings UI Per Plugin
|
||||||
|
- **Define schema**: `ctx.settings.define({ theme: { type: "select", options: [...] } })`
|
||||||
|
- **Supported types**: string, number, boolean, select, color
|
||||||
|
- **Get/Set**: `ctx.settings.get(key)` / `ctx.settings.set(key, value)`
|
||||||
|
- Auto-persisted to localStorage
|
||||||
|
- No JSON editing needed!
|
||||||
|
|
||||||
|
### 5. ✅ Hot Reload
|
||||||
|
- **API**: `pluginRegistry.reloadPlugin(pluginId, newPlugin, newContext)`
|
||||||
|
- Auto-cleanup of old plugin resources
|
||||||
|
- Maintains enabled/disabled state
|
||||||
|
- Dev-friendly workflow
|
||||||
|
|
||||||
|
### 6. ✅ Dependency System
|
||||||
|
- **Declare**: `dependencies: { "paarrot.core-utils": "^1.0.0" }`
|
||||||
|
- **Require**: `const utils = ctx.require("paarrot.utils")`
|
||||||
|
- Error handling for missing dependencies
|
||||||
|
- Plugin exports system
|
||||||
|
|
||||||
|
### 7. ✅ Raw Matrix Events Hook
|
||||||
|
- **Full SDK access**: `ctx.matrix.on("m.room.message", (ev) => { })`
|
||||||
|
- **Any event type**: Room.timeline, sync, RoomState.events, etc.
|
||||||
|
- Direct Matrix.js SDK integration
|
||||||
|
- Power users will go nuts!
|
||||||
|
|
||||||
|
### 8. ✅ Background Tasks / Intervals
|
||||||
|
- **Interval**: `ctx.timers.setInterval(() => { /* cursed shit */ }, 10000)`
|
||||||
|
- **Timeout**: `ctx.timers.setTimeout(() => { }, 5000)`
|
||||||
|
- **Clear**: `ctx.timers.clearInterval(id)` / `ctx.timers.clearTimeout(id)`
|
||||||
|
- Auto-cleanup on plugin unload
|
||||||
|
- Perfect for bots, reminders, auto-cleanup
|
||||||
|
|
||||||
|
### 9. ✅ Notifications API
|
||||||
|
- **Simple**: `ctx.notify("Oi")`
|
||||||
|
- **Advanced**: `ctx.notify({ title: "Oi", body: "New message from Steve", type: "info" })`
|
||||||
|
- Action buttons support
|
||||||
|
- Custom duration
|
||||||
|
|
||||||
|
### 10. ✅ Plugin Logs Panel
|
||||||
|
- **Logging**: `ctx.log()`, `ctx.warn()`, `ctx.error()`
|
||||||
|
- Per-plugin log storage
|
||||||
|
- Visible in `pluginRegistry.getLogs(pluginId)`
|
||||||
|
- Cleared on plugin unload
|
||||||
|
- Max 1000 logs retained
|
||||||
|
|
||||||
|
## 🎯 Bonus Features
|
||||||
|
|
||||||
|
- **Plugin exports**: Share functionality between plugins
|
||||||
|
- **Automatic cleanup**: Timers, handlers, resources auto-cleaned on unload
|
||||||
|
- **Error isolation**: Plugin errors don't crash the app
|
||||||
|
- **Type safety**: Full TypeScript definitions
|
||||||
|
- **Sandboxed execution**: Plugins run in controlled environment
|
||||||
|
- **Rich context**: Full Matrix client, React, all APIs
|
||||||
|
|
||||||
|
## 📁 Files Changed
|
||||||
|
|
||||||
|
### Core System
|
||||||
|
- [`PluginAPI.ts`](cinny/src/app/features/settings/plugins/PluginAPI.ts) - Enhanced with all APIs
|
||||||
|
- [`PluginLoader.tsx`](cinny/src/app/features/settings/plugins/PluginLoader.tsx) - Complete rewrite with context creation
|
||||||
|
- [`index.ts`](cinny/src/app/features/settings/plugins/index.ts) - Exports updated
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- [`PLUGIN_DEVELOPMENT.md`](cinny/src/app/features/settings/plugins/PLUGIN_DEVELOPMENT.md) - Comprehensive guide
|
||||||
|
- [`PLUGINS.md`](PLUGINS.md) - User guide
|
||||||
|
- [`example-plugin/index.js`](example-plugin/index.js) - Full-featured example
|
||||||
|
|
||||||
|
### Integration
|
||||||
|
- [`ClientRoot.tsx`](cinny/src/app/pages/client/ClientRoot.tsx) - PluginLoader integrated
|
||||||
|
- [`main.js`](electron/main.js) - IPC handler for reading plugin code
|
||||||
|
- [`preload.js`](electron/preload.js) - readPluginCode API exposed
|
||||||
|
- [`ext.d.ts`](cinny/src/ext.d.ts) - TypeScript definitions
|
||||||
|
|
||||||
|
## 🚀 Usage Examples
|
||||||
|
|
||||||
|
### Command with Args
|
||||||
|
```javascript
|
||||||
|
ctx.commands.register({
|
||||||
|
name: "echo",
|
||||||
|
args: ["text"],
|
||||||
|
run: ({ text }) => text
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Message Interceptor
|
||||||
|
```javascript
|
||||||
|
ctx.messages.onBeforeSend((msg) => {
|
||||||
|
if (msg.content === "brb") {
|
||||||
|
msg.content = "be right back ya legend";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Settings
|
||||||
|
```javascript
|
||||||
|
ctx.settings.define({
|
||||||
|
theme: { type: "select", options: ["dark", "light"] },
|
||||||
|
spamFilter: { type: "boolean", default: true }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Background Tasks
|
||||||
|
```javascript
|
||||||
|
ctx.timers.setInterval(() => {
|
||||||
|
// do cursed shit every 10s
|
||||||
|
}, 10000);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Raw Matrix Events
|
||||||
|
```javascript
|
||||||
|
ctx.matrix.on("m.room.message", (ev) => {
|
||||||
|
ctx.log('Message:', ev.getContent().body);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plugin Dependencies
|
||||||
|
```javascript
|
||||||
|
const utils = ctx.require("paarrot.utils");
|
||||||
|
utils.doSomething();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 Plugin API Summary
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PluginContext {
|
||||||
|
pluginId: string;
|
||||||
|
matrixClient: MatrixClient;
|
||||||
|
React: typeof import('react');
|
||||||
|
|
||||||
|
commands: {
|
||||||
|
register: (command: PluginCommand) => void;
|
||||||
|
unregister: (name: string) => void;
|
||||||
|
execute: (name: string, args: Record<string, any>) => Promise<string | void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
messages: {
|
||||||
|
onBeforeSend: (interceptor: MessageInterceptor) => void;
|
||||||
|
onReceive: (interceptor: MessageInterceptor) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
ui: {
|
||||||
|
registerButton: (button: UIButtonDefinition) => void;
|
||||||
|
unregisterButton: (id: string) => void;
|
||||||
|
registerRenderer: (type: string, renderer: CustomRenderer) => void;
|
||||||
|
unregisterRenderer: (type: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
define: (schema: SettingsSchema) => void;
|
||||||
|
get: <T = any>(key: string) => T | undefined;
|
||||||
|
set: (key: string, value: any) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
matrix: {
|
||||||
|
on: (eventType: string, handler: (event: MatrixEvent) => void) => void;
|
||||||
|
off: (eventType: string, handler: (event: MatrixEvent) => void) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
timers: {
|
||||||
|
setInterval: (callback: () => void, ms: number) => number;
|
||||||
|
setTimeout: (callback: () => void, ms: number) => number;
|
||||||
|
clearInterval: (id: number) => void;
|
||||||
|
clearTimeout: (id: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
notify: (options: NotificationOptions | string) => void;
|
||||||
|
log: (...args: any[]) => void;
|
||||||
|
warn: (...args: any[]) => void;
|
||||||
|
error: (...args: any[]) => void;
|
||||||
|
require: (pluginId: string) => any;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔥 The Result
|
||||||
|
|
||||||
|
A **production-ready plugin system** that rivals Discord, VSCode, and other extensible apps. Plugin developers will have everything they need to build powerful extensions without fighting the framework.
|
||||||
|
|
||||||
|
**No compromises. All features implemented. Ready to ship.**
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<meta property="og:title" content="Paarrot" />
|
<meta property="og:title" content="Paarrot" />
|
||||||
<meta property="og:url" content="https://synbox.ruv.wtf:8418/litruv/cinny-desktop" />
|
<meta property="og:url" content="https://github.com/Paarrot/Paarrot-Desktop" />
|
||||||
<meta property="og:image" content="./public/res/android/android-chrome-192x192.png" />
|
<meta property="og:image" content="./public/res/android/android-chrome-192x192.png" />
|
||||||
<meta
|
<meta
|
||||||
property="og:description"
|
property="og:description"
|
||||||
|
|||||||
35
package-lock.json
generated
35
package-lock.json
generated
@@ -12,8 +12,10 @@
|
|||||||
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||||
|
"@capacitor/core": "8.3.4",
|
||||||
|
"@capacitor/local-notifications": "8.2.0",
|
||||||
"@fontsource/inter": "4.5.14",
|
"@fontsource/inter": "4.5.14",
|
||||||
"@paarrot/plugin-manager": "file:../../PluginManager",
|
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
|
||||||
"@tanstack/react-query": "5.24.1",
|
"@tanstack/react-query": "5.24.1",
|
||||||
"@tanstack/react-query-devtools": "5.24.1",
|
"@tanstack/react-query-devtools": "5.24.1",
|
||||||
"@tanstack/react-virtual": "3.2.0",
|
"@tanstack/react-virtual": "3.2.0",
|
||||||
@@ -114,15 +116,6 @@
|
|||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"../../PluginManager": {
|
|
||||||
"name": "@paarrot/plugin-manager",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^25.6.0",
|
|
||||||
"rimraf": "^5.0.0",
|
|
||||||
"typescript": "^5.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@ampproject/remapping": {
|
"node_modules/@ampproject/remapping": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||||
@@ -1678,6 +1671,24 @@
|
|||||||
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
|
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
|
||||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||||
},
|
},
|
||||||
|
"node_modules/@capacitor/core": {
|
||||||
|
"version": "8.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.4.tgz",
|
||||||
|
"integrity": "sha512-CqRQCkb6HXxcx/N7s+hHTN6ef2CmamFiRMITwm4qB840ph56mS42bzUgn6tKCP+RZjdDweiRHj9ytDDeN6jFag==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@capacitor/local-notifications": {
|
||||||
|
"version": "8.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.2.0.tgz",
|
||||||
|
"integrity": "sha512-fvLY0w2w4MiX+DD4+Wv4DOwOLdzKZsMDwAcRv/Juudd+QbKbn69s6cM3xVqPwAiDqfnqsY4/S8xtQD6M73wY2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emotion/hash": {
|
"node_modules/@emotion/hash": {
|
||||||
"version": "0.9.2",
|
"version": "0.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
|
||||||
@@ -2344,8 +2355,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@paarrot/plugin-manager": {
|
"node_modules/@paarrot/plugin-manager": {
|
||||||
"resolved": "../../PluginManager",
|
"version": "1.0.0",
|
||||||
"link": true
|
"resolved": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git#6c389381df3966568a40c39d8661f9a4300f7819"
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/breadcrumbs": {
|
"node_modules/@react-aria/breadcrumbs": {
|
||||||
"version": "3.5.20",
|
"version": "3.5.20",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||||
|
"@capacitor/core": "8.3.4",
|
||||||
|
"@capacitor/local-notifications": "8.2.0",
|
||||||
"@fontsource/inter": "4.5.14",
|
"@fontsource/inter": "4.5.14",
|
||||||
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
|
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
|
||||||
"@tanstack/react-query": "5.24.1",
|
"@tanstack/react-query": "5.24.1",
|
||||||
|
|||||||
15
public/res/svg/notification.svg
Normal file
15
public/res/svg/notification.svg
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(1.05922,0,0,1.05922,-0.562821,-0.480534)">
|
||||||
|
<g transform="matrix(1.6762,0,0,1.6762,-4.50707,-10.7901)">
|
||||||
|
<path d="M4.624,14.382C4.624,14.382 1.358,10.79 5.108,8.336C5.501,8.316 7.14,8.899 7.18,10.368C7.22,11.836 5.641,11.172 5.108,11.856C4.575,12.54 4.454,14.121 4.624,14.382Z"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.6762,0,0,1.6762,-4.50707,-10.7901)">
|
||||||
|
<path d="M5.08,13.547C5.08,13.547 7.28,14.842 7.291,10.74C7.592,10.971 8.588,12.107 7.713,13.777C6.838,15.446 5.392,14.322 5.08,13.547Z"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.6762,0,0,1.6762,-4.50707,-10.7901)">
|
||||||
|
<path d="M5.921,14.763C5.584,14.449 6.374,15.403 6.704,16.642C6.873,16.232 8.282,17.562 11.166,14.247C12.324,12.581 13.244,11.279 11.249,8.874C9.254,6.469 6.507,6.615 5.469,7.581C4.431,8.546 5.35,7.566 6.653,8.667C7.52,9.399 7.444,10.431 7.444,10.431C7.774,10.551 9.12,12.332 7.935,14.099C7.205,15.187 6.259,15.076 5.921,14.763Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -25,14 +25,16 @@ import {
|
|||||||
VideoContent,
|
VideoContent,
|
||||||
VideoFrameThumbnail,
|
VideoFrameThumbnail,
|
||||||
} from './message';
|
} from './message';
|
||||||
import { UrlPreviewCard, UrlPreviewHolder, YouTubeEmbed, isYouTubeUrl, TikTokEmbed, isTikTokUrl } from './url-preview';
|
import { UrlPreviewCard, UrlPreviewHolder, YouTubeEmbed, isYouTubeUrl, TikTokEmbed, isTikTokUrl, GiphyEmbed, isGiphyUrl } from './url-preview';
|
||||||
import { Image, MediaControl, Video } from './media';
|
import { Image, MediaControl, Video } from './media';
|
||||||
import { ImageViewer } from './image-viewer';
|
import { ImageViewer } from './image-viewer';
|
||||||
|
import { VideoViewer } from './video-viewer';
|
||||||
import { PdfViewer } from './Pdf-viewer';
|
import { PdfViewer } from './Pdf-viewer';
|
||||||
import { TextViewer } from './text-viewer';
|
import { TextViewer } from './text-viewer';
|
||||||
import { testMatrixTo } from '../plugins/matrix-to';
|
import { testMatrixTo } from '../plugins/matrix-to';
|
||||||
import { IImageContent } from '../../types/matrix/common';
|
import { IImageContent } from '../../types/matrix/common';
|
||||||
import { filterDisabledUrls } from '../hooks/useRoomEmbedFilters';
|
import { filterDisabledUrls } from '../hooks/useRoomEmbedFilters';
|
||||||
|
import { pluginRegistry } from '../features/settings/plugins/PluginAPI';
|
||||||
|
|
||||||
type RenderMessageContentProps = {
|
type RenderMessageContentProps = {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -70,7 +72,8 @@ export function RenderMessageContent({
|
|||||||
// Separate special URLs from generic ones
|
// Separate special URLs from generic ones
|
||||||
const youtubeUrls = filteredUrls.filter(isYouTubeUrl);
|
const youtubeUrls = filteredUrls.filter(isYouTubeUrl);
|
||||||
const tiktokUrls = filteredUrls.filter(isTikTokUrl);
|
const tiktokUrls = filteredUrls.filter(isTikTokUrl);
|
||||||
const otherUrls = filteredUrls.filter((url) => !isYouTubeUrl(url) && !isTikTokUrl(url));
|
const giphyUrls = filteredUrls.filter(isGiphyUrl);
|
||||||
|
const otherUrls = filteredUrls.filter((url) => !isYouTubeUrl(url) && !isTikTokUrl(url) && !isGiphyUrl(url));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -82,6 +85,10 @@ export function RenderMessageContent({
|
|||||||
{tiktokUrls.map((url) => (
|
{tiktokUrls.map((url) => (
|
||||||
<TikTokEmbed key={url} url={url} />
|
<TikTokEmbed key={url} url={url} />
|
||||||
))}
|
))}
|
||||||
|
{/* Render Giphy embeds */}
|
||||||
|
{giphyUrls.map((url) => (
|
||||||
|
<GiphyEmbed key={url} url={url} />
|
||||||
|
))}
|
||||||
{/* Render standard URL previews for other links */}
|
{/* Render standard URL previews for other links */}
|
||||||
{otherUrls.length > 0 && (
|
{otherUrls.length > 0 && (
|
||||||
<UrlPreviewHolder>
|
<UrlPreviewHolder>
|
||||||
@@ -152,6 +159,24 @@ export function RenderMessageContent({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Check for custom renderer from plugins BEFORE standard type handling
|
||||||
|
// so plugins can intercept any msgtype including custom ones.
|
||||||
|
const customRendererEarly = pluginRegistry.getRenderer('message');
|
||||||
|
if (customRendererEarly) {
|
||||||
|
try {
|
||||||
|
const messageDataEarly = {
|
||||||
|
msgtype: msgType,
|
||||||
|
...getContent(),
|
||||||
|
};
|
||||||
|
const customContentEarly = customRendererEarly(messageDataEarly, () => null);
|
||||||
|
if (customContentEarly) {
|
||||||
|
return customContentEarly as React.ReactElement;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[RenderMessageContent] Custom renderer error (early):', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (msgType === MsgType.Text) {
|
if (msgType === MsgType.Text) {
|
||||||
return (
|
return (
|
||||||
<MText
|
<MText
|
||||||
@@ -234,7 +259,6 @@ export function RenderMessageContent({
|
|||||||
content={getContent()}
|
content={getContent()}
|
||||||
renderAsFile={renderFile}
|
renderAsFile={renderFile}
|
||||||
renderVideoContent={({ body, info, mimeType, url, encInfo, ...props }) => {
|
renderVideoContent={({ body, info, mimeType, url, encInfo, ...props }) => {
|
||||||
// Check if there's valid thumbnail metadata
|
|
||||||
const hasValidThumbnail =
|
const hasValidThumbnail =
|
||||||
(typeof info.thumbnail_file?.url === 'string' || typeof info.thumbnail_url === 'string') &&
|
(typeof info.thumbnail_file?.url === 'string' || typeof info.thumbnail_url === 'string') &&
|
||||||
typeof info.thumbnail_info?.mimetype === 'string';
|
typeof info.thumbnail_info?.mimetype === 'string';
|
||||||
@@ -246,11 +270,11 @@ export function RenderMessageContent({
|
|||||||
mimeType={mimeType}
|
mimeType={mimeType}
|
||||||
url={url}
|
url={url}
|
||||||
encInfo={encInfo}
|
encInfo={encInfo}
|
||||||
|
autoPlay={mediaAutoLoad}
|
||||||
{...props}
|
{...props}
|
||||||
renderThumbnail={
|
renderThumbnail={
|
||||||
mediaAutoLoad
|
mediaAutoLoad
|
||||||
? () => hasValidThumbnail ? (
|
? () => hasValidThumbnail ? (
|
||||||
// Try to load the provided thumbnail first
|
|
||||||
<ThumbnailContent
|
<ThumbnailContent
|
||||||
info={info}
|
info={info}
|
||||||
renderImage={(src) => (
|
renderImage={(src) => (
|
||||||
@@ -258,7 +282,6 @@ export function RenderMessageContent({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// Fallback: Extract first frame from the video itself
|
|
||||||
<VideoFrameThumbnail
|
<VideoFrameThumbnail
|
||||||
mimeType={mimeType}
|
mimeType={mimeType}
|
||||||
url={url}
|
url={url}
|
||||||
@@ -270,6 +293,7 @@ export function RenderMessageContent({
|
|||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
renderViewer={(p) => <VideoViewer {...p} />}
|
||||||
renderVideo={(p) => <Video {...p} />}
|
renderVideo={(p) => <Video {...p} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -309,5 +333,36 @@ export function RenderMessageContent({
|
|||||||
return <MBadEncrypted />;
|
return <MBadEncrypted />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for custom renderer from plugins FIRST
|
||||||
|
const customRenderer = pluginRegistry.getRenderer('message');
|
||||||
|
console.log('[RenderMessageContent] customRenderer function:', typeof customRenderer);
|
||||||
|
if (customRenderer) {
|
||||||
|
try {
|
||||||
|
const messageData = {
|
||||||
|
msgtype: msgType,
|
||||||
|
...getContent(),
|
||||||
|
};
|
||||||
|
console.log('[RenderMessageContent] Calling customRenderer with msgtype:', msgType);
|
||||||
|
console.log('[RenderMessageContent] messageData:', messageData);
|
||||||
|
const customContent = customRenderer(
|
||||||
|
messageData,
|
||||||
|
() => {
|
||||||
|
console.log('[RenderMessageContent] defaultRenderer called');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
console.log('[RenderMessageContent] customRenderer returned:', customContent);
|
||||||
|
console.log('[RenderMessageContent] customContent type:', typeof customContent);
|
||||||
|
if (customContent) {
|
||||||
|
console.log('[RenderMessageContent] Returning custom content');
|
||||||
|
return customContent as React.ReactElement;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[RenderMessageContent] Custom renderer error:', error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[RenderMessageContent] No custom renderer registered');
|
||||||
|
}
|
||||||
|
|
||||||
return <UnsupportedContent />;
|
return <UnsupportedContent />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,16 @@ export const createRoomEncryptionState = () => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const createRoomPowerLevelsState = () => ({
|
||||||
|
type: StateEvent.RoomPowerLevels,
|
||||||
|
state_key: '',
|
||||||
|
content: {
|
||||||
|
events: {
|
||||||
|
'org.matrix.msc3401.call.member': 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export type CreateRoomData = {
|
export type CreateRoomData = {
|
||||||
version: string;
|
version: string;
|
||||||
type?: RoomType;
|
type?: RoomType;
|
||||||
@@ -98,6 +108,8 @@ export type CreateRoomData = {
|
|||||||
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => {
|
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => {
|
||||||
const initialState: ICreateRoomStateEvent[] = [];
|
const initialState: ICreateRoomStateEvent[] = [];
|
||||||
|
|
||||||
|
// initialState.push(createRoomPowerLevelsState()); // Removed: causes 403 when setting permissions before admin
|
||||||
|
|
||||||
if (data.encryption) {
|
if (data.encryption) {
|
||||||
initialState.push(createRoomEncryptionState());
|
initialState.push(createRoomEncryptionState());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { useClosedNavCategoriesAtom } from '../../state/hooks/closedNavCategorie
|
|||||||
import { useRoomsUnread } from '../../state/hooks/unread';
|
import { useRoomsUnread } from '../../state/hooks/unread';
|
||||||
import { useMarkRoomsAsRead } from '../../hooks/useMarkAsRead';
|
import { useMarkRoomsAsRead } from '../../hooks/useMarkAsRead';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { PluginButtonSlot } from '../../features/settings/plugins/PluginButtonSlot';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import {
|
import {
|
||||||
@@ -49,6 +50,7 @@ import {
|
|||||||
useRoomsNotificationPreferencesContext,
|
useRoomsNotificationPreferencesContext,
|
||||||
} from '../../hooks/useRoomsNotificationPreferences';
|
} from '../../hooks/useRoomsNotificationPreferences';
|
||||||
import { useDirectCreateSelected } from '../../hooks/router/useDirectSelected';
|
import { useDirectCreateSelected } from '../../hooks/router/useDirectSelected';
|
||||||
|
import { useRoomListReorder, useListReorderAnimation } from '../../hooks/useRoomListReorder';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the DirectMenu component
|
* Props for the DirectMenu component
|
||||||
@@ -121,7 +123,8 @@ export function DirectListHeader({ title = 'Direct Messages' }: DirectListHeader
|
|||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box gap="100" alignItems="Center">
|
||||||
|
<PluginButtonSlot location="direct-messages" />
|
||||||
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
|
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
|
||||||
<Icon src={Icons.VerticalDots} size="200" />
|
<Icon src={Icons.VerticalDots} size="200" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -213,13 +216,17 @@ export function DirectList({
|
|||||||
const selectedRoomId = useSelectedRoom();
|
const selectedRoomId = useSelectedRoom();
|
||||||
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
||||||
|
|
||||||
|
const reorderTrigger = useRoomListReorder(mx, directs);
|
||||||
|
|
||||||
const sortedDirects = useMemo(() => {
|
const sortedDirects = useMemo(() => {
|
||||||
const items = Array.from(directs).sort(factoryRoomIdByActivity(mx));
|
const items = Array.from(directs).sort(factoryRoomIdByActivity(mx));
|
||||||
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
|
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
|
||||||
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
|
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}, [mx, directs, closedCategories, roomToUnread, selectedRoomId]);
|
}, [mx, directs, closedCategories, roomToUnread, selectedRoomId, reorderTrigger]);
|
||||||
|
|
||||||
|
const animationRef = useListReorderAnimation(sortedDirects);
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: sortedDirects.length,
|
count: sortedDirects.length,
|
||||||
@@ -273,6 +280,7 @@ export function DirectList({
|
|||||||
</NavCategoryHeader>
|
</NavCategoryHeader>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
|
ref={animationRef}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
height: virtualizer.getTotalSize(),
|
height: virtualizer.getTotalSize(),
|
||||||
@@ -285,7 +293,12 @@ export function DirectList({
|
|||||||
const selected = selectedRoomId === roomId;
|
const selected = selectedRoomId === roomId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
|
<VirtualTile
|
||||||
|
virtualItem={vItem}
|
||||||
|
key={roomId}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
data-room-id={roomId}
|
||||||
|
>
|
||||||
<RoomNavItem
|
<RoomNavItem
|
||||||
room={room}
|
room={room}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import React, {
|
|||||||
forwardRef,
|
forwardRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
useState,
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Box, Scroll, Text } from 'folds';
|
import { Box, Scroll, Text } from 'folds';
|
||||||
import { Descendant, Editor, createEditor } from 'slate';
|
import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate';
|
||||||
import {
|
import {
|
||||||
Slate,
|
Slate,
|
||||||
Editable,
|
Editable,
|
||||||
@@ -23,6 +25,7 @@ import { RenderElement, RenderLeaf } from './Elements';
|
|||||||
import { CustomElement } from './slate';
|
import { CustomElement } from './slate';
|
||||||
import * as css from './Editor.css';
|
import * as css from './Editor.css';
|
||||||
import { toggleKeyboardShortcut } from './keyboard';
|
import { toggleKeyboardShortcut } from './keyboard';
|
||||||
|
import { createCommandElement } from './utils';
|
||||||
|
|
||||||
const initialValue: CustomElement[] = [
|
const initialValue: CustomElement[] = [
|
||||||
{
|
{
|
||||||
@@ -52,8 +55,25 @@ const withVoid = (editor: Editor): Editor => {
|
|||||||
return editor;
|
return editor;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CommandValidatorFn = (commandName: string) => boolean;
|
||||||
|
|
||||||
|
let commandValidator: CommandValidatorFn | null = null;
|
||||||
|
|
||||||
|
export const setCommandValidator = (validator: CommandValidatorFn) => {
|
||||||
|
commandValidator = validator;
|
||||||
|
};
|
||||||
|
|
||||||
|
const withCommandAutoConvert = (editor: Editor): Editor => {
|
||||||
|
// Removed auto-conversion on space to prevent focus issues
|
||||||
|
// Commands are now highlighted visually via decorations
|
||||||
|
// and executed when Enter is pressed
|
||||||
|
return editor;
|
||||||
|
};
|
||||||
|
|
||||||
export const useEditor = (): Editor => {
|
export const useEditor = (): Editor => {
|
||||||
const [editor] = useState(() => withInline(withVoid(withReact(withHistory(createEditor())))));
|
const [editor] = useState(() =>
|
||||||
|
withCommandAutoConvert(withInline(withVoid(withReact(withHistory(createEditor())))))
|
||||||
|
);
|
||||||
return editor;
|
return editor;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,6 +117,49 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
|
|
||||||
const renderLeaf = useCallback((props: RenderLeafProps) => <RenderLeaf {...props} />, []);
|
const renderLeaf = useCallback((props: RenderLeafProps) => <RenderLeaf {...props} />, []);
|
||||||
|
|
||||||
|
const decorate = useCallback(([node, path]: [any, number[]]) => {
|
||||||
|
const ranges: any[] = [];
|
||||||
|
|
||||||
|
// Only decorate text nodes in the first paragraph
|
||||||
|
if (
|
||||||
|
path.length === 2 &&
|
||||||
|
path[0] === 0 &&
|
||||||
|
path[1] === 0 &&
|
||||||
|
SlateText.isText(node)
|
||||||
|
) {
|
||||||
|
const firstChild = editor.children[0];
|
||||||
|
|
||||||
|
if (SlateElement.isElement(firstChild) && firstChild.type === BlockType.Paragraph) {
|
||||||
|
const [firstInline, secondInline] = firstChild.children;
|
||||||
|
|
||||||
|
// Don't decorate if we already have a CommandElement
|
||||||
|
if (SlateElement.isElement(secondInline) && secondInline.type === BlockType.Command) {
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the text matches /command pattern
|
||||||
|
const text = node.text;
|
||||||
|
const match = text.match(/^(\s*\/\w+)/);
|
||||||
|
|
||||||
|
if (match && commandValidator) {
|
||||||
|
const commandText = match[1].trim();
|
||||||
|
const commandName = commandText.substring(1);
|
||||||
|
|
||||||
|
if (commandValidator(commandName)) {
|
||||||
|
// Create decoration for the command text
|
||||||
|
ranges.push({
|
||||||
|
anchor: { path, offset: 0 },
|
||||||
|
focus: { path, offset: match[1].length },
|
||||||
|
pendingCommand: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
const handleKeydown: KeyboardEventHandler = useCallback(
|
const handleKeydown: KeyboardEventHandler = useCallback(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
onKeyDown?.(evt);
|
onKeyDown?.(evt);
|
||||||
@@ -106,6 +169,48 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
[editor, onKeyDown]
|
[editor, onKeyDown]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleBeforeInput = useCallback(
|
||||||
|
(event: Event) => {
|
||||||
|
const inputEvent = event as InputEvent;
|
||||||
|
|
||||||
|
// Handle autocorrect replacement that causes text duplication
|
||||||
|
if (inputEvent.inputType === 'insertReplacementText' ||
|
||||||
|
inputEvent.inputType === 'insertFromComposition') {
|
||||||
|
const { selection } = editor;
|
||||||
|
if (!selection) return;
|
||||||
|
|
||||||
|
// Get the data being inserted
|
||||||
|
const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain');
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// If there's selected text, delete it first
|
||||||
|
if (selection && !Range.isCollapsed(selection)) {
|
||||||
|
Transforms.delete(editor, { at: selection });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the replacement text
|
||||||
|
editor.insertText(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
);
|
||||||
|
|
||||||
|
const editableRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const editableElement = editableRef.current?.querySelector('[data-slate-editor="true"]');
|
||||||
|
if (!editableElement) return;
|
||||||
|
|
||||||
|
editableElement.addEventListener('beforeinput', handleBeforeInput, { capture: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
editableElement.removeEventListener('beforeinput', handleBeforeInput, { capture: true });
|
||||||
|
};
|
||||||
|
}, [handleBeforeInput]);
|
||||||
|
|
||||||
const renderPlaceholder = useCallback(
|
const renderPlaceholder = useCallback(
|
||||||
({ attributes, children }: RenderPlaceholderProps) => (
|
({ attributes, children }: RenderPlaceholderProps) => (
|
||||||
<span {...attributes} className={css.EditorPlaceholderContainer}>
|
<span {...attributes} className={css.EditorPlaceholderContainer}>
|
||||||
@@ -135,6 +240,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
size="300"
|
size="300"
|
||||||
visibility="Hover"
|
visibility="Hover"
|
||||||
hideTrack
|
hideTrack
|
||||||
|
ref={editableRef}
|
||||||
>
|
>
|
||||||
<Editable
|
<Editable
|
||||||
data-editable-name={editableName}
|
data-editable-name={editableName}
|
||||||
@@ -143,6 +249,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
renderPlaceholder={renderPlaceholder}
|
renderPlaceholder={renderPlaceholder}
|
||||||
renderElement={renderElement}
|
renderElement={renderElement}
|
||||||
renderLeaf={renderLeaf}
|
renderLeaf={renderLeaf}
|
||||||
|
decorate={decorate}
|
||||||
onKeyDown={handleKeydown}
|
onKeyDown={handleKeydown}
|
||||||
onKeyUp={onKeyUp}
|
onKeyUp={onKeyUp}
|
||||||
onPaste={onPaste}
|
onPaste={onPaste}
|
||||||
|
|||||||
@@ -269,6 +269,13 @@ export function RenderLeaf({ attributes, leaf, children }: RenderLeafProps) {
|
|||||||
{child}
|
{child}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
if (leaf.pendingCommand)
|
||||||
|
child = (
|
||||||
|
<span className={css.Command({ active: true })} {...attributes}>
|
||||||
|
<InlineChromiumBugfix />
|
||||||
|
{child}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
if (child !== children) return child;
|
if (child !== children) return child;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export * from './autocomplete';
|
export * from './autocomplete';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
export * from './Editor';
|
export { CustomEditor, useEditor, setCommandValidator } from './Editor';
|
||||||
export * from './Elements';
|
export * from './Elements';
|
||||||
export * from './keyboard';
|
export * from './keyboard';
|
||||||
export * from './output';
|
export * from './output';
|
||||||
|
|||||||
1
src/app/components/editor/slate.d.ts
vendored
1
src/app/components/editor/slate.d.ts
vendored
@@ -18,6 +18,7 @@ export type FormattedText = Text & {
|
|||||||
strikeThrough?: boolean;
|
strikeThrough?: boolean;
|
||||||
code?: boolean;
|
code?: boolean;
|
||||||
spoiler?: boolean;
|
spoiler?: boolean;
|
||||||
|
pendingCommand?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LinkElement = {
|
export type LinkElement = {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { useEmojiGroupIcons } from './useEmojiGroupIcons';
|
|||||||
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
|
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
|
||||||
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
|
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
|
import { useEmojiUsage } from '../../hooks/useEmojiUsage';
|
||||||
import { isUserId, mxcUrlToHttp } from '../../utils/matrix';
|
import { isUserId, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { editableActiveElement, targetFromEvent } from '../../utils/dom';
|
import { editableActiveElement, targetFromEvent } from '../../utils/dom';
|
||||||
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
|
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
|
||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
import { EmojiBoardTab, EmojiType } from './types';
|
import { EmojiBoardTab, EmojiType } from './types';
|
||||||
import { VirtualTile } from '../virtualizer';
|
import { VirtualTile } from '../virtualizer';
|
||||||
|
|
||||||
|
const MOST_USED_GROUP_ID = 'most_used_group';
|
||||||
const RECENT_GROUP_ID = 'recent_group';
|
const RECENT_GROUP_ID = 'recent_group';
|
||||||
const SEARCH_GROUP_ID = 'search_group';
|
const SEARCH_GROUP_ID = 'search_group';
|
||||||
|
|
||||||
@@ -72,19 +73,29 @@ const useGroups = (
|
|||||||
imagePacks: ImagePack[]
|
imagePacks: ImagePack[]
|
||||||
): [EmojiGroupItem[], StickerGroupItem[]] => {
|
): [EmojiGroupItem[], StickerGroupItem[]] => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const { getTopEmojis } = useEmojiUsage();
|
||||||
|
|
||||||
const recentEmojis = useRecentEmoji(mx, 21);
|
|
||||||
const labels = useEmojiGroupLabels();
|
const labels = useEmojiGroupLabels();
|
||||||
|
|
||||||
const emojiGroupItems = useMemo(() => {
|
const emojiGroupItems = useMemo(() => {
|
||||||
const g: EmojiGroupItem[] = [];
|
const g: EmojiGroupItem[] = [];
|
||||||
if (tab !== EmojiBoardTab.Emoji) return g;
|
if (tab !== EmojiBoardTab.Emoji) return g;
|
||||||
|
|
||||||
g.push({
|
// Add most-used emojis
|
||||||
id: RECENT_GROUP_ID,
|
const topShortcodes = getTopEmojis(21);
|
||||||
name: 'Recent',
|
if (topShortcodes.length > 0) {
|
||||||
items: recentEmojis,
|
const mostUsedEmojis = topShortcodes
|
||||||
});
|
.map((shortcode) => emojis.find((e) => e.shortcode === shortcode))
|
||||||
|
.filter((e): e is IEmoji => e !== undefined);
|
||||||
|
|
||||||
|
if (mostUsedEmojis.length > 0) {
|
||||||
|
g.push({
|
||||||
|
id: MOST_USED_GROUP_ID,
|
||||||
|
name: 'Most Used',
|
||||||
|
items: mostUsedEmojis,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
imagePacks.forEach((pack) => {
|
imagePacks.forEach((pack) => {
|
||||||
let label = pack.meta.name;
|
let label = pack.meta.name;
|
||||||
@@ -108,7 +119,7 @@ const useGroups = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
return g;
|
return g;
|
||||||
}, [mx, recentEmojis, labels, imagePacks, tab]);
|
}, [mx, labels, imagePacks, tab, getTopEmojis]);
|
||||||
|
|
||||||
const stickerGroupItems = useMemo(() => {
|
const stickerGroupItems = useMemo(() => {
|
||||||
const g: StickerGroupItem[] = [];
|
const g: StickerGroupItem[] = [];
|
||||||
@@ -187,10 +198,10 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
|
|||||||
<Sidebar>
|
<Sidebar>
|
||||||
<SidebarStack>
|
<SidebarStack>
|
||||||
<GroupIcon
|
<GroupIcon
|
||||||
active={activeGroupId === RECENT_GROUP_ID}
|
active={activeGroupId === MOST_USED_GROUP_ID}
|
||||||
id={RECENT_GROUP_ID}
|
id={MOST_USED_GROUP_ID}
|
||||||
label="Recent"
|
label="Most Used"
|
||||||
icon={Icons.RecentClock}
|
icon={Icons.Heart}
|
||||||
onClick={handleScrollToGroup}
|
onClick={handleScrollToGroup}
|
||||||
/>
|
/>
|
||||||
</SidebarStack>
|
</SidebarStack>
|
||||||
@@ -378,13 +389,30 @@ export function EmojiBoard({
|
|||||||
addToRecentEmoji = true,
|
addToRecentEmoji = true,
|
||||||
}: EmojiBoardProps) {
|
}: EmojiBoardProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const { trackEmojiUsage, getMostUsedEmoji } = useEmojiUsage();
|
||||||
|
|
||||||
const emojiTab = tab === EmojiBoardTab.Emoji;
|
const emojiTab = tab === EmojiBoardTab.Emoji;
|
||||||
const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker;
|
const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker;
|
||||||
|
|
||||||
|
// Get default emoji - use most used or fallback to slight_smile
|
||||||
|
const defaultPreview = useMemo((): PreviewData => {
|
||||||
|
if (!emojiTab) return undefined;
|
||||||
|
|
||||||
|
const mostUsedShortcode = getMostUsedEmoji();
|
||||||
|
if (mostUsedShortcode) {
|
||||||
|
// Find emoji by shortcode
|
||||||
|
const emoji = emojis.find((e) => e.shortcode === mostUsedShortcode);
|
||||||
|
if (emoji) {
|
||||||
|
return { key: emoji.unicode, shortcode: emoji.shortcode };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to default
|
||||||
|
return DefaultEmojiPreview;
|
||||||
|
}, [emojiTab, getMostUsedEmoji]);
|
||||||
|
|
||||||
const previewAtom = useMemo(
|
const previewAtom = useMemo(
|
||||||
() => createPreviewDataAtom(emojiTab ? DefaultEmojiPreview : undefined),
|
() => createPreviewDataAtom(defaultPreview),
|
||||||
[emojiTab]
|
[defaultPreview]
|
||||||
);
|
);
|
||||||
const activeGroupIdAtom = useMemo(() => atom<string | undefined>(undefined), []);
|
const activeGroupIdAtom = useMemo(() => atom<string | undefined>(undefined), []);
|
||||||
const setActiveGroupId = useSetAtom(activeGroupIdAtom);
|
const setActiveGroupId = useSetAtom(activeGroupIdAtom);
|
||||||
@@ -439,6 +467,10 @@ export function EmojiBoard({
|
|||||||
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||||
if (!evt.altKey && !evt.shiftKey && addToRecentEmoji) {
|
if (!evt.altKey && !evt.shiftKey && addToRecentEmoji) {
|
||||||
addRecentEmoji(mx, emojiInfo.data);
|
addRecentEmoji(mx, emojiInfo.data);
|
||||||
|
// Track emoji usage for favorites
|
||||||
|
trackEmojiUsage(emojiInfo.shortcode).catch((err) => {
|
||||||
|
console.error('Failed to track emoji usage:', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (emojiInfo.type === EmojiType.CustomEmoji) {
|
if (emojiInfo.type === EmojiType.CustomEmoji) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { VideoHTMLAttributes, forwardRef, useRef, useState, useEffect } from 'react';
|
import React, { VideoHTMLAttributes, forwardRef, useRef, useState, useEffect } from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Box, IconButton, Icon, Icons, Tooltip, TooltipProvider, Text } from 'folds';
|
import { Box, IconButton, Icon, Icons, Tooltip, TooltipProvider, Text } from 'folds';
|
||||||
import * as css from './media.css';
|
import * as css from './media.css';
|
||||||
@@ -6,16 +7,14 @@ import * as videoCss from './videoControls.css';
|
|||||||
|
|
||||||
type VideoProps = VideoHTMLAttributes<HTMLVideoElement> & {
|
type VideoProps = VideoHTMLAttributes<HTMLVideoElement> & {
|
||||||
disableControls?: boolean;
|
disableControls?: boolean;
|
||||||
|
onExpand?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Video = forwardRef<HTMLVideoElement, VideoProps>(
|
export const Video = forwardRef<HTMLVideoElement, VideoProps>(
|
||||||
({ className, disableControls, ...props }, ref) => {
|
({ className, disableControls, onExpand, ...props }, ref) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const [showControls, setShowControls] = useState(false);
|
const [showControls, setShowControls] = useState(false);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
||||||
|
|
||||||
// Combine refs
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof ref === 'function') {
|
if (typeof ref === 'function') {
|
||||||
ref(videoRef.current);
|
ref(videoRef.current);
|
||||||
@@ -24,98 +23,61 @@ export const Video = forwardRef<HTMLVideoElement, VideoProps>(
|
|||||||
}
|
}
|
||||||
}, [ref]);
|
}, [ref]);
|
||||||
|
|
||||||
// Track fullscreen state
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleFullscreenChange = () => {
|
if (!onExpand) return;
|
||||||
setIsFullscreen(!!document.fullscreenElement);
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
const handleFullscreenRequest = (evt: Event) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopImmediatePropagation();
|
||||||
|
onExpand();
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
video.addEventListener('webkitbeginfullscreen', handleFullscreenRequest);
|
||||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
return () => {
|
||||||
}, []);
|
video.removeEventListener('webkitbeginfullscreen', handleFullscreenRequest);
|
||||||
|
};
|
||||||
const handleFullscreen = async () => {
|
}, [onExpand]);
|
||||||
try {
|
|
||||||
if (!document.fullscreenElement && containerRef.current) {
|
|
||||||
await containerRef.current.requestFullscreen();
|
|
||||||
} else if (document.fullscreenElement) {
|
|
||||||
await document.exitFullscreen();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error toggling fullscreen:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePopOut = () => {
|
|
||||||
const videoSrc = videoRef.current?.src;
|
|
||||||
const videoTitle = props.title || 'Video';
|
|
||||||
|
|
||||||
if (videoSrc) {
|
|
||||||
const popoutWindow = window.open(
|
|
||||||
'',
|
|
||||||
'_blank',
|
|
||||||
'width=800,height=600,menubar=no,toolbar=no,location=no,status=no'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (popoutWindow) {
|
|
||||||
popoutWindow.document.write(`
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>${videoTitle}</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
background: #000;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
video {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<video controls autoplay src="${videoSrc}">
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`);
|
|
||||||
popoutWindow.document.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (disableControls) {
|
if (disableControls) {
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||||
<video className={classNames(css.Video, className)} {...props} ref={videoRef} />
|
<video
|
||||||
|
className={classNames(css.Video, className)}
|
||||||
|
{...props}
|
||||||
|
ref={videoRef}
|
||||||
|
loop={props.autoPlay}
|
||||||
|
muted={props.autoPlay}
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
ref={containerRef}
|
|
||||||
className={videoCss.VideoContainer}
|
className={videoCss.VideoContainer}
|
||||||
onMouseEnter={() => setShowControls(true)}
|
onMouseEnter={() => setShowControls(true)}
|
||||||
onMouseLeave={() => setShowControls(false)}
|
onMouseLeave={() => setShowControls(false)}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||||
<video className={classNames(css.Video, className)} {...props} ref={videoRef} />
|
<video
|
||||||
|
className={classNames(css.Video, className)}
|
||||||
|
{...props}
|
||||||
|
ref={videoRef}
|
||||||
|
loop={props.autoPlay}
|
||||||
|
muted={props.autoPlay}
|
||||||
|
playsInline
|
||||||
|
controlsList={onExpand ? 'nofullscreen' : undefined}
|
||||||
|
onDoubleClick={onExpand ? (e) => { e.stopPropagation(); onExpand(); } : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
{showControls && (
|
{onExpand && (
|
||||||
<Box className={videoCss.VideoControlsOverlay} gap="200">
|
<Box className={classNames(videoCss.VideoControlsOverlay, !showControls && videoCss.VideoControlsOverlayHidden)} gap="200">
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
tooltip={
|
tooltip={
|
||||||
<Tooltip variant="Secondary">
|
<Tooltip variant="Secondary">
|
||||||
<Text size="T200">{isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}</Text>
|
<Text size="T200">Expand</Text>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
@@ -127,36 +89,10 @@ export const Video = forwardRef<HTMLVideoElement, VideoProps>(
|
|||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
onClick={handleFullscreen}
|
onClick={onExpand}
|
||||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
aria-label="Expand video"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon size="50" src={Icons.Plus} />
|
||||||
size="200"
|
|
||||||
src={isFullscreen ? Icons.Minus : Icons.Plus}
|
|
||||||
/>
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
<TooltipProvider
|
|
||||||
tooltip={
|
|
||||||
<Tooltip variant="Secondary">
|
|
||||||
<Text size="T200">Pop Out</Text>
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
position="Bottom"
|
|
||||||
align="Center"
|
|
||||||
>
|
|
||||||
{(triggerRef) => (
|
|
||||||
<IconButton
|
|
||||||
ref={triggerRef}
|
|
||||||
variant="Secondary"
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
onClick={handlePopOut}
|
|
||||||
aria-label="Open in new window"
|
|
||||||
>
|
|
||||||
<Icon size="200" src={Icons.External} />
|
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { DefaultReset } from 'folds';
|
import { DefaultReset, toRem } from 'folds';
|
||||||
|
|
||||||
export const Image = style([
|
export const Image = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
objectFit: 'cover',
|
display: 'block',
|
||||||
width: '100%',
|
maxWidth: '100%',
|
||||||
height: '100%',
|
maxHeight: '100%',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
imageRendering: 'auto',
|
||||||
|
backfaceVisibility: 'hidden',
|
||||||
|
transform: 'translateZ(0)',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const Video = style([
|
export const Video = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
objectFit: 'contain',
|
display: 'block',
|
||||||
width: '100%',
|
maxWidth: '100%',
|
||||||
height: '100%',
|
maxHeight: '100%',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -24,5 +24,12 @@ export const VideoControlsOverlay = style([
|
|||||||
gap: config.space.S200,
|
gap: config.space.S200,
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
|
opacity: 1,
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const VideoControlsOverlayHidden = style({
|
||||||
|
opacity: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
|||||||
@@ -80,7 +80,10 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
|
|||||||
const { body, formatted_body: customBody } = content;
|
const { body, formatted_body: customBody } = content;
|
||||||
|
|
||||||
if (typeof body !== 'string') return <BrokenContent />;
|
if (typeof body !== 'string') return <BrokenContent />;
|
||||||
const trimmedBody = trimReplyFromBody(body);
|
const trimmedBody = trimReplyFromBody(body).trim();
|
||||||
|
const trimmedCustomBody = typeof customBody === 'string'
|
||||||
|
? customBody.trim().replace(/(<br\s*\/?>\s*)+$/gi, '')
|
||||||
|
: undefined;
|
||||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
||||||
|
|
||||||
@@ -93,7 +96,7 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
|
|||||||
>
|
>
|
||||||
{renderBody({
|
{renderBody({
|
||||||
body: trimmedBody,
|
body: trimmedBody,
|
||||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
customBody: trimmedCustomBody,
|
||||||
})}
|
})}
|
||||||
{edited && <MessageEditedContent />}
|
{edited && <MessageEditedContent />}
|
||||||
</MessageTextBody>
|
</MessageTextBody>
|
||||||
@@ -119,7 +122,10 @@ export function MEmote({
|
|||||||
const { body, formatted_body: customBody } = content;
|
const { body, formatted_body: customBody } = content;
|
||||||
|
|
||||||
if (typeof body !== 'string') return <BrokenContent />;
|
if (typeof body !== 'string') return <BrokenContent />;
|
||||||
const trimmedBody = trimReplyFromBody(body);
|
const trimmedBody = trimReplyFromBody(body).trim();
|
||||||
|
const trimmedCustomBody = typeof customBody === 'string'
|
||||||
|
? customBody.trim().replace(/(<br\s*\/?>\s*)+$/gi, '')
|
||||||
|
: undefined;
|
||||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
||||||
|
|
||||||
@@ -133,7 +139,7 @@ export function MEmote({
|
|||||||
<b>{`${displayName} `}</b>
|
<b>{`${displayName} `}</b>
|
||||||
{renderBody({
|
{renderBody({
|
||||||
body: trimmedBody,
|
body: trimmedBody,
|
||||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
customBody: trimmedCustomBody,
|
||||||
})}
|
})}
|
||||||
{edited && <MessageEditedContent />}
|
{edited && <MessageEditedContent />}
|
||||||
</MessageTextBody>
|
</MessageTextBody>
|
||||||
@@ -152,7 +158,10 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
|
|||||||
const { body, formatted_body: customBody } = content;
|
const { body, formatted_body: customBody } = content;
|
||||||
|
|
||||||
if (typeof body !== 'string') return <BrokenContent />;
|
if (typeof body !== 'string') return <BrokenContent />;
|
||||||
const trimmedBody = trimReplyFromBody(body);
|
const trimmedBody = trimReplyFromBody(body).trim();
|
||||||
|
const trimmedCustomBody = typeof customBody === 'string'
|
||||||
|
? customBody.trim().replace(/(<br\s*\/?>\s*)+$/gi, '')
|
||||||
|
: undefined;
|
||||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
||||||
|
|
||||||
@@ -165,7 +174,7 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
|
|||||||
>
|
>
|
||||||
{renderBody({
|
{renderBody({
|
||||||
body: trimmedBody,
|
body: trimmedBody,
|
||||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
customBody: trimmedCustomBody,
|
||||||
})}
|
})}
|
||||||
{edited && <MessageEditedContent />}
|
{edited && <MessageEditedContent />}
|
||||||
</MessageTextBody>
|
</MessageTextBody>
|
||||||
@@ -195,15 +204,10 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
|||||||
if (typeof mxcUrl !== 'string') {
|
if (typeof mxcUrl !== 'string') {
|
||||||
return <BrokenContent />;
|
return <BrokenContent />;
|
||||||
}
|
}
|
||||||
const height = scaleYDimension(imgInfo?.w || 400, 400, imgInfo?.h || 400);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Attachment outlined={outlined}>
|
<Attachment outlined={outlined} transparent>
|
||||||
<AttachmentBox
|
<AttachmentBox>
|
||||||
style={{
|
|
||||||
height: toRem(height < 48 ? 48 : height),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{renderImageContent({
|
{renderImageContent({
|
||||||
body: content.body || 'Image',
|
body: content.body || 'Image',
|
||||||
info: imgInfo,
|
info: imgInfo,
|
||||||
@@ -245,12 +249,10 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
|||||||
return <BrokenContent />;
|
return <BrokenContent />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400);
|
|
||||||
|
|
||||||
const filename = content.filename ?? content.body ?? 'Video';
|
const filename = content.filename ?? content.body ?? 'Video';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Attachment outlined={outlined}>
|
<Attachment outlined={outlined} transparent>
|
||||||
<AttachmentHeader>
|
<AttachmentHeader>
|
||||||
<FileHeader
|
<FileHeader
|
||||||
body={filename}
|
body={filename}
|
||||||
@@ -265,11 +267,7 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</AttachmentHeader>
|
</AttachmentHeader>
|
||||||
<AttachmentBox
|
<AttachmentBox>
|
||||||
style={{
|
|
||||||
height: toRem(height < 48 ? 48 : height),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{renderVideoContent({
|
{renderVideoContent({
|
||||||
body: content.body || 'Video',
|
body: content.body || 'Video',
|
||||||
info: videoInfo,
|
info: videoInfo,
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ export const Reply = style({
|
|||||||
|
|
||||||
export const ReplyContent = style({
|
export const ReplyContent = style({
|
||||||
opacity: config.opacity.P300,
|
opacity: config.opacity.P300,
|
||||||
|
overflow: 'clip',
|
||||||
|
overflowY: 'clip',
|
||||||
|
lineHeight: '1.5',
|
||||||
selectors: {
|
selectors: {
|
||||||
[`${Reply}:hover &`]: {
|
[`${Reply}:hover &`]: {
|
||||||
opacity: config.opacity.P500,
|
opacity: config.opacity.P500,
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ export const Attachment = recipe({
|
|||||||
color: color.SurfaceVariant.OnContainer,
|
color: color.SurfaceVariant.OnContainer,
|
||||||
borderRadius: config.radii.R400,
|
borderRadius: config.radii.R400,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
maxWidth: '100%',
|
maxWidth: toRem(400),
|
||||||
width: toRem(400),
|
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
outlined: {
|
outlined: {
|
||||||
@@ -17,6 +16,11 @@ export const Attachment = recipe({
|
|||||||
boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
|
boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
transparent: {
|
||||||
|
true: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,9 +33,8 @@ export const AttachmentHeader = style({
|
|||||||
export const AttachmentBox = style([
|
export const AttachmentBox = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
maxWidth: '100%',
|
maxWidth: toRem(400),
|
||||||
maxHeight: toRem(600),
|
maxHeight: toRem(400),
|
||||||
width: toRem(400),
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import classNames from 'classnames';
|
|||||||
import * as css from './Attachment.css';
|
import * as css from './Attachment.css';
|
||||||
|
|
||||||
export const Attachment = as<'div', css.AttachmentVariants>(
|
export const Attachment = as<'div', css.AttachmentVariants>(
|
||||||
({ className, outlined, ...props }, ref) => (
|
({ className, outlined, transparent, ...props }, ref) => (
|
||||||
<Box
|
<Box
|
||||||
display="InlineFlex"
|
display="InlineFlex"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
className={classNames(css.Attachment({ outlined }), className)}
|
className={classNames(css.Attachment({ outlined, transparent }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import {
|
|||||||
Chip,
|
Chip,
|
||||||
Icon,
|
Icon,
|
||||||
Icons,
|
Icons,
|
||||||
|
Modal,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -14,6 +18,7 @@ import {
|
|||||||
} from 'folds';
|
} from 'folds';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { BlurhashCanvas } from 'react-blurhash';
|
import { BlurhashCanvas } from 'react-blurhash';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
import {
|
import {
|
||||||
IThumbnailContent,
|
IThumbnailContent,
|
||||||
@@ -33,7 +38,14 @@ import {
|
|||||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { validBlurHash } from '../../../utils/blurHash';
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
|
import { ModalWide } from '../../../styles/Modal.css';
|
||||||
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
|
|
||||||
|
type RenderViewerProps = {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
type RenderVideoProps = {
|
type RenderVideoProps = {
|
||||||
title: string;
|
title: string;
|
||||||
src: string;
|
src: string;
|
||||||
@@ -41,6 +53,7 @@ type RenderVideoProps = {
|
|||||||
onError: () => void;
|
onError: () => void;
|
||||||
autoPlay: boolean;
|
autoPlay: boolean;
|
||||||
controls: boolean;
|
controls: boolean;
|
||||||
|
onExpand?: () => void;
|
||||||
};
|
};
|
||||||
type VideoContentProps = {
|
type VideoContentProps = {
|
||||||
body: string;
|
body: string;
|
||||||
@@ -52,6 +65,7 @@ type VideoContentProps = {
|
|||||||
markedAsSpoiler?: boolean;
|
markedAsSpoiler?: boolean;
|
||||||
spoilerReason?: string;
|
spoilerReason?: string;
|
||||||
renderThumbnail?: () => ReactNode;
|
renderThumbnail?: () => ReactNode;
|
||||||
|
renderViewer?: (props: RenderViewerProps) => ReactNode;
|
||||||
renderVideo: (props: RenderVideoProps) => ReactNode;
|
renderVideo: (props: RenderVideoProps) => ReactNode;
|
||||||
};
|
};
|
||||||
export const VideoContent = as<'div', VideoContentProps>(
|
export const VideoContent = as<'div', VideoContentProps>(
|
||||||
@@ -67,6 +81,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
markedAsSpoiler,
|
markedAsSpoiler,
|
||||||
spoilerReason,
|
spoilerReason,
|
||||||
renderThumbnail,
|
renderThumbnail,
|
||||||
|
renderViewer,
|
||||||
renderVideo,
|
renderVideo,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
@@ -78,6 +93,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
|
|
||||||
const [load, setLoad] = useState(false);
|
const [load, setLoad] = useState(false);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
const [viewer, setViewer] = useState(false);
|
||||||
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
||||||
|
|
||||||
const [srcState, loadSrc] = useAsyncCallback(
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
@@ -113,6 +129,28 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||||
|
{renderViewer && srcState.status === AsyncStatus.Success && (
|
||||||
|
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setViewer(false),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Modal className={ModalWide} size="500">
|
||||||
|
{renderViewer({
|
||||||
|
src: srcState.data,
|
||||||
|
alt: body,
|
||||||
|
requestClose: () => setViewer(false),
|
||||||
|
})}
|
||||||
|
</Modal>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
)}
|
||||||
{typeof blurHash === 'string' && !load && (
|
{typeof blurHash === 'string' && !load && (
|
||||||
<BlurhashCanvas
|
<BlurhashCanvas
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
@@ -154,6 +192,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
onError: handleError,
|
onError: handleError,
|
||||||
autoPlay: true,
|
autoPlay: true,
|
||||||
controls: true,
|
controls: true,
|
||||||
|
onExpand: renderViewer ? () => setViewer(true) : undefined,
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,19 +5,24 @@ export const RelativeBase = style([
|
|||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: '100%',
|
display: 'flex',
|
||||||
height: '100%',
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
borderRadius: '7px',
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const AbsoluteContainer = style([
|
export const AbsoluteContainer = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
position: 'absolute',
|
display: 'flex',
|
||||||
top: 0,
|
alignItems: 'center',
|
||||||
left: 0,
|
justifyContent: 'center',
|
||||||
width: '100%',
|
maxWidth: '100%',
|
||||||
height: '100%',
|
maxHeight: '100%',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -188,8 +188,10 @@ export const BubbleLeftArrow = style({
|
|||||||
|
|
||||||
export const Username = style({
|
export const Username = style({
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
overflowY: 'clip',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
|
lineHeight: '1.5',
|
||||||
// Allow text selection while keeping button clickable
|
// Allow text selection while keeping button clickable
|
||||||
userSelect: 'text',
|
userSelect: 'text',
|
||||||
WebkitUserSelect: 'text',
|
WebkitUserSelect: 'text',
|
||||||
@@ -210,6 +212,9 @@ export const UsernameBold = style({
|
|||||||
export const MessageTextBody = recipe({
|
export const MessageTextBody = recipe({
|
||||||
base: {
|
base: {
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
|
overflow: 'clip',
|
||||||
|
overflowY: 'clip',
|
||||||
|
lineHeight: '1.5',
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
preWrap: {
|
preWrap: {
|
||||||
|
|||||||
22
src/app/components/url-preview/GiphyEmbed.css.tsx
Normal file
22
src/app/components/url-preview/GiphyEmbed.css.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
]);
|
||||||
64
src/app/components/url-preview/GiphyEmbed.tsx
Normal file
64
src/app/components/url-preview/GiphyEmbed.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -2,3 +2,4 @@ export * from './UrlPreview';
|
|||||||
export * from './UrlPreviewCard';
|
export * from './UrlPreviewCard';
|
||||||
export * from './YouTubeEmbed';
|
export * from './YouTubeEmbed';
|
||||||
export * from './TikTokEmbed';
|
export * from './TikTokEmbed';
|
||||||
|
export * from './GiphyEmbed';
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
|
|||||||
const bannerUrl = useOtherUserBanner(userId, avatarMxc); // Now returns blob URL directly
|
const bannerUrl = useOtherUserBanner(userId, avatarMxc); // Now returns blob URL directly
|
||||||
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
|
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
|
||||||
|
|
||||||
|
console.log('[UserHero]', {
|
||||||
|
userId,
|
||||||
|
avatarMxc,
|
||||||
|
bannerUrl,
|
||||||
|
profileStyle,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
direction="Column"
|
direction="Column"
|
||||||
@@ -125,6 +132,13 @@ export function UserHeroName({ displayName, userId, avatarMxc }: UserHeroNamePro
|
|||||||
const username = getMxIdLocalPart(userId);
|
const username = getMxIdLocalPart(userId);
|
||||||
const userColor = useOtherUserColor(userId, avatarMxc);
|
const userColor = useOtherUserColor(userId, avatarMxc);
|
||||||
|
|
||||||
|
console.log('[UserHeroName]', {
|
||||||
|
userId,
|
||||||
|
avatarMxc,
|
||||||
|
userColor,
|
||||||
|
fallbackColor: colorMXID(userId),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" direction="Column" gap="100">
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
<Box alignItems="Baseline" gap="200" wrap="Wrap">
|
<Box alignItems="Baseline" gap="200" wrap="Wrap">
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ import { DirectCreateSearchParams } from '../../pages/paths';
|
|||||||
import { getContrastingTextColor, stripAlphaFromColor, getTextShadowColor } from '../../utils/common';
|
import { getContrastingTextColor, stripAlphaFromColor, getTextShadowColor } from '../../utils/common';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { BreakWord, LineClamp3 } from '../../styles/Text.css';
|
import { BreakWord, LineClamp3 } from '../../styles/Text.css';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID, { getColorMXIDValue } from '../../../util/colorMXID';
|
||||||
|
import { useOtherUserColor } from '../../hooks/useUserColor';
|
||||||
|
|
||||||
type UserRoomProfileProps = {
|
type UserRoomProfileProps = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -64,6 +65,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
|
|
||||||
const presence = useUserPresence(userId);
|
const presence = useUserPresence(userId);
|
||||||
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
|
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
|
||||||
|
const userColor = useOtherUserColor(userId, avatarMxc);
|
||||||
|
|
||||||
// Build gradient CSS if configured
|
// Build gradient CSS if configured
|
||||||
const gradientStyle = profileStyle.gradient
|
const gradientStyle = profileStyle.gradient
|
||||||
@@ -87,9 +89,25 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
(canKickUser && membership === Membership.Join) ||
|
(canKickUser && membership === Membership.Join) ||
|
||||||
(canBanUser && membership !== Membership.Ban);
|
(canBanUser && membership !== Membership.Ban);
|
||||||
|
|
||||||
const profileColor = profileStyle.avatarBorderColor ? stripAlphaFromColor(profileStyle.avatarBorderColor) : colorMXID(userId);
|
// Use userColor for text, or fall back to colorMXID
|
||||||
|
const profileColor = userColor ? userColor : getColorMXIDValue(userId);
|
||||||
const profileTextShadow = `0 1px 4px ${getTextShadowColor(profileColor)}`;
|
const profileTextShadow = `0 1px 4px ${getTextShadowColor(profileColor)}`;
|
||||||
const pillBgColor = profileStyle.avatarBorderColor ? stripAlphaFromColor(profileStyle.avatarBorderColor) : undefined;
|
|
||||||
|
// Use avatarBorderColor for pill background only if it has visible alpha (not fully transparent)
|
||||||
|
const hasVisibleBorder = profileStyle.avatarBorderColor && !profileStyle.avatarBorderColor.endsWith('00');
|
||||||
|
const pillBgColor = hasVisibleBorder ? stripAlphaFromColor(profileStyle.avatarBorderColor!) : undefined;
|
||||||
|
|
||||||
|
console.log('[UserRoomProfile]', {
|
||||||
|
userId,
|
||||||
|
avatarMxc,
|
||||||
|
userColor,
|
||||||
|
profileStyle,
|
||||||
|
profileColor,
|
||||||
|
profileTextShadow,
|
||||||
|
hasVisibleBorder,
|
||||||
|
pillBgColor,
|
||||||
|
fallbackColorMXID: getColorMXIDValue(userId),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column">
|
<Box direction="Column">
|
||||||
|
|||||||
40
src/app/components/video-viewer/VideoViewer.css.ts
Normal file
40
src/app/components/video-viewer/VideoViewer.css.ts
Normal 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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
78
src/app/components/video-viewer/VideoViewer.tsx
Normal file
78
src/app/components/video-viewer/VideoViewer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
1
src/app/components/video-viewer/index.ts
Normal file
1
src/app/components/video-viewer/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './VideoViewer';
|
||||||
316
src/app/features/ShareRoomPicker.tsx
Normal file
316
src/app/features/ShareRoomPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,7 +22,15 @@ import {
|
|||||||
LiveKitJWTResponse,
|
LiveKitJWTResponse,
|
||||||
CallMemberEventContent,
|
CallMemberEventContent,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { fetchLiveKitJWT, fetchWellKnownWithRTC, getLiveKitFocus, requestOpenIdToken } from './useRtcConfig';
|
import {
|
||||||
|
fetchLiveKitJWT,
|
||||||
|
fetchWellKnownWithRTC,
|
||||||
|
getLiveKitFocus,
|
||||||
|
requestOpenIdToken,
|
||||||
|
fetchLiveKitJWTFromServers,
|
||||||
|
getLiveKitHomeserverPriority,
|
||||||
|
} from './useRtcConfig';
|
||||||
|
import { getActiveCallMembers } from './useRoomCallMembers';
|
||||||
import { getAudioSettings, SCREEN_SHARE_RESOLUTIONS, SCREEN_SHARE_BITRATES } from '../settings/audio/Audio';
|
import { getAudioSettings, SCREEN_SHARE_RESOLUTIONS, SCREEN_SHARE_BITRATES } from '../settings/audio/Audio';
|
||||||
import { getCallSounds, CallSoundType } from './CallSounds';
|
import { getCallSounds, CallSoundType } from './CallSounds';
|
||||||
|
|
||||||
@@ -174,6 +182,25 @@ export class CallService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const stateKey = this.config.userId;
|
const stateKey = this.config.userId;
|
||||||
|
|
||||||
|
// Check if user has permission to send this state event
|
||||||
|
const room = this.matrixClient.getRoom(roomId);
|
||||||
|
if (room) {
|
||||||
|
const powerLevelsEvent = room.currentState.getStateEvents('m.room.power_levels', '');
|
||||||
|
const powerLevelsContent = powerLevelsEvent?.getContent() || {};
|
||||||
|
|
||||||
|
const myPower = powerLevelsContent.users?.[this.config.userId] ?? powerLevelsContent.users_default ?? 0;
|
||||||
|
const requiredPower = powerLevelsContent.events?.[CALL_MEMBER_EVENT_TYPE] ?? powerLevelsContent.state_default ?? 50;
|
||||||
|
|
||||||
|
if (myPower < requiredPower) {
|
||||||
|
console.warn(
|
||||||
|
`Cannot send call member event: insufficient permissions. ` +
|
||||||
|
`User power level (${myPower}) < required power level (${requiredPower}). ` +
|
||||||
|
`Call will work locally but room members won't see your call status.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (active) {
|
if (active) {
|
||||||
const content: CallMemberEventContent = {
|
const content: CallMemberEventContent = {
|
||||||
'm.calls': [
|
'm.calls': [
|
||||||
@@ -293,23 +320,59 @@ export class CallService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Get an OpenID token from the Matrix homeserver
|
// Step 1: Get room and determine which homeservers to try
|
||||||
const openIdToken = await requestOpenIdToken(
|
const room = this.matrixClient.getRoom(roomId);
|
||||||
|
if (!room) {
|
||||||
|
throw new Error(`Room ${roomId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.homeserverBaseUrl,
|
||||||
this.config.userId,
|
this.config.userId
|
||||||
this.config.accessToken
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Step 2: Exchange OpenID token for LiveKit JWT
|
console.log('Trying homeservers in order:', homeserverPriority);
|
||||||
console.log('Requesting LiveKit JWT for Matrix room:', roomId);
|
|
||||||
this.livekitJwt = await fetchLiveKitJWT(
|
// Step 2: Try fetching LiveKit JWT from homeservers in priority order
|
||||||
this.config.livekitServiceUrl,
|
const result = await fetchLiveKitJWTFromServers(
|
||||||
|
homeserverPriority,
|
||||||
|
this.config.homeserverBaseUrl,
|
||||||
roomId,
|
roomId,
|
||||||
this.config.userId,
|
this.config.userId,
|
||||||
this.config.deviceId,
|
this.config.deviceId,
|
||||||
openIdToken
|
this.config.accessToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('Failed to get LiveKit JWT from any homeserver');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.livekitJwt = result.jwt;
|
||||||
|
|
||||||
|
// Track if using remote homeserver
|
||||||
|
const userHomeserverDomain = this.config.homeserverBaseUrl
|
||||||
|
.replace('https://', '')
|
||||||
|
.replace('http://', '');
|
||||||
|
const usedHomeserverDomain = result.homeserver
|
||||||
|
.replace('https://', '')
|
||||||
|
.replace('http://', '');
|
||||||
|
|
||||||
|
if (usedHomeserverDomain !== userHomeserverDomain) {
|
||||||
|
this.activeCall!.livekitHomeserver = result.homeserver;
|
||||||
|
console.log(`Using remote homeserver for LiveKit: ${result.homeserver}`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Got LiveKit JWT, connecting to:', this.livekitJwt.url);
|
console.log('Got LiveKit JWT, connecting to:', this.livekitJwt.url);
|
||||||
console.log('LiveKit JWT response:', JSON.stringify(this.livekitJwt));
|
console.log('LiveKit JWT response:', JSON.stringify(this.livekitJwt));
|
||||||
|
|
||||||
@@ -918,6 +981,22 @@ export class CallService {
|
|||||||
return this.livekitRoom;
|
return this.livekitRoom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the current call is using a remote homeserver for LiveKit
|
||||||
|
* @returns true if using a different homeserver than the user's
|
||||||
|
*/
|
||||||
|
isUsingRemoteHomeserver(): boolean {
|
||||||
|
return !!this.activeCall?.livekitHomeserver;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the homeserver being used for LiveKit (if remote)
|
||||||
|
* @returns The remote homeserver URL, or undefined if using user's homeserver
|
||||||
|
*/
|
||||||
|
getLiveKitHomeserver(): string | undefined {
|
||||||
|
return this.activeCall?.livekitHomeserver;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts screen sharing with configurable resolution and bitrate
|
* Starts screen sharing with configurable resolution and bitrate
|
||||||
* @returns true if screen share started successfully
|
* @returns true if screen share started successfully
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export function IncomingCallNotification() {
|
|||||||
const { activeCall, startCall } = useCall();
|
const { activeCall, startCall } = useCall();
|
||||||
const [incomingCall, setIncomingCall] = useState<IncomingCall | null>(null);
|
const [incomingCall, setIncomingCall] = useState<IncomingCall | null>(null);
|
||||||
const [declinedRooms, setDeclinedRooms] = useState<Set<string>>(new Set());
|
const [declinedRooms, setDeclinedRooms] = useState<Set<string>>(new Set());
|
||||||
|
const [seenCalls, setSeenCalls] = useState<Map<string, number>>(new Map());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks all rooms for incoming calls
|
* Checks all rooms for incoming calls
|
||||||
@@ -118,15 +119,25 @@ export function IncomingCallNotification() {
|
|||||||
|
|
||||||
// Check all joined rooms for calls
|
// Check all joined rooms for calls
|
||||||
const rooms = mx.getRooms();
|
const rooms = mx.getRooms();
|
||||||
|
const now = Date.now();
|
||||||
|
const MAX_CALL_AGE = 10000; // 10 seconds
|
||||||
|
|
||||||
// Find the first room with an incoming call
|
// Find the first room with an incoming call
|
||||||
const incomingRoom = rooms.find((room) => {
|
const incomingRoom = rooms.find((room) => {
|
||||||
// Only monitor DMs and small groups
|
// Only monitor 1-1 DMs (exactly 2 members)
|
||||||
if (!isDirectOrSmallGroup(room)) return false;
|
if (room.getJoinedMemberCount() !== 2) return false;
|
||||||
// Skip rooms we've declined
|
// Skip rooms we've declined
|
||||||
if (declinedRooms.has(room.roomId)) return false;
|
if (declinedRooms.has(room.roomId)) return false;
|
||||||
const { members } = getOtherCallMembersWithTime(room, myUserId);
|
const { members, earliestTime } = getOtherCallMembersWithTime(room, myUserId);
|
||||||
return members.length > 0;
|
if (members.length === 0) return false;
|
||||||
|
|
||||||
|
// Skip if we've already seen this exact call session
|
||||||
|
const lastSeenTime = seenCalls.get(room.roomId);
|
||||||
|
if (lastSeenTime && lastSeenTime === earliestTime) return false;
|
||||||
|
|
||||||
|
// Only show if the call is fresh (less than 10 seconds old)
|
||||||
|
const callAge = now - earliestTime;
|
||||||
|
return callAge < MAX_CALL_AGE;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (incomingRoom) {
|
if (incomingRoom) {
|
||||||
@@ -140,28 +151,33 @@ export function IncomingCallNotification() {
|
|||||||
} else {
|
} else {
|
||||||
setIncomingCall(null);
|
setIncomingCall(null);
|
||||||
}
|
}
|
||||||
}, [mx, activeCall, declinedRooms]);
|
}, [mx, activeCall, declinedRooms, seenCalls]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles state events for call member changes
|
* Handles state events for call member changes
|
||||||
*/
|
*/
|
||||||
const handleStateEvent = useCallback((event: MatrixEvent) => {
|
const handleStateEvent = useCallback((event: MatrixEvent) => {
|
||||||
if (event.getType() === CALL_MEMBER_EVENT_TYPE) {
|
if (event.getType() === CALL_MEMBER_EVENT_TYPE) {
|
||||||
// Clear declined status if call ended in that room
|
// Clear declined status and seen calls if call ended in that room
|
||||||
const roomId = event.getRoomId();
|
const roomId = event.getRoomId();
|
||||||
if (roomId && declinedRooms.has(roomId)) {
|
if (roomId) {
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
if (room) {
|
if (room) {
|
||||||
const myUserId = mx.getUserId();
|
const myUserId = mx.getUserId();
|
||||||
if (myUserId) {
|
if (myUserId) {
|
||||||
const members = getOtherCallMembers(room, myUserId);
|
const members = getOtherCallMembers(room, myUserId);
|
||||||
if (members.length === 0) {
|
if (members.length === 0) {
|
||||||
// Call ended, remove from declined
|
// Call ended, clear tracking
|
||||||
setDeclinedRooms((prev) => {
|
setDeclinedRooms((prev) => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
newSet.delete(roomId);
|
newSet.delete(roomId);
|
||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
|
setSeenCalls((prev) => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
newMap.delete(roomId);
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,9 +199,12 @@ export function IncomingCallNotification() {
|
|||||||
}, [mx, handleStateEvent, checkForIncomingCalls]);
|
}, [mx, handleStateEvent, checkForIncomingCalls]);
|
||||||
|
|
||||||
// Play/stop dialing sound based on incoming call state
|
// Play/stop dialing sound based on incoming call state
|
||||||
// Only play sound if call started less than 15 seconds ago
|
// Mark call as seen when displayed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (incomingCall) {
|
if (incomingCall) {
|
||||||
|
// Mark this call as seen so we don't notify again
|
||||||
|
setSeenCalls((prev) => new Map(prev).set(incomingCall.roomId, incomingCall.startTime));
|
||||||
|
|
||||||
const callAge = Date.now() - incomingCall.startTime;
|
const callAge = Date.now() - incomingCall.startTime;
|
||||||
const MAX_CALL_AGE_FOR_SOUND = 15000; // 15 seconds
|
const MAX_CALL_AGE_FOR_SOUND = 15000; // 15 seconds
|
||||||
|
|
||||||
@@ -212,6 +231,7 @@ export function IncomingCallNotification() {
|
|||||||
if (!incomingCall) return;
|
if (!incomingCall) return;
|
||||||
|
|
||||||
getCallSounds().stopDialingLoop();
|
getCallSounds().stopDialingLoop();
|
||||||
|
setSeenCalls((prev) => new Map(prev).set(incomingCall.roomId, incomingCall.startTime));
|
||||||
try {
|
try {
|
||||||
await startCall(incomingCall.roomId, CallType.Voice);
|
await startCall(incomingCall.roomId, CallType.Voice);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -226,6 +246,7 @@ export function IncomingCallNotification() {
|
|||||||
if (!incomingCall) return;
|
if (!incomingCall) return;
|
||||||
|
|
||||||
getCallSounds().stopDialingLoop();
|
getCallSounds().stopDialingLoop();
|
||||||
|
setSeenCalls((prev) => new Map(prev).set(incomingCall.roomId, incomingCall.startTime));
|
||||||
setDeclinedRooms((prev) => new Set(prev).add(incomingCall.roomId));
|
setDeclinedRooms((prev) => new Set(prev).add(incomingCall.roomId));
|
||||||
setIncomingCall(null);
|
setIncomingCall(null);
|
||||||
}, [incomingCall]);
|
}, [incomingCall]);
|
||||||
@@ -237,6 +258,7 @@ export function IncomingCallNotification() {
|
|||||||
|
|
||||||
const room = mx.getRoom(incomingCall.roomId);
|
const room = mx.getRoom(incomingCall.roomId);
|
||||||
const firstCaller = incomingCall.callerIds[0];
|
const firstCaller = incomingCall.callerIds[0];
|
||||||
|
const isGroupCall = room ? room.getJoinedMemberCount() > 2 : false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the display name for a user
|
* Gets the display name for a user
|
||||||
@@ -287,10 +309,12 @@ export function IncomingCallNotification() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T200" className={css.RoomName}>
|
{!isGroupCall && (
|
||||||
<Icon size="100" src={Icons.Phone} />
|
<Text size="T200" className={css.RoomName}>
|
||||||
<span>Calling from {incomingCall.roomName}</span>
|
<Icon size="100" src={Icons.Phone} />
|
||||||
</Text>
|
<span>Calling from {incomingCall.roomName}</span>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ export interface ActiveCall {
|
|||||||
isDeafened: boolean;
|
isDeafened: boolean;
|
||||||
isVideoEnabled: boolean;
|
isVideoEnabled: boolean;
|
||||||
isScreenSharing: boolean;
|
isScreenSharing: boolean;
|
||||||
|
/** The homeserver being used for LiveKit JWT (if different from user's homeserver) */
|
||||||
|
livekitHomeserver?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,7 +152,8 @@ export interface CallEvent {
|
|||||||
* Call service configuration
|
* Call service configuration
|
||||||
*/
|
*/
|
||||||
export interface CallServiceConfig {
|
export interface CallServiceConfig {
|
||||||
livekitServiceUrl: string;
|
/** @deprecated No longer used - JWT fetched dynamically from multiple homeservers */
|
||||||
|
livekitServiceUrl?: string;
|
||||||
homeserverBaseUrl: string;
|
homeserverBaseUrl: string;
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createContext, useContext, useState, useCallback, useEffect, useMemo }
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { CallService } from './CallService';
|
import { CallService } from './CallService';
|
||||||
import { ActiveCall, CallEvent, CallEventType, CallState, CallType } from './types';
|
import { ActiveCall, CallEvent, CallEventType, CallState, CallType } from './types';
|
||||||
import { fetchWellKnownWithRTC, getLiveKitFocus } from './useRtcConfig';
|
import { fetchWellKnownWithRTC, getLiveKitFocus, getLiveKitHomeserverPriority } from './useRtcConfig';
|
||||||
|
|
||||||
interface CallContextValue {
|
interface CallContextValue {
|
||||||
callService: CallService | null;
|
callService: CallService | null;
|
||||||
@@ -82,26 +82,12 @@ export function useCallService(): CallContextValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get LiveKit service URL from Matrix homeserver well-known
|
// Initialize CallService without checking user's homeserver
|
||||||
const wellKnown = await fetchWellKnownWithRTC(baseUrl);
|
// Call support will be determined per-room based on available homeservers
|
||||||
// eslint-disable-next-line no-console
|
console.log('Call service init - Initializing (call support determined per-room)');
|
||||||
console.log('Call service init - well-known:', wellKnown);
|
|
||||||
const livekitFocus = getLiveKitFocus(wellKnown);
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log('Call service init - LiveKit focus:', livekitFocus);
|
|
||||||
const livekitServiceUrl = livekitFocus?.livekit_service_url;
|
|
||||||
|
|
||||||
if (!livekitServiceUrl) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log('Call service init - No LiveKit config in well-known, calls not supported');
|
|
||||||
setCallSupported(false);
|
|
||||||
setCallSupportLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
service = new CallService(
|
service = new CallService(
|
||||||
{
|
{
|
||||||
livekitServiceUrl,
|
|
||||||
homeserverBaseUrl: baseUrl,
|
homeserverBaseUrl: baseUrl,
|
||||||
accessToken,
|
accessToken,
|
||||||
userId,
|
userId,
|
||||||
@@ -112,10 +98,10 @@ export function useCallService(): CallContextValue {
|
|||||||
|
|
||||||
setCallService(service);
|
setCallService(service);
|
||||||
setGlobalCallService(service); // Make available globally for Paarrot API
|
setGlobalCallService(service); // Make available globally for Paarrot API
|
||||||
setCallSupported(true);
|
setCallSupported(true); // Global flag - actual support checked per-room
|
||||||
setCallSupportLoading(false);
|
setCallSupportLoading(false);
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log('Call service init - Success! Calls are supported');
|
console.log('Call service init - Success!');
|
||||||
|
|
||||||
unsubscribe = service.on((event: CallEvent) => {
|
unsubscribe = service.on((event: CallEvent) => {
|
||||||
// Update state for any event that might change the call state
|
// Update state for any event that might change the call state
|
||||||
@@ -157,7 +143,7 @@ export function useCallService(): CallContextValue {
|
|||||||
service?.dispose();
|
service?.dispose();
|
||||||
setGlobalCallService(null); // Clear global reference
|
setGlobalCallService(null); // Clear global reference
|
||||||
};
|
};
|
||||||
}, [mx, configuredLivekitUrl]);
|
}, [mx]);
|
||||||
|
|
||||||
const startCall = useCallback(
|
const startCall = useCallback(
|
||||||
async (roomId: string, callType: CallType) => {
|
async (roomId: string, callType: CallType) => {
|
||||||
@@ -245,10 +231,90 @@ export function useCallService(): CallContextValue {
|
|||||||
* Hook to check if a call is active in a specific room
|
* Hook to check if a call is active in a specific room
|
||||||
*/
|
*/
|
||||||
export function useRoomCall(roomId: string) {
|
export function useRoomCall(roomId: string) {
|
||||||
const { activeCall, startCall, endCall, callSupported, callSupportLoading } = useCall();
|
const { activeCall, startCall, endCall, callSupported: globalCallSupported, callSupportLoading: globalCallSupportLoading, callService } = useCall();
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [roomCallSupported, setRoomCallSupported] = useState(false);
|
||||||
|
const [roomCallSupportLoading, setRoomCallSupportLoading] = useState(true);
|
||||||
|
|
||||||
|
// Check if calls are supported for this specific room by checking all priority homeservers
|
||||||
|
useEffect(() => {
|
||||||
|
const checkRoomCallSupport = async () => {
|
||||||
|
setRoomCallSupportLoading(true);
|
||||||
|
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) {
|
||||||
|
setRoomCallSupported(false);
|
||||||
|
setRoomCallSupportLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get homeserver priority list for this room
|
||||||
|
const homeserverPriority = getLiveKitHomeserverPriority(
|
||||||
|
room,
|
||||||
|
mx.getHomeserverUrl(),
|
||||||
|
mx.getSafeUserId()
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Checking call support for room ${roomId}, homeserver priority:`, homeserverPriority);
|
||||||
|
|
||||||
|
// Check each homeserver in priority order
|
||||||
|
for (const homeserver of homeserverPriority) {
|
||||||
|
try {
|
||||||
|
console.log(`Fetching well-known from ${homeserver}...`);
|
||||||
|
const wellKnown = await fetchWellKnownWithRTC(homeserver);
|
||||||
|
console.log(`Well-known response from ${homeserver}:`, wellKnown);
|
||||||
|
|
||||||
|
const livekitFocus = getLiveKitFocus(wellKnown);
|
||||||
|
console.log(`LiveKit focus from ${homeserver}:`, livekitFocus);
|
||||||
|
|
||||||
|
if (livekitFocus) {
|
||||||
|
console.log(`Call support found for room ${roomId} on homeserver: ${homeserver}`);
|
||||||
|
setRoomCallSupported(true);
|
||||||
|
setRoomCallSupportLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to check call support on ${homeserver}:`, error);
|
||||||
|
// Continue to next homeserver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No homeserver supports calls
|
||||||
|
console.log(`No call support found for room ${roomId}`);
|
||||||
|
setRoomCallSupported(false);
|
||||||
|
setRoomCallSupportLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkRoomCallSupport();
|
||||||
|
}, [roomId, mx]);
|
||||||
|
|
||||||
const isInCall = activeCall?.roomId === roomId;
|
const isInCall = activeCall?.roomId === roomId;
|
||||||
const callState = isInCall ? activeCall.state : CallState.Idle;
|
const callState = isInCall ? activeCall.state : CallState.Idle;
|
||||||
|
const isUsingRemoteHomeserver = isInCall ? callService?.isUsingRemoteHomeserver() ?? false : false;
|
||||||
|
|
||||||
|
// Check if a remote homeserver WOULD be used (pre-call check)
|
||||||
|
const wouldUseRemoteHomeserver = useMemo(() => {
|
||||||
|
if (isInCall) return isUsingRemoteHomeserver;
|
||||||
|
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) return false;
|
||||||
|
|
||||||
|
const homeserverPriority = getLiveKitHomeserverPriority(
|
||||||
|
room,
|
||||||
|
mx.getHomeserverUrl(),
|
||||||
|
mx.getSafeUserId()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if first priority is different from user's homeserver
|
||||||
|
const userHomeserverDomain = mx.getHomeserverUrl()
|
||||||
|
.replace('https://', '')
|
||||||
|
.replace('http://', '');
|
||||||
|
const firstPriorityDomain = homeserverPriority[0]
|
||||||
|
?.replace('https://', '')
|
||||||
|
.replace('http://', '');
|
||||||
|
|
||||||
|
return firstPriorityDomain !== userHomeserverDomain;
|
||||||
|
}, [isInCall, isUsingRemoteHomeserver, mx, roomId]);
|
||||||
|
|
||||||
const startVoiceCall = useCallback(() => startCall(roomId, CallType.Voice), [roomId, startCall]);
|
const startVoiceCall = useCallback(() => startCall(roomId, CallType.Voice), [roomId, startCall]);
|
||||||
const startVideoCall = useCallback(() => startCall(roomId, CallType.Video), [roomId, startCall]);
|
const startVideoCall = useCallback(() => startCall(roomId, CallType.Video), [roomId, startCall]);
|
||||||
@@ -257,10 +323,11 @@ export function useRoomCall(roomId: string) {
|
|||||||
isInCall,
|
isInCall,
|
||||||
callState,
|
callState,
|
||||||
activeCall: isInCall ? activeCall : null,
|
activeCall: isInCall ? activeCall : null,
|
||||||
|
isUsingRemoteHomeserver: wouldUseRemoteHomeserver,
|
||||||
startVoiceCall,
|
startVoiceCall,
|
||||||
startVideoCall,
|
startVideoCall,
|
||||||
endCall,
|
endCall,
|
||||||
callSupported,
|
callSupported: roomCallSupported,
|
||||||
callSupportLoading,
|
callSupportLoading: roomCallSupportLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export interface CallMember {
|
|||||||
* @param room - The Matrix room to check
|
* @param room - The Matrix room to check
|
||||||
* @returns Array of active call members
|
* @returns Array of active call members
|
||||||
*/
|
*/
|
||||||
function getActiveCallMembers(room: Room): CallMember[] {
|
export function getActiveCallMembers(room: Room): CallMember[] {
|
||||||
const members: CallMember[] = [];
|
const members: CallMember[] = [];
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,109 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { LiveKitFocus, LiveKitJWTResponse, OpenIdToken, WellKnownWithRTC } from './types';
|
import { LiveKitFocus, LiveKitJWTResponse, OpenIdToken, WellKnownWithRTC } from './types';
|
||||||
|
import { getMxIdServer } from '../../utils/matrix';
|
||||||
|
import { guessDmRoomUserId } from '../../utils/matrix';
|
||||||
|
import { getActiveCallMembers } from './useRoomCallMembers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get room server from room ID (format: !roomid:server.com)
|
||||||
|
*/
|
||||||
|
function getRoomServer(roomId: string): string | undefined {
|
||||||
|
const server = getMxIdServer(roomId);
|
||||||
|
console.log(`[getRoomServer] Room ID: ${roomId} -> Server: ${server}`);
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine which homeservers to try for LiveKit JWT, in priority order
|
||||||
|
* Prioritizes servers where active call members are located when joining existing calls
|
||||||
|
* @param room - Room to start/join call in
|
||||||
|
* @param userHomeserver - User's homeserver
|
||||||
|
* @param myUserId - Current user's Matrix ID
|
||||||
|
* @returns Array of homeserver base URLs to try in order
|
||||||
|
*/
|
||||||
|
export function getLiveKitHomeserverPriority(
|
||||||
|
room: Room,
|
||||||
|
userHomeserver: string,
|
||||||
|
myUserId: string
|
||||||
|
): string[] {
|
||||||
|
const servers: string[] = [];
|
||||||
|
const memberCount = room.getMembers().length;
|
||||||
|
const isDM = memberCount <= 2;
|
||||||
|
|
||||||
|
console.log(`[getLiveKitHomeserverPriority] Room: ${room.roomId}`);
|
||||||
|
console.log(`[getLiveKitHomeserverPriority] Member count: ${memberCount}, isDM: ${isDM}`);
|
||||||
|
console.log(`[getLiveKitHomeserverPriority] User homeserver: ${userHomeserver}`);
|
||||||
|
|
||||||
|
// Check for active call members
|
||||||
|
const activeCallMembers = getActiveCallMembers(room);
|
||||||
|
const hasActiveCall = activeCallMembers.length > 0;
|
||||||
|
|
||||||
|
console.log(`[getLiveKitHomeserverPriority] Active call members: ${activeCallMembers.length}, hasActiveCall: ${hasActiveCall}`);
|
||||||
|
|
||||||
|
if (hasActiveCall) {
|
||||||
|
// Joining existing call - prioritize room's homeserver (where call is hosted)
|
||||||
|
const roomServer = getRoomServer(room.roomId);
|
||||||
|
console.log(`[getLiveKitHomeserverPriority] Room server (active call): ${roomServer}`);
|
||||||
|
if (roomServer) {
|
||||||
|
servers.push(`https://${roomServer}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add user's homeserver as fallback
|
||||||
|
const userDomain = userHomeserver.replace('https://', '').replace('http://', '');
|
||||||
|
if (!roomServer || roomServer !== userDomain) {
|
||||||
|
servers.push(userHomeserver);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For DMs, also try other member's homeserver
|
||||||
|
if (isDM) {
|
||||||
|
const otherUserId = guessDmRoomUserId(room, myUserId);
|
||||||
|
if (otherUserId && otherUserId !== myUserId) {
|
||||||
|
const otherServer = getMxIdServer(otherUserId);
|
||||||
|
if (otherServer && otherServer !== userDomain && otherServer !== roomServer) {
|
||||||
|
servers.push(`https://${otherServer}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Starting new call - use original priority logic
|
||||||
|
if (isDM) {
|
||||||
|
console.log(`[getLiveKitHomeserverPriority] DM path - user homeserver first`);
|
||||||
|
// For DMs: user's homeserver first, then other member's homeserver
|
||||||
|
servers.push(userHomeserver);
|
||||||
|
|
||||||
|
const otherUserId = guessDmRoomUserId(room, myUserId);
|
||||||
|
console.log(`[getLiveKitHomeserverPriority] Other user ID: ${otherUserId}`);
|
||||||
|
if (otherUserId && otherUserId !== myUserId) {
|
||||||
|
const otherServer = getMxIdServer(otherUserId);
|
||||||
|
console.log(`[getLiveKitHomeserverPriority] Other user server: ${otherServer}`);
|
||||||
|
const userDomain = userHomeserver.replace('https://', '').replace('http://', '');
|
||||||
|
if (otherServer && otherServer !== userDomain) {
|
||||||
|
servers.push(`https://${otherServer}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`[getLiveKitHomeserverPriority] Channel path - room homeserver first`);
|
||||||
|
// For channels: room's homeserver first, then user's homeserver
|
||||||
|
const roomServer = getRoomServer(room.roomId);
|
||||||
|
console.log(`[getLiveKitHomeserverPriority] Room server from ID: ${roomServer}`);
|
||||||
|
if (roomServer) {
|
||||||
|
servers.push(`https://${roomServer}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to user's homeserver if different
|
||||||
|
const userDomain = userHomeserver.replace('https://', '').replace('http://', '');
|
||||||
|
console.log(`[getLiveKitHomeserverPriority] User domain: ${userDomain}, room server: ${roomServer}`);
|
||||||
|
if (!roomServer || roomServer !== userDomain) {
|
||||||
|
servers.push(userHomeserver);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[getLiveKitHomeserverPriority] Final server list:`, servers);
|
||||||
|
return servers;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the LiveKit focus configuration from well-known data
|
* Fetches the LiveKit focus configuration from well-known data
|
||||||
@@ -9,11 +112,16 @@ import { LiveKitFocus, LiveKitJWTResponse, OpenIdToken, WellKnownWithRTC } from
|
|||||||
*/
|
*/
|
||||||
export function getLiveKitFocus(wellKnown: WellKnownWithRTC): LiveKitFocus | undefined {
|
export function getLiveKitFocus(wellKnown: WellKnownWithRTC): LiveKitFocus | undefined {
|
||||||
const rtcFoci = wellKnown['org.matrix.msc4143.rtc_foci'];
|
const rtcFoci = wellKnown['org.matrix.msc4143.rtc_foci'];
|
||||||
|
console.log(`[getLiveKitFocus] rtcFoci from well-known:`, rtcFoci);
|
||||||
|
|
||||||
if (!rtcFoci || !Array.isArray(rtcFoci)) {
|
if (!rtcFoci || !Array.isArray(rtcFoci)) {
|
||||||
|
console.log(`[getLiveKitFocus] No rtc_foci found or not an array`);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return rtcFoci.find((focus): focus is LiveKitFocus => focus.type === 'livekit');
|
const livekitFocus = rtcFoci.find((focus): focus is LiveKitFocus => focus.type === 'livekit');
|
||||||
|
console.log(`[getLiveKitFocus] Found LiveKit focus:`, livekitFocus);
|
||||||
|
return livekitFocus;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,14 +130,28 @@ export function getLiveKitFocus(wellKnown: WellKnownWithRTC): LiveKitFocus | und
|
|||||||
* @returns The well-known response with RTC configuration
|
* @returns The well-known response with RTC configuration
|
||||||
*/
|
*/
|
||||||
export async function fetchWellKnownWithRTC(baseUrl: string): Promise<WellKnownWithRTC> {
|
export async function fetchWellKnownWithRTC(baseUrl: string): Promise<WellKnownWithRTC> {
|
||||||
const wellKnownUrl = `${baseUrl}/.well-known/matrix/client`;
|
// In development mode (localhost), use CORS proxy to avoid CORS issues
|
||||||
|
const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||||
|
|
||||||
|
let wellKnownUrl: string;
|
||||||
|
if (isDev) {
|
||||||
|
// Extract hostname from baseUrl for proxy
|
||||||
|
const hostname = baseUrl.replace('https://', '').replace('http://', '').replace(/\/$/, '');
|
||||||
|
wellKnownUrl = `/_matrix-cors-proxy/${hostname}/.well-known/matrix/client`;
|
||||||
|
} else {
|
||||||
|
wellKnownUrl = `${baseUrl}/.well-known/matrix/client`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[fetchWellKnownWithRTC] Fetching from: ${wellKnownUrl}`);
|
||||||
|
|
||||||
const response = await fetch(wellKnownUrl);
|
const response = await fetch(wellKnownUrl);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch well-known: ${response.status}`);
|
throw new Error(`Failed to fetch well-known: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
const data = await response.json();
|
||||||
|
console.log(`[fetchWellKnownWithRTC] Response data:`, data);
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,7 +167,16 @@ export async function requestOpenIdToken(
|
|||||||
userId: string,
|
userId: string,
|
||||||
accessToken: string
|
accessToken: string
|
||||||
): Promise<OpenIdToken> {
|
): Promise<OpenIdToken> {
|
||||||
const url = `${homeserverBaseUrl}/_matrix/client/v3/user/${encodeURIComponent(userId)}/openid/request_token`;
|
// In development mode (localhost), use CORS proxy to avoid CORS issues
|
||||||
|
const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||||
|
|
||||||
|
let url: string;
|
||||||
|
if (isDev) {
|
||||||
|
const hostname = homeserverBaseUrl.replace('https://', '').replace('http://', '').replace(/\/$/, '');
|
||||||
|
url = `/_matrix-cors-proxy/${hostname}/_matrix/client/v3/user/${encodeURIComponent(userId)}/openid/request_token`;
|
||||||
|
} else {
|
||||||
|
url = `${homeserverBaseUrl}/_matrix/client/v3/user/${encodeURIComponent(userId)}/openid/request_token`;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -88,7 +219,21 @@ export async function fetchLiveKitJWT(
|
|||||||
// to create the LiveKit room name.
|
// to create the LiveKit room name.
|
||||||
const slotId = 'm.call';
|
const slotId = 'm.call';
|
||||||
|
|
||||||
const response = await fetch(livekitServiceUrl, {
|
// In development mode (localhost), use CORS proxy to avoid CORS issues
|
||||||
|
const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||||
|
|
||||||
|
let url: string;
|
||||||
|
if (isDev) {
|
||||||
|
// Extract hostname and path from livekitServiceUrl
|
||||||
|
const urlObj = new URL(livekitServiceUrl);
|
||||||
|
const hostname = urlObj.hostname;
|
||||||
|
const pathname = urlObj.pathname;
|
||||||
|
url = `/_matrix-cors-proxy/${hostname}${pathname}`;
|
||||||
|
} else {
|
||||||
|
url = livekitServiceUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -113,6 +258,62 @@ export async function fetchLiveKitJWT(
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try fetching LiveKit JWT from multiple homeservers in priority order
|
||||||
|
* @param homeservers - Array of homeserver base URLs to check for LiveKit config
|
||||||
|
* @param userHomeserver - The user's own homeserver URL (for OpenID token)
|
||||||
|
* @param roomId - The Matrix room ID
|
||||||
|
* @param userId - The Matrix user ID
|
||||||
|
* @param deviceId - The Matrix device ID
|
||||||
|
* @param accessToken - The Matrix access token
|
||||||
|
* @returns LiveKit JWT response and the homeserver that provided it, or null
|
||||||
|
*/
|
||||||
|
export async function fetchLiveKitJWTFromServers(
|
||||||
|
homeservers: string[],
|
||||||
|
userHomeserver: string,
|
||||||
|
roomId: string,
|
||||||
|
userId: string,
|
||||||
|
deviceId: string,
|
||||||
|
accessToken: string
|
||||||
|
): Promise<{ jwt: LiveKitJWTResponse; homeserver: string } | null> {
|
||||||
|
for (const homeserver of homeservers) {
|
||||||
|
try {
|
||||||
|
console.log(`Trying LiveKit JWT from homeserver: ${homeserver}`);
|
||||||
|
|
||||||
|
// Fetch well-known from this homeserver
|
||||||
|
const wellKnown = await fetchWellKnownWithRTC(homeserver);
|
||||||
|
const livekitFocus = getLiveKitFocus(wellKnown);
|
||||||
|
|
||||||
|
if (!livekitFocus) {
|
||||||
|
console.log(`No LiveKit config on ${homeserver}, trying next...`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always get OpenID token from user's own homeserver
|
||||||
|
// (Can't use access token on remote homeserver)
|
||||||
|
const openIdToken = await requestOpenIdToken(userHomeserver, userId, accessToken);
|
||||||
|
|
||||||
|
// Exchange for LiveKit JWT
|
||||||
|
const jwt = await fetchLiveKitJWT(
|
||||||
|
livekitFocus.livekit_service_url,
|
||||||
|
roomId,
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
openIdToken
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Successfully got LiveKit JWT from ${homeserver}`);
|
||||||
|
return { jwt, homeserver };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to get LiveKit JWT from ${homeserver}:`, error);
|
||||||
|
// Continue to next homeserver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Failed to get LiveKit JWT from any homeserver');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to get RTC configuration functions
|
* Hook to get RTC configuration functions
|
||||||
* Provides utilities for fetching well-known data and LiveKit JWTs
|
* Provides utilities for fetching well-known data and LiveKit JWTs
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { ErrorCode } from '../../cs-errorcode';
|
import { ErrorCode } from '../../cs-errorcode';
|
||||||
import { millisecondsToMinutes } from '../../utils/common';
|
import { millisecondsToMinutes } from '../../utils/common';
|
||||||
import { createRoomEncryptionState } from '../../components/create-room';
|
import { createRoomEncryptionState, createRoomPowerLevelsState } from '../../components/create-room';
|
||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
import { getDirectRoomPath } from '../../pages/pathUtils';
|
import { getDirectRoomPath } from '../../pages/pathUtils';
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ export function CreateChat({ defaultUserId }: CreateChatProps) {
|
|||||||
async (userId, encrypted) => {
|
async (userId, encrypted) => {
|
||||||
const initialState: ICreateRoomStateEvent[] = [];
|
const initialState: ICreateRoomStateEvent[] = [];
|
||||||
|
|
||||||
|
initialState.push(createRoomPowerLevelsState());
|
||||||
if (encrypted) initialState.push(createRoomEncryptionState());
|
if (encrypted) initialState.push(createRoomEncryptionState());
|
||||||
|
|
||||||
const result = await mx.createRoom({
|
const result = await mx.createRoom({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { MouseEventHandler, forwardRef, useState, useMemo } from 'react';
|
import React, { MouseEventHandler, forwardRef, useState, useMemo } from 'react';
|
||||||
import { Room, EventTimeline } from 'matrix-js-sdk';
|
import { Room, EventTimeline } from 'matrix-js-sdk';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
@@ -44,7 +45,7 @@ import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../util
|
|||||||
import { getViaServers } from '../../plugins/via-servers';
|
import { getViaServers } from '../../plugins/via-servers';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom, ScrollToLatestBehavior } from '../../state/settings';
|
||||||
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import {
|
import {
|
||||||
@@ -56,7 +57,7 @@ import { useRoomCreators } from '../../hooks/useRoomCreators';
|
|||||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||||
import { CallParticipantsIndicator } from './CallParticipantsIndicator';
|
import { CallParticipantsIndicator } from './CallParticipantsIndicator';
|
||||||
import { useCall } from '../call/useCall';
|
import { useCall, useRoomCall } from '../call/useCall';
|
||||||
import { CallType } from '../call/types';
|
import { CallType } from '../call/types';
|
||||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
@@ -75,7 +76,8 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||||
const powerLevels = usePowerLevels(room);
|
const powerLevels = usePowerLevels(room);
|
||||||
const creators = useRoomCreators(room);
|
const creators = useRoomCreators(room);
|
||||||
const { startCall, endCall, activeCall, callSupported } = useCall();
|
const { startCall: startCallGlobal, endCall, activeCall } = useCall();
|
||||||
|
const { callSupported } = useRoomCall(room.roomId);
|
||||||
const markAsRead = useMarkAsRead(mx);
|
const markAsRead = useMarkAsRead(mx);
|
||||||
|
|
||||||
const permissions = useRoomPermissions(creators, powerLevels);
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
@@ -119,7 +121,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
if (activeCall) {
|
if (activeCall) {
|
||||||
await endCall();
|
await endCall();
|
||||||
}
|
}
|
||||||
await startCall(room.roomId, CallType.Voice);
|
await startCallGlobal(room.roomId, CallType.Voice);
|
||||||
requestClose();
|
requestClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to join voice call:', error);
|
console.error('Failed to join voice call:', error);
|
||||||
@@ -293,6 +295,8 @@ export function RoomNavItem({
|
|||||||
}: RoomNavItemProps) {
|
}: RoomNavItemProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [scrollToLatestBehavior] = useSetting(settingsAtom, 'scrollToLatestBehavior');
|
||||||
const [hover, setHover] = useState(false);
|
const [hover, setHover] = useState(false);
|
||||||
const { hoverProps } = useHover({ onHoverChange: setHover });
|
const { hoverProps } = useHover({ onHoverChange: setHover });
|
||||||
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
|
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
|
||||||
@@ -364,6 +368,20 @@ export function RoomNavItem({
|
|||||||
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRoomClick: MouseEventHandler<HTMLAnchorElement> = (evt) => {
|
||||||
|
if (selected) {
|
||||||
|
evt.preventDefault();
|
||||||
|
|
||||||
|
const shouldScroll =
|
||||||
|
scrollToLatestBehavior === ScrollToLatestBehavior.Always ||
|
||||||
|
(scrollToLatestBehavior === ScrollToLatestBehavior.OnNewMessage && unread !== undefined);
|
||||||
|
|
||||||
|
if (shouldScroll) {
|
||||||
|
navigate(linkPath, { replace: true, state: { scrollToLatest: Date.now() } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const optionsVisible = hover || !!menuAnchor;
|
const optionsVisible = hover || !!menuAnchor;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -379,7 +397,7 @@ export function RoomNavItem({
|
|||||||
{...hoverProps}
|
{...hoverProps}
|
||||||
{...focusWithinProps}
|
{...focusWithinProps}
|
||||||
>
|
>
|
||||||
<NavLink to={linkPath}>
|
<NavLink to={linkPath} onClick={handleRoomClick}>
|
||||||
<NavItemContent>
|
<NavItemContent>
|
||||||
<Box as="span" grow="Yes" alignItems="Center" gap={shouldShowIcon ? "200" : "100"}>
|
<Box as="span" grow="Yes" alignItems="Center" gap={shouldShowIcon ? "200" : "100"}>
|
||||||
{shouldShowIcon && (
|
{shouldShowIcon && (
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
PopOut,
|
PopOut,
|
||||||
Scroll,
|
Scroll,
|
||||||
Text,
|
Text,
|
||||||
|
color,
|
||||||
config,
|
config,
|
||||||
toRem,
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
@@ -53,6 +54,7 @@ import {
|
|||||||
getBeginCommand,
|
getBeginCommand,
|
||||||
trimCommand,
|
trimCommand,
|
||||||
getMentions,
|
getMentions,
|
||||||
|
setCommandValidator,
|
||||||
} from '../../components/editor';
|
} from '../../components/editor';
|
||||||
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
@@ -103,6 +105,7 @@ import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../.
|
|||||||
import { CommandAutocomplete } from './CommandAutocomplete';
|
import { CommandAutocomplete } from './CommandAutocomplete';
|
||||||
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
|
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
|
||||||
import { pluginRegistry } from '../settings/plugins/PluginAPI';
|
import { pluginRegistry } from '../settings/plugins/PluginAPI';
|
||||||
|
import { PluginButtonSlot } from '../settings/plugins/PluginButtonSlot';
|
||||||
import { mobileOrTablet } from '../../utils/user-agent';
|
import { mobileOrTablet } from '../../utils/user-agent';
|
||||||
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
|
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
|
||||||
import { ReplyLayout, ThreadIndicator } from '../../components/message';
|
import { ReplyLayout, ThreadIndicator } from '../../components/message';
|
||||||
@@ -122,6 +125,17 @@ import { useOtherUserColor } from '../../hooks/useUserColor';
|
|||||||
import { getMemberAvatarMxc } from '../../utils/room';
|
import { getMemberAvatarMxc } from '../../utils/room';
|
||||||
import { convertEmoticons, convertEmoticonsInHtml } from '../../utils/emoticonConverter';
|
import { convertEmoticons, convertEmoticonsInHtml } from '../../utils/emoticonConverter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a UUID used to group image events into one carousel.
|
||||||
|
*/
|
||||||
|
const createCarouselUuid = (): string => {
|
||||||
|
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
||||||
|
return globalThis.crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
};
|
||||||
|
|
||||||
interface RoomInputProps {
|
interface RoomInputProps {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
fileDropContainerRef: RefObject<HTMLElement>;
|
fileDropContainerRef: RefObject<HTMLElement>;
|
||||||
@@ -186,9 +200,22 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||||
const [autocompleteQuery, setAutocompleteQuery] =
|
const [autocompleteQuery, setAutocompleteQuery] =
|
||||||
useState<AutocompleteQuery<AutocompletePrefix>>();
|
useState<AutocompleteQuery<AutocompletePrefix>>();
|
||||||
|
const [commandHint, setCommandHint] = useState<{
|
||||||
|
command: string;
|
||||||
|
description: string;
|
||||||
|
args?: string[];
|
||||||
|
}>();
|
||||||
|
|
||||||
const sendTypingStatus = useTypingStatusUpdater(mx, roomId);
|
const sendTypingStatus = useTypingStatusUpdater(mx, roomId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCommandValidator((commandName: string) => {
|
||||||
|
const isBuiltInCommand = commands[commandName as Command];
|
||||||
|
const isPluginCommand = pluginRegistry.getCommands().some(cmd => cmd.name === commandName);
|
||||||
|
return !!(isBuiltInCommand || isPluginCommand);
|
||||||
|
});
|
||||||
|
}, [commands]);
|
||||||
|
|
||||||
const handleFiles = useCallback(
|
const handleFiles = useCallback(
|
||||||
async (files: File[]) => {
|
async (files: File[]) => {
|
||||||
setUploadBoard(true);
|
setUploadBoard(true);
|
||||||
@@ -289,12 +316,34 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSendUpload = async (uploads: UploadSuccess[]) => {
|
const handleSendUpload = async (uploads: UploadSuccess[]) => {
|
||||||
|
const imageUploads = uploads.filter((upload) => {
|
||||||
|
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
||||||
|
return !!fileItem && fileItem.file.type.startsWith('image');
|
||||||
|
});
|
||||||
|
const carouselUuid = imageUploads.length > 1 ? createCarouselUuid() : undefined;
|
||||||
|
const imageUploadIndexByFile = new Map(
|
||||||
|
imageUploads.map((upload, index) => [upload.file, index] as const)
|
||||||
|
);
|
||||||
|
|
||||||
const contentsPromises = uploads.map(async (upload) => {
|
const contentsPromises = uploads.map(async (upload) => {
|
||||||
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
||||||
if (!fileItem) throw new Error('Broken upload');
|
if (!fileItem) throw new Error('Broken upload');
|
||||||
|
|
||||||
if (fileItem.file.type.startsWith('image')) {
|
if (fileItem.file.type.startsWith('image')) {
|
||||||
return getImageMsgContent(mx, fileItem, upload.mxc);
|
const imageIndex = imageUploadIndexByFile.get(upload.file);
|
||||||
|
|
||||||
|
return getImageMsgContent(
|
||||||
|
mx,
|
||||||
|
fileItem,
|
||||||
|
upload.mxc,
|
||||||
|
carouselUuid !== undefined && imageIndex !== undefined
|
||||||
|
? {
|
||||||
|
uuid: carouselUuid,
|
||||||
|
index: imageIndex,
|
||||||
|
total: imageUploads.length,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (fileItem.file.type.startsWith('video')) {
|
if (fileItem.file.type.startsWith('video')) {
|
||||||
return getVideoMsgContent(mx, fileItem, upload.mxc);
|
return getVideoMsgContent(mx, fileItem, upload.mxc);
|
||||||
@@ -312,7 +361,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const submit = useCallback(async () => {
|
const submit = useCallback(async () => {
|
||||||
uploadBoardHandlers.current?.handleSend();
|
uploadBoardHandlers.current?.handleSend();
|
||||||
|
|
||||||
const commandName = getBeginCommand(editor);
|
let commandName = getBeginCommand(editor);
|
||||||
let plainText = toPlainText(editor.children, isMarkdown).trim();
|
let plainText = toPlainText(editor.children, isMarkdown).trim();
|
||||||
let customHtml = trimCustomHtml(
|
let customHtml = trimCustomHtml(
|
||||||
toMatrixCustomHTML(editor.children, {
|
toMatrixCustomHTML(editor.children, {
|
||||||
@@ -323,6 +372,21 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
);
|
);
|
||||||
let msgType = MsgType.Text;
|
let msgType = MsgType.Text;
|
||||||
|
|
||||||
|
// If no CommandElement, check if text starts with a slash command
|
||||||
|
if (!commandName && plainText.startsWith('/')) {
|
||||||
|
const match = plainText.match(/^\/(\w+)(\s|$)/);
|
||||||
|
if (match) {
|
||||||
|
const potentialCommand = match[1];
|
||||||
|
// Check if it's a valid command (built-in or plugin)
|
||||||
|
const isBuiltInCommand = commands[potentialCommand as Command];
|
||||||
|
const isPluginCommand = pluginRegistry.getCommands().some(cmd => cmd.name === potentialCommand);
|
||||||
|
|
||||||
|
if (isBuiltInCommand || isPluginCommand) {
|
||||||
|
commandName = potentialCommand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (commandName) {
|
if (commandName) {
|
||||||
plainText = trimCommand(commandName, plainText);
|
plainText = trimCommand(commandName, plainText);
|
||||||
customHtml = trimCommand(commandName, customHtml);
|
customHtml = trimCommand(commandName, customHtml);
|
||||||
@@ -347,13 +411,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
commandContent.exe(plainText);
|
commandContent.exe(plainText);
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
|
setCommandHint(undefined);
|
||||||
sendTypingStatus(false);
|
sendTypingStatus(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check plugin commands
|
// Check plugin commands
|
||||||
try {
|
try {
|
||||||
const result = await pluginRegistry.executeCommand(commandName, plainText);
|
const result = await pluginRegistry.executeCommand(commandName, plainText, roomId);
|
||||||
if (result) {
|
if (result) {
|
||||||
// If command returns text, send it as a message
|
// If command returns text, send it as a message
|
||||||
const content: IContent = {
|
const content: IContent = {
|
||||||
@@ -368,14 +433,15 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
}
|
}
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
|
setCommandHint(undefined);
|
||||||
sendTypingStatus(false);
|
sendTypingStatus(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (plainText === '') return;
|
let body = plainText.trim();
|
||||||
|
let formattedBody = customHtml.trim().replace(/(<br\s*\/?>\s*)+$/gi, '');
|
||||||
|
|
||||||
let body = plainText;
|
if (body === '') return;
|
||||||
let formattedBody = customHtml;
|
|
||||||
|
|
||||||
// Apply emoticon conversion if enabled
|
// Apply emoticon conversion if enabled
|
||||||
if (autoConvertEmoticons) {
|
if (autoConvertEmoticons) {
|
||||||
@@ -445,6 +511,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
setReplyDraft(undefined);
|
setReplyDraft(undefined);
|
||||||
|
setCommandHint(undefined);
|
||||||
sendTypingStatus(false);
|
sendTypingStatus(false);
|
||||||
}, [mx, roomId, threadRootId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, autoConvertEmoticons]);
|
}, [mx, roomId, threadRootId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, autoConvertEmoticons]);
|
||||||
|
|
||||||
@@ -463,10 +530,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
setAutocompleteQuery(undefined);
|
setAutocompleteQuery(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (commandHint) {
|
||||||
|
setCommandHint(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setReplyDraft(undefined);
|
setReplyDraft(undefined);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing]
|
[submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing, commandHint]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyUp: KeyboardEventHandler = useCallback(
|
const handleKeyUp: KeyboardEventHandler = useCallback(
|
||||||
@@ -485,8 +556,35 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
|
? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
|
||||||
: undefined;
|
: undefined;
|
||||||
setAutocompleteQuery(query);
|
setAutocompleteQuery(query);
|
||||||
|
|
||||||
|
// Check if user is typing a command and show hint
|
||||||
|
const plainText = toPlainText(editor.children, false).trim();
|
||||||
|
const commandMatch = plainText.match(/^\/(\w+)(\s|$)/);
|
||||||
|
|
||||||
|
if (commandMatch) {
|
||||||
|
const commandName = commandMatch[1];
|
||||||
|
const builtInCmd = commands[commandName as Command];
|
||||||
|
const pluginCmd = pluginRegistry.getCommands().find(cmd => cmd.name === commandName);
|
||||||
|
|
||||||
|
if (builtInCmd) {
|
||||||
|
setCommandHint({
|
||||||
|
command: commandName,
|
||||||
|
description: builtInCmd.description,
|
||||||
|
});
|
||||||
|
} else if (pluginCmd) {
|
||||||
|
setCommandHint({
|
||||||
|
command: commandName,
|
||||||
|
description: pluginCmd.command.description || '',
|
||||||
|
args: pluginCmd.command.args,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setCommandHint(undefined);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCommandHint(undefined);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[editor, sendTypingStatus, hideActivity]
|
[editor, sendTypingStatus, hideActivity, commands]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCloseAutocomplete = useCallback(() => {
|
const handleCloseAutocomplete = useCallback(() => {
|
||||||
@@ -597,7 +695,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
requestClose={handleCloseAutocomplete}
|
requestClose={handleCloseAutocomplete}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{autocompleteQuery?.prefix === AutocompletePrefix.Command && (
|
{autocompleteQuery?.prefix === AutocompletePrefix.Command && !commandHint && (
|
||||||
<CommandAutocomplete
|
<CommandAutocomplete
|
||||||
room={room}
|
room={room}
|
||||||
editor={editor}
|
editor={editor}
|
||||||
@@ -614,53 +712,85 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
onKeyUp={handleKeyUp}
|
onKeyUp={handleKeyUp}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
top={
|
top={
|
||||||
replyDraft && (
|
<>
|
||||||
<div>
|
{commandHint && (
|
||||||
<Box
|
<Box
|
||||||
alignItems="Center"
|
style={{
|
||||||
gap="300"
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||||||
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
|
borderBottom: 'none',
|
||||||
|
borderTopLeftRadius: config.radii.R400,
|
||||||
|
borderTopRightRadius: config.radii.R400,
|
||||||
|
marginBottom: config.space.S100,
|
||||||
|
}}
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
>
|
>
|
||||||
<IconButton
|
<Text size="T200" priority="400">
|
||||||
onClick={() => setReplyDraft(undefined)}
|
<b>/{commandHint.command}</b>
|
||||||
variant="SurfaceVariant"
|
{commandHint.args && commandHint.args.length > 0 && (
|
||||||
size="300"
|
<span style={{ opacity: 0.6 }}> {commandHint.args.map(arg => `[${arg}]`).join(' ')}</span>
|
||||||
radii="300"
|
)}
|
||||||
>
|
</Text>
|
||||||
<Icon src={Icons.Cross} size="50" />
|
{commandHint.description && (
|
||||||
</IconButton>
|
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
|
||||||
<Box direction="Row" gap="200" alignItems="Center">
|
{commandHint.description}
|
||||||
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
|
</Text>
|
||||||
<ReplyLayout
|
)}
|
||||||
userColor={replyUsernameColor}
|
|
||||||
username={
|
|
||||||
<Text size="T300" truncate>
|
|
||||||
<b>
|
|
||||||
{getMemberDisplayName(room, replyDraft.userId) ??
|
|
||||||
getMxIdLocalPart(replyDraft.userId) ??
|
|
||||||
replyDraft.userId}
|
|
||||||
</b>
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text size="T300" truncate>
|
|
||||||
{trimReplyFromBody(replyDraft.body)}
|
|
||||||
</Text>
|
|
||||||
</ReplyLayout>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
)}
|
||||||
)
|
{replyDraft && (
|
||||||
|
<div>
|
||||||
|
<Box
|
||||||
|
alignItems="Center"
|
||||||
|
gap="300"
|
||||||
|
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setReplyDraft(undefined)}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} size="50" />
|
||||||
|
</IconButton>
|
||||||
|
<Box direction="Row" gap="200" alignItems="Center">
|
||||||
|
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
|
||||||
|
<ReplyLayout
|
||||||
|
userColor={replyUsernameColor}
|
||||||
|
username={
|
||||||
|
<Text size="T300" truncate>
|
||||||
|
<b>
|
||||||
|
{getMemberDisplayName(room, replyDraft.userId) ??
|
||||||
|
getMxIdLocalPart(replyDraft.userId) ??
|
||||||
|
replyDraft.userId}
|
||||||
|
</b>
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="T300" truncate>
|
||||||
|
{trimReplyFromBody(replyDraft.body)}
|
||||||
|
</Text>
|
||||||
|
</ReplyLayout>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
before={
|
before={
|
||||||
<IconButton
|
<>
|
||||||
onClick={() => pickFile('*')}
|
<IconButton
|
||||||
variant="SurfaceVariant"
|
onClick={() => pickFile('*')}
|
||||||
size="300"
|
variant="SurfaceVariant"
|
||||||
radii="300"
|
size="300"
|
||||||
>
|
radii="300"
|
||||||
<Icon src={Icons.PlusCircle} />
|
>
|
||||||
</IconButton>
|
<Icon src={Icons.PlusCircle} />
|
||||||
|
</IconButton>
|
||||||
|
<PluginButtonSlot location="composer-actions" />
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
after={
|
after={
|
||||||
<>
|
<>
|
||||||
@@ -739,6 +869,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
</PopOut>
|
</PopOut>
|
||||||
)}
|
)}
|
||||||
</UseStateProvider>
|
</UseStateProvider>
|
||||||
|
<PluginButtonSlot location="text-composer-toolbar" />
|
||||||
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
|
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
|
||||||
<Icon src={Icons.Send} />
|
<Icon src={Icons.Send} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
|
import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
|
||||||
import { DefaultReset, config } from 'folds';
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { DefaultReset, color, config, toRem } from 'folds';
|
||||||
|
|
||||||
export const TimelineFloat = recipe({
|
export const TimelineFloat = recipe({
|
||||||
base: [
|
base: [
|
||||||
@@ -28,3 +29,82 @@ export const TimelineFloat = recipe({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>;
|
export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>;
|
||||||
|
|
||||||
|
export const CarouselScroller = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
display: 'flex',
|
||||||
|
gap: config.space.S200,
|
||||||
|
overflowX: 'auto',
|
||||||
|
overflowY: 'hidden',
|
||||||
|
paddingBottom: config.space.S100,
|
||||||
|
width: '100%',
|
||||||
|
cursor: 'grab',
|
||||||
|
userSelect: 'none',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const CarouselScrollerDragging = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
cursor: 'grabbing',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const CarouselItem = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
flex: '0 0 auto',
|
||||||
|
width: `min(${toRem(380)}, 72vw)`,
|
||||||
|
maxWidth: '100%',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const CarouselEdgeGradient = recipe({
|
||||||
|
base: [
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
height: '100%',
|
||||||
|
width: toRem(16),
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
variants: {
|
||||||
|
position: {
|
||||||
|
Left: {
|
||||||
|
left: 0,
|
||||||
|
background: `linear-gradient(to right, ${color.Surface.Container}, rgba(116, 116, 116, 0))`,
|
||||||
|
},
|
||||||
|
Right: {
|
||||||
|
right: 0,
|
||||||
|
background: `linear-gradient(to left, ${color.Surface.Container}, rgba(116, 116, 116, 0))`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CarouselScrollBtn = recipe({
|
||||||
|
base: [
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
variants: {
|
||||||
|
position: {
|
||||||
|
Left: {
|
||||||
|
left: 0,
|
||||||
|
transform: 'translate(-25%, -50%)',
|
||||||
|
},
|
||||||
|
Right: {
|
||||||
|
right: 0,
|
||||||
|
transform: 'translate(25%, -50%)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
IContent,
|
IContent,
|
||||||
MatrixClient,
|
MatrixClient,
|
||||||
MatrixEvent,
|
MatrixEvent,
|
||||||
|
MsgType,
|
||||||
Room,
|
Room,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
RoomEventHandlerMap,
|
RoomEventHandlerMap,
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
Chip,
|
Chip,
|
||||||
ContainerColor,
|
ContainerColor,
|
||||||
Icon,
|
Icon,
|
||||||
|
IconButton,
|
||||||
Icons,
|
Icons,
|
||||||
Line,
|
Line,
|
||||||
Scroll,
|
Scroll,
|
||||||
@@ -47,11 +49,12 @@ import {
|
|||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix';
|
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
import { editableActiveElement, scrollToBottom } from '../../utils/dom';
|
import { autoScrollToBottom, editableActiveElement } from '../../utils/dom';
|
||||||
import {
|
import {
|
||||||
DefaultPlaceholder,
|
DefaultPlaceholder,
|
||||||
CompactPlaceholder,
|
CompactPlaceholder,
|
||||||
@@ -61,6 +64,7 @@ import {
|
|||||||
Time,
|
Time,
|
||||||
MessageNotDecryptedContent,
|
MessageNotDecryptedContent,
|
||||||
RedactedContent,
|
RedactedContent,
|
||||||
|
MImage,
|
||||||
MSticker,
|
MSticker,
|
||||||
ImageContent,
|
ImageContent,
|
||||||
EventContent,
|
EventContent,
|
||||||
@@ -84,7 +88,7 @@ import {
|
|||||||
reactionOrEditEvent,
|
reactionOrEditEvent,
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { MessageLayout, settingsAtom } from '../../state/settings';
|
import { MessageLayout, ScrollToLatestBehavior, settingsAtom } from '../../state/settings';
|
||||||
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
||||||
import { Reactions, Message, Event, EncryptedContent } from './message';
|
import { Reactions, Message, Event, EncryptedContent } from './message';
|
||||||
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
|
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
|
||||||
@@ -104,6 +108,12 @@ import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
|||||||
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
||||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||||
|
import {
|
||||||
|
IImageContent,
|
||||||
|
PAARROT_CAROUSEL_INDEX_PROPERTY_NAME,
|
||||||
|
PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME,
|
||||||
|
PAARROT_CAROUSEL_UUID_PROPERTY_NAME,
|
||||||
|
} from '../../../types/matrix/common';
|
||||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||||
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
||||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||||
@@ -145,6 +155,55 @@ interface CallEventInfo {
|
|||||||
mEvent: MatrixEvent;
|
mEvent: MatrixEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CarouselEventMetadata {
|
||||||
|
uuid: string;
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
content: IImageContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CarouselRenderableEvent {
|
||||||
|
mEvent: MatrixEvent;
|
||||||
|
mEventId: string;
|
||||||
|
item: number;
|
||||||
|
content: IImageContent;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts carousel metadata from an m.image room message.
|
||||||
|
*/
|
||||||
|
const getCarouselEventMetadata = (mEvent: MatrixEvent): CarouselEventMetadata | undefined => {
|
||||||
|
if (mEvent.getType() !== MessageEvent.RoomMessage || mEvent.isRedacted()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = mEvent.getContent() as IImageContent;
|
||||||
|
const uuid = content[PAARROT_CAROUSEL_UUID_PROPERTY_NAME];
|
||||||
|
const index = content[PAARROT_CAROUSEL_INDEX_PROPERTY_NAME];
|
||||||
|
const total = content[PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME];
|
||||||
|
|
||||||
|
if (content.msgtype !== MsgType.Image) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof uuid !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof index !== 'number' || !Number.isInteger(index) || index < 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof total !== 'number' || !Number.isInteger(total) || total < 1) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
uuid,
|
||||||
|
index,
|
||||||
|
total,
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
|
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
|
||||||
({ position, className, ...props }, ref) => (
|
({ position, className, ...props }, ref) => (
|
||||||
<Box
|
<Box
|
||||||
@@ -168,6 +227,183 @@ const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
type CarouselScrollerProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CarouselScroller = ({ children }: CarouselScrollerProps) => {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const backAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const frontAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [backVisible, setBackVisible] = useState(true);
|
||||||
|
const [frontVisible, setFrontVisible] = useState(true);
|
||||||
|
const dragStateRef = useRef({
|
||||||
|
startX: 0,
|
||||||
|
startScrollLeft: 0,
|
||||||
|
dragging: false,
|
||||||
|
moved: false,
|
||||||
|
suppressClick: false,
|
||||||
|
});
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const intersectionObserver = useIntersectionObserver(
|
||||||
|
useCallback((entries) => {
|
||||||
|
const backAnchor = backAnchorRef.current;
|
||||||
|
const frontAnchor = frontAnchorRef.current;
|
||||||
|
const backEntry = backAnchor && getIntersectionObserverEntry(backAnchor, entries);
|
||||||
|
const frontEntry = frontAnchor && getIntersectionObserverEntry(frontAnchor, entries);
|
||||||
|
if (backEntry) {
|
||||||
|
setBackVisible(backEntry.isIntersecting);
|
||||||
|
}
|
||||||
|
if (frontEntry) {
|
||||||
|
setFrontVisible(frontEntry.isIntersecting);
|
||||||
|
}
|
||||||
|
}, []),
|
||||||
|
useCallback(
|
||||||
|
() => ({
|
||||||
|
root: scrollRef.current,
|
||||||
|
rootMargin: '10px',
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const backAnchor = backAnchorRef.current;
|
||||||
|
const frontAnchor = frontAnchorRef.current;
|
||||||
|
if (backAnchor) intersectionObserver?.observe(backAnchor);
|
||||||
|
if (frontAnchor) intersectionObserver?.observe(frontAnchor);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (backAnchor) intersectionObserver?.unobserve(backAnchor);
|
||||||
|
if (frontAnchor) intersectionObserver?.unobserve(frontAnchor);
|
||||||
|
};
|
||||||
|
}, [intersectionObserver]);
|
||||||
|
|
||||||
|
const handleMouseDown = (evt: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (evt.button !== 0) return;
|
||||||
|
const scroll = scrollRef.current;
|
||||||
|
if (!scroll) return;
|
||||||
|
|
||||||
|
const dragState = dragStateRef.current;
|
||||||
|
dragState.startX = evt.clientX;
|
||||||
|
dragState.startScrollLeft = scroll.scrollLeft;
|
||||||
|
dragState.dragging = true;
|
||||||
|
dragState.moved = false;
|
||||||
|
dragState.suppressClick = false;
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (evt: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const dragState = dragStateRef.current;
|
||||||
|
if (!dragState.dragging) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
evt.preventDefault();
|
||||||
|
const scroll = scrollRef.current;
|
||||||
|
if (!scroll) return;
|
||||||
|
|
||||||
|
const deltaX = evt.clientX - dragState.startX;
|
||||||
|
if (Math.abs(deltaX) > 5) {
|
||||||
|
dragState.moved = true;
|
||||||
|
}
|
||||||
|
scroll.scrollLeft = dragState.startScrollLeft - deltaX;
|
||||||
|
};
|
||||||
|
|
||||||
|
const endDrag = () => {
|
||||||
|
const dragState = dragStateRef.current;
|
||||||
|
if (!dragState.dragging) return;
|
||||||
|
|
||||||
|
if (dragState.moved) {
|
||||||
|
dragState.suppressClick = true;
|
||||||
|
}
|
||||||
|
dragState.dragging = false;
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClickCapture = (evt: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const dragState = dragStateRef.current;
|
||||||
|
if (!dragState.suppressClick) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dragState.suppressClick = false;
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScrollFront = () => {
|
||||||
|
const scroll = scrollRef.current;
|
||||||
|
if (!scroll) return;
|
||||||
|
|
||||||
|
scroll.scrollTo({
|
||||||
|
left: scroll.scrollLeft + scroll.offsetWidth / 1.3,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScrollBack = () => {
|
||||||
|
const scroll = scrollRef.current;
|
||||||
|
if (!scroll) return;
|
||||||
|
|
||||||
|
scroll.scrollTo({
|
||||||
|
left: scroll.scrollLeft - scroll.offsetWidth / 1.3,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box style={{ position: 'relative' }}>
|
||||||
|
<Box
|
||||||
|
ref={scrollRef}
|
||||||
|
className={classNames(css.CarouselScroller, isDragging && css.CarouselScrollerDragging)}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={endDrag}
|
||||||
|
onMouseLeave={endDrag}
|
||||||
|
onClickCapture={handleClickCapture}
|
||||||
|
>
|
||||||
|
<div ref={backAnchorRef} />
|
||||||
|
{children}
|
||||||
|
<div ref={frontAnchorRef} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{!backVisible && (
|
||||||
|
<>
|
||||||
|
<div className={css.CarouselEdgeGradient({ position: 'Left' })} />
|
||||||
|
<IconButton
|
||||||
|
className={css.CarouselScrollBtn({ position: 'Left' })}
|
||||||
|
variant="Secondary"
|
||||||
|
radii="Pill"
|
||||||
|
size="300"
|
||||||
|
outlined
|
||||||
|
onClick={handleScrollBack}
|
||||||
|
>
|
||||||
|
<Icon size="300" src={Icons.ArrowLeft} />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!frontVisible && (
|
||||||
|
<>
|
||||||
|
<div className={css.CarouselEdgeGradient({ position: 'Right' })} />
|
||||||
|
<IconButton
|
||||||
|
className={css.CarouselScrollBtn({ position: 'Right' })}
|
||||||
|
variant="Primary"
|
||||||
|
radii="Pill"
|
||||||
|
size="300"
|
||||||
|
outlined
|
||||||
|
onClick={handleScrollFront}
|
||||||
|
>
|
||||||
|
<Icon size="300" src={Icons.ArrowRight} />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const getLiveTimeline = (room: Room): EventTimeline =>
|
export const getLiveTimeline = (room: Room): EventTimeline =>
|
||||||
room.getUnfilteredTimelineSet().getLiveTimeline();
|
room.getUnfilteredTimelineSet().getLiveTimeline();
|
||||||
|
|
||||||
@@ -473,6 +709,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
|
|
||||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
const [scrollToLatestBehavior] = useSetting(settingsAtom, 'scrollToLatestBehavior');
|
||||||
|
|
||||||
// Track consecutive call member events for collapsing
|
// Track consecutive call member events for collapsing
|
||||||
const pendingCallEventsRef = useRef<CallEventInfo[]>([]);
|
const pendingCallEventsRef = useRef<CallEventInfo[]>([]);
|
||||||
@@ -511,10 +748,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const openUserRoomProfile = useOpenUserRoomProfile();
|
const openUserRoomProfile = useOpenUserRoomProfile();
|
||||||
const space = useSpaceOptionally();
|
const space = useSpaceOptionally();
|
||||||
const markAsRead = useMarkAsRead(mx);
|
const markAsRead = useMarkAsRead(mx);
|
||||||
|
const location = useLocation();
|
||||||
|
const scrollToLatest = (location.state as { scrollToLatest?: boolean } | null)?.scrollToLatest;
|
||||||
|
|
||||||
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
|
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
|
||||||
|
|
||||||
const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
|
const [unreadInfo, setUnreadInfo] = useState(() =>
|
||||||
|
getRoomUnreadInfo(room, scrollToLatestBehavior !== ScrollToLatestBehavior.Always)
|
||||||
|
);
|
||||||
const readUptoEventIdRef = useRef<string>();
|
const readUptoEventIdRef = useRef<string>();
|
||||||
if (unreadInfo) {
|
if (unreadInfo) {
|
||||||
readUptoEventIdRef.current = unreadInfo.readUptoEventId;
|
readUptoEventIdRef.current = unreadInfo.readUptoEventId;
|
||||||
@@ -525,10 +766,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const atBottomRef = useRef(atBottom);
|
const atBottomRef = useRef(atBottom);
|
||||||
atBottomRef.current = atBottom;
|
atBottomRef.current = atBottom;
|
||||||
|
|
||||||
|
const [latestUnreadMessage, setLatestUnreadMessage] = useState<{
|
||||||
|
sender: string;
|
||||||
|
content: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const timelineContentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const timelineContentHeightRef = useRef(0);
|
||||||
const scrollToBottomRef = useRef({
|
const scrollToBottomRef = useRef({
|
||||||
count: 0,
|
count: 0,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [focusItem, setFocusItem] = useState<
|
const [focusItem, setFocusItem] = useState<
|
||||||
@@ -628,6 +877,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
setTimeline(getInitialTimeline(room));
|
setTimeline(getInitialTimeline(room));
|
||||||
scrollToBottomRef.current.count += 1;
|
scrollToBottomRef.current.count += 1;
|
||||||
scrollToBottomRef.current.smooth = false;
|
scrollToBottomRef.current.smooth = false;
|
||||||
|
scrollToBottomRef.current.force = true;
|
||||||
}, [alive, room])
|
}, [alive, room])
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -635,38 +885,50 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
room,
|
room,
|
||||||
useCallback(
|
useCallback(
|
||||||
(mEvt: MatrixEvent) => {
|
(mEvt: MatrixEvent) => {
|
||||||
// if user is at bottom of timeline
|
const isOwnMessage = mEvt.getSender() === mx.getUserId();
|
||||||
// keep paginating timeline and conditionally mark as read
|
const shouldStickToBottom = atBottomRef.current || isOwnMessage;
|
||||||
// otherwise we update timeline without paginating
|
|
||||||
// so timeline can be updated with evt like: edits, reactions etc
|
// Always update timeline with new message
|
||||||
if (atBottomRef.current) {
|
setTimeline((ct) => ({
|
||||||
if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) {
|
...ct,
|
||||||
// Check if the document is in focus (user is actively viewing the app),
|
range: {
|
||||||
// and either there are no unread messages or the latest message is from the current user.
|
start: ct.range.start + 1,
|
||||||
// If either condition is met, trigger the markAsRead function to send a read receipt.
|
end: ct.range.end + 1,
|
||||||
requestAnimationFrame(() => markAsRead(mEvt.getRoomId()!, hideActivity));
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Handle own messages and focus
|
||||||
|
if (isOwnMessage) {
|
||||||
|
setLatestUnreadMessage(null);
|
||||||
|
if (document.hasFocus() && (!unreadInfo)) {
|
||||||
|
const roomId = mEvt.getRoomId();
|
||||||
|
if (roomId) {
|
||||||
|
requestAnimationFrame(() => markAsRead(roomId, hideActivity));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else if (!document.hasFocus() && !unreadInfo) {
|
||||||
if (!document.hasFocus() && !unreadInfo) {
|
// Show unread notification for other users' messages when unfocused
|
||||||
setUnreadInfo(getRoomUnreadInfo(room));
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollToBottomRef.current.count += 1;
|
|
||||||
scrollToBottomRef.current.smooth = true;
|
|
||||||
|
|
||||||
setTimeline((ct) => ({
|
|
||||||
...ct,
|
|
||||||
range: {
|
|
||||||
start: ct.range.start + 1,
|
|
||||||
end: ct.range.end + 1,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTimeline((ct) => ({ ...ct }));
|
|
||||||
if (!unreadInfo) {
|
|
||||||
setUnreadInfo(getRoomUnreadInfo(room));
|
setUnreadInfo(getRoomUnreadInfo(room));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preserve the pre-update bottom state so a newly added message keeps the view pinned.
|
||||||
|
scrollToBottomRef.current.count += 1;
|
||||||
|
scrollToBottomRef.current.smooth = !isOwnMessage;
|
||||||
|
scrollToBottomRef.current.force = shouldStickToBottom;
|
||||||
|
|
||||||
|
// Show unread message preview for incoming messages when not at bottom
|
||||||
|
if (!isOwnMessage && !atBottomRef.current) {
|
||||||
|
const senderName =
|
||||||
|
getMemberDisplayName(room, mEvt.getSender() ?? '') ??
|
||||||
|
getMxIdLocalPart(mEvt.getSender() ?? '') ??
|
||||||
|
'Unknown';
|
||||||
|
const content = mEvt.getContent();
|
||||||
|
const messageText = content.body ?? content.msgtype ?? 'New message';
|
||||||
|
setLatestUnreadMessage({
|
||||||
|
sender: senderName,
|
||||||
|
content: messageText.length > 100 ? `${messageText.substring(0, 100)}...` : messageText,
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[mx, room, unreadInfo, hideActivity, markAsRead]
|
[mx, room, unreadInfo, hideActivity, markAsRead]
|
||||||
)
|
)
|
||||||
@@ -727,13 +989,35 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
if (!editorBaseEntry || !scrollElement) return;
|
if (!editorBaseEntry || !scrollElement) return;
|
||||||
|
|
||||||
if (atBottomRef.current) {
|
if (atBottomRef.current) {
|
||||||
scrollToBottom(scrollElement);
|
autoScrollToBottom(scrollElement, false, true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [getScrollElement, roomInputRef]),
|
}, [getScrollElement, roomInputRef]),
|
||||||
useCallback(() => roomInputRef.current, [roomInputRef])
|
useCallback(() => roomInputRef.current, [roomInputRef])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useResizeObserver(
|
||||||
|
useCallback((entries) => {
|
||||||
|
const timelineContent = timelineContentRef.current;
|
||||||
|
const scrollElement = getScrollElement();
|
||||||
|
if (!timelineContent || !scrollElement) return;
|
||||||
|
|
||||||
|
const timelineEntry = getResizeObserverEntry(timelineContent, entries);
|
||||||
|
if (!timelineEntry) return;
|
||||||
|
|
||||||
|
const nextHeight = timelineEntry.contentRect.height;
|
||||||
|
const previousHeight = timelineContentHeightRef.current;
|
||||||
|
timelineContentHeightRef.current = nextHeight;
|
||||||
|
|
||||||
|
if (previousHeight === 0 || nextHeight <= previousHeight || !atBottomRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
autoScrollToBottom(scrollElement, false, true);
|
||||||
|
}, [getScrollElement]),
|
||||||
|
useCallback(() => timelineContentRef.current, [])
|
||||||
|
);
|
||||||
|
|
||||||
const tryAutoMarkAsRead = useCallback(() => {
|
const tryAutoMarkAsRead = useCallback(() => {
|
||||||
const readUptoEventId = readUptoEventIdRef.current;
|
const readUptoEventId = readUptoEventIdRef.current;
|
||||||
if (!readUptoEventId) {
|
if (!readUptoEventId) {
|
||||||
@@ -749,7 +1033,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
|
|
||||||
const debounceSetAtBottom = useDebounce(
|
const debounceSetAtBottom = useDebounce(
|
||||||
useCallback((entry: IntersectionObserverEntry) => {
|
useCallback((entry: IntersectionObserverEntry) => {
|
||||||
if (!entry.isIntersecting) setAtBottom(false);
|
if (!entry.isIntersecting) {
|
||||||
|
setAtBottom(false);
|
||||||
|
}
|
||||||
}, []),
|
}, []),
|
||||||
{ wait: 1000 }
|
{ wait: 1000 }
|
||||||
);
|
);
|
||||||
@@ -762,6 +1048,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
if (targetEntry) debounceSetAtBottom(targetEntry);
|
if (targetEntry) debounceSetAtBottom(targetEntry);
|
||||||
if (targetEntry?.isIntersecting && atLiveEndRef.current) {
|
if (targetEntry?.isIntersecting && atLiveEndRef.current) {
|
||||||
setAtBottom(true);
|
setAtBottom(true);
|
||||||
|
setLatestUnreadMessage(null);
|
||||||
if (document.hasFocus()) {
|
if (document.hasFocus()) {
|
||||||
tryAutoMarkAsRead();
|
tryAutoMarkAsRead();
|
||||||
}
|
}
|
||||||
@@ -831,11 +1118,23 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
}
|
}
|
||||||
}, [eventId, loadEventTimeline]);
|
}, [eventId, loadEventTimeline]);
|
||||||
|
|
||||||
|
// Scroll to latest when clicking on already-selected room
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollToLatest) {
|
||||||
|
setLatestUnreadMessage(null);
|
||||||
|
setUnreadInfo(undefined);
|
||||||
|
setTimeline(getInitialTimeline(room));
|
||||||
|
scrollToBottomRef.current.count += 1;
|
||||||
|
scrollToBottomRef.current.smooth = false;
|
||||||
|
scrollToBottomRef.current.force = true;
|
||||||
|
}
|
||||||
|
}, [scrollToLatest, room]);
|
||||||
|
|
||||||
// Scroll to bottom on initial timeline load
|
// Scroll to bottom on initial timeline load
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const scrollEl = scrollRef.current;
|
const scrollEl = scrollRef.current;
|
||||||
if (scrollEl) {
|
if (scrollEl) {
|
||||||
scrollToBottom(scrollEl);
|
autoScrollToBottom(scrollEl, false, true);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -882,8 +1181,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (scrollToBottomCount > 0) {
|
if (scrollToBottomCount > 0) {
|
||||||
const scrollEl = scrollRef.current;
|
const scrollEl = scrollRef.current;
|
||||||
if (scrollEl)
|
if (scrollEl) {
|
||||||
scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant');
|
autoScrollToBottom(
|
||||||
|
scrollEl,
|
||||||
|
scrollToBottomRef.current.smooth,
|
||||||
|
scrollToBottomRef.current.force
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [scrollToBottomCount]);
|
}, [scrollToBottomCount]);
|
||||||
|
|
||||||
@@ -936,9 +1240,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
if (eventId) {
|
if (eventId) {
|
||||||
navigateRoom(room.roomId, undefined, { replace: true });
|
navigateRoom(room.roomId, undefined, { replace: true });
|
||||||
}
|
}
|
||||||
|
setLatestUnreadMessage(null);
|
||||||
setTimeline(getInitialTimeline(room));
|
setTimeline(getInitialTimeline(room));
|
||||||
scrollToBottomRef.current.count += 1;
|
scrollToBottomRef.current.count += 1;
|
||||||
scrollToBottomRef.current.smooth = false;
|
scrollToBottomRef.current.smooth = false;
|
||||||
|
scrollToBottomRef.current.force = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleJumpToUnread = () => {
|
const handleJumpToUnread = () => {
|
||||||
@@ -1677,6 +1983,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
let isPrevRendered = false;
|
let isPrevRendered = false;
|
||||||
let newDivider = false;
|
let newDivider = false;
|
||||||
let dayDivider = false;
|
let dayDivider = false;
|
||||||
|
const groupedCarouselEventIds = new Set<string>();
|
||||||
|
|
||||||
// Clear pending call events at the start of each render
|
// Clear pending call events at the start of each render
|
||||||
pendingCallEventsRef.current = [];
|
pendingCallEventsRef.current = [];
|
||||||
@@ -1798,6 +2105,106 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderImageCarouselMessage = (
|
||||||
|
carouselEvents: CarouselRenderableEvent[],
|
||||||
|
mEventId: string,
|
||||||
|
mEvent: MatrixEvent,
|
||||||
|
item: number,
|
||||||
|
timelineSet: EventTimelineSet,
|
||||||
|
collapse: boolean
|
||||||
|
): React.ReactNode => {
|
||||||
|
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
||||||
|
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
||||||
|
const hasReactions = reactions && reactions.length > 0;
|
||||||
|
const { replyEventId, threadRootId } = mEvent;
|
||||||
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||||
|
const senderId = mEvent.getSender() ?? '';
|
||||||
|
|
||||||
|
const orderedCarouselEvents = [...carouselEvents].sort((a, b) => a.index - b.index);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Message
|
||||||
|
key={`carousel-${mEventId}`}
|
||||||
|
data-message-item={item}
|
||||||
|
data-message-id={mEventId}
|
||||||
|
room={room}
|
||||||
|
mEvent={mEvent}
|
||||||
|
messageSpacing={messageSpacing}
|
||||||
|
messageLayout={messageLayout}
|
||||||
|
collapse={collapse}
|
||||||
|
highlight={highlighted}
|
||||||
|
edit={editId === mEventId}
|
||||||
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
|
canSendReaction={canSendReaction}
|
||||||
|
canPinEvent={canPinEvent}
|
||||||
|
imagePackRooms={imagePackRooms}
|
||||||
|
relations={hasReactions ? reactionRelations : undefined}
|
||||||
|
onUserClick={handleUserClick}
|
||||||
|
onUsernameClick={handleUsernameClick}
|
||||||
|
onReplyClick={handleReplyClick}
|
||||||
|
onReactionToggle={handleReactionToggle}
|
||||||
|
onEditId={handleEdit}
|
||||||
|
reply={
|
||||||
|
replyEventId && (
|
||||||
|
<Reply
|
||||||
|
room={room}
|
||||||
|
timelineSet={timelineSet}
|
||||||
|
replyEventId={replyEventId}
|
||||||
|
threadRootId={threadRootId}
|
||||||
|
onClick={handleOpenReply}
|
||||||
|
onOpenThread={handleOpenThread}
|
||||||
|
getMemberPowerTag={getMemberPowerTag}
|
||||||
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
reactions={
|
||||||
|
reactionRelations && (
|
||||||
|
<Reactions
|
||||||
|
style={{ marginTop: config.space.S200 }}
|
||||||
|
room={room}
|
||||||
|
relations={reactionRelations}
|
||||||
|
mEventId={mEventId}
|
||||||
|
canSendReaction={canSendReaction}
|
||||||
|
onReactionToggle={handleReactionToggle}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
hideReadReceipts={hideActivity}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
|
memberPowerTag={getMemberPowerTag(senderId)}
|
||||||
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
>
|
||||||
|
{mEvent.isRedacted() ? (
|
||||||
|
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||||
|
) : (
|
||||||
|
<CarouselScroller>
|
||||||
|
{orderedCarouselEvents.map((carouselEvent) => (
|
||||||
|
<Box key={carouselEvent.mEventId} className={css.CarouselItem}>
|
||||||
|
<MImage
|
||||||
|
content={carouselEvent.content}
|
||||||
|
renderImageContent={(props) => (
|
||||||
|
<ImageContent
|
||||||
|
{...props}
|
||||||
|
autoPlay={mediaAutoLoad}
|
||||||
|
renderImage={(p) => <Image {...p} loading="lazy" />}
|
||||||
|
renderViewer={(p) => <ImageViewer {...p} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
outlined={messageLayout === MessageLayout.Bubble}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</CarouselScroller>
|
||||||
|
)}
|
||||||
|
</Message>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const eventRenderer = (item: number, index: number, allItems: number[]) => {
|
const eventRenderer = (item: number, index: number, allItems: number[]) => {
|
||||||
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
|
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
|
||||||
if (!eventTimeline) return null;
|
if (!eventTimeline) return null;
|
||||||
@@ -1831,17 +2238,77 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
prevEvent.getType() === mEvent.getType() &&
|
prevEvent.getType() === mEvent.getType() &&
|
||||||
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
|
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
|
||||||
|
|
||||||
const rawEventJSX = reactionOrEditEvent(mEvent)
|
if (groupedCarouselEventIds.has(mEventId)) {
|
||||||
? null
|
prevEvent = mEvent;
|
||||||
: renderMatrixEvent(
|
isPrevRendered = true;
|
||||||
mEvent.getType(),
|
return null;
|
||||||
typeof mEvent.getStateKey() === 'string',
|
}
|
||||||
mEventId,
|
|
||||||
mEvent,
|
const getEventByItem = (
|
||||||
item,
|
timelineItem: number
|
||||||
timelineSet,
|
): { event: MatrixEvent; eventId: string } | undefined => {
|
||||||
collapsed
|
const [tm, bIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, timelineItem);
|
||||||
);
|
if (!tm) return undefined;
|
||||||
|
|
||||||
|
const event = getTimelineEvent(tm, getTimelineRelativeIndex(timelineItem, bIndex));
|
||||||
|
const eventId = event?.getId();
|
||||||
|
if (!event || !eventId) return undefined;
|
||||||
|
|
||||||
|
return { event, eventId };
|
||||||
|
};
|
||||||
|
|
||||||
|
const carouselMetadata = getCarouselEventMetadata(mEvent);
|
||||||
|
const carouselEvents: CarouselRenderableEvent[] = [];
|
||||||
|
if (carouselMetadata && eventSender) {
|
||||||
|
carouselEvents.push({
|
||||||
|
mEvent,
|
||||||
|
mEventId,
|
||||||
|
item,
|
||||||
|
content: carouselMetadata.content,
|
||||||
|
index: carouselMetadata.index,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let nextIndex = index + 1; nextIndex < allItems.length; nextIndex += 1) {
|
||||||
|
const nextEvt = getEventByItem(allItems[nextIndex]);
|
||||||
|
if (!nextEvt) break;
|
||||||
|
|
||||||
|
const nextSender = nextEvt.event.getSender();
|
||||||
|
const nextMeta = getCarouselEventMetadata(nextEvt.event);
|
||||||
|
|
||||||
|
if (!nextMeta || nextSender !== eventSender || nextMeta.uuid !== carouselMetadata.uuid) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
groupedCarouselEventIds.add(nextEvt.eventId);
|
||||||
|
carouselEvents.push({
|
||||||
|
mEvent: nextEvt.event,
|
||||||
|
mEventId: nextEvt.eventId,
|
||||||
|
item: allItems[nextIndex],
|
||||||
|
content: nextMeta.content,
|
||||||
|
index: nextMeta.index,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (carouselEvents.length >= carouselMetadata.total) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawEventJSX: React.ReactNode = null;
|
||||||
|
if (!reactionOrEditEvent(mEvent)) {
|
||||||
|
rawEventJSX =
|
||||||
|
carouselEvents.length > 1
|
||||||
|
? renderImageCarouselMessage(carouselEvents, mEventId, mEvent, item, timelineSet, collapsed)
|
||||||
|
: renderMatrixEvent(
|
||||||
|
mEvent.getType(),
|
||||||
|
typeof mEvent.getStateKey() === 'string',
|
||||||
|
mEventId,
|
||||||
|
mEvent,
|
||||||
|
item,
|
||||||
|
timelineSet,
|
||||||
|
collapsed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this is a call event that should be grouped
|
// Check if this is a call event that should be grouped
|
||||||
const isCallEventPending = rawEventJSX === ('CALL_EVENT_PENDING' as unknown as React.ReactNode);
|
const isCallEventPending = rawEventJSX === ('CALL_EVENT_PENDING' as unknown as React.ReactNode);
|
||||||
@@ -1949,6 +2416,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
)}
|
)}
|
||||||
<Scroll ref={scrollRef} visibility="Hover">
|
<Scroll ref={scrollRef} visibility="Hover">
|
||||||
<Box
|
<Box
|
||||||
|
ref={timelineContentRef}
|
||||||
direction="Column"
|
direction="Column"
|
||||||
justifyContent="End"
|
justifyContent="End"
|
||||||
style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
|
style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
|
||||||
@@ -2036,15 +2504,36 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
</Scroll>
|
</Scroll>
|
||||||
{!atBottom && (
|
{!atBottom && (
|
||||||
<TimelineFloat position="Bottom">
|
<TimelineFloat position="Bottom">
|
||||||
<Chip
|
<Box direction="Column" gap="200" alignItems="Center">
|
||||||
variant="SurfaceVariant"
|
{latestUnreadMessage && (
|
||||||
radii="Pill"
|
<Box
|
||||||
outlined
|
style={{
|
||||||
before={<Icon size="50" src={Icons.ArrowBottom} />}
|
backgroundColor: color.Surface.Container,
|
||||||
onClick={handleJumpToLatest}
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||||||
>
|
borderRadius: config.radii.R400,
|
||||||
<Text size="L400">Jump to Latest</Text>
|
maxWidth: toRem(400),
|
||||||
</Chip>
|
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="T300" priority="300" style={{
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}>
|
||||||
|
<b>{latestUnreadMessage.sender}:</b> {latestUnreadMessage.content}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Chip
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="Pill"
|
||||||
|
outlined
|
||||||
|
before={<Icon size="50" src={Icons.ArrowBottom} />}
|
||||||
|
onClick={handleJumpToLatest}
|
||||||
|
>
|
||||||
|
<Text size="L400">Jump to Latest</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
</TimelineFloat>
|
</TimelineFloat>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -88,7 +88,13 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||||||
if (portalContainer && portalContainer.children.length > 0) {
|
if (portalContainer && portalContainer.children.length > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (shouldFocusMessageField(evt) || isKeyHotkey('mod+v', evt)) {
|
if (shouldFocusMessageField(evt)) {
|
||||||
|
evt.preventDefault();
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
if (evt.key.length === 1) {
|
||||||
|
editor.insertText(evt.key);
|
||||||
|
}
|
||||||
|
} else if (isKeyHotkey('mod+v', evt)) {
|
||||||
ReactEditor.focus(editor);
|
ReactEditor.focus(editor);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
|||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
||||||
import { getViaServers } from '../../plugins/via-servers';
|
import { getViaServers } from '../../plugins/via-servers';
|
||||||
|
import { PluginButtonSlot } from '../settings/plugins/PluginButtonSlot';
|
||||||
import { BackRouteHandler } from '../../components/BackRouteHandler';
|
import { BackRouteHandler } from '../../components/BackRouteHandler';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
|
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
|
||||||
@@ -229,6 +230,10 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
|||||||
</UseStateProvider>
|
</UseStateProvider>
|
||||||
</Box>
|
</Box>
|
||||||
<Line variant="Surface" size="300" />
|
<Line variant="Surface" size="300" />
|
||||||
|
<Box direction="Row" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
<PluginButtonSlot location="room-menu" />
|
||||||
|
</Box>
|
||||||
|
<Line variant="Surface" size="300" />
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
<UseStateProvider initial={false}>
|
<UseStateProvider initial={false}>
|
||||||
{(promptLeave, setPromptLeave) => (
|
{(promptLeave, setPromptLeave) => (
|
||||||
@@ -381,6 +386,7 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) {
|
|||||||
isInCall,
|
isInCall,
|
||||||
callState,
|
callState,
|
||||||
activeCall,
|
activeCall,
|
||||||
|
isUsingRemoteHomeserver,
|
||||||
startVoiceCall,
|
startVoiceCall,
|
||||||
startVideoCall,
|
startVideoCall,
|
||||||
endCall,
|
endCall,
|
||||||
@@ -433,7 +439,10 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) {
|
|||||||
offset={4}
|
offset={4}
|
||||||
tooltip={
|
tooltip={
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<Text>{isInCall ? 'End Call' : 'Voice Call'}</Text>
|
<Text>
|
||||||
|
{isInCall ? 'End Call' : 'Voice Call'}
|
||||||
|
{isUsingRemoteHomeserver && !isInCall && ' (Remote Server)'}
|
||||||
|
</Text>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -456,7 +465,10 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) {
|
|||||||
offset={4}
|
offset={4}
|
||||||
tooltip={
|
tooltip={
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<Text>{isInCall ? 'End Call' : 'Video Call'}</Text>
|
<Text>
|
||||||
|
{isInCall ? 'End Call' : 'Video Call'}
|
||||||
|
{isUsingRemoteHomeserver && !isInCall && ' (Remote Server)'}
|
||||||
|
</Text>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -706,6 +718,7 @@ export function RoomViewHeader() {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
<PluginButtonSlot location="room-header" />
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
align="End"
|
align="End"
|
||||||
|
|||||||
@@ -24,4 +24,7 @@ export const RoomViewTyping = style([
|
|||||||
]);
|
]);
|
||||||
export const TypingText = style({
|
export const TypingText = style({
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
|
overflow: 'clip',
|
||||||
|
overflowY: 'clip',
|
||||||
|
lineHeight: '1.5',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,8 +31,11 @@ import {
|
|||||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
|
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||||
|
import { editableActiveElement } from '../../utils/dom';
|
||||||
import {
|
import {
|
||||||
useEditor,
|
useEditor,
|
||||||
createMentionElement,
|
createMentionElement,
|
||||||
@@ -93,13 +96,44 @@ import {
|
|||||||
} from '../../utils/matrix';
|
} from '../../utils/matrix';
|
||||||
import { GetContentCallback, MessageEvent } from '../../../types/matrix/room';
|
import { GetContentCallback, MessageEvent } from '../../../types/matrix/room';
|
||||||
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
||||||
import { scrollToBottom } from '../../utils/dom';
|
import { autoScrollToBottom, scrollElementIntoView } from '../../utils/dom';
|
||||||
|
|
||||||
type ThreadViewProps = {
|
type ThreadViewProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
threadRootId: string;
|
threadRootId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FN_KEYS_REGEX = /^F\d+$/;
|
||||||
|
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
||||||
|
const { code } = evt;
|
||||||
|
if (evt.metaKey || evt.altKey || evt.ctrlKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FN_KEYS_REGEX.test(code)) return false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
code.startsWith('OS') ||
|
||||||
|
code.startsWith('Meta') ||
|
||||||
|
code.startsWith('Shift') ||
|
||||||
|
code.startsWith('Alt') ||
|
||||||
|
code.startsWith('Control') ||
|
||||||
|
code.startsWith('Arrow') ||
|
||||||
|
code.startsWith('Page') ||
|
||||||
|
code.startsWith('End') ||
|
||||||
|
code.startsWith('Home') ||
|
||||||
|
code === 'Tab' ||
|
||||||
|
code === 'Space' ||
|
||||||
|
code === 'Enter' ||
|
||||||
|
code === 'NumLock' ||
|
||||||
|
code === 'ScrollLock'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a thread inline, replacing the main room timeline.
|
* Renders a thread inline, replacing the main room timeline.
|
||||||
* Structured as direct flex siblings of `Page` to match the RoomView layout contract.
|
* Structured as direct flex siblings of `Page` to match the RoomView layout contract.
|
||||||
@@ -285,6 +319,29 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
|||||||
const inputRef = useRef<HTMLDivElement>(null);
|
const inputRef = useRef<HTMLDivElement>(null);
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
|
|
||||||
|
useKeyDown(
|
||||||
|
window,
|
||||||
|
useCallback(
|
||||||
|
(evt) => {
|
||||||
|
if (editableActiveElement()) return;
|
||||||
|
const portalContainer = document.getElementById('portalContainer');
|
||||||
|
if (portalContainer && portalContainer.children.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shouldFocusMessageField(evt)) {
|
||||||
|
evt.preventDefault();
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
if (evt.key.length === 1) {
|
||||||
|
editor.insertText(evt.key);
|
||||||
|
}
|
||||||
|
} else if (isKeyHotkey('mod+v', evt)) {
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// Subscribe to thread and room timeline events.
|
// Subscribe to thread and room timeline events.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
@@ -349,7 +406,7 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
|||||||
// Scroll to bottom on mount and when new events arrive.
|
// Scroll to bottom on mount and when new events arrive.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (el) scrollToBottom(el);
|
if (el) autoScrollToBottom(el);
|
||||||
}, [events.length]);
|
}, [events.length]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
@@ -424,7 +481,7 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
|||||||
const el = scrollRef.current?.querySelector(
|
const el = scrollRef.current?.querySelector(
|
||||||
`[data-message-id="${targetId}"]`
|
`[data-message-id="${targetId}"]`
|
||||||
) as HTMLElement | null;
|
) as HTMLElement | null;
|
||||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
if (el) scrollElementIntoView(el, { behavior: 'smooth', block: 'center' });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleReactionToggle = useCallback(
|
const handleReactionToggle = useCallback(
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ import {
|
|||||||
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||||
|
import { useEmojiUsage } from '../../../hooks/useEmojiUsage';
|
||||||
|
import { emojis } from '../../../plugins/emoji';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
import { EventReaders } from '../../../components/event-readers';
|
import { EventReaders } from '../../../components/event-readers';
|
||||||
import { TextViewer } from '../../../components/text-viewer';
|
import { TextViewer } from '../../../components/text-viewer';
|
||||||
@@ -75,6 +77,7 @@ import { stopPropagation } from '../../../utils/keyboard';
|
|||||||
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
|
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
|
||||||
import { getViaServers } from '../../../plugins/via-servers';
|
import { getViaServers } from '../../../plugins/via-servers';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
|
import { PluginButtonSlot } from '../../../features/settings/plugins/PluginButtonSlot';
|
||||||
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
|
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
|
||||||
import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
|
import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
|
||||||
import { PowerIcon } from '../../../components/power';
|
import { PowerIcon } from '../../../components/power';
|
||||||
@@ -90,10 +93,13 @@ type MessageQuickReactionsProps = {
|
|||||||
};
|
};
|
||||||
export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
|
export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
|
||||||
({ onReaction, ...props }, ref) => {
|
({ onReaction, ...props }, ref) => {
|
||||||
const mx = useMatrixClient();
|
const { getTopEmojis } = useEmojiUsage();
|
||||||
const recentEmojis = useRecentEmoji(mx, 4);
|
const topShortcodes = getTopEmojis(4);
|
||||||
|
const mostUsedEmojis = topShortcodes
|
||||||
|
.map((shortcode) => emojis.find((e) => e.shortcode === shortcode))
|
||||||
|
.filter((e): e is IEmoji => e !== undefined);
|
||||||
|
|
||||||
if (recentEmojis.length === 0) return <span />;
|
if (mostUsedEmojis.length === 0) return <span />;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box
|
<Box
|
||||||
@@ -104,7 +110,7 @@ export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
|
|||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
{recentEmojis.map((emoji) => (
|
{mostUsedEmojis.map((emoji) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
key={emoji.unicode}
|
key={emoji.unicode}
|
||||||
className={css.MessageQuickReaction}
|
className={css.MessageQuickReaction}
|
||||||
@@ -1070,6 +1076,7 @@ export const Message = as<'div', MessageProps>(
|
|||||||
<Icon src={Icons.Delete} size="100" />
|
<Icon src={Icons.Delete} size="100" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
|
<PluginButtonSlot location="message-actions" />
|
||||||
<PopOut
|
<PopOut
|
||||||
anchor={menuAnchor}
|
anchor={menuAnchor}
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
@@ -1333,6 +1340,7 @@ export const Event = as<'div', EventProps>(
|
|||||||
<div className={css.MessageOptionsBase}>
|
<div className={css.MessageOptionsBase}>
|
||||||
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
|
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
|
||||||
<Box gap="100">
|
<Box gap="100">
|
||||||
|
<PluginButtonSlot location="message-actions" />
|
||||||
<PopOut
|
<PopOut
|
||||||
anchor={menuAnchor}
|
anchor={menuAnchor}
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import {
|
|||||||
IThumbnailContent,
|
IThumbnailContent,
|
||||||
MATRIX_BLUR_HASH_PROPERTY_NAME,
|
MATRIX_BLUR_HASH_PROPERTY_NAME,
|
||||||
MATRIX_SPOILER_PROPERTY_NAME,
|
MATRIX_SPOILER_PROPERTY_NAME,
|
||||||
|
PAARROT_CAROUSEL_INDEX_PROPERTY_NAME,
|
||||||
|
PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME,
|
||||||
|
PAARROT_CAROUSEL_UUID_PROPERTY_NAME,
|
||||||
} from '../../../types/matrix/common';
|
} from '../../../types/matrix/common';
|
||||||
import {
|
import {
|
||||||
getImageFileUrl,
|
getImageFileUrl,
|
||||||
@@ -18,6 +21,12 @@ import { TUploadItem } from '../../state/room/roomInputDrafts';
|
|||||||
import { encodeBlurHash } from '../../utils/blurHash';
|
import { encodeBlurHash } from '../../utils/blurHash';
|
||||||
import { scaleYDimension } from '../../utils/common';
|
import { scaleYDimension } from '../../utils/common';
|
||||||
|
|
||||||
|
type CarouselMetadata = {
|
||||||
|
uuid: string;
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
const generateThumbnailContent = async (
|
const generateThumbnailContent = async (
|
||||||
mx: MatrixClient,
|
mx: MatrixClient,
|
||||||
img: HTMLImageElement | HTMLVideoElement,
|
img: HTMLImageElement | HTMLVideoElement,
|
||||||
@@ -46,7 +55,8 @@ const generateThumbnailContent = async (
|
|||||||
export const getImageMsgContent = async (
|
export const getImageMsgContent = async (
|
||||||
mx: MatrixClient,
|
mx: MatrixClient,
|
||||||
item: TUploadItem,
|
item: TUploadItem,
|
||||||
mxc: string
|
mxc: string,
|
||||||
|
carouselMetadata?: CarouselMetadata
|
||||||
): Promise<IContent> => {
|
): Promise<IContent> => {
|
||||||
const { file, originalFile, encInfo, metadata } = item;
|
const { file, originalFile, encInfo, metadata } = item;
|
||||||
const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
|
const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
|
||||||
@@ -74,6 +84,11 @@ export const getImageMsgContent = async (
|
|||||||
} else {
|
} else {
|
||||||
content.url = mxc;
|
content.url = mxc;
|
||||||
}
|
}
|
||||||
|
if (carouselMetadata) {
|
||||||
|
content[PAARROT_CAROUSEL_UUID_PROPERTY_NAME] = carouselMetadata.uuid;
|
||||||
|
content[PAARROT_CAROUSEL_INDEX_PROPERTY_NAME] = carouselMetadata.index;
|
||||||
|
content[PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME] = carouselMetadata.total;
|
||||||
|
}
|
||||||
return content;
|
return content;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ function PinnedMessage({
|
|||||||
}, [room, eventId, mx])
|
}, [room, eventId, mx])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sender = pinnedEvent?.getSender();
|
||||||
|
const senderAvatarMxc = sender ? getMemberAvatarMxc(room, sender) : undefined;
|
||||||
|
const customUserColor = useOtherUserColor(sender ?? '', senderAvatarMxc);
|
||||||
|
|
||||||
const handleOpenClick: MouseEventHandler = (evt) => {
|
const handleOpenClick: MouseEventHandler = (evt) => {
|
||||||
evt.stopPropagation();
|
evt.stopPropagation();
|
||||||
const evtId = evt.currentTarget.getAttribute('data-event-id');
|
const evtId = evt.currentTarget.getAttribute('data-event-id');
|
||||||
@@ -175,14 +179,9 @@ function PinnedMessage({
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
const sender = pinnedEvent.getSender()!;
|
const displayName = getMemberDisplayName(room, sender!) ?? getMxIdLocalPart(sender!) ?? sender!;
|
||||||
const displayName = getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender;
|
|
||||||
const senderAvatarMxc = getMemberAvatarMxc(room, sender);
|
|
||||||
const getContent = (() => pinnedEvent.getContent()) as GetContentCallback;
|
const getContent = (() => pinnedEvent.getContent()) as GetContentCallback;
|
||||||
|
|
||||||
// Get custom user color from avatar metadata
|
|
||||||
const customUserColor = useOtherUserColor(sender, senderAvatarMxc);
|
|
||||||
|
|
||||||
const memberPowerTag = getMemberPowerTag(sender);
|
const memberPowerTag = getMemberPowerTag(sender);
|
||||||
const tagColor = memberPowerTag?.color
|
const tagColor = memberPowerTag?.color
|
||||||
? accessibleTagColors?.get(memberPowerTag.color)
|
? accessibleTagColors?.get(memberPowerTag.color)
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function About({ requestClose }: AboutProps) {
|
|||||||
<Box gap="200" wrap="Wrap">
|
<Box gap="200" wrap="Wrap">
|
||||||
<Button
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
href="https://synbox.ruv.wtf:8418/litruv/cinny-desktop"
|
href="https://github.com/Paarrot/Paarrot-Desktop"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ import { useUserProfileStyle } from '../../../hooks/useUserProfileStyle';
|
|||||||
import { useUserPresence } from '../../../hooks/useUserPresence';
|
import { useUserPresence } from '../../../hooks/useUserPresence';
|
||||||
import { AvatarPresence, PresenceBadge } from '../../../components/presence';
|
import { AvatarPresence, PresenceBadge } from '../../../components/presence';
|
||||||
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
|
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
|
||||||
import colorMXID from '../../../../util/colorMXID';
|
import colorMXID, { getColorMXIDValue } from '../../../../util/colorMXID';
|
||||||
import {
|
import {
|
||||||
extractMetadataFromImage,
|
extractMetadataFromImage,
|
||||||
embedMetadataInImage,
|
embedMetadataInImage,
|
||||||
@@ -574,7 +574,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
: profileStyle.avatarBorderColor;
|
: profileStyle.avatarBorderColor;
|
||||||
|
|
||||||
const previewPillBgColor = previewBorderColor ? stripAlphaFromColor(previewBorderColor) : undefined;
|
const previewPillBgColor = previewBorderColor ? stripAlphaFromColor(previewBorderColor) : undefined;
|
||||||
const previewProfileColor = userColor || colorMXID(userId);
|
const previewProfileColor = userColor || getColorMXIDValue(userId);
|
||||||
|
|
||||||
const previewTextShadow = `0 1px 4px ${getTextShadowColor(previewProfileColor)}`;
|
const previewTextShadow = `0 1px 4px ${getTextShadowColor(previewProfileColor)}`;
|
||||||
|
|
||||||
|
|||||||
@@ -454,7 +454,11 @@ export function Audio({ requestClose }: AudioProps) {
|
|||||||
const [micTestState, setMicTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle');
|
const [micTestState, setMicTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle');
|
||||||
const [micLevel, setMicLevel] = useState(0);
|
const [micLevel, setMicLevel] = useState(0);
|
||||||
const [speakerTestState, setSpeakerTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle');
|
const [speakerTestState, setSpeakerTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle');
|
||||||
|
const [micMonitoring, setMicMonitoring] = useState(false);
|
||||||
const micAnimFrameRef = useRef<number | null>(null);
|
const micAnimFrameRef = useRef<number | null>(null);
|
||||||
|
const micMonitorContextRef = useRef<AudioContext | null>(null);
|
||||||
|
const micMonitorStreamRef = useRef<MediaStream | null>(null);
|
||||||
|
const micMonitorGainRef = useRef<GainNode | null>(null);
|
||||||
const [showRemoteCursor, setShowRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor');
|
const [showRemoteCursor, setShowRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -627,6 +631,101 @@ export function Audio({ requestClose }: AudioProps) {
|
|||||||
}
|
}
|
||||||
}, [speakerTestState]);
|
}, [speakerTestState]);
|
||||||
|
|
||||||
|
const handleMicMonitoringToggle = useCallback(async () => {
|
||||||
|
if (micMonitoring) {
|
||||||
|
// Stop monitoring
|
||||||
|
if (micAnimFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(micAnimFrameRef.current);
|
||||||
|
micAnimFrameRef.current = null;
|
||||||
|
}
|
||||||
|
if (micMonitorStreamRef.current) {
|
||||||
|
micMonitorStreamRef.current.getTracks().forEach((track) => track.stop());
|
||||||
|
micMonitorStreamRef.current = null;
|
||||||
|
}
|
||||||
|
if (micMonitorContextRef.current) {
|
||||||
|
micMonitorContextRef.current.close();
|
||||||
|
micMonitorContextRef.current = null;
|
||||||
|
}
|
||||||
|
micMonitorGainRef.current = null;
|
||||||
|
setMicLevel(0);
|
||||||
|
setMicMonitoring(false);
|
||||||
|
} else {
|
||||||
|
// Start monitoring
|
||||||
|
try {
|
||||||
|
const constraints: MediaStreamConstraints = {
|
||||||
|
audio: settings.microphoneId
|
||||||
|
? {
|
||||||
|
deviceId: { exact: settings.microphoneId },
|
||||||
|
noiseSuppression: settings.noiseSuppression,
|
||||||
|
echoCancellation: settings.echoCancellation,
|
||||||
|
autoGainControl: settings.autoGainControl,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
noiseSuppression: settings.noiseSuppression,
|
||||||
|
echoCancellation: settings.echoCancellation,
|
||||||
|
autoGainControl: settings.autoGainControl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
const audioContext = new AudioContext();
|
||||||
|
const analyser = audioContext.createAnalyser();
|
||||||
|
analyser.fftSize = 256;
|
||||||
|
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||||||
|
const microphone = audioContext.createMediaStreamSource(stream);
|
||||||
|
const gainNode = audioContext.createGain();
|
||||||
|
|
||||||
|
// Connect microphone to analyser for level monitoring
|
||||||
|
microphone.connect(analyser);
|
||||||
|
|
||||||
|
// Connect microphone to speakers for playback
|
||||||
|
microphone.connect(gainNode);
|
||||||
|
gainNode.connect(audioContext.destination);
|
||||||
|
gainNode.gain.value = 1.0; // Full volume playback
|
||||||
|
|
||||||
|
micMonitorContextRef.current = audioContext;
|
||||||
|
micMonitorStreamRef.current = stream;
|
||||||
|
micMonitorGainRef.current = gainNode;
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
analyser.getByteFrequencyData(dataArray);
|
||||||
|
const avg = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
|
||||||
|
setMicLevel(Math.min(100, Math.round((avg / 255) * 100 * 3)));
|
||||||
|
micAnimFrameRef.current = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
micAnimFrameRef.current = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
setMicMonitoring(true);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Microphone monitoring failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [micMonitoring, settings.microphoneId, settings.noiseSuppression, settings.echoCancellation, settings.autoGainControl]);
|
||||||
|
|
||||||
|
// Cleanup monitoring on unmount or when settings change
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (micAnimFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(micAnimFrameRef.current);
|
||||||
|
}
|
||||||
|
if (micMonitorStreamRef.current) {
|
||||||
|
micMonitorStreamRef.current.getTracks().forEach((track) => track.stop());
|
||||||
|
}
|
||||||
|
if (micMonitorContextRef.current) {
|
||||||
|
micMonitorContextRef.current.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Restart monitoring when mic settings change
|
||||||
|
useEffect(() => {
|
||||||
|
if (micMonitoring) {
|
||||||
|
handleMicMonitoringToggle(); // Stop
|
||||||
|
setTimeout(() => handleMicMonitoringToggle(), 100); // Restart
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [settings.microphoneId, settings.noiseSuppression, settings.echoCancellation, settings.autoGainControl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
@@ -686,21 +785,35 @@ export function Audio({ requestClose }: AudioProps) {
|
|||||||
onSelect={handleMicrophoneSelect}
|
onSelect={handleMicrophoneSelect}
|
||||||
/>
|
/>
|
||||||
<Box direction="Column" gap="100" alignItems="End">
|
<Box direction="Column" gap="100" alignItems="End">
|
||||||
<Button
|
<Box gap="200" alignItems="Center">
|
||||||
size="300"
|
<Button
|
||||||
variant={micTestState === 'error' ? 'Critical' : micTestState === 'done' ? 'Success' : 'Secondary'}
|
size="300"
|
||||||
fill="Soft"
|
variant={micTestState === 'error' ? 'Critical' : micTestState === 'done' ? 'Success' : 'Secondary'}
|
||||||
radii="300"
|
fill="Soft"
|
||||||
onClick={handleTestMicrophone}
|
radii="300"
|
||||||
disabled={micTestState === 'testing'}
|
onClick={handleTestMicrophone}
|
||||||
>
|
disabled={micTestState === 'testing' || micMonitoring}
|
||||||
<Text size="T300">
|
>
|
||||||
{micTestState === 'testing' ? 'Testing...' : micTestState === 'done' ? 'Done ✓' : micTestState === 'error' ? 'Failed' : 'Test'}
|
<Text size="T300">
|
||||||
</Text>
|
{micTestState === 'testing' ? 'Testing...' : micTestState === 'done' ? 'Done ✓' : micTestState === 'error' ? 'Failed' : 'Test'}
|
||||||
</Button>
|
</Text>
|
||||||
{micTestState === 'testing' && (
|
</Button>
|
||||||
<Box style={{ width: toRem(80), height: toRem(6), background: color.Surface.Container, borderRadius: toRem(3), overflow: 'hidden' }}>
|
<Button
|
||||||
<Box style={{ width: `${micLevel}%`, height: '100%', background: color.Primary.Main, transition: 'width 0.05s ease' }} />
|
size="300"
|
||||||
|
variant={micMonitoring ? 'Primary' : 'Secondary'}
|
||||||
|
fill={micMonitoring ? 'Solid' : 'Soft'}
|
||||||
|
radii="300"
|
||||||
|
onClick={handleMicMonitoringToggle}
|
||||||
|
disabled={micTestState === 'testing'}
|
||||||
|
>
|
||||||
|
<Text size="T300">
|
||||||
|
{micMonitoring ? 'Stop' : 'Monitor'}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{(micTestState === 'testing' || micMonitoring) && (
|
||||||
|
<Box style={{ width: toRem(160), height: toRem(6), background: color.Surface.Container, borderRadius: toRem(3), overflow: 'hidden' }}>
|
||||||
|
<Box style={{ width: `${micLevel}%`, height: '100%', background: micMonitoring ? color.Success.Main : color.Primary.Main, transition: 'width 0.05s ease' }} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import FocusTrap from 'focus-trap-react';
|
|||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
import { DateFormat, EmojiStyle, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
|
import { DateFormat, EmojiStyle, MessageLayout, MessageSpacing, ScrollToLatestBehavior, settingsAtom } from '../../../state/settings';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
import { KeySymbol } from '../../../utils/key-symbol';
|
import { KeySymbol } from '../../../utils/key-symbol';
|
||||||
import { isMacOS } from '../../../utils/user-agent';
|
import { isMacOS } from '../../../utils/user-agent';
|
||||||
@@ -984,6 +984,83 @@ function SelectMessageSpacing() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SelectScrollToLatestBehavior() {
|
||||||
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
const [scrollToLatestBehavior, setScrollToLatestBehavior] = useSetting(
|
||||||
|
settingsAtom,
|
||||||
|
'scrollToLatestBehavior'
|
||||||
|
);
|
||||||
|
|
||||||
|
const behaviors = [
|
||||||
|
{ value: ScrollToLatestBehavior.Always, label: 'Always' },
|
||||||
|
{ value: ScrollToLatestBehavior.OnNewMessage, label: 'On New Message' },
|
||||||
|
{ value: ScrollToLatestBehavior.Never, label: 'Never' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (behavior: ScrollToLatestBehavior) => {
|
||||||
|
setScrollToLatestBehavior(behavior);
|
||||||
|
setMenuCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentLabel = behaviors.find((b) => b.value === scrollToLatestBehavior)?.label ?? 'Always';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
outlined
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||||||
|
onClick={handleMenu}
|
||||||
|
>
|
||||||
|
<Text size="T300">{currentLabel}</Text>
|
||||||
|
</Button>
|
||||||
|
<PopOut
|
||||||
|
anchor={menuCords}
|
||||||
|
offset={5}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setMenuCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{behaviors.map((item) => (
|
||||||
|
<MenuItem
|
||||||
|
key={item.value}
|
||||||
|
size="300"
|
||||||
|
variant={scrollToLatestBehavior === item.value ? 'Primary' : 'Surface'}
|
||||||
|
radii="300"
|
||||||
|
onClick={() => handleSelect(item.value)}
|
||||||
|
>
|
||||||
|
<Text size="T300">{item.label}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Messages() {
|
function Messages() {
|
||||||
const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
|
const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
|
||||||
settingsAtom,
|
settingsAtom,
|
||||||
@@ -1011,6 +1088,12 @@ function Messages() {
|
|||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile title="Message Spacing" after={<SelectMessageSpacing />} />
|
<SettingTile title="Message Spacing" after={<SelectMessageSpacing />} />
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Scroll to Latest on Room Reselect"
|
||||||
|
after={<SelectScrollToLatestBehavior />}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Legacy Username Color"
|
title="Legacy Username Color"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Box, Text, Switch, Button, color, Spinner } from 'folds';
|
import { Box, Text, Switch, Button, color, Spinner } from 'folds';
|
||||||
import { IPusherRequest } from 'matrix-js-sdk';
|
import { IPusherRequest } from 'matrix-js-sdk';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
@@ -10,6 +10,13 @@ import { getNotificationState, usePermissionState } from '../../../hooks/usePerm
|
|||||||
import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
|
import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { isCapacitorNative, requestSystemNotificationPermission } from '../../../utils/tauri';
|
||||||
|
import {
|
||||||
|
isBackgroundSyncSupported,
|
||||||
|
getBackgroundSyncStatus,
|
||||||
|
requestResetPushRegistration,
|
||||||
|
triggerBackgroundSyncPing,
|
||||||
|
} from '../../../utils/backgroundSync';
|
||||||
|
|
||||||
function EmailNotification() {
|
function EmailNotification() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
@@ -86,14 +93,15 @@ function EmailNotification() {
|
|||||||
|
|
||||||
export function SystemNotification() {
|
export function SystemNotification() {
|
||||||
const notifPermission = usePermissionState('notifications', getNotificationState());
|
const notifPermission = usePermissionState('notifications', getNotificationState());
|
||||||
|
const capacitorNative = isCapacitorNative();
|
||||||
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
|
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||||
const [isNotificationSounds, setIsNotificationSounds] = useSetting(
|
const [isNotificationSounds, setIsNotificationSounds] = useSetting(
|
||||||
settingsAtom,
|
settingsAtom,
|
||||||
'isNotificationSounds'
|
'isNotificationSounds'
|
||||||
);
|
);
|
||||||
|
|
||||||
const requestNotificationPermission = () => {
|
const requestNotificationPermission = async () => {
|
||||||
window.Notification.requestPermission();
|
await requestSystemNotificationPermission();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -125,7 +133,7 @@ export function SystemNotification() {
|
|||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Switch
|
<Switch
|
||||||
disabled={notifPermission !== 'granted'}
|
disabled={!capacitorNative && notifPermission !== 'granted'}
|
||||||
value={showNotifications}
|
value={showNotifications}
|
||||||
onChange={setShowNotifications}
|
onChange={setShowNotifications}
|
||||||
/>
|
/>
|
||||||
@@ -145,6 +153,7 @@ export function SystemNotification() {
|
|||||||
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
|
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
{isBackgroundSyncSupported() && <AndroidPushNotifications />}
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={SequenceCardStyle}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
@@ -156,3 +165,126 @@ export function SystemNotification() {
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PushStatus = {
|
||||||
|
registered: boolean;
|
||||||
|
distributor: string;
|
||||||
|
endpoint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Android-only section showing UnifiedPush registration status and controls. */
|
||||||
|
function AndroidPushNotifications() {
|
||||||
|
const [status, setStatus] = useState<PushStatus | undefined>(undefined);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const s = await getBackgroundSyncStatus();
|
||||||
|
setStatus(
|
||||||
|
s
|
||||||
|
? {
|
||||||
|
registered: s.registered,
|
||||||
|
distributor: s.distributor || '',
|
||||||
|
endpoint: s.endpoint || '',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const [resetState, reset] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
await requestResetPushRegistration();
|
||||||
|
await refresh();
|
||||||
|
}, [refresh])
|
||||||
|
);
|
||||||
|
|
||||||
|
const [pingState, ping] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
await triggerBackgroundSyncPing('manual-test');
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const isBusy =
|
||||||
|
loading ||
|
||||||
|
resetState.status === AsyncStatus.Loading ||
|
||||||
|
pingState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Android Push (UnifiedPush)</Text>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Background Notifications"
|
||||||
|
description={
|
||||||
|
loading ? (
|
||||||
|
'Loading status…'
|
||||||
|
) : status === undefined ? (
|
||||||
|
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||||
|
Failed to read push status.
|
||||||
|
</Text>
|
||||||
|
) : status.registered ? (
|
||||||
|
<>
|
||||||
|
<Text as="span" size="T200">
|
||||||
|
{`Distributor: ${status.distributor || 'unknown'}`}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
|
||||||
|
Not registered. Tap "Reset" to choose a distributor app.
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
after={loading ? <Spinner variant="Secondary" /> : undefined}
|
||||||
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Change Distributor"
|
||||||
|
description="Re-open the UnifiedPush distributor selection dialog."
|
||||||
|
after={
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Secondary"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => void reset()}
|
||||||
|
>
|
||||||
|
{resetState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner variant="Secondary" size="200" />
|
||||||
|
) : (
|
||||||
|
<Text size="B300">Reset</Text>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Test Notification"
|
||||||
|
description="Trigger a test push ping to verify the pipeline works."
|
||||||
|
after={
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Secondary"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => void ping()}
|
||||||
|
>
|
||||||
|
{pingState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner variant="Secondary" size="200" />
|
||||||
|
) : (
|
||||||
|
<Text size="B300">Send Test</Text>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export type {
|
|||||||
PluginContext,
|
PluginContext,
|
||||||
PluginSettingsSection,
|
PluginSettingsSection,
|
||||||
UILocation,
|
UILocation,
|
||||||
|
UIButtonDefinition,
|
||||||
|
UIButtonPosition,
|
||||||
Plugin,
|
Plugin,
|
||||||
PluginLogEntry,
|
PluginLogEntry,
|
||||||
} from '@paarrot/plugin-manager';
|
} from '@paarrot/plugin-manager';
|
||||||
@@ -54,8 +56,10 @@ import {
|
|||||||
} from '@paarrot/plugin-manager';
|
} from '@paarrot/plugin-manager';
|
||||||
|
|
||||||
const PLUGIN_INDEX_URL =
|
const PLUGIN_INDEX_URL =
|
||||||
'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/index.json';
|
import.meta.env.VITE_PLUGIN_INDEX_URL ||
|
||||||
|
`https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/index.json?cache_burst=${(Math.random()*256).toString()}`;
|
||||||
const PLUGIN_BASE_URL =
|
const PLUGIN_BASE_URL =
|
||||||
|
import.meta.env.VITE_PLUGIN_BASE_URL ||
|
||||||
'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/';
|
'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/';
|
||||||
|
|
||||||
function injectThemeStyle(styleId: string, css: string): void {
|
function injectThemeStyle(styleId: string, css: string): void {
|
||||||
@@ -107,6 +111,13 @@ export const pluginMarketplaceManager = new PluginMarketplaceManager({
|
|||||||
return result.data.map((plugin) => ({
|
return result.data.map((plugin) => ({
|
||||||
id: plugin.id,
|
id: plugin.id,
|
||||||
name: plugin.name,
|
name: plugin.name,
|
||||||
|
version: plugin.version,
|
||||||
|
description: plugin.description,
|
||||||
|
author: plugin.author,
|
||||||
|
repository: plugin.repository,
|
||||||
|
thumbnail: plugin.thumbnail,
|
||||||
|
homepage: plugin.homepage,
|
||||||
|
tags: plugin.tags,
|
||||||
installedDate: plugin.installedDate,
|
installedDate: plugin.installedDate,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|||||||
71
src/app/features/settings/plugins/PluginButtonSlot.tsx
Normal file
71
src/app/features/settings/plugins/PluginButtonSlot.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import React, { useSyncExternalStore } from 'react';
|
||||||
|
import { IconButton } from 'folds';
|
||||||
|
import { UILocation } from '@paarrot/plugin-manager';
|
||||||
|
import { pluginRegistry } from './PluginAPI';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Module-level subscription store — works correctly with React 18 concurrent
|
||||||
|
// rendering, lazy-mounted components (nav panels, route changes), and popup
|
||||||
|
// menus that mount fresh after plugins have already loaded.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
let storeVersion = 0;
|
||||||
|
|
||||||
|
export function subscribeToButtons(callback: Listener): () => void {
|
||||||
|
listeners.add(callback);
|
||||||
|
return () => listeners.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoreVersion(): number {
|
||||||
|
return storeVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify all subscribed slots that the button registry has changed.
|
||||||
|
* Call this after plugins load or unload.
|
||||||
|
*/
|
||||||
|
export function dispatchPluginButtonsChanged(): void {
|
||||||
|
storeVersion += 1;
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginButtonSlotProps {
|
||||||
|
/** The UI location to render buttons for. */
|
||||||
|
location: UILocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders all plugin-registered buttons for the given UI location.
|
||||||
|
* Correctly handles components that mount before OR after plugins load,
|
||||||
|
* including popup menus, navigation panels, and concurrent React renders.
|
||||||
|
*/
|
||||||
|
export function PluginButtonSlot({ location }: PluginButtonSlotProps): React.ReactElement | null {
|
||||||
|
useSyncExternalStore(subscribeToButtons, getStoreVersion);
|
||||||
|
|
||||||
|
const buttons = pluginRegistry.getButtonsForLocation(location);
|
||||||
|
if (buttons.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{buttons.map((button) => (
|
||||||
|
<IconButton
|
||||||
|
key={button.id}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
aria-label={button.label}
|
||||||
|
title={button.label}
|
||||||
|
onClick={() => button.onClick?.()}
|
||||||
|
>
|
||||||
|
<span aria-hidden style={{ fontSize: '1.1em', lineHeight: 1 }}>
|
||||||
|
{button.icon ?? '🔌'}
|
||||||
|
</span>
|
||||||
|
</IconButton>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { MatrixClient } from 'matrix-js-sdk';
|
|||||||
import { Plugin, createPluginContext } from '@paarrot/plugin-manager';
|
import { Plugin, createPluginContext } from '@paarrot/plugin-manager';
|
||||||
import { pluginMarketplaceManager, pluginRegistry } from './PluginAPI';
|
import { pluginMarketplaceManager, pluginRegistry } from './PluginAPI';
|
||||||
import { sendNotification } from '../../../utils/tauri';
|
import { sendNotification } from '../../../utils/tauri';
|
||||||
|
import { dispatchPluginButtonsChanged } from './PluginButtonSlot';
|
||||||
|
|
||||||
interface PluginLoaderProps {
|
interface PluginLoaderProps {
|
||||||
matrixClient: MatrixClient;
|
matrixClient: MatrixClient;
|
||||||
@@ -14,10 +15,14 @@ interface PluginLoaderProps {
|
|||||||
* Should be placed high in the component tree after MatrixClient is available.
|
* Should be placed high in the component tree after MatrixClient is available.
|
||||||
*/
|
*/
|
||||||
export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
|
export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
|
||||||
|
console.log('[PluginLoader] Component rendering, matrixClient:', !!matrixClient);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadPlugins = async () => {
|
const loadPlugins = async () => {
|
||||||
try {
|
try {
|
||||||
console.log('[PluginLoader] Loading plugins...');
|
console.log('[PluginLoader] Loading plugins...');
|
||||||
|
console.log('[PluginLoader] window.electron:', !!window.electron);
|
||||||
|
console.log('[PluginLoader] window.electron.plugins:', !!window.electron?.plugins);
|
||||||
|
|
||||||
if (!window.electron?.plugins) {
|
if (!window.electron?.plugins) {
|
||||||
console.log('[PluginLoader] Running in web mode, plugins not supported');
|
console.log('[PluginLoader] Running in web mode, plugins not supported');
|
||||||
@@ -27,6 +32,7 @@ export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
|
|||||||
const pluginsToLoad = await pluginMarketplaceManager.listEnabledPlugins();
|
const pluginsToLoad = await pluginMarketplaceManager.listEnabledPlugins();
|
||||||
|
|
||||||
console.log('[PluginLoader] Plugins to load:', pluginsToLoad.length);
|
console.log('[PluginLoader] Plugins to load:', pluginsToLoad.length);
|
||||||
|
console.log('[PluginLoader] Plugin list:', pluginsToLoad.map(p => p.id));
|
||||||
|
|
||||||
for (const installedPlugin of pluginsToLoad) {
|
for (const installedPlugin of pluginsToLoad) {
|
||||||
try {
|
try {
|
||||||
@@ -65,6 +71,7 @@ export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const compatContext = Object.assign(context as Record<string, unknown>, {
|
const compatContext = Object.assign(context as Record<string, unknown>, {
|
||||||
|
matrixClient,
|
||||||
matrix: {
|
matrix: {
|
||||||
on: (eventType: string, handler: (...args: unknown[]) => void) =>
|
on: (eventType: string, handler: (...args: unknown[]) => void) =>
|
||||||
matrixClient.on(eventType as any, handler as any),
|
matrixClient.on(eventType as any, handler as any),
|
||||||
@@ -74,9 +81,14 @@ export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
|
|||||||
React,
|
React,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('[PluginLoader] compatContext.React:', !!compatContext.React);
|
||||||
|
console.log('[PluginLoader] compatContext.ui:', !!compatContext.ui);
|
||||||
|
console.log('[PluginLoader] About to register plugin and call onLoad...');
|
||||||
|
|
||||||
pluginRegistry.registerPlugin(installedPlugin.id, plugin, context);
|
pluginRegistry.registerPlugin(installedPlugin.id, plugin, context);
|
||||||
await plugin.onLoad(compatContext as any);
|
await plugin.onLoad(compatContext as any);
|
||||||
console.log(`[PluginLoader] Successfully loaded plugin: ${installedPlugin.id}`);
|
console.log(`[PluginLoader] Successfully loaded plugin: ${installedPlugin.id}`);
|
||||||
|
dispatchPluginButtonsChanged();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[PluginLoader] Failed to load plugin ${installedPlugin.id}:`, error);
|
console.error(`[PluginLoader] Failed to load plugin ${installedPlugin.id}:`, error);
|
||||||
pluginRegistry.addLog(installedPlugin.id, 'error', ['Failed to load:', error]);
|
pluginRegistry.addLog(installedPlugin.id, 'error', ['Failed to load:', error]);
|
||||||
@@ -92,6 +104,7 @@ export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
console.log('[PluginLoader] Cleaning up plugins');
|
console.log('[PluginLoader] Cleaning up plugins');
|
||||||
pluginRegistry.clear();
|
pluginRegistry.clear();
|
||||||
|
dispatchPluginButtonsChanged();
|
||||||
};
|
};
|
||||||
}, [matrixClient]);
|
}, [matrixClient]);
|
||||||
|
|
||||||
|
|||||||
48
src/app/features/settings/plugins/PluginNavSlot.tsx
Normal file
48
src/app/features/settings/plugins/PluginNavSlot.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React, { useSyncExternalStore } from 'react';
|
||||||
|
import { Avatar, Box, Text } from 'folds';
|
||||||
|
import { UILocation } from '@paarrot/plugin-manager';
|
||||||
|
import { NavItem, NavButton, NavItemContent } from '../../../components/nav';
|
||||||
|
import { pluginRegistry } from './PluginAPI';
|
||||||
|
import { subscribeToButtons, getStoreVersion } from './PluginButtonSlot';
|
||||||
|
|
||||||
|
interface PluginNavSlotProps {
|
||||||
|
/** The UI location to render nav list entries for. */
|
||||||
|
location: UILocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders plugin buttons as `NavItem` list entries, matching the style of
|
||||||
|
* built-in sidebar items like "Create Room" and "Message Search".
|
||||||
|
* Intended for locations that live inside the channel / room list, not toolbars.
|
||||||
|
*/
|
||||||
|
export function PluginNavSlot({ location }: PluginNavSlotProps): React.ReactElement | null {
|
||||||
|
useSyncExternalStore(subscribeToButtons, getStoreVersion);
|
||||||
|
|
||||||
|
const buttons = pluginRegistry.getButtonsForLocation(location);
|
||||||
|
if (buttons.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{buttons.map((button) => (
|
||||||
|
<NavItem key={button.id} variant="Background" radii="400">
|
||||||
|
<NavButton onClick={() => button.onClick?.()}>
|
||||||
|
<NavItemContent>
|
||||||
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Avatar size="200" radii="400">
|
||||||
|
<span aria-hidden style={{ fontSize: '1em', lineHeight: 1 }}>
|
||||||
|
{button.icon ?? '🔌'}
|
||||||
|
</span>
|
||||||
|
</Avatar>
|
||||||
|
<Box as="span" grow="Yes">
|
||||||
|
<Text as="span" size="Inherit" truncate>
|
||||||
|
{button.label}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</NavItemContent>
|
||||||
|
</NavButton>
|
||||||
|
</NavItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/app/features/settings/plugins/PluginSidebarSlot.tsx
Normal file
49
src/app/features/settings/plugins/PluginSidebarSlot.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import React, { useSyncExternalStore } from 'react';
|
||||||
|
import { UILocation } from '@paarrot/plugin-manager';
|
||||||
|
import {
|
||||||
|
SidebarItem,
|
||||||
|
SidebarItemTooltip,
|
||||||
|
SidebarAvatar,
|
||||||
|
} from '../../../components/sidebar';
|
||||||
|
import { pluginRegistry } from './PluginAPI';
|
||||||
|
import { subscribeToButtons, getStoreVersion } from './PluginButtonSlot';
|
||||||
|
|
||||||
|
interface PluginSidebarSlotProps {
|
||||||
|
/** The UI location to render sidebar icon entries for. */
|
||||||
|
location: UILocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders plugin buttons as `SidebarItem` entries, matching the style of
|
||||||
|
* built-in sidebar tabs like SearchTab, InboxTab, and SettingsTab.
|
||||||
|
* Intended for the `sidebar-actions` location in the main left sidebar.
|
||||||
|
*/
|
||||||
|
export function PluginSidebarSlot({ location }: PluginSidebarSlotProps): React.ReactElement | null {
|
||||||
|
useSyncExternalStore(subscribeToButtons, getStoreVersion);
|
||||||
|
|
||||||
|
const buttons = pluginRegistry.getButtonsForLocation(location);
|
||||||
|
if (buttons.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{buttons.map((button) => (
|
||||||
|
<SidebarItem key={button.id}>
|
||||||
|
<SidebarItemTooltip tooltip={button.label}>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<SidebarAvatar
|
||||||
|
as="button"
|
||||||
|
ref={triggerRef}
|
||||||
|
onClick={() => button.onClick?.()}
|
||||||
|
aria-label={button.label}
|
||||||
|
>
|
||||||
|
<span aria-hidden style={{ fontSize: '1.2em', lineHeight: 1 }}>
|
||||||
|
{button.icon ?? '🔌'}
|
||||||
|
</span>
|
||||||
|
</SidebarAvatar>
|
||||||
|
)}
|
||||||
|
</SidebarItemTooltip>
|
||||||
|
</SidebarItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,7 +29,9 @@ export const useAuthenticatedMediaUrl = (
|
|||||||
// Check if this is an authenticated media URL
|
// Check if this is an authenticated media URL
|
||||||
const isAuthenticatedMediaUrl =
|
const isAuthenticatedMediaUrl =
|
||||||
src.includes('/_matrix/client/v1/media/download') ||
|
src.includes('/_matrix/client/v1/media/download') ||
|
||||||
src.includes('/_matrix/client/v1/media/thumbnail');
|
src.includes('/_matrix/client/v1/media/thumbnail') ||
|
||||||
|
(src.includes('/_matrix/media/') &&
|
||||||
|
(src.includes('/download/') || src.includes('/thumbnail/')));
|
||||||
|
|
||||||
if (!isAuthenticatedMediaUrl) {
|
if (!isAuthenticatedMediaUrl) {
|
||||||
setBlobUrl(src);
|
setBlobUrl(src);
|
||||||
@@ -99,7 +101,9 @@ export const useAuthenticatedMediaFetch = () => {
|
|||||||
async (src: string): Promise<string> => {
|
async (src: string): Promise<string> => {
|
||||||
const isAuthenticatedMediaUrl =
|
const isAuthenticatedMediaUrl =
|
||||||
src.includes('/_matrix/client/v1/media/download') ||
|
src.includes('/_matrix/client/v1/media/download') ||
|
||||||
src.includes('/_matrix/client/v1/media/thumbnail');
|
src.includes('/_matrix/client/v1/media/thumbnail') ||
|
||||||
|
(src.includes('/_matrix/media/') &&
|
||||||
|
(src.includes('/download/') || src.includes('/thumbnail/')));
|
||||||
|
|
||||||
if (!isAuthenticatedMediaUrl) {
|
if (!isAuthenticatedMediaUrl) {
|
||||||
return src;
|
return src;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { useRoomNavigate } from './useRoomNavigate';
|
|||||||
import { Membership, StateEvent } from '../../types/matrix/room';
|
import { Membership, StateEvent } from '../../types/matrix/room';
|
||||||
import { getStateEvent } from '../utils/room';
|
import { getStateEvent } from '../utils/room';
|
||||||
import { splitWithSpace } from '../utils/common';
|
import { splitWithSpace } from '../utils/common';
|
||||||
import { createRoomEncryptionState } from '../components/create-room';
|
import { createRoomEncryptionState, createRoomPowerLevelsState } from '../components/create-room';
|
||||||
|
|
||||||
export const SHRUG = '¯\\_(ツ)_/¯';
|
export const SHRUG = '¯\\_(ツ)_/¯';
|
||||||
export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻';
|
export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻';
|
||||||
@@ -218,7 +218,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
|||||||
invite: userIds,
|
invite: userIds,
|
||||||
visibility: Visibility.Private,
|
visibility: Visibility.Private,
|
||||||
preset: Preset.TrustedPrivateChat,
|
preset: Preset.TrustedPrivateChat,
|
||||||
initial_state: [createRoomEncryptionState()],
|
initial_state: [createRoomPowerLevelsState(), createRoomEncryptionState()],
|
||||||
});
|
});
|
||||||
addRoomIdToMDirect(mx, result.room_id, userIds[0]);
|
addRoomIdToMDirect(mx, result.room_id, userIds[0]);
|
||||||
navigateRoom(result.room_id);
|
navigateRoom(result.room_id);
|
||||||
|
|||||||
104
src/app/hooks/useEmojiUsage.ts
Normal file
104
src/app/hooks/useEmojiUsage.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { useAccountData } from './useAccountData';
|
||||||
|
|
||||||
|
const PAARROT_EMOJI_USAGE_EVENT = 'paarrot.favemojis';
|
||||||
|
const MAX_TRACKED_EMOJIS = 50;
|
||||||
|
|
||||||
|
export type EmojiUsageData = {
|
||||||
|
[shortcode: string]: {
|
||||||
|
count: number;
|
||||||
|
lastUsed: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for tracking emoji usage and storing favorites in Matrix account data
|
||||||
|
* Stores emoji usage stats under paarrot.favemojis in user's account data
|
||||||
|
*/
|
||||||
|
export function useEmojiUsage() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const event = useAccountData(PAARROT_EMOJI_USAGE_EVENT);
|
||||||
|
|
||||||
|
const emojiUsage = useMemo((): EmojiUsageData => {
|
||||||
|
const content = event?.getContent<EmojiUsageData>();
|
||||||
|
return content || {};
|
||||||
|
}, [event]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track emoji usage (max 1 per message as per spec)
|
||||||
|
* @param shortcode - Emoji shortcode (e.g., 'thumbsup', 'smile')
|
||||||
|
*/
|
||||||
|
const trackEmojiUsage = useCallback(
|
||||||
|
async (shortcode: string) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const currentUsage = { ...emojiUsage };
|
||||||
|
|
||||||
|
// Update or create emoji entry
|
||||||
|
currentUsage[shortcode] = {
|
||||||
|
count: (currentUsage[shortcode]?.count || 0) + 1,
|
||||||
|
lastUsed: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep only top MAX_TRACKED_EMOJIS by usage count
|
||||||
|
const entries = Object.entries(currentUsage);
|
||||||
|
if (entries.length > MAX_TRACKED_EMOJIS) {
|
||||||
|
entries.sort((a, b) => b[1].count - a[1].count);
|
||||||
|
const topEmojis = entries.slice(0, MAX_TRACKED_EMOJIS);
|
||||||
|
const newUsage: EmojiUsageData = {};
|
||||||
|
topEmojis.forEach(([key, value]) => {
|
||||||
|
newUsage[key] = value;
|
||||||
|
});
|
||||||
|
await mx.setAccountData(PAARROT_EMOJI_USAGE_EVENT, newUsage);
|
||||||
|
} else {
|
||||||
|
await mx.setAccountData(PAARROT_EMOJI_USAGE_EVENT, currentUsage);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mx, emojiUsage]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get most used emoji shortcode
|
||||||
|
* @returns Shortcode of most used emoji, or undefined if none tracked
|
||||||
|
*/
|
||||||
|
const getMostUsedEmoji = useCallback((): string | undefined => {
|
||||||
|
const entries = Object.entries(emojiUsage);
|
||||||
|
if (entries.length === 0) return undefined;
|
||||||
|
|
||||||
|
// Sort by count descending, then by lastUsed descending
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
if (b[1].count !== a[1].count) {
|
||||||
|
return b[1].count - a[1].count;
|
||||||
|
}
|
||||||
|
return b[1].lastUsed - a[1].lastUsed;
|
||||||
|
});
|
||||||
|
|
||||||
|
return entries[0][0];
|
||||||
|
}, [emojiUsage]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get top N most used emojis
|
||||||
|
* @param count - Number of top emojis to return
|
||||||
|
* @returns Array of shortcodes sorted by usage
|
||||||
|
*/
|
||||||
|
const getTopEmojis = useCallback(
|
||||||
|
(count: number = 10): string[] => {
|
||||||
|
const entries = Object.entries(emojiUsage);
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
if (b[1].count !== a[1].count) {
|
||||||
|
return b[1].count - a[1].count;
|
||||||
|
}
|
||||||
|
return b[1].lastUsed - a[1].lastUsed;
|
||||||
|
});
|
||||||
|
return entries.slice(0, count).map(([shortcode]) => shortcode);
|
||||||
|
},
|
||||||
|
[emojiUsage]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
emojiUsage,
|
||||||
|
trackEmojiUsage,
|
||||||
|
getMostUsedEmoji,
|
||||||
|
getTopEmojis,
|
||||||
|
};
|
||||||
|
}
|
||||||
110
src/app/hooks/useRoomListReorder.ts
Normal file
110
src/app/hooks/useRoomListReorder.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { MatrixClient, MatrixEvent, Room, RoomEvent, IRoomTimelineData } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that tracks timeline events across all rooms to trigger re-sorting of room lists.
|
||||||
|
* Returns a counter that increments whenever a message is sent or received in any room.
|
||||||
|
* This can be used as a dependency in useMemo to trigger re-sorting of room lists.
|
||||||
|
*
|
||||||
|
* @param mx - The Matrix client instance
|
||||||
|
* @param roomIds - Optional array of room IDs to track. If not provided, tracks all rooms.
|
||||||
|
* @returns A number that increments on each timeline event
|
||||||
|
*/
|
||||||
|
export const useRoomListReorder = (mx: MatrixClient, roomIds?: string[]): number => {
|
||||||
|
const [updateCounter, setUpdateCounter] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleTimelineEvent = (
|
||||||
|
mEvent: MatrixEvent,
|
||||||
|
room: Room | undefined,
|
||||||
|
toStartOfTimeline: boolean | undefined,
|
||||||
|
removed: boolean,
|
||||||
|
data: IRoomTimelineData
|
||||||
|
) => {
|
||||||
|
if (!room || !data.liveEvent) return;
|
||||||
|
if (toStartOfTimeline) return;
|
||||||
|
|
||||||
|
if (roomIds && !roomIds.includes(room.roomId)) return;
|
||||||
|
|
||||||
|
setUpdateCounter((prev) => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
||||||
|
};
|
||||||
|
}, [mx, roomIds]);
|
||||||
|
|
||||||
|
return updateCounter;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that implements FLIP (First, Last, Invert, Play) animation for list reordering.
|
||||||
|
* Tracks element positions before and after reorder, then animates the difference.
|
||||||
|
*
|
||||||
|
* @param items - Array of item IDs in their current order
|
||||||
|
* @returns Ref callback to attach to the list container
|
||||||
|
*/
|
||||||
|
export const useListReorderAnimation = <T extends string>(
|
||||||
|
items: T[]
|
||||||
|
): React.RefCallback<HTMLElement> => {
|
||||||
|
const positionsRef = useRef<Map<T, number>>(new Map());
|
||||||
|
const containerRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const currentPositions = new Map<T, number>();
|
||||||
|
const elements = new Map<T, HTMLElement>();
|
||||||
|
|
||||||
|
items.forEach((itemId) => {
|
||||||
|
const element = containerRef.current!.querySelector(`[data-room-id="${itemId}"]`) as HTMLElement;
|
||||||
|
if (element) {
|
||||||
|
currentPositions.set(itemId, element.offsetTop);
|
||||||
|
elements.set(itemId, element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (positionsRef.current.size === 0) {
|
||||||
|
positionsRef.current = currentPositions;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const animations: Array<{ element: HTMLElement; deltaY: number }> = [];
|
||||||
|
|
||||||
|
items.forEach((itemId) => {
|
||||||
|
const element = elements.get(itemId);
|
||||||
|
const oldPos = positionsRef.current.get(itemId);
|
||||||
|
const newPos = currentPositions.get(itemId);
|
||||||
|
|
||||||
|
if (element && oldPos !== undefined && newPos !== undefined) {
|
||||||
|
const deltaY = oldPos - newPos;
|
||||||
|
|
||||||
|
if (Math.abs(deltaY) > 1) {
|
||||||
|
animations.push({ element, deltaY });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (animations.length > 0) {
|
||||||
|
animations.forEach(({ element, deltaY }) => {
|
||||||
|
element.style.transform = `translateY(${deltaY}px)`;
|
||||||
|
element.style.transition = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
animations.forEach(({ element }) => {
|
||||||
|
element.style.transition = 'transform 300ms cubic-bezier(0.4, 0, 0.2, 1)';
|
||||||
|
element.style.transform = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
positionsRef.current = currentPositions;
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
return (container: HTMLElement | null) => {
|
||||||
|
containerRef.current = container;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -73,6 +73,7 @@ export function useUserBanner(): [
|
|||||||
|
|
||||||
const profile = await mx.getProfileInfo(userId);
|
const profile = await mx.getProfileInfo(userId);
|
||||||
const avatarUrl = profile.avatar_url;
|
const avatarUrl = profile.avatar_url;
|
||||||
|
console.log('[useUserBanner.loadBanner] Loading banner from avatar:', avatarUrl);
|
||||||
|
|
||||||
if (!avatarUrl) {
|
if (!avatarUrl) {
|
||||||
setBanner(undefined);
|
setBanner(undefined);
|
||||||
@@ -90,6 +91,7 @@ export function useUserBanner(): [
|
|||||||
// Always use current session's token to avoid stale tokens during account switches
|
// Always use current session's token to avoid stale tokens during account switches
|
||||||
const accessToken = getCurrentAccessToken();
|
const accessToken = getCurrentAccessToken();
|
||||||
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
||||||
|
console.log('[useUserBanner.loadBanner] Extracted banner:', metadata.banner);
|
||||||
setBanner(metadata.banner);
|
setBanner(metadata.banner);
|
||||||
} catch {
|
} catch {
|
||||||
setBanner(undefined);
|
setBanner(undefined);
|
||||||
@@ -104,7 +106,8 @@ export function useUserBanner(): [
|
|||||||
if (!userId) return undefined;
|
if (!userId) return undefined;
|
||||||
|
|
||||||
const user = mx.getUser(userId);
|
const user = mx.getUser(userId);
|
||||||
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = () => {
|
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = (newAvatarUrl) => {
|
||||||
|
console.log('[useUserBanner] Avatar changed event fired, new URL:', newAvatarUrl);
|
||||||
loadBanner();
|
loadBanner();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,12 +146,16 @@ export function useUserBanner(): [
|
|||||||
|
|
||||||
// Get existing metadata to preserve
|
// Get existing metadata to preserve
|
||||||
const existingMetadata = extractMetadataFromImage(avatarData);
|
const existingMetadata = extractMetadataFromImage(avatarData);
|
||||||
|
console.log('[updateBanner] Existing metadata:', existingMetadata);
|
||||||
|
|
||||||
// Modify image metadata with new banner, preserving color
|
// Modify image metadata with new banner, preserving color
|
||||||
const newMetadata: ImageMetadata = {
|
const newMetadata: ImageMetadata = {
|
||||||
color: existingMetadata.color,
|
color: existingMetadata.color,
|
||||||
banner: newBanner,
|
banner: newBanner,
|
||||||
|
avatarBorderColor: existingMetadata.avatarBorderColor,
|
||||||
|
gradient: existingMetadata.gradient,
|
||||||
};
|
};
|
||||||
|
console.log('[updateBanner] New metadata to embed:', newMetadata);
|
||||||
|
|
||||||
const newAvatarData = embedMetadataInImage(avatarData, newMetadata);
|
const newAvatarData = embedMetadataInImage(avatarData, newMetadata);
|
||||||
|
|
||||||
@@ -158,10 +165,13 @@ export function useUserBanner(): [
|
|||||||
|
|
||||||
// Verify the banner was embedded correctly before uploading
|
// Verify the banner was embedded correctly before uploading
|
||||||
const verifyMetadata = extractMetadataFromImage(newAvatarData);
|
const verifyMetadata = extractMetadataFromImage(newAvatarData);
|
||||||
|
console.log('[updateBanner] Verification - extracted metadata from new avatar:', verifyMetadata);
|
||||||
|
|
||||||
if (newBanner && verifyMetadata.banner !== newBanner) {
|
if (newBanner && verifyMetadata.banner !== newBanner) {
|
||||||
|
console.error('[updateBanner] Banner verification FAILED!', 'Expected:', newBanner, 'Got:', verifyMetadata.banner);
|
||||||
throw new Error('Banner verification failed');
|
throw new Error('Banner verification failed');
|
||||||
}
|
}
|
||||||
|
console.log('[updateBanner] Banner verification passed');
|
||||||
|
|
||||||
// Upload modified avatar with correct MIME type
|
// Upload modified avatar with correct MIME type
|
||||||
const mimeType = getMimeType(format);
|
const mimeType = getMimeType(format);
|
||||||
@@ -172,21 +182,26 @@ export function useUserBanner(): [
|
|||||||
name: `avatar.${extension}`,
|
name: `avatar.${extension}`,
|
||||||
type: mimeType,
|
type: mimeType,
|
||||||
});
|
});
|
||||||
|
console.log('[updateBanner] Avatar uploaded successfully, MXC:', uploadResponse.content_uri);
|
||||||
|
|
||||||
// Update profile with new avatar
|
// Update profile with new avatar
|
||||||
try {
|
try {
|
||||||
|
console.log('[updateBanner] Setting avatar URL to:', uploadResponse.content_uri);
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
mx.setAvatarUrl(uploadResponse.content_uri),
|
mx.setAvatarUrl(uploadResponse.content_uri),
|
||||||
new Promise((_, reject) => {
|
new Promise((_, reject) => {
|
||||||
setTimeout(() => reject(new Error('setAvatarUrl timeout')), 30000);
|
setTimeout(() => reject(new Error('setAvatarUrl timeout')), 30000);
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
console.log('[updateBanner] setAvatarUrl completed');
|
||||||
|
|
||||||
// Manually sync user object to ensure event listeners are triggered
|
// Manually sync user object to ensure event listeners are triggered
|
||||||
const user = mx.getUser(userId);
|
const user = mx.getUser(userId);
|
||||||
if (user && user.avatarUrl !== uploadResponse.content_uri) {
|
if (user && user.avatarUrl !== uploadResponse.content_uri) {
|
||||||
|
console.log('[updateBanner] Manually syncing user avatar URL');
|
||||||
user.setAvatarUrl(uploadResponse.content_uri);
|
user.setAvatarUrl(uploadResponse.content_uri);
|
||||||
}
|
}
|
||||||
|
console.log('[updateBanner] Banner update complete');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to update avatar URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
throw new Error(`Failed to update avatar URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
}
|
}
|
||||||
@@ -217,11 +232,14 @@ export function useOtherUserBanner(userId: string, avatarMxc: string | undefined
|
|||||||
let blobUrl: string | undefined;
|
let blobUrl: string | undefined;
|
||||||
|
|
||||||
if (!avatarMxc) {
|
if (!avatarMxc) {
|
||||||
|
console.log('[useOtherUserBanner] No avatarMxc provided for', userId);
|
||||||
setBannerBlobUrl(undefined);
|
setBannerBlobUrl(undefined);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadBanner = async () => {
|
const loadBanner = async () => {
|
||||||
|
console.log('[useOtherUserBanner] Loading banner for', userId, 'avatarMxc:', avatarMxc);
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
const cacheKey = `${userId}:${avatarMxc}`;
|
const cacheKey = `${userId}:${avatarMxc}`;
|
||||||
const cached = userBannerCache.get(cacheKey);
|
const cached = userBannerCache.get(cacheKey);
|
||||||
@@ -230,11 +248,15 @@ export function useOtherUserBanner(userId: string, avatarMxc: string | undefined
|
|||||||
|
|
||||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||||
// Use cached banner MXC
|
// Use cached banner MXC
|
||||||
|
console.log('[useOtherUserBanner] Using cached bannerMxc:', cached.bannerMxc);
|
||||||
bannerMxc = cached.bannerMxc;
|
bannerMxc = cached.bannerMxc;
|
||||||
} else {
|
} else {
|
||||||
// Fetch banner MXC from avatar metadata
|
// Fetch banner MXC from avatar metadata
|
||||||
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
||||||
|
console.log('[useOtherUserBanner] Fetching metadata from:', httpUrl);
|
||||||
|
|
||||||
if (!httpUrl) {
|
if (!httpUrl) {
|
||||||
|
console.log('[useOtherUserBanner] Failed to get HTTP URL');
|
||||||
setBannerBlobUrl(undefined);
|
setBannerBlobUrl(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -243,6 +265,8 @@ export function useOtherUserBanner(userId: string, avatarMxc: string | undefined
|
|||||||
const accessToken = getCurrentAccessToken();
|
const accessToken = getCurrentAccessToken();
|
||||||
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
||||||
|
|
||||||
|
console.log('[useOtherUserBanner] Extracted metadata:', metadata);
|
||||||
|
|
||||||
bannerMxc = metadata.banner;
|
bannerMxc = metadata.banner;
|
||||||
|
|
||||||
// Cache the banner MXC (not the blob URL)
|
// Cache the banner MXC (not the blob URL)
|
||||||
@@ -250,17 +274,23 @@ export function useOtherUserBanner(userId: string, avatarMxc: string | undefined
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!bannerMxc) {
|
if (!bannerMxc) {
|
||||||
|
console.log('[useOtherUserBanner] No banner MXC found');
|
||||||
setBannerBlobUrl(undefined);
|
setBannerBlobUrl(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[useOtherUserBanner] Fetching banner image from MXC:', bannerMxc);
|
||||||
|
|
||||||
// Fetch the banner image data with authentication
|
// Fetch the banner image data with authentication
|
||||||
const bannerHttpUrl = mxcUrlToHttp(mx, bannerMxc, useAuthentication);
|
const bannerHttpUrl = mxcUrlToHttp(mx, bannerMxc, useAuthentication);
|
||||||
if (!bannerHttpUrl) {
|
if (!bannerHttpUrl) {
|
||||||
|
console.log('[useOtherUserBanner] Failed to convert banner MXC to HTTP URL');
|
||||||
setBannerBlobUrl(undefined);
|
setBannerBlobUrl(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[useOtherUserBanner] Banner HTTP URL:', bannerHttpUrl);
|
||||||
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
// Always use current session's token to avoid stale tokens during account switches
|
||||||
const accessToken = getCurrentAccessToken();
|
const accessToken = getCurrentAccessToken();
|
||||||
const headers: HeadersInit = {};
|
const headers: HeadersInit = {};
|
||||||
|
|||||||
@@ -204,9 +204,52 @@ export function useUserColor(): [
|
|||||||
return [color, updateColor, loading];
|
return [color, updateColor, loading];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache for user colors to avoid refetching for each message
|
/**
|
||||||
const userColorCache = new Map<string, { color: string | undefined; timestamp: number }>();
|
* LRU Cache implementation with max size limit
|
||||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
*/
|
||||||
|
class LRUCache<K, V> {
|
||||||
|
private cache: Map<K, V>;
|
||||||
|
|
||||||
|
private maxSize: number;
|
||||||
|
|
||||||
|
constructor(maxSize: number) {
|
||||||
|
this.cache = new Map();
|
||||||
|
this.maxSize = maxSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: K): V | undefined {
|
||||||
|
const value = this.cache.get(key);
|
||||||
|
if (value !== undefined) {
|
||||||
|
// Move to end (most recently used)
|
||||||
|
this.cache.delete(key);
|
||||||
|
this.cache.set(key, value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: K, value: V): void {
|
||||||
|
// If key exists, delete it first to re-add at end
|
||||||
|
if (this.cache.has(key)) {
|
||||||
|
this.cache.delete(key);
|
||||||
|
}
|
||||||
|
// If at max size, remove oldest (first) entry
|
||||||
|
else if (this.cache.size >= this.maxSize) {
|
||||||
|
const firstKey = this.cache.keys().next().value;
|
||||||
|
if (firstKey !== undefined) {
|
||||||
|
this.cache.delete(firstKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.cache.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LRU cache for user colors to avoid refetching for each message
|
||||||
|
const userColorCache = new LRUCache<string, { color: string | undefined; timestamp: number }>(100);
|
||||||
|
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to get another user's chosen profile color from their avatar metadata
|
* Hook to get another user's chosen profile color from their avatar metadata
|
||||||
@@ -217,10 +260,19 @@ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|||||||
export function useOtherUserColor(userId: string, avatarMxc: string | undefined): string | undefined {
|
export function useOtherUserColor(userId: string, avatarMxc: string | undefined): string | undefined {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const [color, setColor] = useState<string | undefined>();
|
|
||||||
|
// Check cache synchronously BEFORE initializing state to avoid flash
|
||||||
|
const cacheKey = avatarMxc ? `${userId}:${avatarMxc}` : '';
|
||||||
|
const cachedEntry = cacheKey ? userColorCache.get(cacheKey) : undefined;
|
||||||
|
const cachedColor = cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL_MS
|
||||||
|
? cachedEntry.color
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const [color, setColor] = useState<string | undefined>(cachedColor);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!avatarMxc) {
|
if (!avatarMxc) {
|
||||||
|
console.log('[useOtherUserColor] No avatarMxc provided for', userId);
|
||||||
setColor(undefined);
|
setColor(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -229,13 +281,16 @@ export function useOtherUserColor(userId: string, avatarMxc: string | undefined)
|
|||||||
const cacheKey = `${userId}:${avatarMxc}`;
|
const cacheKey = `${userId}:${avatarMxc}`;
|
||||||
const cached = userColorCache.get(cacheKey);
|
const cached = userColorCache.get(cacheKey);
|
||||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||||
|
console.log('[useOtherUserColor] Using cached color for', userId, ':', cached.color);
|
||||||
setColor(cached.color);
|
setColor(cached.color);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadColor = async () => {
|
const loadColor = async () => {
|
||||||
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
||||||
|
console.log('[useOtherUserColor] Loading color for', userId, 'from', httpUrl);
|
||||||
if (!httpUrl) {
|
if (!httpUrl) {
|
||||||
|
console.log('[useOtherUserColor] Failed to get HTTP URL for', avatarMxc);
|
||||||
setColor(undefined);
|
setColor(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -244,6 +299,8 @@ export function useOtherUserColor(userId: string, avatarMxc: string | undefined)
|
|||||||
const accessToken = getCurrentAccessToken();
|
const accessToken = getCurrentAccessToken();
|
||||||
const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null);
|
const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null);
|
||||||
|
|
||||||
|
console.log('[useOtherUserColor] Extracted color for', userId, ':', extractedColor);
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
userColorCache.set(cacheKey, { color: extractedColor, timestamp: Date.now() });
|
userColorCache.set(cacheKey, { color: extractedColor, timestamp: Date.now() });
|
||||||
|
|
||||||
|
|||||||
@@ -212,22 +212,29 @@ export function useOtherUserProfileStyle(userId: string, avatarMxc: string | und
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!avatarMxc) {
|
if (!avatarMxc) {
|
||||||
|
console.log('[useOtherUserProfileStyle] No avatarMxc provided for', userId);
|
||||||
setStyle({});
|
setStyle({});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadStyle = async () => {
|
const loadStyle = async () => {
|
||||||
|
console.log('[useOtherUserProfileStyle] Loading style for', userId, 'avatarMxc:', avatarMxc);
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
const cacheKey = `${userId}:${avatarMxc}`;
|
const cacheKey = `${userId}:${avatarMxc}`;
|
||||||
const cached = userStyleCache.get(cacheKey);
|
const cached = userStyleCache.get(cacheKey);
|
||||||
|
|
||||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||||
|
console.log('[useOtherUserProfileStyle] Using cached style:', cached.style);
|
||||||
setStyle(cached.style);
|
setStyle(cached.style);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
||||||
|
console.log('[useOtherUserProfileStyle] Fetching metadata from:', httpUrl);
|
||||||
|
|
||||||
if (!httpUrl) {
|
if (!httpUrl) {
|
||||||
|
console.log('[useOtherUserProfileStyle] Failed to get HTTP URL');
|
||||||
setStyle({});
|
setStyle({});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -236,11 +243,15 @@ export function useOtherUserProfileStyle(userId: string, avatarMxc: string | und
|
|||||||
const accessToken = getCurrentAccessToken();
|
const accessToken = getCurrentAccessToken();
|
||||||
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
||||||
|
|
||||||
|
console.log('[useOtherUserProfileStyle] Extracted metadata:', metadata);
|
||||||
|
|
||||||
const styleData: ProfileStyleData = {
|
const styleData: ProfileStyleData = {
|
||||||
avatarBorderColor: metadata.avatarBorderColor,
|
avatarBorderColor: metadata.avatarBorderColor,
|
||||||
gradient: metadata.gradient,
|
gradient: metadata.gradient,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log('[useOtherUserProfileStyle] Final styleData:', styleData);
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
userStyleCache.set(cacheKey, { style: styleData, timestamp: Date.now() });
|
userStyleCache.set(cacheKey, { style: styleData, timestamp: Date.now() });
|
||||||
setStyle(styleData);
|
setStyle(styleData);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
getScrollInfo,
|
getScrollInfo,
|
||||||
isInScrollView,
|
isInScrollView,
|
||||||
isIntersectingScrollView,
|
isIntersectingScrollView,
|
||||||
|
scrollToPosition,
|
||||||
} from '../utils/dom';
|
} from '../utils/dom';
|
||||||
|
|
||||||
const PAGINATOR_ANCHOR_ATTR = 'data-paginator-anchor';
|
const PAGINATOR_ANCHOR_ATTR = 'data-paginator-anchor';
|
||||||
@@ -218,7 +219,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
|
|||||||
scrollTo = element.offsetTop - Math.round(scrollInfo.viewHeight) + element.clientHeight;
|
scrollTo = element.offsetTop - Math.round(scrollInfo.viewHeight) + element.clientHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollElement.scrollTo({
|
scrollToPosition(scrollElement, {
|
||||||
top: scrollTo - (opts?.offset ?? 0),
|
top: scrollTo - (opts?.offset ?? 0),
|
||||||
behavior: opts?.behavior,
|
behavior: opts?.behavior,
|
||||||
});
|
});
|
||||||
@@ -253,10 +254,12 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
|
|||||||
|
|
||||||
if (!itemElement) {
|
if (!itemElement) {
|
||||||
const scrollElement = getScrollElement();
|
const scrollElement = getScrollElement();
|
||||||
scrollElement?.scrollTo({
|
if (scrollElement) {
|
||||||
top: opts?.offset ?? 0,
|
scrollToPosition(scrollElement, {
|
||||||
behavior: opts?.behavior,
|
top: opts?.offset ?? 0,
|
||||||
});
|
behavior: opts?.behavior,
|
||||||
|
});
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return scrollToElement(itemElement, opts);
|
return scrollToElement(itemElement, opts);
|
||||||
@@ -364,7 +367,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
|
|||||||
const offsetAddition = offsetTop - oldOffsetTop;
|
const offsetAddition = offsetTop - oldOffsetTop;
|
||||||
const restoreTop = oldScrollTop + offsetAddition;
|
const restoreTop = oldScrollTop + offsetAddition;
|
||||||
|
|
||||||
scrollEl.scrollTo({
|
scrollToPosition(scrollEl, {
|
||||||
top: restoreTop,
|
top: restoreTop,
|
||||||
behavior: 'instant',
|
behavior: 'instant',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ async function sendMessage(matrixClient: any, roomId: string, message: string) {
|
|||||||
|
|
||||||
const content = {
|
const content = {
|
||||||
msgtype: 'm.text',
|
msgtype: 'm.text',
|
||||||
body: message,
|
body: message.trim(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await matrixClient.sendMessage(roomId, content);
|
const result = await matrixClient.sendMessage(roomId, content);
|
||||||
|
|||||||
@@ -221,6 +221,8 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||||||
>
|
>
|
||||||
{mobile ? null : <Route index element={<WelcomePage />} />}
|
{mobile ? null : <Route index element={<WelcomePage />} />}
|
||||||
<Route path={_CREATE_PATH} element={<DirectCreate />} />
|
<Route path={_CREATE_PATH} element={<DirectCreate />} />
|
||||||
|
<Route path={_NOTIFICATIONS_PATH} element={<Notifications />} />
|
||||||
|
<Route path={_INVITES_PATH} element={<Invites />} />
|
||||||
<Route
|
<Route
|
||||||
path={_ROOM_PATH}
|
path={_ROOM_PATH}
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import * as css from './styles.css';
|
|||||||
export function AuthFooter() {
|
export function AuthFooter() {
|
||||||
return (
|
return (
|
||||||
<Box className={css.AuthFooter} justifyContent="Center" gap="400" wrap="Wrap">
|
<Box className={css.AuthFooter} justifyContent="Center" gap="400" wrap="Wrap">
|
||||||
<Text as="a" size="T300" href="https://synbox.ruv.wtf:8418/litruv/cinny-desktop" target="_blank" rel="noreferrer">
|
<Text as="a" size="T300" href="https://github.com/Paarrot/Paarrot-Desktop" target="_blank" rel="noreferrer">
|
||||||
About
|
About
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
as="a"
|
as="a"
|
||||||
size="T300"
|
size="T300"
|
||||||
href="https://synbox.ruv.wtf:8418/litruv/cinny-desktop/releases"
|
href="https://github.com/Paarrot/Paarrot-Desktop/releases/latest"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, {
|
import React, {
|
||||||
ChangeEventHandler,
|
ChangeEventHandler,
|
||||||
|
FocusEventHandler,
|
||||||
KeyboardEventHandler,
|
KeyboardEventHandler,
|
||||||
MouseEventHandler,
|
MouseEventHandler,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -39,8 +40,13 @@ export function ServerPicker({
|
|||||||
const serverInputRef = useRef<HTMLInputElement>(null);
|
const serverInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// sync input with it outside server changes
|
// Only sync input when server changes externally (e.g., from menu selection)
|
||||||
if (serverInputRef.current && serverInputRef.current.value !== server) {
|
// and input is not focused to avoid cursor jumping during typing
|
||||||
|
if (
|
||||||
|
serverInputRef.current &&
|
||||||
|
serverInputRef.current.value !== server &&
|
||||||
|
document.activeElement !== serverInputRef.current
|
||||||
|
) {
|
||||||
serverInputRef.current.value = server;
|
serverInputRef.current.value = server;
|
||||||
}
|
}
|
||||||
}, [server]);
|
}, [server]);
|
||||||
@@ -52,6 +58,13 @@ export function ServerPicker({
|
|||||||
if (inputServer) debounceServerSelect(inputServer);
|
if (inputServer) debounceServerSelect(inputServer);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBlur: FocusEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
const inputServer = evt.target.value.trim();
|
||||||
|
if (inputServer && inputServer !== server) {
|
||||||
|
onServerChange(inputServer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
|
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
|
||||||
if (evt.key === 'ArrowDown') {
|
if (evt.key === 'ArrowDown') {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
@@ -85,6 +98,7 @@ export function ServerPicker({
|
|||||||
outlined
|
outlined
|
||||||
defaultValue={server}
|
defaultValue={server}
|
||||||
onChange={handleServerChange}
|
onChange={handleServerChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
size="500"
|
size="500"
|
||||||
readOnly={!allowCustomServer}
|
readOnly={!allowCustomServer}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
import { useAtom, useSetAtom } from 'jotai';
|
||||||
|
import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
||||||
@@ -28,8 +29,35 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
|
|||||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||||
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { isTauri, isElectron, sendNotification, setupNotificationTapListener } from '../../utils/tauri';
|
import { plainToEditorInput, toPlainText } from '../../components/editor';
|
||||||
|
import { ShareRoomPicker } from '../../features/ShareRoomPicker';
|
||||||
|
import {
|
||||||
|
isTauri,
|
||||||
|
isElectron,
|
||||||
|
isCapacitorNative,
|
||||||
|
sendNotification,
|
||||||
|
setupNotificationTapListener,
|
||||||
|
} from '../../utils/tauri';
|
||||||
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
|
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
|
||||||
|
import {
|
||||||
|
startBackgroundSync,
|
||||||
|
stopBackgroundSync,
|
||||||
|
setAppForegroundState,
|
||||||
|
} from '../../utils/backgroundSync';
|
||||||
|
import {
|
||||||
|
TUploadItem,
|
||||||
|
roomIdToMsgDraftAtomFamily,
|
||||||
|
roomIdToUploadItemsAtomFamily,
|
||||||
|
} from '../../state/room/roomInputDrafts';
|
||||||
|
import { encryptFile } from '../../utils/matrix';
|
||||||
|
import {
|
||||||
|
AndroidSharePayload,
|
||||||
|
clearPendingAndroidShare,
|
||||||
|
getPendingAndroidShare,
|
||||||
|
isAndroidShareSupported,
|
||||||
|
listenForAndroidShares,
|
||||||
|
materializeSharedFile,
|
||||||
|
} from '../../utils/androidShare';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the selected emoji style font to the document.
|
* Applies the selected emoji style font to the document.
|
||||||
@@ -118,7 +146,7 @@ function InviteNotifications() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTauri() && !isElectron()) {
|
if ((isTauri() && !isElectron()) || isCapacitorNative()) {
|
||||||
sendNotification({
|
sendNotification({
|
||||||
title: 'Invitation',
|
title: 'Invitation',
|
||||||
body,
|
body,
|
||||||
@@ -257,7 +285,7 @@ function MessageNotifications() {
|
|||||||
console.warn('[Notifications] flashFrame not available, isElectron:', isElectron());
|
console.warn('[Notifications] flashFrame not available, isElectron:', isElectron());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTauri() && !isElectron()) {
|
if ((isTauri() && !isElectron()) || isCapacitorNative()) {
|
||||||
const roomPath = isDm
|
const roomPath = isDm
|
||||||
? getDirectRoomPath(roomId, eventId)
|
? getDirectRoomPath(roomId, eventId)
|
||||||
: getHomeRoomPath(roomId, eventId);
|
: getHomeRoomPath(roomId, eventId);
|
||||||
@@ -350,7 +378,10 @@ function MessageNotifications() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showNotifications && ((isTauri() && !isElectron()) || notificationPermission('granted'))) {
|
if (
|
||||||
|
showNotifications &&
|
||||||
|
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
|
||||||
|
) {
|
||||||
const avatarMxc =
|
const avatarMxc =
|
||||||
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
|
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
|
||||||
const content = mEvent.getContent();
|
const content = mEvent.getContent();
|
||||||
@@ -404,6 +435,36 @@ function MessageNotifications() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures native Android push-ping background sync on login, keeps it
|
||||||
|
* informed of foreground state so it doesn't double-fire notifications,
|
||||||
|
* and clears it cleanly on unmount (logout).
|
||||||
|
* Only active on Android Capacitor builds.
|
||||||
|
*/
|
||||||
|
function BackgroundSyncSetup() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
// Try to make a simple fetch to verify component is rendering
|
||||||
|
try {
|
||||||
|
fetch('/_matrix/client/v3/sync', { method: 'HEAD' }).catch(() => {});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('BackgroundSyncSetup: Starting background sync for', mx.getUserId());
|
||||||
|
startBackgroundSync(mx);
|
||||||
|
|
||||||
|
const onVisibility = () => setAppForegroundState(!document.hidden);
|
||||||
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
setAppForegroundState(!document.hidden);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
stopBackgroundSync();
|
||||||
|
};
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the Paarrot API for Electron integration
|
* Initializes the Paarrot API for Electron integration
|
||||||
* Registers the navigate function and sets up IPC handlers
|
* Registers the navigate function and sets up IPC handlers
|
||||||
@@ -454,6 +515,167 @@ function TaskbarFlashStopper() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AndroidShareIntentHandler() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null);
|
||||||
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
|
||||||
|
const applyPendingShare = useCallback(
|
||||||
|
async (share: AndroidSharePayload, roomId: string) => {
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) return false;
|
||||||
|
|
||||||
|
// Send text message if present
|
||||||
|
const nextParts = [share.subject?.trim(), share.text?.trim()].filter(
|
||||||
|
(value): value is string => !!value
|
||||||
|
);
|
||||||
|
if (nextParts.length > 0) {
|
||||||
|
const textContent = {
|
||||||
|
msgtype: 'm.text' as const,
|
||||||
|
body: nextParts.join('\n'),
|
||||||
|
};
|
||||||
|
await mx.sendMessage(roomId, textContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload and send files if present
|
||||||
|
if (share.files.length > 0) {
|
||||||
|
for (const sharedFile of share.files) {
|
||||||
|
const originalFile = await materializeSharedFile(sharedFile, share.receivedAt);
|
||||||
|
|
||||||
|
let fileToUpload = originalFile;
|
||||||
|
let encInfo: any = undefined;
|
||||||
|
|
||||||
|
if (room.hasEncryptionStateEvent()) {
|
||||||
|
const encrypted = await encryptFile(originalFile);
|
||||||
|
fileToUpload = encrypted.file;
|
||||||
|
encInfo = encrypted.encInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload file
|
||||||
|
const uploadResult = await mx.uploadContent(fileToUpload);
|
||||||
|
const mxc = uploadResult?.content_uri;
|
||||||
|
if (!mxc) continue;
|
||||||
|
|
||||||
|
// Determine message type and send
|
||||||
|
const fileType = originalFile.type;
|
||||||
|
let msgtype = 'm.file' as const;
|
||||||
|
if (fileType.startsWith('image/')) msgtype = 'm.image' as const;
|
||||||
|
else if (fileType.startsWith('video/')) msgtype = 'm.video' as const;
|
||||||
|
else if (fileType.startsWith('audio/')) msgtype = 'm.audio' as const;
|
||||||
|
|
||||||
|
const fileContent: any = {
|
||||||
|
msgtype,
|
||||||
|
body: originalFile.name,
|
||||||
|
filename: originalFile.name,
|
||||||
|
info: {
|
||||||
|
mimetype: originalFile.type,
|
||||||
|
size: originalFile.size,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (encInfo) {
|
||||||
|
fileContent.file = {
|
||||||
|
...encInfo,
|
||||||
|
url: mxc,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
fileContent.url = mxc;
|
||||||
|
}
|
||||||
|
|
||||||
|
await mx.sendMessage(roomId, fileContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await clearPendingAndroidShare();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePickRoom = useCallback(
|
||||||
|
(roomId: string) => {
|
||||||
|
if (!pendingShare) return;
|
||||||
|
|
||||||
|
applyPendingShare(pendingShare, roomId)
|
||||||
|
.then((applied) => {
|
||||||
|
if (applied) {
|
||||||
|
setPendingShare(null);
|
||||||
|
|
||||||
|
// Navigate to the selected room
|
||||||
|
const isDirect = mDirects.has(roomId);
|
||||||
|
if (isDirect) {
|
||||||
|
navigate(getDirectRoomPath(roomId));
|
||||||
|
} else {
|
||||||
|
const parents = roomToParents.get(roomId);
|
||||||
|
const parent = parents && parents.length > 0 ? parents[0] : undefined;
|
||||||
|
if (parent) {
|
||||||
|
navigate(getSpaceRoomPath(parent, roomId));
|
||||||
|
} else {
|
||||||
|
navigate(getHomeRoomPath(roomId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[AndroidShare] Failed to apply share after pick:', err);
|
||||||
|
setPendingShare(null);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[applyPendingShare, pendingShare, navigate, mDirects, roomToParents]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
clearPendingAndroidShare().catch(() => {});
|
||||||
|
setPendingShare(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAndroidShareSupported()) return;
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
let listenerHandle: { remove: () => Promise<void> } | undefined;
|
||||||
|
|
||||||
|
getPendingAndroidShare()
|
||||||
|
.then((share) => {
|
||||||
|
if (mounted && share) {
|
||||||
|
setPendingShare(share);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[AndroidShare] Failed to get pending share:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
listenForAndroidShares((share) => {
|
||||||
|
if (mounted) {
|
||||||
|
setPendingShare(share);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((handle) => {
|
||||||
|
listenerHandle = handle;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[AndroidShare] Failed to listen for shares:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
void listenerHandle?.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!pendingShare) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ShareRoomPicker
|
||||||
|
share={pendingShare}
|
||||||
|
onPick={handlePickRoom}
|
||||||
|
onDismiss={handleDismiss}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type ClientNonUIFeaturesProps = {
|
type ClientNonUIFeaturesProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
@@ -466,8 +688,10 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<FaviconUpdater />
|
<FaviconUpdater />
|
||||||
<InviteNotifications />
|
<InviteNotifications />
|
||||||
<MessageNotifications />
|
<MessageNotifications />
|
||||||
|
<BackgroundSyncSetup />
|
||||||
<PaarrotAPIInitializer />
|
<PaarrotAPIInitializer />
|
||||||
<TaskbarFlashStopper />
|
<TaskbarFlashStopper />
|
||||||
|
<AndroidShareIntentHandler />
|
||||||
{children}
|
{children}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
DirectTab,
|
DirectTab,
|
||||||
HomeTab,
|
HomeTab,
|
||||||
SpaceTabs,
|
SpaceTabs,
|
||||||
InboxTab,
|
|
||||||
ExploreTab,
|
ExploreTab,
|
||||||
SettingsTab,
|
SettingsTab,
|
||||||
UnverifiedTab,
|
UnverifiedTab,
|
||||||
@@ -20,6 +19,7 @@ import {
|
|||||||
import { CreateTab } from './sidebar/CreateTab';
|
import { CreateTab } from './sidebar/CreateTab';
|
||||||
import { HiddenSpacesTabs } from './sidebar/SpaceTabs';
|
import { HiddenSpacesTabs } from './sidebar/SpaceTabs';
|
||||||
import { useHomeHidden } from '../../hooks/useSidebarItems';
|
import { useHomeHidden } from '../../hooks/useSidebarItems';
|
||||||
|
import { PluginSidebarSlot } from '../../features/settings/plugins/PluginSidebarSlot';
|
||||||
|
|
||||||
export function SidebarNav() {
|
export function SidebarNav() {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -37,6 +37,7 @@ export function SidebarNav() {
|
|||||||
<SpaceTabs scrollRef={scrollRef} />
|
<SpaceTabs scrollRef={scrollRef} />
|
||||||
<SidebarStackSeparator />
|
<SidebarStackSeparator />
|
||||||
<SidebarStack>
|
<SidebarStack>
|
||||||
|
<PluginSidebarSlot location="sidebar-actions" />
|
||||||
<ExploreTab />
|
<ExploreTab />
|
||||||
<CreateTab />
|
<CreateTab />
|
||||||
</SidebarStack>
|
</SidebarStack>
|
||||||
@@ -47,9 +48,9 @@ export function SidebarNav() {
|
|||||||
<>
|
<>
|
||||||
<SidebarStackSeparator />
|
<SidebarStackSeparator />
|
||||||
<SidebarStack>
|
<SidebarStack>
|
||||||
|
<PluginSidebarSlot location="sidebar-actions" />
|
||||||
<SearchTab />
|
<SearchTab />
|
||||||
<UnverifiedTab />
|
<UnverifiedTab />
|
||||||
<InboxTab />
|
|
||||||
<SettingsTab />
|
<SettingsTab />
|
||||||
</SidebarStack>
|
</SidebarStack>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function WelcomePage() {
|
|||||||
Squawk to yer mateys! {' '}
|
Squawk to yer mateys! {' '}
|
||||||
{version && (
|
{version && (
|
||||||
<a
|
<a
|
||||||
href="https://synbox.ruv.wtf:8418/litruv/cinny-desktop/releases"
|
href="https://github.com/Paarrot/Paarrot-Desktop/releases/latest"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
>
|
>
|
||||||
@@ -41,7 +41,7 @@ export function WelcomePage() {
|
|||||||
<Box grow="Yes" style={{ maxWidth: toRem(300) }} direction="Column" gap="300">
|
<Box grow="Yes" style={{ maxWidth: toRem(300) }} direction="Column" gap="300">
|
||||||
<Button
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
href="https://synbox.ruv.wtf:8418/litruv/cinny-desktop"
|
href="https://github.com/Paarrot/Paarrot-Desktop"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
before={<Icon size="200" src={Icons.Code} />}
|
before={<Icon size="200" src={Icons.Code} />}
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ import {
|
|||||||
NavEmptyLayout,
|
NavEmptyLayout,
|
||||||
NavItem,
|
NavItem,
|
||||||
NavItemContent,
|
NavItemContent,
|
||||||
|
NavLink,
|
||||||
} from '../../../components/nav';
|
} from '../../../components/nav';
|
||||||
import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils';
|
import { getDirectCreatePath, getDirectRoomPath, getDirectNotificationsPath, getDirectInvitesPath } from '../../pathUtils';
|
||||||
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
||||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||||
import { VirtualTile } from '../../../components/virtualizer';
|
import { VirtualTile } from '../../../components/virtualizer';
|
||||||
@@ -44,13 +45,18 @@ import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCatego
|
|||||||
import { useRoomsUnread } from '../../../state/hooks/unread';
|
import { useRoomsUnread } from '../../../state/hooks/unread';
|
||||||
import { useMarkRoomsAsRead } from '../../../hooks/useMarkAsRead';
|
import { useMarkRoomsAsRead } from '../../../hooks/useMarkAsRead';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
|
import { PluginButtonSlot } from '../../../features/settings/plugins/PluginButtonSlot';
|
||||||
|
import { PluginNavSlot } from '../../../features/settings/plugins/PluginNavSlot';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { useRoomListReorder, useListReorderAnimation } from '../../../hooks/useRoomListReorder';
|
||||||
import { settingsAtom } from '../../../state/settings';
|
import { settingsAtom } from '../../../state/settings';
|
||||||
import {
|
import {
|
||||||
getRoomNotificationMode,
|
getRoomNotificationMode,
|
||||||
useRoomsNotificationPreferencesContext,
|
useRoomsNotificationPreferencesContext,
|
||||||
} from '../../../hooks/useRoomsNotificationPreferences';
|
} from '../../../hooks/useRoomsNotificationPreferences';
|
||||||
import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
|
import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
|
||||||
|
import { allInvitesAtom } from '../../../state/room-list/inviteList';
|
||||||
|
import { UnreadBadge } from '../../../components/unread-badge';
|
||||||
|
|
||||||
type DirectMenuProps = {
|
type DirectMenuProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
@@ -168,7 +174,64 @@ function DirectEmpty() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation item for room invites
|
||||||
|
*/
|
||||||
|
function InvitesNavItem() {
|
||||||
|
const allInvites = useAtomValue(allInvitesAtom);
|
||||||
|
const inviteCount = allInvites.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavItem
|
||||||
|
variant="Background"
|
||||||
|
radii="400"
|
||||||
|
highlight={inviteCount > 0}
|
||||||
|
>
|
||||||
|
<NavLink to={getDirectInvitesPath()}>
|
||||||
|
<NavItemContent>
|
||||||
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Avatar size="200" radii="400">
|
||||||
|
<Icon src={Icons.Mail} size="100" />
|
||||||
|
</Avatar>
|
||||||
|
<Box as="span" grow="Yes">
|
||||||
|
<Text as="span" size="Inherit" truncate>
|
||||||
|
Invites
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{inviteCount > 0 && <UnreadBadge highlight count={inviteCount} />}
|
||||||
|
</Box>
|
||||||
|
</NavItemContent>
|
||||||
|
</NavLink>
|
||||||
|
</NavItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation item for notifications
|
||||||
|
*/
|
||||||
|
function NotificationsNavItem() {
|
||||||
|
return (
|
||||||
|
<NavItem variant="Background" radii="400">
|
||||||
|
<NavLink to={getDirectNotificationsPath()}>
|
||||||
|
<NavItemContent>
|
||||||
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Avatar size="200" radii="400">
|
||||||
|
<Icon src={Icons.MessageUnread} size="100" />
|
||||||
|
</Avatar>
|
||||||
|
<Box as="span" grow="Yes">
|
||||||
|
<Text as="span" size="Inherit" truncate>
|
||||||
|
Notifications
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</NavItemContent>
|
||||||
|
</NavLink>
|
||||||
|
</NavItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct');
|
const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct');
|
||||||
|
|
||||||
export function Direct() {
|
export function Direct() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
useNavToActivePathMapper('direct');
|
useNavToActivePathMapper('direct');
|
||||||
@@ -184,13 +247,17 @@ export function Direct() {
|
|||||||
const noRoomToDisplay = directs.length === 0;
|
const noRoomToDisplay = directs.length === 0;
|
||||||
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
||||||
|
|
||||||
|
const reorderTrigger = useRoomListReorder(mx, directs);
|
||||||
|
|
||||||
const sortedDirects = useMemo(() => {
|
const sortedDirects = useMemo(() => {
|
||||||
const items = Array.from(directs).sort(factoryRoomIdByActivity(mx));
|
const items = Array.from(directs).sort(factoryRoomIdByActivity(mx));
|
||||||
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
|
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
|
||||||
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
|
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}, [mx, directs, closedCategories, roomToUnread, selectedRoomId]);
|
}, [mx, directs, closedCategories, roomToUnread, selectedRoomId, reorderTrigger]);
|
||||||
|
|
||||||
|
const animationRef = useListReorderAnimation(sortedDirects);
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: sortedDirects.length,
|
count: sortedDirects.length,
|
||||||
@@ -228,6 +295,11 @@ export function Direct() {
|
|||||||
</NavItemContent>
|
</NavItemContent>
|
||||||
</NavButton>
|
</NavButton>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
|
<PluginNavSlot location="direct-messages" />
|
||||||
|
</NavCategory>
|
||||||
|
<NavCategory>
|
||||||
|
<NotificationsNavItem />
|
||||||
|
<InvitesNavItem />
|
||||||
</NavCategory>
|
</NavCategory>
|
||||||
<NavCategory>
|
<NavCategory>
|
||||||
<NavCategoryHeader>
|
<NavCategoryHeader>
|
||||||
@@ -240,6 +312,7 @@ export function Direct() {
|
|||||||
</RoomNavCategoryButton>
|
</RoomNavCategoryButton>
|
||||||
</NavCategoryHeader>
|
</NavCategoryHeader>
|
||||||
<div
|
<div
|
||||||
|
ref={animationRef}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
height: virtualizer.getTotalSize(),
|
height: virtualizer.getTotalSize(),
|
||||||
@@ -254,8 +327,9 @@ export function Direct() {
|
|||||||
return (
|
return (
|
||||||
<VirtualTile
|
<VirtualTile
|
||||||
virtualItem={vItem}
|
virtualItem={vItem}
|
||||||
key={vItem.index}
|
key={roomId}
|
||||||
ref={virtualizer.measureElement}
|
ref={virtualizer.measureElement}
|
||||||
|
data-room-id={roomId}
|
||||||
>
|
>
|
||||||
<RoomNavItem
|
<RoomNavItem
|
||||||
room={room}
|
room={room}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import { useRoomsUnread } from '../../../state/hooks/unread';
|
|||||||
import { useMarkRoomsAsRead } from '../../../hooks/useMarkAsRead';
|
import { useMarkRoomsAsRead } from '../../../hooks/useMarkAsRead';
|
||||||
import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
|
import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
|
import { PluginNavSlot } from '../../../features/settings/plugins/PluginNavSlot';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../../state/settings';
|
import { settingsAtom } from '../../../state/settings';
|
||||||
import {
|
import {
|
||||||
@@ -152,7 +153,7 @@ function HomeHeader() {
|
|||||||
Home
|
Home
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box alignItems="Center" gap="100">
|
||||||
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
|
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
|
||||||
<Icon src={Icons.VerticalDots} size="200" />
|
<Icon src={Icons.VerticalDots} size="200" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -409,6 +410,7 @@ export function Home() {
|
|||||||
</NavItemContent>
|
</NavItemContent>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
|
<PluginNavSlot location="channel-list" />
|
||||||
</NavCategory>
|
</NavCategory>
|
||||||
<NavCategory>
|
<NavCategory>
|
||||||
<NavCategoryHeader>
|
<NavCategoryHeader>
|
||||||
@@ -420,6 +422,7 @@ export function Home() {
|
|||||||
Rooms
|
Rooms
|
||||||
</RoomNavCategoryButton>
|
</RoomNavCategoryButton>
|
||||||
</NavCategoryHeader>
|
</NavCategoryHeader>
|
||||||
|
<PluginNavSlot location="home-section" />
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler';
|
|||||||
import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler';
|
import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||||
import { BackRouteHandler } from '../../../components/BackRouteHandler';
|
import { BackRouteHandler } from '../../../components/BackRouteHandler';
|
||||||
|
import { PluginButtonSlot } from '../../../features/settings/plugins/PluginButtonSlot';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||||
import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
||||||
@@ -701,7 +702,9 @@ export function Notifications() {
|
|||||||
Notification Messages
|
Notification Messages
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box grow="Yes" basis="No" />
|
<Box grow="Yes" basis="No" justifyContent="End" alignItems="Center">
|
||||||
|
<PluginButtonSlot location="search-notification-section" />
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useUserProfile } from '../../../hooks/useUserProfile';
|
|||||||
import { Modal500 } from '../../../components/Modal500';
|
import { Modal500 } from '../../../components/Modal500';
|
||||||
import { AccountSwitcher } from '../../../components/account-switcher';
|
import { AccountSwitcher } from '../../../components/account-switcher';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
|
import { PluginButtonSlot } from '../../../features/settings/plugins/PluginButtonSlot';
|
||||||
import { getLoginPath } from '../../pathUtils';
|
import { getLoginPath } from '../../pathUtils';
|
||||||
import { logoutClient } from '../../../../client/initMatrix';
|
import { logoutClient } from '../../../../client/initMatrix';
|
||||||
import { CustomStatusDialog } from '../../../components/custom-status';
|
import { CustomStatusDialog } from '../../../components/custom-status';
|
||||||
@@ -110,6 +111,10 @@ export function SettingsTab() {
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Box>
|
</Box>
|
||||||
<Line variant="Surface" size="300" />
|
<Line variant="Surface" size="300" />
|
||||||
|
<Box direction="Row" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
<PluginButtonSlot location="user-menu" />
|
||||||
|
</Box>
|
||||||
|
<Line variant="Surface" size="300" />
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCatego
|
|||||||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||||
import { Membership, StateEvent } from '../../../../types/matrix/room';
|
import { Membership, StateEvent } from '../../../../types/matrix/room';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
|
import { PluginNavSlot } from '../../../features/settings/plugins/PluginNavSlot';
|
||||||
import { getMatrixToRoom } from '../../../plugins/matrix-to';
|
import { getMatrixToRoom } from '../../../plugins/matrix-to';
|
||||||
import { getViaServers } from '../../../plugins/via-servers';
|
import { getViaServers } from '../../../plugins/via-servers';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
@@ -549,6 +550,7 @@ export function Space() {
|
|||||||
</NavItemContent>
|
</NavItemContent>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
|
<PluginNavSlot location="channel-list" />
|
||||||
</NavCategory>
|
</NavCategory>
|
||||||
<NavCategory
|
<NavCategory
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
DIRECT_CREATE_PATH,
|
DIRECT_CREATE_PATH,
|
||||||
DIRECT_PATH,
|
DIRECT_PATH,
|
||||||
DIRECT_ROOM_PATH,
|
DIRECT_ROOM_PATH,
|
||||||
|
DIRECT_NOTIFICATIONS_PATH,
|
||||||
|
DIRECT_INVITES_PATH,
|
||||||
EXPLORE_FEATURED_PATH,
|
EXPLORE_FEATURED_PATH,
|
||||||
EXPLORE_PATH,
|
EXPLORE_PATH,
|
||||||
EXPLORE_SERVER_PATH,
|
EXPLORE_SERVER_PATH,
|
||||||
@@ -110,6 +112,8 @@ export const getDirectRoomPath = (roomIdOrAlias: string, eventId?: string): stri
|
|||||||
|
|
||||||
return generatePath(DIRECT_ROOM_PATH, params);
|
return generatePath(DIRECT_ROOM_PATH, params);
|
||||||
};
|
};
|
||||||
|
export const getDirectNotificationsPath = (): string => DIRECT_NOTIFICATIONS_PATH;
|
||||||
|
export const getDirectInvitesPath = (): string => DIRECT_INVITES_PATH;
|
||||||
|
|
||||||
export const getSpacePath = (spaceIdOrAlias: string): string => {
|
export const getSpacePath = (spaceIdOrAlias: string): string => {
|
||||||
const params = {
|
const params = {
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ export type InboxNotificationsPathSearchParams = {
|
|||||||
export const INBOX_NOTIFICATIONS_PATH = `/inbox/${_NOTIFICATIONS_PATH}`;
|
export const INBOX_NOTIFICATIONS_PATH = `/inbox/${_NOTIFICATIONS_PATH}`;
|
||||||
export const INBOX_INVITES_PATH = `/inbox/${_INVITES_PATH}`;
|
export const INBOX_INVITES_PATH = `/inbox/${_INVITES_PATH}`;
|
||||||
|
|
||||||
|
export const DIRECT_NOTIFICATIONS_PATH = `/direct/${_NOTIFICATIONS_PATH}`;
|
||||||
|
export const DIRECT_INVITES_PATH = `/direct/${_INVITES_PATH}`;
|
||||||
|
|
||||||
export const SPACE_SETTINGS_PATH = '/space-settings/';
|
export const SPACE_SETTINGS_PATH = '/space-settings/';
|
||||||
|
|
||||||
export const ROOM_SETTINGS_PATH = '/room-settings/';
|
export const ROOM_SETTINGS_PATH = '/room-settings/';
|
||||||
|
|||||||
@@ -289,7 +289,15 @@ export function CodeBlock({
|
|||||||
fill="None"
|
fill="None"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
before={copied && <Icon size="50" src={Icons.Check} />}
|
before={
|
||||||
|
copied ? (
|
||||||
|
<Icon size="50" src={Icons.Check} />
|
||||||
|
) : (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||||
|
<path d="M3 5V12.73C2.4 12.38 2 11.74 2 11V5C2 2.79 3.79 1 6 1H9C9.74 1 10.38 1.4 10.73 2H6C4.35 2 3 3.35 3 5ZM11 15H6C4.897 15 4 14.103 4 13V5C4 3.897 4.897 3 6 3H11C12.103 3 13 3.897 13 5V13C13 14.103 12.103 15 11 15ZM12 5C12 4.448 11.552 4 11 4H6C5.448 4 5 4.448 5 5V13C5 13.552 5.448 14 6 14H11C11.552 14 12 13.552 12 13V5Z"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Text size="B300">{copied ? 'Copied' : 'Copy'}</Text>
|
<Text size="B300">{copied ? 'Copied' : 'Copy'}</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
@@ -327,6 +335,41 @@ export function CodeBlock({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function InlineCode({
|
||||||
|
children,
|
||||||
|
opts,
|
||||||
|
props,
|
||||||
|
}: {
|
||||||
|
children: ChildNode[];
|
||||||
|
opts: HTMLReactParserOptions;
|
||||||
|
props: any;
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useTimeoutToggle();
|
||||||
|
|
||||||
|
const handleCopy = (e: MouseEvent<HTMLSpanElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
copyToClipboard(extractTextFromChildren(children));
|
||||||
|
setCopied();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={css.InlineCodeWrapper}>
|
||||||
|
<span className={css.InlineCodeCopyButton} onClick={handleCopy}>
|
||||||
|
{copied ? (
|
||||||
|
<Icon size="50" src={Icons.Check} />
|
||||||
|
) : (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||||
|
<path d="M3 5V12.73C2.4 12.38 2 11.74 2 11V5C2 2.79 3.79 1 6 1H9C9.74 1 10.38 1.4 10.73 2H6C4.35 2 3 3.35 3 5ZM11 15H6C4.897 15 4 14.103 4 13V5C4 3.897 4.897 3 6 3H11C12.103 3 13 3.897 13 5V13C13 14.103 12.103 15 11 15ZM12 5C12 4.448 11.552 4 11 4H6C5.448 4 5 4.448 5 5V13C5 13.552 5.448 14 6 14H11C11.552 14 12 13.552 12 13V5Z"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<Text as="code" size="T300" className={css.Code} {...props}>
|
||||||
|
{domToReact(children, opts)}
|
||||||
|
</Text>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const getReactCustomHtmlParser = (
|
export const getReactCustomHtmlParser = (
|
||||||
mx: MatrixClient,
|
mx: MatrixClient,
|
||||||
roomId: string | undefined,
|
roomId: string | undefined,
|
||||||
@@ -450,11 +493,7 @@ export const getReactCustomHtmlParser = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return (
|
return <InlineCode opts={opts} props={props}>{children}</InlineCode>;
|
||||||
<Text as="code" size="T300" className={css.Code} {...props}>
|
|
||||||
{domToReact(children, opts)}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ export enum EmojiStyle {
|
|||||||
Twemoji = 'twemoji',
|
Twemoji = 'twemoji',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ScrollToLatestBehavior {
|
||||||
|
Always = 'always',
|
||||||
|
Never = 'never',
|
||||||
|
OnNewMessage = 'onNewMessage',
|
||||||
|
}
|
||||||
|
|
||||||
export type CallPanelDockPosition = 'right' | 'sidebar';
|
export type CallPanelDockPosition = 'right' | 'sidebar';
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
@@ -56,6 +62,8 @@ export interface Settings {
|
|||||||
developerTools: boolean;
|
developerTools: boolean;
|
||||||
|
|
||||||
autoJoinSpaceRooms: boolean;
|
autoJoinSpaceRooms: boolean;
|
||||||
|
|
||||||
|
scrollToLatestBehavior: ScrollToLatestBehavior;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSettings: Settings = {
|
const defaultSettings: Settings = {
|
||||||
@@ -97,6 +105,8 @@ const defaultSettings: Settings = {
|
|||||||
developerTools: false,
|
developerTools: false,
|
||||||
|
|
||||||
autoJoinSpaceRooms: true,
|
autoJoinSpaceRooms: true,
|
||||||
|
|
||||||
|
scrollToLatestBehavior: ScrollToLatestBehavior.Always,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSettings = () => {
|
export const getSettings = () => {
|
||||||
|
|||||||
@@ -16,13 +16,21 @@ export const MarginSpaced = style({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Paragraph = style([DefaultReset]);
|
export const Paragraph = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
overflow: 'hidden',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
export const Heading = style([
|
export const Heading = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
MarginSpaced,
|
MarginSpaced,
|
||||||
{
|
{
|
||||||
marginTop: config.space.S400,
|
marginTop: config.space.S400,
|
||||||
|
overflow: 'hidden',
|
||||||
|
lineHeight: '1.5',
|
||||||
selectors: {
|
selectors: {
|
||||||
'&:first-child': {
|
'&:first-child': {
|
||||||
marginTop: 0,
|
marginTop: 0,
|
||||||
@@ -38,6 +46,8 @@ export const BlockQuote = style([
|
|||||||
paddingLeft: config.space.S200,
|
paddingLeft: config.space.S200,
|
||||||
borderLeft: `${config.borderWidth.B700} solid ${color.SurfaceVariant.ContainerLine}`,
|
borderLeft: `${config.borderWidth.B700} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
fontStyle: 'italic',
|
fontStyle: 'italic',
|
||||||
|
overflow: 'hidden',
|
||||||
|
lineHeight: '1.5',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -51,15 +61,58 @@ const CodeFont = style({
|
|||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const InlineCodeWrapper = style({
|
||||||
|
position: 'relative',
|
||||||
|
display: 'inline-block',
|
||||||
|
});
|
||||||
|
|
||||||
export const Code = style([
|
export const Code = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
BaseCode,
|
BaseCode,
|
||||||
CodeFont,
|
CodeFont,
|
||||||
{
|
{
|
||||||
padding: `0 ${config.space.S100}`,
|
padding: `0 ${config.space.S100}`,
|
||||||
|
display: 'inline',
|
||||||
|
overflow: 'hidden',
|
||||||
|
lineHeight: '1.5',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const InlineCodeCopyButton = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
transform: 'translateY(-100%)',
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 0.15s ease-in-out',
|
||||||
|
background: color.SurfaceVariant.Container,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
|
borderBottom: 'none',
|
||||||
|
borderTopLeftRadius: config.radii.R300,
|
||||||
|
borderTopRightRadius: config.radii.R300,
|
||||||
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: toRem(11),
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
fontWeight: config.fontWeight.W500,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
zIndex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
selectors: {
|
||||||
|
[`${InlineCodeWrapper}:hover &`]: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
'&:hover': {
|
||||||
|
background: color.SurfaceVariant.ContainerHover,
|
||||||
|
},
|
||||||
|
'&:active': {
|
||||||
|
background: color.SurfaceVariant.ContainerActive,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const Spoiler = recipe({
|
export const Spoiler = recipe({
|
||||||
base: [
|
base: [
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
@@ -67,6 +120,8 @@ export const Spoiler = recipe({
|
|||||||
padding: `0 ${config.space.S100}`,
|
padding: `0 ${config.space.S100}`,
|
||||||
backgroundColor: color.SurfaceVariant.ContainerActive,
|
backgroundColor: color.SurfaceVariant.ContainerActive,
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
|
overflow: 'hidden',
|
||||||
|
lineHeight: '1.5',
|
||||||
selectors: {
|
selectors: {
|
||||||
'&[aria-pressed=true]': {
|
'&[aria-pressed=true]': {
|
||||||
color: 'transparent',
|
color: 'transparent',
|
||||||
@@ -91,6 +146,7 @@ export const CodeBlock = style([
|
|||||||
fontStyle: 'normal',
|
fontStyle: 'normal',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
lineHeight: '1.5',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
export const CodeBlockHeader = style([
|
export const CodeBlockHeader = style([
|
||||||
@@ -126,6 +182,8 @@ export const List = style([
|
|||||||
{
|
{
|
||||||
padding: `0 ${config.space.S100}`,
|
padding: `0 ${config.space.S100}`,
|
||||||
paddingLeft: config.space.S600,
|
paddingLeft: config.space.S600,
|
||||||
|
overflow: 'hidden',
|
||||||
|
lineHeight: '1.5',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -153,6 +211,8 @@ export const Mention = recipe({
|
|||||||
padding: `0 ${toRem(2)}`,
|
padding: `0 ${toRem(2)}`,
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
fontWeight: config.fontWeight.W500,
|
fontWeight: config.fontWeight.W500,
|
||||||
|
overflow: 'hidden',
|
||||||
|
lineHeight: '1.5',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
variants: {
|
variants: {
|
||||||
@@ -178,6 +238,8 @@ export const Command = recipe({
|
|||||||
padding: `0 ${toRem(2)}`,
|
padding: `0 ${toRem(2)}`,
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
fontWeight: config.fontWeight.W500,
|
fontWeight: config.fontWeight.W500,
|
||||||
|
overflow: 'hidden',
|
||||||
|
lineHeight: '1.5',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
variants: {
|
variants: {
|
||||||
@@ -246,5 +308,7 @@ export const highlightText = style([
|
|||||||
{
|
{
|
||||||
backgroundColor: 'yellow',
|
backgroundColor: 'yellow',
|
||||||
color: 'black',
|
color: 'black',
|
||||||
|
overflow: 'hidden',
|
||||||
|
lineHeight: '1.5',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
73
src/app/utils/androidShare.ts
Normal file
73
src/app/utils/androidShare.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
|
||||||
|
|
||||||
|
/** Metadata for a file received from an Android share intent. */
|
||||||
|
export type AndroidSharedFile = {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Payload persisted by the native Android share handler. */
|
||||||
|
export type AndroidSharePayload = {
|
||||||
|
text?: string;
|
||||||
|
subject?: string;
|
||||||
|
files: AndroidSharedFile[];
|
||||||
|
receivedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AndroidShareHandlerPlugin {
|
||||||
|
/** Returns the last pending share payload, if one exists. */
|
||||||
|
getPendingShare(): Promise<{ share: AndroidSharePayload | null }>;
|
||||||
|
/** Clears the last pending share payload after it has been consumed. */
|
||||||
|
clearPendingShare(): Promise<void>;
|
||||||
|
addListener(
|
||||||
|
eventName: 'shareReceived',
|
||||||
|
listenerFunc: (payload: AndroidSharePayload) => void
|
||||||
|
): Promise<PluginListenerHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AndroidShareHandler = registerPlugin<AndroidShareHandlerPlugin>('AndroidShareHandler');
|
||||||
|
|
||||||
|
/** Returns true when the Android share bridge is available. */
|
||||||
|
export const isAndroidShareSupported = (): boolean =>
|
||||||
|
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
|
||||||
|
|
||||||
|
/** Fetches the current pending native share payload. */
|
||||||
|
export const getPendingAndroidShare = async (): Promise<AndroidSharePayload | null> => {
|
||||||
|
if (!isAndroidShareSupported()) return null;
|
||||||
|
const result = await AndroidShareHandler.getPendingShare();
|
||||||
|
return result.share;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Clears the native pending share payload. */
|
||||||
|
export const clearPendingAndroidShare = async (): Promise<void> => {
|
||||||
|
if (!isAndroidShareSupported()) return;
|
||||||
|
await AndroidShareHandler.clearPendingShare();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Subscribes to new incoming native share payloads. */
|
||||||
|
export const listenForAndroidShares = async (
|
||||||
|
listener: (payload: AndroidSharePayload) => void
|
||||||
|
): Promise<PluginListenerHandle | undefined> => {
|
||||||
|
if (!isAndroidShareSupported()) return undefined;
|
||||||
|
return AndroidShareHandler.addListener('shareReceived', listener);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Converts a cached native shared file into a browser File for upload. */
|
||||||
|
export const materializeSharedFile = async (
|
||||||
|
sharedFile: AndroidSharedFile,
|
||||||
|
receivedAt: number
|
||||||
|
): Promise<File> => {
|
||||||
|
const fileUrl = Capacitor.convertFileSrc(sharedFile.path);
|
||||||
|
const response = await fetch(fileUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to read shared file: ${sharedFile.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
return new File([blob], sharedFile.name, {
|
||||||
|
type: sharedFile.mimeType || blob.type,
|
||||||
|
lastModified: receivedAt,
|
||||||
|
});
|
||||||
|
};
|
||||||
518
src/app/utils/backgroundSync.ts
Normal file
518
src/app/utils/backgroundSync.ts
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
|
||||||
|
import type { IPusherRequest, MatrixClient } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
type UnifiedPushStatus = {
|
||||||
|
running: boolean;
|
||||||
|
endpoint: string;
|
||||||
|
instance: string;
|
||||||
|
registered: boolean;
|
||||||
|
distributor: string;
|
||||||
|
distributors: string[] | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UnifiedPushEndpointEvent = {
|
||||||
|
endpoint: string;
|
||||||
|
previousEndpoint: string;
|
||||||
|
instance: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UnifiedPushUnregisteredEvent = {
|
||||||
|
previousEndpoint: string;
|
||||||
|
instance: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UnifiedPushRegistrationFailedEvent = {
|
||||||
|
reason: string;
|
||||||
|
instance: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MatrixBackgroundSyncPlugin {
|
||||||
|
/** Persist credentials and request UnifiedPush registration. */
|
||||||
|
start(options: {
|
||||||
|
homeserverUrl: string;
|
||||||
|
accessToken: string;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
}): Promise<void>;
|
||||||
|
/** Trigger a one-shot fetch as if a push ping arrived. */
|
||||||
|
triggerPing(options: { reason?: string }): Promise<void>;
|
||||||
|
/** Re-open distributor setup flow and retry registration. */
|
||||||
|
requestDistributorSetup(): Promise<{ success: boolean }>;
|
||||||
|
/** Stop any in-flight fetch, clear credentials, and unregister UnifiedPush. */
|
||||||
|
stop(): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Notify the service whether the app UI is visible.
|
||||||
|
* When foreground is true, the service suppresses native notifications
|
||||||
|
* because the JS layer handles them via LocalNotifications.
|
||||||
|
*/
|
||||||
|
setAppForeground(options: { foreground: boolean }): Promise<void>;
|
||||||
|
/** Returns fetch state and current UnifiedPush registration details. */
|
||||||
|
getStatus(): Promise<UnifiedPushStatus>;
|
||||||
|
addListener(
|
||||||
|
eventName: 'unifiedPushNewEndpoint',
|
||||||
|
listenerFunc: (event: UnifiedPushEndpointEvent) => void
|
||||||
|
): Promise<PluginListenerHandle>;
|
||||||
|
addListener(
|
||||||
|
eventName: 'unifiedPushUnregistered',
|
||||||
|
listenerFunc: (event: UnifiedPushUnregisteredEvent) => void
|
||||||
|
): Promise<PluginListenerHandle>;
|
||||||
|
addListener(
|
||||||
|
eventName: 'unifiedPushRegistrationFailed',
|
||||||
|
listenerFunc: (event: UnifiedPushRegistrationFailedEvent) => void
|
||||||
|
): Promise<PluginListenerHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StoredPusherState = {
|
||||||
|
endpoint: string;
|
||||||
|
appId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||||
|
const DEFAULT_UNIFIED_PUSH_GATEWAY = 'https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify';
|
||||||
|
const PUSHER_APP_ID_BASE = 'com.paarrot.app.android';
|
||||||
|
const PUSHER_STORAGE_PREFIX = 'paarrot.unifiedpush';
|
||||||
|
|
||||||
|
/** Returns true when the current platform is Android Capacitor. */
|
||||||
|
export const isBackgroundSyncSupported = (): boolean =>
|
||||||
|
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
|
||||||
|
|
||||||
|
const getStoredPusherKey = (userId: string | null, deviceId: string | null): string =>
|
||||||
|
`${PUSHER_STORAGE_PREFIX}:${userId ?? 'unknown'}:${deviceId ?? 'unknown'}`;
|
||||||
|
|
||||||
|
const buildPusherAppId = (deviceId: string | null): string => {
|
||||||
|
const raw = deviceId ? `${PUSHER_APP_ID_BASE}.${deviceId}` : PUSHER_APP_ID_BASE;
|
||||||
|
return raw.length > 64 ? raw.slice(0, 64) : raw;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadStoredPusherState = (
|
||||||
|
userId: string | null,
|
||||||
|
deviceId: string | null
|
||||||
|
): StoredPusherState | undefined => {
|
||||||
|
if (typeof window === 'undefined' || !window.localStorage) return undefined;
|
||||||
|
|
||||||
|
const raw = window.localStorage.getItem(getStoredPusherKey(userId, deviceId));
|
||||||
|
if (!raw) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as StoredPusherState;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveStoredPusherState = (
|
||||||
|
userId: string | null,
|
||||||
|
deviceId: string | null,
|
||||||
|
state: StoredPusherState
|
||||||
|
): void => {
|
||||||
|
if (typeof window === 'undefined' || !window.localStorage) return;
|
||||||
|
window.localStorage.setItem(getStoredPusherKey(userId, deviceId), JSON.stringify(state));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearStoredPusherState = (userId: string | null, deviceId: string | null): void => {
|
||||||
|
if (typeof window === 'undefined' || !window.localStorage) return;
|
||||||
|
window.localStorage.removeItem(getStoredPusherKey(userId, deviceId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeDistributors = (raw: UnifiedPushStatus['distributors']): string[] => {
|
||||||
|
if (Array.isArray(raw)) return raw;
|
||||||
|
if (typeof raw !== 'string') return [];
|
||||||
|
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed || trimmed === '[]') return [];
|
||||||
|
|
||||||
|
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||||
|
return trimmed
|
||||||
|
.slice(1, -1)
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [trimmed];
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveUnifiedPushGateway = async (endpoint: string): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const discoveryUrl = new URL(endpoint);
|
||||||
|
discoveryUrl.pathname = '/_matrix/push/v1/notify';
|
||||||
|
discoveryUrl.search = '';
|
||||||
|
|
||||||
|
const response = await fetch(discoveryUrl.toString(), { method: 'GET' });
|
||||||
|
if (!response.ok) return DEFAULT_UNIFIED_PUSH_GATEWAY;
|
||||||
|
|
||||||
|
const body = (await response.json()) as {
|
||||||
|
gateway?: string;
|
||||||
|
unifiedpush?: { gateway?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body.gateway === 'matrix' || body.unifiedpush?.gateway === 'matrix') {
|
||||||
|
return discoveryUrl.toString();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] UnifiedPush gateway discovery failed:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_UNIFIED_PUSH_GATEWAY;
|
||||||
|
};
|
||||||
|
|
||||||
|
class AndroidUnifiedPushManager {
|
||||||
|
private client: MatrixClient | undefined;
|
||||||
|
|
||||||
|
private listenerHandles: PluginListenerHandle[] = [];
|
||||||
|
|
||||||
|
private listenersReady = false;
|
||||||
|
|
||||||
|
private distributorSetupAttempted = false;
|
||||||
|
|
||||||
|
private distributorPromptShown = false;
|
||||||
|
|
||||||
|
/** Start native UnifiedPush registration and synchronize the Matrix pusher. */
|
||||||
|
async start(mx: MatrixClient): Promise<void> {
|
||||||
|
console.log('[BackgroundSync] start() called, platform:', Capacitor.getPlatform(), 'isNative:', Capacitor.isNativePlatform());
|
||||||
|
|
||||||
|
if (!isBackgroundSyncSupported()) {
|
||||||
|
console.log('[BackgroundSync] Background sync not supported on this platform');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const homeserverUrl = mx.getHomeserverUrl();
|
||||||
|
const accessToken = mx.getAccessToken();
|
||||||
|
const userId = mx.getUserId();
|
||||||
|
const deviceId = mx.getDeviceId();
|
||||||
|
|
||||||
|
console.log('[BackgroundSync] Credentials check:', { userId, deviceId, hasToken: !!accessToken, hasUrl: !!homeserverUrl });
|
||||||
|
|
||||||
|
if (!homeserverUrl || !accessToken || !userId) {
|
||||||
|
console.warn('[BackgroundSync] Missing credentials, not starting');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client = mx;
|
||||||
|
this.distributorSetupAttempted = false;
|
||||||
|
this.distributorPromptShown = false;
|
||||||
|
await this.ensureListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[BackgroundSync] Calling plugin.start()...');
|
||||||
|
await MatrixBackgroundSync.start({
|
||||||
|
homeserverUrl,
|
||||||
|
accessToken,
|
||||||
|
userId,
|
||||||
|
deviceId: deviceId ?? '',
|
||||||
|
});
|
||||||
|
console.log('[BackgroundSync] plugin.start() completed');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BackgroundSync] plugin.start() failed:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.syncExistingEndpoint();
|
||||||
|
await this.ensureDistributorPromptFromStatus();
|
||||||
|
console.log('[BackgroundSync] UnifiedPush registration requested');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop native UnifiedPush integration and remove the Matrix pusher for this device. */
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (!isBackgroundSyncSupported()) return;
|
||||||
|
|
||||||
|
const client = this.client;
|
||||||
|
if (client) {
|
||||||
|
const deviceId = client.getDeviceId();
|
||||||
|
const userId = client.getUserId();
|
||||||
|
const status = await this.safeGetStatus();
|
||||||
|
const stored = loadStoredPusherState(userId, deviceId);
|
||||||
|
const endpoint = status?.endpoint || stored?.endpoint;
|
||||||
|
const appId = stored?.appId ?? buildPusherAppId(deviceId);
|
||||||
|
|
||||||
|
if (endpoint) {
|
||||||
|
await this.removePusher(client, endpoint, appId);
|
||||||
|
}
|
||||||
|
clearStoredPusherState(userId, deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await MatrixBackgroundSync.stop();
|
||||||
|
await this.disposeListeners();
|
||||||
|
this.client = undefined;
|
||||||
|
console.log('[BackgroundSync] UnifiedPush stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the Matrix pusher when a new native endpoint is published. */
|
||||||
|
private async handleNewEndpoint(event: UnifiedPushEndpointEvent): Promise<void> {
|
||||||
|
const client = this.client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
if (event.previousEndpoint) {
|
||||||
|
await this.removePusher(client, event.previousEndpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.upsertPusher(client, event.endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the Matrix pusher when UnifiedPush unregisters this instance. */
|
||||||
|
private async handleUnregistered(event: UnifiedPushUnregisteredEvent): Promise<void> {
|
||||||
|
const client = this.client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
const stored = loadStoredPusherState(client.getUserId(), client.getDeviceId());
|
||||||
|
const endpoint = event.previousEndpoint || stored?.endpoint;
|
||||||
|
const appId = stored?.appId ?? buildPusherAppId(client.getDeviceId());
|
||||||
|
|
||||||
|
if (endpoint) {
|
||||||
|
await this.removePusher(client, endpoint, appId);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearStoredPusherState(client.getUserId(), client.getDeviceId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Log native registration failures so the missing distributor path is visible. */
|
||||||
|
private handleRegistrationFailed(event: UnifiedPushRegistrationFailedEvent): void {
|
||||||
|
console.warn('[BackgroundSync] UnifiedPush registration failed:', event.reason);
|
||||||
|
|
||||||
|
if (event.reason !== 'ACTION_REQUIRED' || this.distributorSetupAttempted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.distributorSetupAttempted = true;
|
||||||
|
void this.tryRequestDistributorSetup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-opens distributor selection once after ACTION_REQUIRED and logs actionable status. */
|
||||||
|
private async tryRequestDistributorSetup(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const setupResult = await MatrixBackgroundSync.requestDistributorSetup();
|
||||||
|
console.warn('[BackgroundSync] requestDistributorSetup result:', setupResult);
|
||||||
|
const status = await this.safeGetStatus();
|
||||||
|
console.warn('[BackgroundSync] UnifiedPush status after setup attempt:', status);
|
||||||
|
const distributors = normalizeDistributors(status?.distributors ?? []);
|
||||||
|
if (distributors.length === 0) {
|
||||||
|
console.error(
|
||||||
|
'[BackgroundSync] No UnifiedPush distributor installed. Install one (for example ntfy) to enable Android background notifications.'
|
||||||
|
);
|
||||||
|
this.showNoDistributorPrompt();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] requestDistributorSetup failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fallback check so users still get prompted even when ACTION_REQUIRED event is missed. */
|
||||||
|
private async ensureDistributorPromptFromStatus(): Promise<void> {
|
||||||
|
const status = await this.safeGetStatus();
|
||||||
|
if (!status) return;
|
||||||
|
|
||||||
|
const distributors = normalizeDistributors(status.distributors);
|
||||||
|
if (!status.registered && distributors.length === 0) {
|
||||||
|
console.error(
|
||||||
|
'[BackgroundSync] No UnifiedPush distributor installed. Install one (for example ntfy) to enable Android background notifications.'
|
||||||
|
);
|
||||||
|
this.showNoDistributorPrompt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show a one-time actionable prompt when no distributor app is installed. */
|
||||||
|
private showNoDistributorPrompt(): void {
|
||||||
|
if (this.distributorPromptShown) return;
|
||||||
|
this.distributorPromptShown = true;
|
||||||
|
|
||||||
|
const message =
|
||||||
|
'Android background notifications need a UnifiedPush distributor app. Install one (for example ntfy), then reopen Paarrot.';
|
||||||
|
const docsUrl = 'https://unifiedpush.org/users/distributors/';
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const openDocs = window.confirm(`${message}\n\nOpen distributor list now?`);
|
||||||
|
if (openDocs) {
|
||||||
|
window.open(docsUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
window.alert(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Install plugin listeners once for the active Matrix client. */
|
||||||
|
private async ensureListeners(): Promise<void> {
|
||||||
|
if (this.listenersReady) return;
|
||||||
|
|
||||||
|
this.listenerHandles = [
|
||||||
|
await MatrixBackgroundSync.addListener('unifiedPushNewEndpoint', (event) => {
|
||||||
|
void this.handleNewEndpoint(event).catch((err) => {
|
||||||
|
console.error('[BackgroundSync] handleNewEndpoint failed:', err);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
await MatrixBackgroundSync.addListener('unifiedPushUnregistered', (event) => {
|
||||||
|
void this.handleUnregistered(event);
|
||||||
|
}),
|
||||||
|
await MatrixBackgroundSync.addListener('unifiedPushRegistrationFailed', (event) => {
|
||||||
|
this.handleRegistrationFailed(event);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
this.listenersReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove all plugin listeners when the manager stops. */
|
||||||
|
private async disposeListeners(): Promise<void> {
|
||||||
|
await Promise.all(this.listenerHandles.map((handle) => handle.remove()));
|
||||||
|
this.listenerHandles = [];
|
||||||
|
this.listenersReady = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconcile an already-persisted native endpoint after app startup. */
|
||||||
|
private async syncExistingEndpoint(): Promise<void> {
|
||||||
|
const client = this.client;
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
const status = await this.safeGetStatus();
|
||||||
|
console.log('[BackgroundSync] Native status before pusher sync:', status);
|
||||||
|
if (status?.registered && status.endpoint) {
|
||||||
|
await this.upsertPusher(client, status.endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create or refresh the Matrix HTTP pusher for the current device. */
|
||||||
|
private async upsertPusher(mx: MatrixClient, endpoint: string): Promise<void> {
|
||||||
|
const deviceId = mx.getDeviceId();
|
||||||
|
const userId = mx.getUserId();
|
||||||
|
const appId = buildPusherAppId(deviceId);
|
||||||
|
const gatewayUrl = await resolveUnifiedPushGateway(endpoint);
|
||||||
|
|
||||||
|
console.log('[BackgroundSync] Upserting Matrix pusher', {
|
||||||
|
appId,
|
||||||
|
endpoint,
|
||||||
|
gatewayUrl,
|
||||||
|
deviceId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mx.setPusher({
|
||||||
|
kind: 'http',
|
||||||
|
app_id: appId,
|
||||||
|
pushkey: endpoint,
|
||||||
|
app_display_name: 'Paarrot',
|
||||||
|
device_display_name: deviceId ?? 'Android',
|
||||||
|
lang: 'en',
|
||||||
|
data: {
|
||||||
|
url: gatewayUrl,
|
||||||
|
format: 'event_id_only',
|
||||||
|
},
|
||||||
|
append: false,
|
||||||
|
device_id: deviceId ?? undefined,
|
||||||
|
} as unknown as IPusherRequest);
|
||||||
|
|
||||||
|
const pushers = (await mx.getPushers())?.pushers ?? [];
|
||||||
|
const thisPusher = pushers.find((p) => p.pushkey === endpoint && p.app_id === appId);
|
||||||
|
console.log('[BackgroundSync] Matrix pusher upserted successfully', {
|
||||||
|
totalPushers: pushers.length,
|
||||||
|
foundThisPusher: Boolean(thisPusher),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BackgroundSync] Matrix pusher upsert failed:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveStoredPusherState(userId, deviceId, { endpoint, appId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a previously-registered Matrix HTTP pusher for this device. */
|
||||||
|
private async removePusher(
|
||||||
|
mx: MatrixClient,
|
||||||
|
endpoint: string,
|
||||||
|
appId = buildPusherAppId(mx.getDeviceId())
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await mx.setPusher({
|
||||||
|
pushkey: endpoint,
|
||||||
|
app_id: appId,
|
||||||
|
kind: null,
|
||||||
|
} as unknown as IPusherRequest);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] Failed to remove UnifiedPush pusher:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read native registration state without failing the caller. */
|
||||||
|
private async safeGetStatus(): Promise<UnifiedPushStatus | undefined> {
|
||||||
|
try {
|
||||||
|
return await MatrixBackgroundSync.getStatus();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] Failed to read UnifiedPush status:', err);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unifiedPushManager = new AndroidUnifiedPushManager();
|
||||||
|
|
||||||
|
/** Start native UnifiedPush registration and sync the Matrix pusher. */
|
||||||
|
export const startBackgroundSync = async (mx: MatrixClient): Promise<void> => {
|
||||||
|
await unifiedPushManager.start(mx);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually triggers a one-shot native fetch.
|
||||||
|
* Useful for diagnostics and bridge testing.
|
||||||
|
*/
|
||||||
|
export const triggerBackgroundSyncPing = async (reason?: string): Promise<void> => {
|
||||||
|
if (!isBackgroundSyncSupported()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await MatrixBackgroundSync.triggerPing({ reason });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] triggerPing failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stop UnifiedPush integration and remove the Matrix pusher for this session. */
|
||||||
|
export const stopBackgroundSync = async (): Promise<void> => {
|
||||||
|
await unifiedPushManager.stop();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the native service whether the app UI is in the foreground.
|
||||||
|
* Call when document visibility changes so the service can suppress
|
||||||
|
* duplicate notifications while the JS layer is active.
|
||||||
|
* @param foreground true if the WebView UI is currently visible
|
||||||
|
*/
|
||||||
|
export const setAppForegroundState = async (foreground: boolean): Promise<void> => {
|
||||||
|
if (!isBackgroundSyncSupported()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await MatrixBackgroundSync.setAppForeground({ foreground });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] setAppForeground failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-opens the UnifiedPush distributor selection dialog.
|
||||||
|
* Allows users to re-select or change their push notification distributor without terminal access.
|
||||||
|
* Useful when the current endpoint becomes unavailable.
|
||||||
|
*/
|
||||||
|
export const requestResetPushRegistration = async (): Promise<{ success: boolean }> => {
|
||||||
|
if (!isBackgroundSyncSupported()) {
|
||||||
|
console.warn('[BackgroundSync] Background sync not supported on this platform');
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await MatrixBackgroundSync.requestDistributorSetup();
|
||||||
|
console.log('[BackgroundSync] requestDistributorSetup completed:', result);
|
||||||
|
return result ?? { success: false };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BackgroundSync] requestDistributorSetup failed:', err);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Returns the current native UnifiedPush status, or undefined if unavailable. */
|
||||||
|
export const getBackgroundSyncStatus = async (): Promise<UnifiedPushStatus | undefined> => {
|
||||||
|
if (!isBackgroundSyncSupported()) return undefined;
|
||||||
|
try {
|
||||||
|
return await MatrixBackgroundSync.getStatus();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] getStatus failed:', err);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -172,6 +172,32 @@ export type ScrollInfo = {
|
|||||||
viewHeight: number;
|
viewHeight: number;
|
||||||
scrollable: boolean;
|
scrollable: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ScrollBehavior = 'auto' | 'instant' | 'smooth';
|
||||||
|
|
||||||
|
export type ScrollPosition = {
|
||||||
|
top?: number;
|
||||||
|
left?: number;
|
||||||
|
behavior?: ScrollBehavior;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized wrapper for element scrolling.
|
||||||
|
*/
|
||||||
|
export const scrollToPosition = (scrollEl: HTMLElement, position: ScrollPosition): void => {
|
||||||
|
scrollEl.scrollTo(position);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized wrapper for scrolling a target into the current viewport.
|
||||||
|
*/
|
||||||
|
export const scrollElementIntoView = (
|
||||||
|
element: HTMLElement,
|
||||||
|
options?: ScrollIntoViewOptions
|
||||||
|
): void => {
|
||||||
|
element.scrollIntoView(options);
|
||||||
|
};
|
||||||
|
|
||||||
export const getScrollInfo = (target: HTMLElement): ScrollInfo => ({
|
export const getScrollInfo = (target: HTMLElement): ScrollInfo => ({
|
||||||
offsetTop: Math.round(target.offsetTop),
|
offsetTop: Math.round(target.offsetTop),
|
||||||
top: Math.round(target.scrollTop),
|
top: Math.round(target.scrollTop),
|
||||||
@@ -180,17 +206,44 @@ export const getScrollInfo = (target: HTMLElement): ScrollInfo => ({
|
|||||||
scrollable: target.scrollHeight > target.offsetHeight,
|
scrollable: target.scrollHeight > target.offsetHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const scrollToBottom = (scrollEl: HTMLElement, behavior?: 'auto' | 'instant' | 'smooth') => {
|
export const scrollToBottom = (scrollEl: HTMLElement, behavior?: ScrollBehavior) => {
|
||||||
scrollEl.scrollTo({
|
scrollToPosition(scrollEl, {
|
||||||
top: Math.round(scrollEl.scrollHeight - scrollEl.offsetHeight),
|
top: Math.round(scrollEl.scrollHeight - scrollEl.offsetHeight),
|
||||||
behavior,
|
behavior,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if scroll element is at or near the bottom (within threshold).
|
||||||
|
* Useful for "sticky to bottom" behavior in chat-like interfaces.
|
||||||
|
* @param scrollEl - The scrollable element
|
||||||
|
* @param threshold - Distance from bottom in pixels (default: 50px)
|
||||||
|
* @returns true if scrolled to bottom within threshold
|
||||||
|
*/
|
||||||
|
export const isScrolledToBottom = (scrollEl: HTMLElement, threshold = 50): boolean => {
|
||||||
|
const { scrollTop, offsetHeight, scrollHeight } = scrollEl;
|
||||||
|
const scrollPosition = scrollTop + offsetHeight;
|
||||||
|
return scrollHeight - scrollPosition <= threshold;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared auto-scroll behavior for chat-like timelines that should stay pinned to bottom.
|
||||||
|
* Only scrolls if already near the bottom unless a call site explicitly forces it.
|
||||||
|
*/
|
||||||
|
export const autoScrollToBottom = (
|
||||||
|
scrollEl: HTMLElement,
|
||||||
|
smooth = false,
|
||||||
|
force = false
|
||||||
|
): void => {
|
||||||
|
if (force || isScrolledToBottom(scrollEl)) {
|
||||||
|
scrollToBottom(scrollEl, smooth ? 'smooth' : 'instant');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const copyToClipboard = (text: string) => {
|
export const copyToClipboard = (text: string) => {
|
||||||
// Try Electron clipboard first (if available)
|
// Try Electron clipboard first (if available)
|
||||||
if ('electron' in window && (window as any).electron?.clipboard?.writeText) {
|
if ('electron' in window && window.electron?.clipboard?.writeText) {
|
||||||
(window as any).electron.clipboard.writeText(text);
|
window.electron.clipboard.writeText(text);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,46 @@ import { embedColorInWebP, extractColorFromWebP, removeColorFromWebP } from './w
|
|||||||
import { embedColorInJPEG, extractColorFromJPEG, removeColorFromJPEG } from './jpegMetadata';
|
import { embedColorInJPEG, extractColorFromJPEG, removeColorFromJPEG } from './jpegMetadata';
|
||||||
import { embedColorInGIF, extractColorFromGIF, removeColorFromGIF } from './gifMetadata';
|
import { embedColorInGIF, extractColorFromGIF, removeColorFromGIF } from './gifMetadata';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LRU Cache implementation with max size limit
|
||||||
|
*/
|
||||||
|
class LRUCache<K, V> {
|
||||||
|
private cache: Map<K, V>;
|
||||||
|
|
||||||
|
private maxSize: number;
|
||||||
|
|
||||||
|
constructor(maxSize: number) {
|
||||||
|
this.cache = new Map();
|
||||||
|
this.maxSize = maxSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: K): V | undefined {
|
||||||
|
const value = this.cache.get(key);
|
||||||
|
if (value !== undefined) {
|
||||||
|
this.cache.delete(key);
|
||||||
|
this.cache.set(key, value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: K, value: V): void {
|
||||||
|
if (this.cache.has(key)) {
|
||||||
|
this.cache.delete(key);
|
||||||
|
}
|
||||||
|
else if (this.cache.size >= this.maxSize) {
|
||||||
|
const firstKey = this.cache.keys().next().value;
|
||||||
|
if (firstKey !== undefined) {
|
||||||
|
this.cache.delete(firstKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.cache.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gradient configuration for profile styling
|
* Gradient configuration for profile styling
|
||||||
* All colors support RGBA format (e.g., #FF550080 for 50% opacity)
|
* All colors support RGBA format (e.g., #FF550080 for 50% opacity)
|
||||||
@@ -130,6 +170,8 @@ export function detectImageFormat(data: ArrayBuffer | Uint8Array): ImageFormat {
|
|||||||
export function extractColorFromImage(imageData: ArrayBuffer | Uint8Array): string | undefined {
|
export function extractColorFromImage(imageData: ArrayBuffer | Uint8Array): string | undefined {
|
||||||
const format = detectImageFormat(imageData);
|
const format = detectImageFormat(imageData);
|
||||||
|
|
||||||
|
console.log('[extractColorFromImage] Detected format:', format);
|
||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case 'png':
|
case 'png':
|
||||||
return extractColorFromPNG(imageData);
|
return extractColorFromPNG(imageData);
|
||||||
@@ -171,14 +213,19 @@ export function extractBannerFromImage(imageData: ArrayBuffer | Uint8Array): str
|
|||||||
export function extractMetadataFromImage(imageData: ArrayBuffer | Uint8Array): ImageMetadata {
|
export function extractMetadataFromImage(imageData: ArrayBuffer | Uint8Array): ImageMetadata {
|
||||||
const format = detectImageFormat(imageData);
|
const format = detectImageFormat(imageData);
|
||||||
|
|
||||||
|
console.log('[extractMetadataFromImage] Detected format:', format);
|
||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case 'png':
|
case 'png':
|
||||||
return extractMetadataFromPNG(imageData);
|
const pngMetadata = extractMetadataFromPNG(imageData);
|
||||||
|
console.log('[extractMetadataFromImage] PNG metadata:', pngMetadata);
|
||||||
|
return pngMetadata;
|
||||||
// For other formats, extract color only for now
|
// For other formats, extract color only for now
|
||||||
case 'webp':
|
case 'webp':
|
||||||
case 'jpeg':
|
case 'jpeg':
|
||||||
case 'gif': {
|
case 'gif': {
|
||||||
const color = extractColorFromImage(imageData);
|
const color = extractColorFromImage(imageData);
|
||||||
|
console.log('[extractMetadataFromImage] Non-PNG color:', color);
|
||||||
return color ? { color } : {};
|
return color ? { color } : {};
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -300,6 +347,14 @@ export function getExtension(format: ImageFormat): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LRU cache for avatar metadata to avoid refetching
|
||||||
|
const avatarMetadataCache = new LRUCache<string, { metadata: ImageMetadata; timestamp: number }>(100);
|
||||||
|
const METADATA_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||||
|
|
||||||
|
// In-flight fetch tracking to prevent duplicate parallel requests
|
||||||
|
const inflightColorFetches = new Map<string, Promise<string | undefined>>();
|
||||||
|
const inflightMetadataFetches = new Map<string, Promise<ImageMetadata>>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch and extract color from an image URL (supports all formats)
|
* Fetch and extract color from an image URL (supports all formats)
|
||||||
* @param url - HTTP URL to fetch the image from
|
* @param url - HTTP URL to fetch the image from
|
||||||
@@ -307,18 +362,54 @@ export function getExtension(format: ImageFormat): string {
|
|||||||
* @returns The color string if found, or undefined
|
* @returns The color string if found, or undefined
|
||||||
*/
|
*/
|
||||||
export async function fetchAndExtractColor(url: string, accessToken?: string | null): Promise<string | undefined> {
|
export async function fetchAndExtractColor(url: string, accessToken?: string | null): Promise<string | undefined> {
|
||||||
try {
|
// Check if full metadata is cached
|
||||||
const response = await fetch(url, {
|
const cached = avatarMetadataCache.get(url);
|
||||||
method: 'GET',
|
if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) {
|
||||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
console.log('[fetchAndExtractColor] Using cached color for', url, ':', cached.metadata.color);
|
||||||
});
|
return cached.metadata.color;
|
||||||
if (!response.ok) return undefined;
|
|
||||||
|
|
||||||
const data = await response.arrayBuffer();
|
|
||||||
return extractColorFromImage(data);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if fetch already in progress
|
||||||
|
const inflightKey = `${url}:${accessToken || ''}`;
|
||||||
|
const existingFetch = inflightColorFetches.get(inflightKey);
|
||||||
|
if (existingFetch) {
|
||||||
|
console.log('[fetchAndExtractColor] Reusing in-flight fetch for', url);
|
||||||
|
return existingFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[fetchAndExtractColor] Fetching and extracting color from', url);
|
||||||
|
|
||||||
|
const fetchPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[fetchAndExtractColor] Fetch response status:', response.ok, response.status);
|
||||||
|
|
||||||
|
if (!response.ok) return undefined;
|
||||||
|
|
||||||
|
const data = await response.arrayBuffer();
|
||||||
|
console.log('[fetchAndExtractColor] Downloaded', data.byteLength, 'bytes');
|
||||||
|
|
||||||
|
const color = extractColorFromImage(data);
|
||||||
|
console.log('[fetchAndExtractColor] Extracted color:', color);
|
||||||
|
|
||||||
|
// Don't cache here - fetchAndExtractMetadata will cache full metadata
|
||||||
|
// This avoids cache collision where color-only fetch overwrites full metadata
|
||||||
|
|
||||||
|
return color;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[fetchAndExtractColor] Error fetching/extracting color:', error);
|
||||||
|
return undefined;
|
||||||
|
} finally {
|
||||||
|
inflightColorFetches.delete(inflightKey);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
inflightColorFetches.set(inflightKey, fetchPromise);
|
||||||
|
return fetchPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -328,16 +419,54 @@ export async function fetchAndExtractColor(url: string, accessToken?: string | n
|
|||||||
* @returns Object with color and banner if found
|
* @returns Object with color and banner if found
|
||||||
*/
|
*/
|
||||||
export async function fetchAndExtractMetadata(url: string, accessToken?: string | null): Promise<ImageMetadata> {
|
export async function fetchAndExtractMetadata(url: string, accessToken?: string | null): Promise<ImageMetadata> {
|
||||||
try {
|
// Check cache first
|
||||||
const response = await fetch(url, {
|
const cached = avatarMetadataCache.get(url);
|
||||||
method: 'GET',
|
if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) {
|
||||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
console.log('[fetchAndExtractMetadata] Using cached metadata for', url, ':', cached.metadata);
|
||||||
});
|
return cached.metadata;
|
||||||
if (!response.ok) return {};
|
|
||||||
|
|
||||||
const data = await response.arrayBuffer();
|
|
||||||
return extractMetadataFromImage(data);
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if fetch already in progress
|
||||||
|
const inflightKey = `${url}:${accessToken || ''}`;
|
||||||
|
const existingFetch = inflightMetadataFetches.get(inflightKey);
|
||||||
|
if (existingFetch) {
|
||||||
|
console.log('[fetchAndExtractMetadata] Reusing in-flight fetch for', url);
|
||||||
|
return existingFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[fetchAndExtractMetadata] Fetching metadata from', url);
|
||||||
|
|
||||||
|
const fetchPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[fetchAndExtractMetadata] Fetch response status:', response.ok, response.status);
|
||||||
|
|
||||||
|
if (!response.ok) return {};
|
||||||
|
|
||||||
|
const data = await response.arrayBuffer();
|
||||||
|
console.log('[fetchAndExtractMetadata] Downloaded', data.byteLength, 'bytes');
|
||||||
|
|
||||||
|
const metadata = extractMetadataFromImage(data);
|
||||||
|
console.log('[fetchAndExtractMetadata] Extracted metadata:', metadata);
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
avatarMetadataCache.set(url, {
|
||||||
|
metadata,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
} finally {
|
||||||
|
inflightMetadataFetches.delete(inflightKey);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
inflightMetadataFetches.set(inflightKey, fetchPromise);
|
||||||
|
return fetchPromise;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -301,7 +301,9 @@ export const mxcUrlToHttp = (
|
|||||||
*/
|
*/
|
||||||
export const isAuthenticatedMediaUrl = (url: string): boolean =>
|
export const isAuthenticatedMediaUrl = (url: string): boolean =>
|
||||||
url.includes('/_matrix/client/v1/media/download') ||
|
url.includes('/_matrix/client/v1/media/download') ||
|
||||||
url.includes('/_matrix/client/v1/media/thumbnail');
|
url.includes('/_matrix/client/v1/media/thumbnail') ||
|
||||||
|
url.includes('/_matrix/media/') &&
|
||||||
|
(url.includes('/download/') || url.includes('/thumbnail/'));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads media with optional authentication.
|
* Downloads media with optional authentication.
|
||||||
|
|||||||
@@ -153,21 +153,30 @@ export function extractColorFromPNG(imageData: ArrayBuffer | Uint8Array): string
|
|||||||
// Verify PNG signature
|
// Verify PNG signature
|
||||||
for (let i = 0; i < 8; i++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
if (data[i] !== PNG_SIGNATURE[i]) {
|
if (data[i] !== PNG_SIGNATURE[i]) {
|
||||||
|
console.warn('[extractColorFromPNG] Not a valid PNG');
|
||||||
return undefined; // Not a PNG
|
return undefined; // Not a PNG
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunks = parseChunks(data);
|
const chunks = parseChunks(data);
|
||||||
|
console.log('[extractColorFromPNG] Found', chunks.length, 'chunks');
|
||||||
|
|
||||||
|
let textChunksFound = 0;
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
if (chunk.type === 'tEXt') {
|
if (chunk.type === 'tEXt') {
|
||||||
|
textChunksFound++;
|
||||||
const text = parseTextChunk(chunk.data);
|
const text = parseTextChunk(chunk.data);
|
||||||
if (text && text.key === PAARROT_COLOR_KEY) {
|
if (text) {
|
||||||
return text.value;
|
console.log('[extractColorFromPNG] Found tEXt chunk:', text.key, '=', text.value);
|
||||||
|
if (text.key === PAARROT_COLOR_KEY) {
|
||||||
|
console.log('[extractColorFromPNG] Found paarrot:color =', text.value);
|
||||||
|
return text.value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[extractColorFromPNG] No paarrot:color found (found', textChunksFound, 'tEXt chunks)');
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,16 +223,21 @@ export function extractMetadataFromPNG(imageData: ArrayBuffer | Uint8Array): Ima
|
|||||||
// Verify PNG signature
|
// Verify PNG signature
|
||||||
for (let i = 0; i < 8; i++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
if (data[i] !== PNG_SIGNATURE[i]) {
|
if (data[i] !== PNG_SIGNATURE[i]) {
|
||||||
|
console.warn('[extractMetadataFromPNG] Not a valid PNG');
|
||||||
return metadata; // Not a PNG
|
return metadata; // Not a PNG
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunks = parseChunks(data);
|
const chunks = parseChunks(data);
|
||||||
|
console.log('[extractMetadataFromPNG] Found', chunks.length, 'chunks');
|
||||||
|
|
||||||
|
let textChunksFound = 0;
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
if (chunk.type === 'tEXt') {
|
if (chunk.type === 'tEXt') {
|
||||||
|
textChunksFound++;
|
||||||
const text = parseTextChunk(chunk.data);
|
const text = parseTextChunk(chunk.data);
|
||||||
if (text) {
|
if (text) {
|
||||||
|
console.log('[extractMetadataFromPNG] Found tEXt chunk:', text.key, '=', text.value);
|
||||||
if (text.key === PAARROT_COLOR_KEY) {
|
if (text.key === PAARROT_COLOR_KEY) {
|
||||||
metadata.color = text.value;
|
metadata.color = text.value;
|
||||||
} else if (text.key === PAARROT_BANNER_KEY) {
|
} else if (text.key === PAARROT_BANNER_KEY) {
|
||||||
@@ -241,6 +255,8 @@ export function extractMetadataFromPNG(imageData: ArrayBuffer | Uint8Array): Ima
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[extractMetadataFromPNG] Final metadata extracted:', metadata, 'found', textChunksFound, 'tEXt chunks');
|
||||||
|
|
||||||
return metadata;
|
return metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user