Compare commits
6 Commits
0da4f0d6cb
...
e08b4ec22e
| Author | SHA1 | Date | |
|---|---|---|---|
| e08b4ec22e | |||
| 13523fea2b | |||
| 8a68a1e30a | |||
| 32bf2cbed5 | |||
| 17706ae019 | |||
| 82b22fa739 |
53
.gitea/workflows/trigger-mobile.yml
Normal file
53
.gitea/workflows/trigger-mobile.yml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
name: Trigger cinny-mobile
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
dispatch:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ !contains(github.event.head_commit.message || '', '[skip mobile]') }}
|
||||||
|
steps:
|
||||||
|
- name: Dispatch update-submodule on cinny-mobile
|
||||||
|
env:
|
||||||
|
# Prefer a PAT with write:repository on cinny-mobile (secret: ACTIONS_TOKEN).
|
||||||
|
# Default GITHUB_TOKEN is usually scoped to this repo only.
|
||||||
|
ACTIONS_TOKEN: ${{ secrets.ACTIONS_TOKEN }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
API_BASE: ${{ github.server_url }}/api/v1
|
||||||
|
CINNY_SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TOKEN="${ACTIONS_TOKEN:-${GITHUB_TOKEN:-}}"
|
||||||
|
if [ -z "${TOKEN}" ]; then
|
||||||
|
echo "No ACTIONS_TOKEN/GITHUB_TOKEN available to dispatch cinny-mobile"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BODY=$(jq -n \
|
||||||
|
--arg ref "master" \
|
||||||
|
--arg sha "${CINNY_SHA}" \
|
||||||
|
'{ref: $ref, inputs: {cinny_sha: $sha}}')
|
||||||
|
|
||||||
|
echo "Dispatching litruv/cinny-mobile update-submodule.yml for ${CINNY_SHA}"
|
||||||
|
HTTP_CODE=$(curl -sS -o /tmp/dispatch-body.txt -w "%{http_code}" \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "${BODY}" \
|
||||||
|
"${API_BASE}/repos/litruv/cinny-mobile/actions/workflows/update-submodule.yml/dispatches?return_run_details=true")
|
||||||
|
|
||||||
|
echo "HTTP ${HTTP_CODE}"
|
||||||
|
cat /tmp/dispatch-body.txt || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
case "${HTTP_CODE}" in
|
||||||
|
200|201|204) echo "Dispatch accepted" ;;
|
||||||
|
*)
|
||||||
|
echo "Dispatch failed"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -55,6 +55,13 @@ curl -X POST http://127.0.0.1:33384/message/current \
|
|||||||
| `/channels` | GET | Get list of rooms |
|
| `/channels` | GET | Get list of rooms |
|
||||||
| `/channel` | POST | Switch to a room |
|
| `/channel` | POST | Switch to a room |
|
||||||
| `/message/current` | POST | Send message to current room |
|
| `/message/current` | POST | Send message to current room |
|
||||||
|
| `/room/current` | GET | Get current room info |
|
||||||
|
| `/messages/groups` | GET | Last N messages across groups (`?limit=10`) |
|
||||||
|
| `/messages/dms` | GET | Last N messages across DMs (`?limit=10`) |
|
||||||
|
| `/messages/combined` | GET | Last N messages across groups + DMs (`?limit=10`) |
|
||||||
|
| `/unreads/groups` | GET | Unread group conversations |
|
||||||
|
| `/unreads/dms` | GET | Unread DM conversations |
|
||||||
|
| `/unreads/combined` | GET | Unread groups + DMs |
|
||||||
|
|
||||||
## 🎮 Stream Deck Integration
|
## 🎮 Stream Deck Integration
|
||||||
|
|
||||||
|
|||||||
117
docs/API.md
117
docs/API.md
@@ -280,6 +280,123 @@ Get information about the currently active room.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Get Recent Messages (Groups)
|
||||||
|
|
||||||
|
Get the most recent messages across joined group channels (non-DMs).
|
||||||
|
|
||||||
|
**Endpoint**: `GET /messages/groups`
|
||||||
|
|
||||||
|
**Query Parameters**:
|
||||||
|
- `limit` (optional) — number of messages to return (1–100, default `10`)
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"scope": "groups",
|
||||||
|
"limit": 10,
|
||||||
|
"count": 2,
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"eventId": "$event1",
|
||||||
|
"roomId": "!abc123:matrix.org",
|
||||||
|
"roomName": "General",
|
||||||
|
"isDirect": false,
|
||||||
|
"sender": "@alice:matrix.org",
|
||||||
|
"senderName": "Alice",
|
||||||
|
"timestamp": 1710000000000,
|
||||||
|
"msgtype": "m.text",
|
||||||
|
"body": "Hello!"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Recent Messages (DMs)
|
||||||
|
|
||||||
|
Get the most recent messages across joined direct messages.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /messages/dms`
|
||||||
|
|
||||||
|
**Query Parameters**:
|
||||||
|
- `limit` (optional) — number of messages to return (1–100, default `10`)
|
||||||
|
|
||||||
|
**Response**: same shape as groups, with `"scope": "dms"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Recent Messages (Combined)
|
||||||
|
|
||||||
|
Get the most recent messages across groups and DMs together.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /messages/combined`
|
||||||
|
|
||||||
|
**Query Parameters**:
|
||||||
|
- `limit` (optional) — number of messages to return (1–100, default `10`)
|
||||||
|
|
||||||
|
**Response**: same shape as groups, with `"scope": "combined"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Unread Conversations (Groups)
|
||||||
|
|
||||||
|
List joined group channels that currently have unread activity.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /unreads/groups`
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"scope": "groups",
|
||||||
|
"count": 1,
|
||||||
|
"unreads": [
|
||||||
|
{
|
||||||
|
"roomId": "!abc123:matrix.org",
|
||||||
|
"name": "General",
|
||||||
|
"isDirect": false,
|
||||||
|
"avatar": "mxc://matrix.org/abc123",
|
||||||
|
"total": 3,
|
||||||
|
"highlight": 1,
|
||||||
|
"latest": {
|
||||||
|
"eventId": "$event1",
|
||||||
|
"sender": "@alice:matrix.org",
|
||||||
|
"senderName": "Alice",
|
||||||
|
"timestamp": 1710000000000,
|
||||||
|
"msgtype": "m.text",
|
||||||
|
"body": "Hello!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Muted rooms are excluded. Sorted by latest activity, then highlight/total counts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Unread Conversations (DMs)
|
||||||
|
|
||||||
|
**Endpoint**: `GET /unreads/dms`
|
||||||
|
|
||||||
|
**Response**: same shape as groups, with `"scope": "dms"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Unread Conversations (Combined)
|
||||||
|
|
||||||
|
**Endpoint**: `GET /unreads/combined`
|
||||||
|
|
||||||
|
**Response**: same shape as groups, with `"scope": "combined"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
All endpoints return error responses in the following format:
|
All endpoints return error responses in the following format:
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ New feature? Copy [`handoff/TEMPLATE.md`](./handoff/TEMPLATE.md) into `handoff/f
|
|||||||
| Sub-rooms | [sub-rooms](./handoff/features/sub-rooms/HANDOFF.md) |
|
| Sub-rooms | [sub-rooms](./handoff/features/sub-rooms/HANDOFF.md) |
|
||||||
| Lobby (space card view) | [lobby-forums](./handoff/features/lobby-forums/HANDOFF.md) |
|
| Lobby (space card view) | [lobby-forums](./handoff/features/lobby-forums/HANDOFF.md) |
|
||||||
| Forum spaces (post feed UI) | [forum](./handoff/features/forum/HANDOFF.md) |
|
| Forum spaces (post feed UI) | [forum](./handoff/features/forum/HANDOFF.md) |
|
||||||
|
| Room App UI (bot-driven) | [room-app-ui](./handoff/features/room-app-ui/HANDOFF.md) |
|
||||||
|
|
||||||
### Settings & customization
|
### Settings & customization
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ No persistent storage. Actions read/write ephemeral app state (current room, cal
|
|||||||
| `send-message` | `{ roomId, message }` | send result |
|
| `send-message` | `{ roomId, message }` | send result |
|
||||||
| `send-message-current` | `{ message }` | send to active room |
|
| `send-message-current` | `{ message }` | send to active room |
|
||||||
| `get-current-room` | — | current room id |
|
| `get-current-room` | — | current room id |
|
||||||
|
| `get-messages-groups` | `{ limit? }` | recent messages across group channels |
|
||||||
|
| `get-messages-dms` | `{ limit? }` | recent messages across DMs |
|
||||||
|
| `get-messages-combined` | `{ limit? }` | recent messages across groups + DMs |
|
||||||
|
| `get-unreads-groups` | — | unread group conversations |
|
||||||
|
| `get-unreads-dms` | — | unread DM conversations |
|
||||||
|
| `get-unreads-combined` | — | unread groups + DMs |
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
|
|||||||
93
docs/handoff/features/room-app-ui/HANDOFF.md
Normal file
93
docs/handoff/features/room-app-ui/HANDOFF.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Room App UI — handoff
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Bots can publish a declarative UI into Matrix room state (`im.paarrot.ui`). Paarrot takes over the room view with that UI. User interactions are sent as timeline events (`im.paarrot.ui.action`), not chat messages. The bot updates state to re-render panels.
|
||||||
|
|
||||||
|
## User-facing behavior
|
||||||
|
|
||||||
|
- When a room has a valid `im.paarrot.ui` state event, opening the room shows the app UI instead of the chat timeline (forum rooms still win over app UI).
|
||||||
|
- **Show chat** returns to the normal timeline without clearing bot state; a banner offers **Show app** to go back.
|
||||||
|
- Action events are hidden from the chat timeline (same as confetti relay events).
|
||||||
|
- Room settings → Permissions includes **Set Room App UI** and **Send Room App Actions**.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Bot → state im.paarrot.ui
|
||||||
|
→ Room.tsx detects via useStateEvent
|
||||||
|
→ RoomAppView → RoomAppSchema (declarative nodes)
|
||||||
|
→ user clicks → mx.sendEvent(im.paarrot.ui.action)
|
||||||
|
→ Bot updates im.paarrot.ui → live re-render
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
| Path | Role |
|
||||||
|
|------|------|
|
||||||
|
| `cinny/src/types/matrix/room.ts` | `StateEvent.PaarrotUi`, `MessageEvent.PaarrotUiAction`, node types |
|
||||||
|
| `cinny/src/app/utils/room.ts` | `getPaarrotUiContent`, `hasRoomAppUi` |
|
||||||
|
| `cinny/src/app/features/room/Room.tsx` | Takeover branch + Show chat / Show app |
|
||||||
|
| `cinny/src/app/features/room-app/` | `RoomAppView`, schema renderer, CSS sanitize/scope |
|
||||||
|
| `cinny/src/app/features/room/RoomTimeline.tsx` | Hides `im.paarrot.ui.action` |
|
||||||
|
| `cinny/src/app/features/room-settings/permissions/usePermissionItems.ts` | Permission labels |
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
| Event | Kind | Content |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `im.paarrot.ui` | state (`""`) | `{ version, title?, css?, root }` |
|
||||||
|
| `im.paarrot.ui.action` | timeline | `{ action, component_id, value?, values?, ui_event_id? }` |
|
||||||
|
|
||||||
|
See [samples/](./samples/) for pasteable Developer Tools payloads.
|
||||||
|
|
||||||
|
MVP node types: `panel`, `row`, `text`, `button`, `input`, `select`, `image`, `spacer`.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- folds UI primitives
|
||||||
|
- matrix-js-sdk `sendEvent` / state sync
|
||||||
|
- No bot-supplied JavaScript; CSS is sanitized and scoped under `[data-room-app]`
|
||||||
|
|
||||||
|
## Integration points
|
||||||
|
|
||||||
|
- Same room takeover pattern as forums (`Room.tsx` branch)
|
||||||
|
- Developer Tools can send state/events for demos without a bot process
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Manual
|
||||||
|
|
||||||
|
1. In Developer Tools → Send State Event, type `im.paarrot.ui`, paste `samples/trivia-ui-state.json`.
|
||||||
|
2. Open the room — app UI should replace the timeline.
|
||||||
|
3. Click an answer — an `im.paarrot.ui.action` event is sent (hidden in timeline; visible in raw/dev tools).
|
||||||
|
4. Click **Show chat**, then **Show app**.
|
||||||
|
5. Clear state content / redact state to restore normal chat.
|
||||||
|
|
||||||
|
### Automated
|
||||||
|
|
||||||
|
- None yet
|
||||||
|
|
||||||
|
## Known issues & gotchas
|
||||||
|
|
||||||
|
- CSS scoping is best-effort, not a full CSS parser
|
||||||
|
- Images allow `mxc://`, `https://`, and `http://` only
|
||||||
|
- Forum rooms take priority over app UI
|
||||||
|
- `sendEvent(..., as any)` cast mirrors other custom event types
|
||||||
|
|
||||||
|
## Future work
|
||||||
|
|
||||||
|
- `form` / `tabs` / `markdown` / `progress` nodes
|
||||||
|
- Bot SDK helpers
|
||||||
|
- Stronger CSS sandbox (Shadow DOM)
|
||||||
|
- Encrypt custom action payloads helpers if needed
|
||||||
|
|
||||||
|
## Related docs
|
||||||
|
|
||||||
|
- [samples/trivia-ui-state.json](./samples/trivia-ui-state.json)
|
||||||
|
- [samples/ui-action-event.json](./samples/ui-action-event.json)
|
||||||
|
|
||||||
|
## Add your extra things here
|
||||||
|
|
||||||
|
- Power level: bots need PL ≥ `state_default` (usually 50) or an explicit level for `im.paarrot.ui`
|
||||||
|
- Action events use normal message event power levels unless overridden for `im.paarrot.ui.action`
|
||||||
34
docs/handoff/features/room-app-ui/samples/poll-ui-state.json
Normal file
34
docs/handoff/features/room-app-ui/samples/poll-ui-state.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"title": "Poll Booth",
|
||||||
|
"root": {
|
||||||
|
"type": "panel",
|
||||||
|
"id": "poll",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "prompt",
|
||||||
|
"text": "Where should we go for lunch?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "select",
|
||||||
|
"id": "choice",
|
||||||
|
"name": "choice",
|
||||||
|
"label": "Your vote",
|
||||||
|
"options": [
|
||||||
|
{ "label": "Ramen", "value": "ramen" },
|
||||||
|
{ "label": "Pizza", "value": "pizza" },
|
||||||
|
{ "label": "Salad", "value": "salad" }
|
||||||
|
],
|
||||||
|
"value": "ramen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "button",
|
||||||
|
"id": "vote",
|
||||||
|
"label": "Submit vote",
|
||||||
|
"action": "vote",
|
||||||
|
"variant": "Primary"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"title": "Trivia Night",
|
||||||
|
"css": ".score { font-weight: 700; } .hint { opacity: 0.75; }",
|
||||||
|
"root": {
|
||||||
|
"type": "panel",
|
||||||
|
"id": "main",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "q",
|
||||||
|
"text": "Capital of France?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "hint",
|
||||||
|
"className": "hint",
|
||||||
|
"text": "Pick one answer below."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "spacer",
|
||||||
|
"id": "sp1",
|
||||||
|
"size": 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "row",
|
||||||
|
"id": "answers",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "button",
|
||||||
|
"id": "a",
|
||||||
|
"label": "Paris",
|
||||||
|
"action": "answer",
|
||||||
|
"value": "paris",
|
||||||
|
"variant": "Primary"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "button",
|
||||||
|
"id": "b",
|
||||||
|
"label": "Lyon",
|
||||||
|
"action": "answer",
|
||||||
|
"value": "lyon",
|
||||||
|
"variant": "Secondary"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "button",
|
||||||
|
"id": "c",
|
||||||
|
"label": "Marseille",
|
||||||
|
"action": "answer",
|
||||||
|
"value": "marseille",
|
||||||
|
"variant": "Secondary"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "spacer",
|
||||||
|
"id": "sp2",
|
||||||
|
"size": 24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "score_label",
|
||||||
|
"className": "score",
|
||||||
|
"text": "Score: 0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "input",
|
||||||
|
"id": "nick",
|
||||||
|
"name": "nickname",
|
||||||
|
"label": "Display name on the board",
|
||||||
|
"placeholder": "Optional nickname"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "row",
|
||||||
|
"id": "submit_row",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "button",
|
||||||
|
"id": "join",
|
||||||
|
"label": "Join board",
|
||||||
|
"action": "join",
|
||||||
|
"variant": "Success"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"action": "answer",
|
||||||
|
"component_id": "a",
|
||||||
|
"value": "paris",
|
||||||
|
"values": {
|
||||||
|
"nickname": "Ada"
|
||||||
|
},
|
||||||
|
"ui_event_id": "$REPLACE_WITH_CURRENT_im.paarrot.ui_EVENT_ID"
|
||||||
|
}
|
||||||
@@ -1,42 +1,127 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { DefaultReset, color, config } from 'folds';
|
import { DefaultReset } from 'folds';
|
||||||
|
|
||||||
export const ImageViewer = style([
|
export const Root = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
height: '100%',
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 200,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 16,
|
||||||
|
boxSizing: 'border-box',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ImageViewerHeader = style([
|
export const Backdrop = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
paddingLeft: config.space.S200,
|
position: 'absolute',
|
||||||
paddingRight: config.space.S200,
|
inset: 0,
|
||||||
borderBottomWidth: config.borderWidth.B300,
|
backgroundColor: 'rgba(0, 0, 0, 0.72)',
|
||||||
flexShrink: 0,
|
backdropFilter: 'blur(18px)',
|
||||||
gap: config.space.S200,
|
WebkitBackdropFilter: 'blur(18px)',
|
||||||
|
cursor: 'zoom-out',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ImageViewerContent = style([
|
export const Stage = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
backgroundColor: color.Background.Container,
|
position: 'relative',
|
||||||
color: color.Background.OnContainer,
|
zIndex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
gap: 10,
|
||||||
|
maxWidth: 'min(96vw, 1100px)',
|
||||||
|
maxHeight: 'min(92vh, 900px)',
|
||||||
|
width: '100%',
|
||||||
|
minWidth: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const Chrome = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
width: '100%',
|
||||||
|
minWidth: 0,
|
||||||
|
color: '#fff',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const Title = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: '18px',
|
||||||
|
color: 'rgba(255, 255, 255, 0.9)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ImageViewerImg = style([
|
export const ChromeButton = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
objectFit: 'contain',
|
display: 'inline-flex',
|
||||||
width: 'auto',
|
alignItems: 'center',
|
||||||
height: 'auto',
|
justifyContent: 'center',
|
||||||
maxWidth: '100%',
|
flexShrink: 0,
|
||||||
maxHeight: '100%',
|
padding: 6,
|
||||||
backgroundColor: color.Surface.Container,
|
border: 'none',
|
||||||
transition: 'transform 100ms linear',
|
borderRadius: 4,
|
||||||
|
background: 'rgba(0, 0, 0, 0.4)',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const Frame = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'relative',
|
||||||
|
alignSelf: 'center',
|
||||||
|
maxWidth: 'min(96vw, 1100px)',
|
||||||
|
maxHeight: 'min(80vh, 820px)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
cursor: 'zoom-in',
|
||||||
|
touchAction: 'none',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
selectors: {
|
||||||
|
'&[data-zoomed]': {
|
||||||
|
cursor: 'zoom-out',
|
||||||
|
overflow: 'visible',
|
||||||
|
},
|
||||||
|
'&[data-pannable]': {
|
||||||
|
cursor: 'grab',
|
||||||
|
},
|
||||||
|
'&[data-dragging]': {
|
||||||
|
cursor: 'grabbing',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const Image = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
display: 'block',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'transparent',
|
||||||
|
transform: 'translate(var(--img-tx, 0px), var(--img-ty, 0px)) scale(var(--img-zoom, 1))',
|
||||||
|
transformOrigin: '0 0',
|
||||||
|
userSelect: 'none',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,16 +1,62 @@
|
|||||||
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
||||||
import React from 'react';
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import FileSaver from 'file-saver';
|
import FileSaver from 'file-saver';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Box, Chip, Header, IconButton, Text, as } from 'folds';
|
import { as } from 'folds';
|
||||||
import { Icon, Icons } from '../icons';
|
import { Icon, Icons } from '../icons';
|
||||||
import * as css from './ImageViewer.css';
|
import * as css from './ImageViewer.css';
|
||||||
import { useZoom } from '../../hooks/useZoom';
|
|
||||||
import { usePan } from '../../hooks/usePan';
|
|
||||||
import { downloadMedia } from '../../utils/matrix';
|
import { downloadMedia } from '../../utils/matrix';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
||||||
import { getCurrentAccessToken } from '../../utils/auth';
|
import { getCurrentAccessToken } from '../../utils/auth';
|
||||||
|
|
||||||
|
const ZOOM_MIN = 1;
|
||||||
|
const ZOOM_MAX = 5;
|
||||||
|
const ZOOM_CLICK = 2.5;
|
||||||
|
const DRAG_CLICK_SLOP = 6;
|
||||||
|
|
||||||
|
type ZoomState = {
|
||||||
|
scale: number;
|
||||||
|
tx: number;
|
||||||
|
ty: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PinchSession = {
|
||||||
|
startDist: number;
|
||||||
|
startScale: number;
|
||||||
|
startTx: number;
|
||||||
|
startTy: number;
|
||||||
|
startMidX: number;
|
||||||
|
startMidY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INITIAL_ZOOM: ZoomState = { scale: 1, tx: 0, ty: 0 };
|
||||||
|
|
||||||
|
function imageViewportMax() {
|
||||||
|
return {
|
||||||
|
w: Math.min(window.innerWidth * 0.96, 1100),
|
||||||
|
h: Math.min(window.innerHeight * 0.8, 820),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerDistance(
|
||||||
|
a: { x: number; y: number },
|
||||||
|
b: { x: number; y: number }
|
||||||
|
): number {
|
||||||
|
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerMidpoint(
|
||||||
|
a: { x: number; y: number },
|
||||||
|
b: { x: number; y: number }
|
||||||
|
): { x: number; y: number } {
|
||||||
|
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
export type ImageViewerProps = {
|
export type ImageViewerProps = {
|
||||||
alt: string;
|
alt: string;
|
||||||
src: string;
|
src: string;
|
||||||
@@ -19,18 +65,215 @@ export type ImageViewerProps = {
|
|||||||
|
|
||||||
export const ImageViewer = as<'div', ImageViewerProps>(
|
export const ImageViewer = as<'div', ImageViewerProps>(
|
||||||
({ className, alt, src, requestClose, ...props }, ref) => {
|
({ className, alt, src, requestClose, ...props }, ref) => {
|
||||||
const mx = useMatrixClient();
|
const frameRef = useRef<HTMLDivElement>(null);
|
||||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
const zoomRef = useRef<ZoomState>({ ...INITIAL_ZOOM });
|
||||||
|
const pointersRef = useRef<Map<number, { x: number; y: number }>>(new Map());
|
||||||
|
const dragRef = useRef<{
|
||||||
|
id: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
tx: number;
|
||||||
|
ty: number;
|
||||||
|
moved: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
const pinchRef = useRef<PinchSession | null>(null);
|
||||||
|
/** Skip the next click after a pinch so we don't toggle zoom. */
|
||||||
|
const suppressClickRef = useRef(false);
|
||||||
|
const baseSizeRef = useRef({ w: 0, h: 0 });
|
||||||
|
|
||||||
|
const [zoom, setZoom] = useState<ZoomState>(INITIAL_ZOOM);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
|
const applyZoomCss = useCallback((next: ZoomState) => {
|
||||||
|
const img = imgRef.current;
|
||||||
|
if (!img) return;
|
||||||
|
img.style.setProperty('--img-zoom', String(next.scale));
|
||||||
|
img.style.setProperty('--img-tx', `${next.tx}px`);
|
||||||
|
img.style.setProperty('--img-ty', `${next.ty}px`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clampPan = useCallback((state: ZoomState): ZoomState => {
|
||||||
|
const img = imgRef.current;
|
||||||
|
const frame = frameRef.current;
|
||||||
|
if (!img || !frame) return state;
|
||||||
|
|
||||||
|
const baseW = img.offsetWidth || baseSizeRef.current.w;
|
||||||
|
const baseH = img.offsetHeight || baseSizeRef.current.h;
|
||||||
|
const frameW = frame.clientWidth;
|
||||||
|
const frameH = frame.clientHeight;
|
||||||
|
if (!baseW || !baseH || !frameW || !frameH) return state;
|
||||||
|
|
||||||
|
const scaledW = baseW * state.scale;
|
||||||
|
const scaledH = baseH * state.scale;
|
||||||
|
let { tx, ty } = state;
|
||||||
|
|
||||||
|
if (scaledW <= frameW) tx = (frameW - scaledW) / 2;
|
||||||
|
else tx = Math.min(0, Math.max(frameW - scaledW, tx));
|
||||||
|
|
||||||
|
if (scaledH <= frameH) ty = (frameH - scaledH) / 2;
|
||||||
|
else ty = Math.min(0, Math.max(frameH - scaledH, ty));
|
||||||
|
|
||||||
|
return { ...state, tx, ty };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const commitZoom = useCallback(
|
||||||
|
(next: ZoomState) => {
|
||||||
|
const clamped = clampPan(next);
|
||||||
|
zoomRef.current = clamped;
|
||||||
|
applyZoomCss(clamped);
|
||||||
|
setZoom(clamped);
|
||||||
|
},
|
||||||
|
[applyZoomCss, clampPan]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetZoom = useCallback(() => {
|
||||||
|
dragRef.current = null;
|
||||||
|
pinchRef.current = null;
|
||||||
|
setDragging(false);
|
||||||
|
commitZoom({ ...INITIAL_ZOOM });
|
||||||
|
}, [commitZoom]);
|
||||||
|
|
||||||
|
const fitImage = useCallback(() => {
|
||||||
|
const img = imgRef.current;
|
||||||
|
if (!img) return;
|
||||||
|
const iw = img.naturalWidth || 0;
|
||||||
|
const ih = img.naturalHeight || 0;
|
||||||
|
if (iw <= 0 || ih <= 0) return;
|
||||||
|
|
||||||
|
const { w: maxW, h: maxH } = imageViewportMax();
|
||||||
|
const s = Math.min(1, maxW / iw, maxH / ih);
|
||||||
|
const w = Math.max(1, Math.round(iw * s));
|
||||||
|
const h = Math.max(1, Math.round(ih * s));
|
||||||
|
img.style.width = `${w}px`;
|
||||||
|
img.style.height = `${h}px`;
|
||||||
|
baseSizeRef.current = { w, h };
|
||||||
|
commitZoom({ ...INITIAL_ZOOM });
|
||||||
|
}, [commitZoom]);
|
||||||
|
|
||||||
|
const zoomAround = useCallback(
|
||||||
|
(px: number, py: number, newScale: number) => {
|
||||||
|
const z = zoomRef.current;
|
||||||
|
const scale = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newScale));
|
||||||
|
const ix = (px - z.tx) / z.scale;
|
||||||
|
const iy = (py - z.ty) / z.scale;
|
||||||
|
commitZoom({
|
||||||
|
scale,
|
||||||
|
tx: px - ix * scale,
|
||||||
|
ty: py - iy * scale,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[commitZoom]
|
||||||
|
);
|
||||||
|
|
||||||
|
const framePoint = (clientX: number, clientY: number) => {
|
||||||
|
const frame = frameRef.current;
|
||||||
|
if (!frame) return { x: 0, y: 0 };
|
||||||
|
const r = frame.getBoundingClientRect();
|
||||||
|
return { x: clientX - r.left, y: clientY - r.top };
|
||||||
|
};
|
||||||
|
|
||||||
|
const beginPinch = useCallback(() => {
|
||||||
|
const pts = [...pointersRef.current.values()];
|
||||||
|
if (pts.length < 2) return;
|
||||||
|
const [a, b] = pts;
|
||||||
|
const mid = pointerMidpoint(a, b);
|
||||||
|
const local = framePoint(mid.x, mid.y);
|
||||||
|
const z = zoomRef.current;
|
||||||
|
pinchRef.current = {
|
||||||
|
startDist: Math.max(1, pointerDistance(a, b)),
|
||||||
|
startScale: z.scale,
|
||||||
|
startTx: z.tx,
|
||||||
|
startTy: z.ty,
|
||||||
|
startMidX: local.x,
|
||||||
|
startMidY: local.y,
|
||||||
|
};
|
||||||
|
dragRef.current = null;
|
||||||
|
setDragging(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updatePinch = useCallback(() => {
|
||||||
|
const pinch = pinchRef.current;
|
||||||
|
const pts = [...pointersRef.current.values()];
|
||||||
|
if (!pinch || pts.length < 2) return;
|
||||||
|
|
||||||
|
const [a, b] = pts;
|
||||||
|
const dist = Math.max(1, pointerDistance(a, b));
|
||||||
|
const mid = pointerMidpoint(a, b);
|
||||||
|
const local = framePoint(mid.x, mid.y);
|
||||||
|
const scale = Math.min(
|
||||||
|
ZOOM_MAX,
|
||||||
|
Math.max(ZOOM_MIN, pinch.startScale * (dist / pinch.startDist))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep the original content under the pinch midpoint while scaling + panning.
|
||||||
|
const ix = (pinch.startMidX - pinch.startTx) / pinch.startScale;
|
||||||
|
const iy = (pinch.startMidY - pinch.startTy) / pinch.startScale;
|
||||||
|
commitZoom({
|
||||||
|
scale,
|
||||||
|
tx: local.x - ix * scale,
|
||||||
|
ty: local.y - iy * scale,
|
||||||
|
});
|
||||||
|
suppressClickRef.current = true;
|
||||||
|
}, [commitZoom]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
resetZoom();
|
||||||
|
}, [src, resetZoom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
requestClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
|
}, [requestClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const frame = frameRef.current;
|
||||||
|
if (!frame) return undefined;
|
||||||
|
|
||||||
|
const onWheel = (event: WheelEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const img = imgRef.current;
|
||||||
|
if (!img?.src) return;
|
||||||
|
const factor = Math.exp(-event.deltaY * 0.002);
|
||||||
|
const r = frame.getBoundingClientRect();
|
||||||
|
const x = event.clientX - r.left;
|
||||||
|
const y = event.clientY - r.top;
|
||||||
|
zoomAround(x, y, zoomRef.current.scale * factor);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Block browser gesture zoom / scroll while pinching on the frame.
|
||||||
|
const blockGesture = (event: Event) => {
|
||||||
|
if (pointersRef.current.size >= 2 || pinchRef.current) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
frame.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
frame.addEventListener('touchmove', blockGesture, { passive: false });
|
||||||
|
return () => {
|
||||||
|
frame.removeEventListener('wheel', onWheel);
|
||||||
|
frame.removeEventListener('touchmove', blockGesture);
|
||||||
|
};
|
||||||
|
}, [zoomAround]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onResize = () => fitImage();
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
return () => window.removeEventListener('resize', onResize);
|
||||||
|
}, [fitImage]);
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
try {
|
try {
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const fileContent = await downloadMedia(src, getCurrentAccessToken());
|
const fileContent = await downloadMedia(src, getCurrentAccessToken());
|
||||||
FileSaver.saveAs(fileContent, alt);
|
FileSaver.saveAs(fileContent, alt);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[ImageViewer] Failed to download media:', error);
|
console.warn('[ImageViewer] Failed to download media:', error);
|
||||||
// Fallback: try to fetch via standard fetch as blob
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(src);
|
const response = await fetch(src);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -38,81 +281,171 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||||||
FileSaver.saveAs(blob, alt);
|
FileSaver.saveAs(blob, alt);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// If all else fails, open in new tab to let browser handle it
|
|
||||||
window.open(src, '_blank');
|
window.open(src, '_blank');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClick = (event: React.MouseEvent) => {
|
||||||
|
if (suppressClickRef.current) {
|
||||||
|
suppressClickRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dragRef.current?.moved) {
|
||||||
|
dragRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const img = imgRef.current;
|
||||||
|
if (!img?.src) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const { x, y } = framePoint(event.clientX, event.clientY);
|
||||||
|
if (zoomRef.current.scale <= 1.01) {
|
||||||
|
zoomAround(x, y, ZOOM_CLICK);
|
||||||
|
} else {
|
||||||
|
resetZoom();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerDown = (event: React.PointerEvent) => {
|
||||||
|
if (event.button !== 0 && event.pointerType === 'mouse') return;
|
||||||
|
|
||||||
|
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||||
|
frameRef.current?.setPointerCapture(event.pointerId);
|
||||||
|
|
||||||
|
if (pointersRef.current.size >= 2) {
|
||||||
|
beginPinch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zoomRef.current.scale <= 1.01) return;
|
||||||
|
const z = zoomRef.current;
|
||||||
|
dragRef.current = {
|
||||||
|
id: event.pointerId,
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
tx: z.tx,
|
||||||
|
ty: z.ty,
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
|
setDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (event: React.PointerEvent) => {
|
||||||
|
if (!pointersRef.current.has(event.pointerId)) return;
|
||||||
|
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||||
|
|
||||||
|
if (pointersRef.current.size >= 2 && pinchRef.current) {
|
||||||
|
event.preventDefault();
|
||||||
|
updatePinch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag || drag.id !== event.pointerId) return;
|
||||||
|
const dx = event.clientX - drag.x;
|
||||||
|
const dy = event.clientY - drag.y;
|
||||||
|
if (!drag.moved && Math.hypot(dx, dy) > DRAG_CLICK_SLOP) drag.moved = true;
|
||||||
|
commitZoom({
|
||||||
|
scale: zoomRef.current.scale,
|
||||||
|
tx: drag.tx + dx,
|
||||||
|
ty: drag.ty + dy,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (event: React.PointerEvent) => {
|
||||||
|
pointersRef.current.delete(event.pointerId);
|
||||||
|
frameRef.current?.releasePointerCapture(event.pointerId);
|
||||||
|
|
||||||
|
if (pinchRef.current) {
|
||||||
|
if (pointersRef.current.size >= 2) {
|
||||||
|
beginPinch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pinchRef.current = null;
|
||||||
|
setDragging(false);
|
||||||
|
// Extra Things: one finger left after pinch — hand off to pan if still zoomed.
|
||||||
|
if (pointersRef.current.size === 1 && zoomRef.current.scale > 1.01) {
|
||||||
|
const [id, pt] = [...pointersRef.current.entries()][0];
|
||||||
|
const z = zoomRef.current;
|
||||||
|
dragRef.current = {
|
||||||
|
id,
|
||||||
|
x: pt.x,
|
||||||
|
y: pt.y,
|
||||||
|
tx: z.tx,
|
||||||
|
ty: z.ty,
|
||||||
|
moved: true,
|
||||||
|
};
|
||||||
|
setDragging(true);
|
||||||
|
} else {
|
||||||
|
dragRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag || drag.id !== event.pointerId) return;
|
||||||
|
setDragging(false);
|
||||||
|
if (!drag.moved) dragRef.current = null;
|
||||||
|
else dragRef.current = { ...drag, moved: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomed = zoom.scale > 1.01;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<div
|
||||||
className={classNames(css.ImageViewer, className)}
|
className={classNames(css.Root, className)}
|
||||||
direction="Column"
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={alt || 'Image viewer'}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
<Header className={css.ImageViewerHeader} size="400">
|
<div className={css.Backdrop} onClick={requestClose} aria-hidden />
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
<div className={css.Stage}>
|
||||||
<IconButton size="300" radii="300" onClick={requestClose}>
|
<div className={css.Chrome}>
|
||||||
<Icon size="50" src={Icons.ArrowLeft} />
|
<div className={css.Title} title={alt}>
|
||||||
</IconButton>
|
|
||||||
<Text size="T300" truncate>
|
|
||||||
{alt}
|
{alt}
|
||||||
</Text>
|
</div>
|
||||||
</Box>
|
<button
|
||||||
<Box shrink="No" alignItems="Center" gap="200">
|
type="button"
|
||||||
<IconButton
|
className={css.ChromeButton}
|
||||||
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
|
aria-label="Download"
|
||||||
outlined={zoom < 1}
|
|
||||||
size="300"
|
|
||||||
radii="Pill"
|
|
||||||
onClick={zoomOut}
|
|
||||||
aria-label="Zoom Out"
|
|
||||||
>
|
|
||||||
<Icon size="50" src={Icons.Minus} />
|
|
||||||
</IconButton>
|
|
||||||
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
|
|
||||||
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
|
||||||
</Chip>
|
|
||||||
<IconButton
|
|
||||||
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
|
|
||||||
outlined={zoom > 1}
|
|
||||||
size="300"
|
|
||||||
radii="Pill"
|
|
||||||
onClick={zoomIn}
|
|
||||||
aria-label="Zoom In"
|
|
||||||
>
|
|
||||||
<Icon size="50" src={Icons.Plus} />
|
|
||||||
</IconButton>
|
|
||||||
<Chip
|
|
||||||
variant="Primary"
|
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
radii="300"
|
|
||||||
before={<Icon size="50" src={Icons.Download} />}
|
|
||||||
>
|
>
|
||||||
<Text size="B300">Download</Text>
|
<Icon size="50" src={Icons.Download} />
|
||||||
</Chip>
|
</button>
|
||||||
</Box>
|
<button
|
||||||
</Header>
|
type="button"
|
||||||
<Box
|
className={css.ChromeButton}
|
||||||
grow="Yes"
|
aria-label="Close"
|
||||||
className={css.ImageViewerContent}
|
onClick={requestClose}
|
||||||
justifyContent="Center"
|
>
|
||||||
alignItems="Center"
|
<Icon size="50" src={Icons.Cross} />
|
||||||
>
|
</button>
|
||||||
<img
|
</div>
|
||||||
className={css.ImageViewerImg}
|
<div
|
||||||
style={{
|
ref={frameRef}
|
||||||
cursor,
|
className={css.Frame}
|
||||||
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
|
data-zoomed={zoomed || undefined}
|
||||||
}}
|
data-pannable={zoomed || undefined}
|
||||||
src={src}
|
data-dragging={dragging || undefined}
|
||||||
alt={alt}
|
onClick={handleClick}
|
||||||
draggable={false}
|
onPointerDown={handlePointerDown}
|
||||||
onMouseDown={onMouseDown}
|
onPointerMove={handlePointerMove}
|
||||||
/>
|
onPointerUp={handlePointerUp}
|
||||||
</Box>
|
onPointerCancel={handlePointerUp}
|
||||||
</Box>
|
>
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
className={css.Image}
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
draggable={false}
|
||||||
|
onLoad={fitImage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||||
import { Badge, Box, Button, Chip, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
|
import { Badge, Box, Button, Chip, Overlay, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
|
||||||
import { Icon, Icons } from '../../icons';
|
import { Icon, Icons } from '../../icons';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Blurhash } from 'react-blurhash';
|
import { Blurhash } from 'react-blurhash';
|
||||||
@@ -14,7 +14,6 @@ import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
|
|||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
|
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { ModalWide } from '../../../styles/Modal.css';
|
|
||||||
import { validBlurHash } from '../../../utils/blurHash';
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||||
import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
|
import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
|
||||||
@@ -192,28 +191,21 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
<div className={css.MediaSkeleton} />
|
<div className={css.MediaSkeleton} />
|
||||||
))}
|
))}
|
||||||
{srcState.status === AsyncStatus.Success && (
|
{srcState.status === AsyncStatus.Success && (
|
||||||
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
<Overlay open={viewer} backdrop={null}>
|
||||||
<OverlayCenter>
|
<FocusTrap
|
||||||
<FocusTrap
|
focusTrapOptions={{
|
||||||
focusTrapOptions={{
|
initialFocus: false,
|
||||||
initialFocus: false,
|
onDeactivate: () => setViewer(false),
|
||||||
onDeactivate: () => setViewer(false),
|
clickOutsideDeactivates: true,
|
||||||
clickOutsideDeactivates: true,
|
escapeDeactivates: stopPropagation,
|
||||||
escapeDeactivates: stopPropagation,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{renderViewer({
|
||||||
<Modal
|
src: srcState.data,
|
||||||
className={ModalWide}
|
alt: body,
|
||||||
size="500"
|
requestClose: () => setViewer(false),
|
||||||
>
|
})}
|
||||||
{renderViewer({
|
</FocusTrap>
|
||||||
src: srcState.data,
|
|
||||||
alt: body,
|
|
||||||
requestClose: () => setViewer(false),
|
|
||||||
})}
|
|
||||||
</Modal>
|
|
||||||
</FocusTrap>
|
|
||||||
</OverlayCenter>
|
|
||||||
</Overlay>
|
</Overlay>
|
||||||
)}
|
)}
|
||||||
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (
|
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Avatar, Box, Modal, Overlay, OverlayBackdrop, OverlayCenter, Text, toRem } from 'folds';
|
import { Avatar, Box, Overlay, Text, toRem } from 'folds';
|
||||||
import { Icon, Icons } from '../icons';
|
import { Icon, Icons } from '../icons';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
@@ -87,25 +87,21 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
</AvatarPresence>
|
</AvatarPresence>
|
||||||
{viewAvatar && (
|
{viewAvatar && (
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<Overlay open backdrop={null}>
|
||||||
<OverlayCenter>
|
<FocusTrap
|
||||||
<FocusTrap
|
focusTrapOptions={{
|
||||||
focusTrapOptions={{
|
initialFocus: false,
|
||||||
initialFocus: false,
|
onDeactivate: () => setViewAvatar(undefined),
|
||||||
onDeactivate: () => setViewAvatar(undefined),
|
clickOutsideDeactivates: true,
|
||||||
clickOutsideDeactivates: true,
|
escapeDeactivates: stopPropagation,
|
||||||
escapeDeactivates: stopPropagation,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<ImageViewer
|
||||||
<Modal size="500" onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}>
|
src={viewAvatar}
|
||||||
<ImageViewer
|
alt={userId}
|
||||||
src={viewAvatar}
|
requestClose={() => setViewAvatar(undefined)}
|
||||||
alt={userId}
|
/>
|
||||||
requestClose={() => setViewAvatar(undefined)}
|
</FocusTrap>
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
</FocusTrap>
|
|
||||||
</OverlayCenter>
|
|
||||||
</Overlay>
|
</Overlay>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
205
src/app/features/room-app/RoomAppSchema.tsx
Normal file
205
src/app/features/room-app/RoomAppSchema.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { Box, Button, Input, Text, config } from 'folds';
|
||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import {
|
||||||
|
PaarrotUiActionContent,
|
||||||
|
PaarrotUiButtonNode,
|
||||||
|
PaarrotUiContent,
|
||||||
|
PaarrotUiNode,
|
||||||
|
} from '../../../types/matrix/room';
|
||||||
|
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||||
|
import { isAllowedRoomAppImageSrc, scopeRoomAppCss } from './sanitizeRoomAppCss';
|
||||||
|
import * as css from './RoomAppView.css';
|
||||||
|
|
||||||
|
const SCOPE = '[data-room-app]';
|
||||||
|
|
||||||
|
type RoomAppSchemaProps = {
|
||||||
|
mx: MatrixClient;
|
||||||
|
content: PaarrotUiContent;
|
||||||
|
uiEventId?: string;
|
||||||
|
useAuthentication: boolean;
|
||||||
|
onAction: (payload: PaarrotUiActionContent) => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fieldName(node: { id: string; name?: string }): string {
|
||||||
|
return node.name || node.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function spacerSize(size?: number): 'sm' | 'md' | 'lg' {
|
||||||
|
if (typeof size === 'number' && size <= 8) return 'sm';
|
||||||
|
if (typeof size === 'number' && size >= 24) return 'lg';
|
||||||
|
return 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoomAppSchema({
|
||||||
|
mx,
|
||||||
|
content,
|
||||||
|
uiEventId,
|
||||||
|
useAuthentication,
|
||||||
|
onAction,
|
||||||
|
}: RoomAppSchemaProps) {
|
||||||
|
const [values, setValues] = useState<Record<string, string>>({});
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
|
const scopedCss = useMemo(
|
||||||
|
() => (content.css ? scopeRoomAppCss(content.css, SCOPE) : ''),
|
||||||
|
[content.css]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setField = useCallback((name: string, value: string) => {
|
||||||
|
setValues((prev) => ({ ...prev, [name]: value }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleButton = useCallback(
|
||||||
|
async (node: PaarrotUiButtonNode) => {
|
||||||
|
if (node.disabled || sending) return;
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
await onAction({
|
||||||
|
action: node.action,
|
||||||
|
component_id: node.id,
|
||||||
|
value: node.value,
|
||||||
|
values: { ...values },
|
||||||
|
ui_event_id: uiEventId,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onAction, sending, uiEventId, values]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderNode = (node: PaarrotUiNode): React.ReactNode => {
|
||||||
|
switch (node.type) {
|
||||||
|
case 'panel':
|
||||||
|
return (
|
||||||
|
<div key={node.id} className={css.Panel} data-room-app-node={node.id}>
|
||||||
|
{node.children?.map((child) => renderNode(child))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'row':
|
||||||
|
return (
|
||||||
|
<div key={node.id} className={css.Row} data-room-app-node={node.id}>
|
||||||
|
{node.children?.map((child) => renderNode(child))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'text':
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
key={node.id}
|
||||||
|
as="p"
|
||||||
|
size="T400"
|
||||||
|
className={node.className}
|
||||||
|
data-room-app-node={node.id}
|
||||||
|
>
|
||||||
|
{node.text ?? ''}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
case 'button': {
|
||||||
|
const variant = node.variant ?? 'Primary';
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={node.id}
|
||||||
|
type="button"
|
||||||
|
size="400"
|
||||||
|
variant={variant}
|
||||||
|
radii="300"
|
||||||
|
disabled={node.disabled || sending}
|
||||||
|
onClick={() => handleButton(node)}
|
||||||
|
data-room-app-node={node.id}
|
||||||
|
>
|
||||||
|
<Text size="B400">{node.label}</Text>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'input': {
|
||||||
|
const name = fieldName(node);
|
||||||
|
const current = values[name] ?? node.value ?? '';
|
||||||
|
return (
|
||||||
|
<div key={node.id} className={css.Field} data-room-app-node={node.id}>
|
||||||
|
{node.label && (
|
||||||
|
<Text as="label" size="L400" htmlFor={`room-app-${node.id}`}>
|
||||||
|
{node.label}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
id={`room-app-${node.id}`}
|
||||||
|
name={name}
|
||||||
|
variant="Background"
|
||||||
|
radii="300"
|
||||||
|
type={node.inputType ?? 'text'}
|
||||||
|
placeholder={node.placeholder}
|
||||||
|
value={current}
|
||||||
|
onChange={(evt) => setField(name, evt.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'select': {
|
||||||
|
const name = fieldName(node);
|
||||||
|
const current = values[name] ?? node.value ?? '';
|
||||||
|
return (
|
||||||
|
<div key={node.id} className={css.Field} data-room-app-node={node.id}>
|
||||||
|
{node.label && (
|
||||||
|
<Text as="label" size="L400" htmlFor={`room-app-${node.id}`}>
|
||||||
|
{node.label}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Box as="span" grow="Yes" style={{ position: 'relative' }}>
|
||||||
|
<select
|
||||||
|
id={`room-app-${node.id}`}
|
||||||
|
name={name}
|
||||||
|
value={current}
|
||||||
|
onChange={(evt) => setField(name, evt.currentTarget.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
border: '1px solid var(--bq-border, CurrentColor)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'inherit',
|
||||||
|
font: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(node.options ?? []).map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'image': {
|
||||||
|
if (!isAllowedRoomAppImageSrc(node.src)) return null;
|
||||||
|
const httpSrc = node.src.startsWith('mxc://')
|
||||||
|
? mxcUrlToHttp(mx, node.src, useAuthentication) ?? undefined
|
||||||
|
: node.src;
|
||||||
|
if (!httpSrc) return null;
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
key={node.id}
|
||||||
|
className={css.Image}
|
||||||
|
src={httpSrc}
|
||||||
|
alt={node.alt ?? ''}
|
||||||
|
width={node.width}
|
||||||
|
height={node.height}
|
||||||
|
data-room-app-node={node.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'spacer':
|
||||||
|
return <div key={node.id} className={css.Spacer({ size: spacerSize(node.size) })} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-room-app="">
|
||||||
|
{scopedCss ? <style>{scopedCss}</style> : null}
|
||||||
|
{renderNode(content.root)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/app/features/room-app/RoomAppView.css.ts
Normal file
74
src/app/features/room-app/RoomAppView.css.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { recipe } from '@vanilla-extract/recipes';
|
||||||
|
import { DefaultReset, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const Root = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flexGrow: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const Header = style({
|
||||||
|
paddingLeft: config.space.S200,
|
||||||
|
paddingRight: config.space.S200,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Body = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
flexGrow: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: config.space.S400,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const Panel = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: config.space.S300,
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Row = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: config.space.S200,
|
||||||
|
alignItems: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Field = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: config.space.S100,
|
||||||
|
minWidth: toRem(160),
|
||||||
|
flexGrow: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Image = style({
|
||||||
|
maxWidth: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Spacer = recipe({
|
||||||
|
base: {
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: { height: config.space.S200 },
|
||||||
|
md: { height: config.space.S400 },
|
||||||
|
lg: { height: config.space.S600 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: 'md',
|
||||||
|
},
|
||||||
|
});
|
||||||
67
src/app/features/room-app/RoomAppView.tsx
Normal file
67
src/app/features/room-app/RoomAppView.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { Box, Button, Header, Text, config } from 'folds';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { Page } from '../../components/page';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
|
import { useRoomName } from '../../hooks/useRoomMeta';
|
||||||
|
import {
|
||||||
|
MessageEvent,
|
||||||
|
PaarrotUiActionContent,
|
||||||
|
StateEvent,
|
||||||
|
} from '../../../types/matrix/room';
|
||||||
|
import { parsePaarrotUiContent } from '../../utils/room';
|
||||||
|
import { RoomAppSchema } from './RoomAppSchema';
|
||||||
|
import * as css from './RoomAppView.css';
|
||||||
|
|
||||||
|
type RoomAppViewProps = {
|
||||||
|
room: Room;
|
||||||
|
onShowChat: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoomAppView({ room, onShowChat }: RoomAppViewProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const roomName = useRoomName(room);
|
||||||
|
const uiEvent = useStateEvent(room, StateEvent.PaarrotUi);
|
||||||
|
const content = parsePaarrotUiContent(uiEvent?.getContent());
|
||||||
|
const title = content?.title || roomName || 'Room App';
|
||||||
|
|
||||||
|
const handleAction = useCallback(
|
||||||
|
async (payload: PaarrotUiActionContent) => {
|
||||||
|
await mx.sendEvent(room.roomId, MessageEvent.PaarrotUiAction as any, payload);
|
||||||
|
},
|
||||||
|
[mx, room.roomId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<Header className={css.Header} size="600">
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200" minWidth="0">
|
||||||
|
<Text size="H6" truncate>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Button size="300" variant="Secondary" radii="300" onClick={onShowChat}>
|
||||||
|
<Text size="B300">Show chat</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Header>
|
||||||
|
<Box className={css.Body} grow="Yes" direction="Column" style={{ gap: config.space.S400 }}>
|
||||||
|
{content ? (
|
||||||
|
<RoomAppSchema
|
||||||
|
mx={mx}
|
||||||
|
content={content}
|
||||||
|
uiEventId={uiEvent?.getId()}
|
||||||
|
useAuthentication={useAuthentication}
|
||||||
|
onAction={handleAction}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text size="T400">This room’s app UI is missing or invalid.</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/app/features/room-app/index.ts
Normal file
7
src/app/features/room-app/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { RoomAppView } from './RoomAppView';
|
||||||
|
export { RoomAppSchema } from './RoomAppSchema';
|
||||||
|
export {
|
||||||
|
sanitizeRoomAppCss,
|
||||||
|
scopeRoomAppCss,
|
||||||
|
isAllowedRoomAppImageSrc,
|
||||||
|
} from './sanitizeRoomAppCss';
|
||||||
91
src/app/features/room-app/sanitizeRoomAppCss.ts
Normal file
91
src/app/features/room-app/sanitizeRoomAppCss.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Strip dangerous CSS constructs from bot-supplied stylesheets.
|
||||||
|
* Not a full CSS parser — best-effort for MVP scoped injection.
|
||||||
|
*/
|
||||||
|
export function sanitizeRoomAppCss(css: string): string {
|
||||||
|
let out = css;
|
||||||
|
out = out.replace(/@import\b[^;{]*;?/gi, '');
|
||||||
|
out = out.replace(/url\s*\(\s*['"]?\s*javascript:[^)]*\)/gi, 'url(about:blank)');
|
||||||
|
out = out.replace(/expression\s*\([^)]*\)/gi, 'initial');
|
||||||
|
out = out.replace(/-moz-binding\s*:[^;]+;?/gi, '');
|
||||||
|
out = out.replace(/behavior\s*:[^;]+;?/gi, '');
|
||||||
|
out = out.replace(/@charset\b[^;]*;?/gi, '');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefix plain selectors with a scope so bot CSS cannot escape the room app root.
|
||||||
|
* At-rules (@media, @supports, @keyframes) are kept with nested rules re-scoped where possible.
|
||||||
|
*/
|
||||||
|
export function scopeRoomAppCss(css: string, scopeSelector: string): string {
|
||||||
|
const sanitized = sanitizeRoomAppCss(css).trim();
|
||||||
|
if (!sanitized) return '';
|
||||||
|
|
||||||
|
const scopeRule = (selectors: string, body: string): string => {
|
||||||
|
const scoped = selectors
|
||||||
|
.split(',')
|
||||||
|
.map((s) => {
|
||||||
|
const sel = s.trim();
|
||||||
|
if (!sel) return '';
|
||||||
|
if (sel.startsWith(scopeSelector)) return sel;
|
||||||
|
return `${scopeSelector} ${sel}`;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
return `${scoped}{${body}}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rewriteBlock = (block: string): string => {
|
||||||
|
const trimmed = block.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
|
||||||
|
if (trimmed.startsWith('@')) {
|
||||||
|
const open = trimmed.indexOf('{');
|
||||||
|
if (open === -1) return `${trimmed};`;
|
||||||
|
const header = trimmed.slice(0, open).trim();
|
||||||
|
const inner = trimmed.slice(open + 1);
|
||||||
|
// @keyframes / @font-face: keep as-is (namespaced risk is low for MVP)
|
||||||
|
if (/^@(keyframes|font-face)\b/i.test(header)) {
|
||||||
|
return `${header}{${inner}}`;
|
||||||
|
}
|
||||||
|
// @media / @supports: re-scope nested rules
|
||||||
|
const nested = rewriteCssChunk(inner);
|
||||||
|
return `${header}{${nested}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = trimmed.indexOf('{');
|
||||||
|
if (open === -1) return '';
|
||||||
|
const selectors = trimmed.slice(0, open);
|
||||||
|
const body = trimmed.slice(open + 1);
|
||||||
|
return scopeRule(selectors, body);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rewriteCssChunk = (chunk: string): string => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let depth = 0;
|
||||||
|
let start = 0;
|
||||||
|
for (let i = 0; i < chunk.length; i += 1) {
|
||||||
|
const ch = chunk[i];
|
||||||
|
if (ch === '{') depth += 1;
|
||||||
|
else if (ch === '}') {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) {
|
||||||
|
parts.push(rewriteBlock(chunk.slice(start, i)));
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.filter(Boolean).join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
return rewriteCssChunk(sanitized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAllowedRoomAppImageSrc(src: string): boolean {
|
||||||
|
const trimmed = src.trim();
|
||||||
|
return (
|
||||||
|
trimmed.startsWith('mxc://') ||
|
||||||
|
trimmed.startsWith('https://') ||
|
||||||
|
trimmed.startsWith('http://')
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -204,6 +204,19 @@ export const usePermissionGroups = (): PermissionGroup[] => {
|
|||||||
},
|
},
|
||||||
name: 'Modify Widgets',
|
name: 'Modify Widgets',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
location: {
|
||||||
|
state: true,
|
||||||
|
key: StateEvent.PaarrotUi,
|
||||||
|
},
|
||||||
|
name: 'Set Room App UI',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
location: {
|
||||||
|
key: MessageEvent.PaarrotUiAction,
|
||||||
|
},
|
||||||
|
name: 'Send Room App Actions',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Box, Line } from 'folds';
|
import { Box, Button, Line, Text, config } from 'folds';
|
||||||
import { decodeRouteParam } from '../../pages/pathUtils';
|
import { decodeRouteParam } from '../../pages/pathUtils';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
@@ -18,8 +18,11 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||||
import { activeRoomIdAtom } from '../../state/activeRoom';
|
import { activeRoomIdAtom } from '../../state/activeRoom';
|
||||||
import { isMediaDrawerAtom } from '../../state/mediaDrawer';
|
import { isMediaDrawerAtom } from '../../state/mediaDrawer';
|
||||||
import { isForum } from '../../utils/room';
|
import { isForum, parsePaarrotUiContent } from '../../utils/room';
|
||||||
import { ForumRoomView } from './ForumRoomView';
|
import { ForumRoomView } from './ForumRoomView';
|
||||||
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
|
import { RoomAppView } from '../room-app';
|
||||||
|
|
||||||
export function Room() {
|
export function Room() {
|
||||||
const { eventId: rawEventId } = useParams();
|
const { eventId: rawEventId } = useParams();
|
||||||
@@ -41,6 +44,16 @@ export function Room() {
|
|||||||
const showRightDrawer =
|
const showRightDrawer =
|
||||||
!skinny && (isPeopleDrawer || isMediaDrawer);
|
!skinny && (isPeopleDrawer || isMediaDrawer);
|
||||||
|
|
||||||
|
const uiEvent = useStateEvent(room, StateEvent.PaarrotUi);
|
||||||
|
const hasAppUi = useMemo(() => !!parsePaarrotUiContent(uiEvent?.getContent()), [uiEvent]);
|
||||||
|
const [preferChat, setPreferChat] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPreferChat(false);
|
||||||
|
}, [room.roomId]);
|
||||||
|
|
||||||
|
const showAppView = !forumRoom && hasAppUi && !preferChat;
|
||||||
|
|
||||||
// Update titlebar with current room ID
|
// Update titlebar with current room ID
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveRoomId(room.roomId);
|
setActiveRoomId(room.roomId);
|
||||||
@@ -63,10 +76,38 @@ export function Room() {
|
|||||||
<PowerLevelsContextProvider value={powerLevels}>
|
<PowerLevelsContextProvider value={powerLevels}>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
{!showMediaSolo &&
|
{!showMediaSolo &&
|
||||||
(forumRoom ? (
|
(showAppView ? (
|
||||||
|
<RoomAppView room={room} onShowChat={() => setPreferChat(true)} />
|
||||||
|
) : forumRoom ? (
|
||||||
<ForumRoomView room={room} eventId={eventId} />
|
<ForumRoomView room={room} eventId={eventId} />
|
||||||
) : (
|
) : (
|
||||||
<RoomView room={room} eventId={eventId} />
|
<Box grow="Yes" direction="Column" style={{ minWidth: 0, minHeight: 0 }}>
|
||||||
|
{hasAppUi && preferChat && (
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="SpaceBetween"
|
||||||
|
gap="200"
|
||||||
|
style={{
|
||||||
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||||||
|
borderBottom: '1px solid var(--bq-surface-border, CurrentColor)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="T300">Room app available</Text>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Primary"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => setPreferChat(false)}
|
||||||
|
>
|
||||||
|
<Text size="B300">Show app</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box grow="Yes" direction="Column" style={{ minWidth: 0, minHeight: 0 }}>
|
||||||
|
<RoomView room={room} eventId={eventId} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
))}
|
))}
|
||||||
{showMediaSolo && <MediaDrawer room={room} solo />}
|
{showMediaSolo && <MediaDrawer room={room} solo />}
|
||||||
{!showMediaSolo && showRightDrawer && (
|
{!showMediaSolo && showRightDrawer && (
|
||||||
|
|||||||
@@ -2011,6 +2011,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
return 'CALL_EVENT_PENDING' as unknown as React.ReactNode;
|
return 'CALL_EVENT_PENDING' as unknown as React.ReactNode;
|
||||||
},
|
},
|
||||||
[MessageEvent.RelayEmojiConfetti]: () => null,
|
[MessageEvent.RelayEmojiConfetti]: () => null,
|
||||||
|
[MessageEvent.PaarrotUiAction]: () => null,
|
||||||
},
|
},
|
||||||
(mEventId, mEvent, item) => {
|
(mEventId, mEvent, item) => {
|
||||||
if (!showHiddenEvents) return null;
|
if (!showHiddenEvents) return null;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { EmojiConfettiBurst } from './types';
|
import { EmojiConfettiBurst } from './types';
|
||||||
import { findJumboEmojiElement, getLocalBurstCanvasSize } from './findJumboMount';
|
import { findJumboEmojiElement, getLocalBurstCanvasSize } from './findJumboMount';
|
||||||
@@ -8,6 +8,14 @@ import {
|
|||||||
spawnEmojiBurst,
|
spawnEmojiBurst,
|
||||||
stepBurstParticles,
|
stepBurstParticles,
|
||||||
} from './emojiBurstEngine';
|
} from './emojiBurstEngine';
|
||||||
|
import { getEmojiBurstProfile, isFullscreenBurstEmoji } from './emojiParticleProfiles';
|
||||||
|
import {
|
||||||
|
createFireworkSim,
|
||||||
|
drawFireworkSim,
|
||||||
|
FireworkSim,
|
||||||
|
getFireworkDpr,
|
||||||
|
stepFireworkSim,
|
||||||
|
} from './fireworkParticleEngine';
|
||||||
|
|
||||||
const MAX_DPR = 2;
|
const MAX_DPR = 2;
|
||||||
|
|
||||||
@@ -17,6 +25,147 @@ type EmojiConfettiBurstCanvasProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBurstCanvasProps) {
|
export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBurstCanvasProps) {
|
||||||
|
const primaryEmoji = burst.emojis[0] ?? '🎉';
|
||||||
|
const fullscreen = isFullscreenBurstEmoji(primaryEmoji);
|
||||||
|
|
||||||
|
if (fullscreen) {
|
||||||
|
return <FireworkBurstCanvas burst={burst} primaryEmoji={primaryEmoji} onComplete={onComplete} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LocalBurstCanvas burst={burst} primaryEmoji={primaryEmoji} onComplete={onComplete} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BurstCanvasProps = {
|
||||||
|
burst: EmojiConfettiBurst;
|
||||||
|
primaryEmoji: string;
|
||||||
|
onComplete: (burstId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function FireworkBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const simRef = useRef<FireworkSim | null>(null);
|
||||||
|
const frameRef = useRef<number | null>(null);
|
||||||
|
const lastFrameTimeRef = useRef<number | null>(null);
|
||||||
|
const spawnedRef = useRef(false);
|
||||||
|
const onCompleteRef = useRef(onComplete);
|
||||||
|
onCompleteRef.current = onComplete;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current || spawnedRef.current) return undefined;
|
||||||
|
|
||||||
|
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
if (reducedMotion) {
|
||||||
|
onCompleteRef.current(burst.id);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const context = canvas.getContext('2d', { alpha: true });
|
||||||
|
if (!context) {
|
||||||
|
onCompleteRef.current(burst.id);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
context.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
spawnedRef.current = true;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const dpr = getFireworkDpr();
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
canvas.width = Math.round(width * dpr);
|
||||||
|
canvas.height = Math.round(height * dpr);
|
||||||
|
canvas.style.width = `${width}px`;
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
return { width, height, dpr };
|
||||||
|
};
|
||||||
|
|
||||||
|
const { width, height } = resize();
|
||||||
|
const jumbo = findJumboEmojiElement(burst.targetEventId);
|
||||||
|
const jumboRect = jumbo?.getBoundingClientRect();
|
||||||
|
const origin = {
|
||||||
|
x: jumboRect ? jumboRect.left + jumboRect.width / 2 : burst.origin.x,
|
||||||
|
y: jumboRect ? jumboRect.top + jumboRect.height / 2 : burst.origin.y,
|
||||||
|
maskRadius: burst.origin.maskRadius ?? 36,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clamp origin into the viewport so off-screen messages still blast on-screen.
|
||||||
|
origin.x = Math.min(width - 24, Math.max(24, origin.x));
|
||||||
|
origin.y = Math.min(height - 24, Math.max(24, origin.y));
|
||||||
|
|
||||||
|
const profile = getEmojiBurstProfile(primaryEmoji);
|
||||||
|
simRef.current = createFireworkSim(width, height, origin, primaryEmoji, profile);
|
||||||
|
|
||||||
|
const onResize = () => {
|
||||||
|
// Extra Things: match CSS size; sim keeps its launch-time dimensions.
|
||||||
|
resize();
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const sim = simRef.current;
|
||||||
|
if (!sim) return;
|
||||||
|
|
||||||
|
const last = lastFrameTimeRef.current ?? now;
|
||||||
|
const dtSeconds = Math.min((now - last) / 1000, 0.05);
|
||||||
|
lastFrameTimeRef.current = now;
|
||||||
|
|
||||||
|
const alive = stepFireworkSim(sim, now, dtSeconds);
|
||||||
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
if (alive) {
|
||||||
|
drawFireworkSim(context, sim, now);
|
||||||
|
frameRef.current = requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onCompleteRef.current(burst.id);
|
||||||
|
frameRef.current = null;
|
||||||
|
lastFrameTimeRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
lastFrameTimeRef.current = performance.now();
|
||||||
|
frameRef.current = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
if (frameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(frameRef.current);
|
||||||
|
frameRef.current = null;
|
||||||
|
}
|
||||||
|
simRef.current = null;
|
||||||
|
};
|
||||||
|
}, [burst.id, burst.origin.maskRadius, burst.origin.x, burst.origin.y, burst.targetEventId, primaryEmoji]);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
width: '100vw',
|
||||||
|
height: '100vh',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocalBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const layerRef = useRef<HTMLDivElement>(null);
|
const layerRef = useRef<HTMLDivElement>(null);
|
||||||
const particlesRef = useRef<BurstParticle[]>([]);
|
const particlesRef = useRef<BurstParticle[]>([]);
|
||||||
@@ -89,13 +238,7 @@ export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBur
|
|||||||
canvas.style.width = `${canvasSize}px`;
|
canvas.style.width = `${canvasSize}px`;
|
||||||
canvas.style.height = `${canvasSize}px`;
|
canvas.style.height = `${canvasSize}px`;
|
||||||
|
|
||||||
spawnEmojiBurst(
|
spawnEmojiBurst(particlesRef.current, burst.id, localOrigin, primaryEmoji, performance.now());
|
||||||
particlesRef.current,
|
|
||||||
burst.id,
|
|
||||||
localOrigin,
|
|
||||||
burst.emojis[0] ?? '🎉',
|
|
||||||
performance.now()
|
|
||||||
);
|
|
||||||
|
|
||||||
const tick = (now: number) => {
|
const tick = (now: number) => {
|
||||||
updateLayerPosition();
|
updateLayerPosition();
|
||||||
@@ -152,7 +295,7 @@ export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBur
|
|||||||
window.clearInterval(retry);
|
window.clearInterval(retry);
|
||||||
frameCleanup?.();
|
frameCleanup?.();
|
||||||
};
|
};
|
||||||
}, [burst.emojis, burst.id, burst.targetEventId, canvasSize, localOrigin]);
|
}, [burst.id, burst.targetEventId, canvasSize, localOrigin, primaryEmoji]);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export type BurstMotionStyle =
|
|||||||
| 'spiral'
|
| 'spiral'
|
||||||
| 'punch'
|
| 'punch'
|
||||||
| 'scatter'
|
| 'scatter'
|
||||||
| 'shower';
|
| 'shower'
|
||||||
|
| 'firework';
|
||||||
|
|
||||||
export type EmojiBurstProfile = {
|
export type EmojiBurstProfile = {
|
||||||
style: BurstMotionStyle;
|
style: BurstMotionStyle;
|
||||||
@@ -32,6 +33,8 @@ export type EmojiBurstProfile = {
|
|||||||
angleSpread?: number;
|
angleSpread?: number;
|
||||||
twinkle?: boolean;
|
twinkle?: boolean;
|
||||||
wobble?: boolean;
|
wobble?: boolean;
|
||||||
|
/** Full-viewport overlay (Box2D pile for fireworks). */
|
||||||
|
fullscreen?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_PROFILE: EmojiBurstProfile = {
|
const DEFAULT_PROFILE: EmojiBurstProfile = {
|
||||||
@@ -340,6 +343,45 @@ const PROFILE_OVERRIDES: Record<string, Partial<EmojiBurstProfile>> = {
|
|||||||
companions: ['🎉', '🎊', '🎈'],
|
companions: ['🎉', '🎊', '🎈'],
|
||||||
companionChance: 0.4,
|
companionChance: 0.4,
|
||||||
},
|
},
|
||||||
|
'🎆': {
|
||||||
|
style: 'firework',
|
||||||
|
fullscreen: true,
|
||||||
|
particleCount: 320,
|
||||||
|
gravity: 980,
|
||||||
|
drag: 1,
|
||||||
|
speedMin: 420,
|
||||||
|
speedMax: 980,
|
||||||
|
launchUpMin: 180,
|
||||||
|
launchUpMax: 420,
|
||||||
|
spinMin: -520,
|
||||||
|
spinMax: 520,
|
||||||
|
fontSizeMin: 16,
|
||||||
|
fontSizeMax: 30,
|
||||||
|
heroFontSizeMin: 40,
|
||||||
|
heroFontSizeMax: 52,
|
||||||
|
companions: ['🎇', '✨', '💥', '⭐', '🎉'],
|
||||||
|
companionChance: 0.55,
|
||||||
|
},
|
||||||
|
'🎇': {
|
||||||
|
style: 'firework',
|
||||||
|
fullscreen: true,
|
||||||
|
particleCount: 280,
|
||||||
|
gravity: 960,
|
||||||
|
drag: 1,
|
||||||
|
speedMin: 380,
|
||||||
|
speedMax: 920,
|
||||||
|
launchUpMin: 160,
|
||||||
|
launchUpMax: 400,
|
||||||
|
spinMin: -480,
|
||||||
|
spinMax: 480,
|
||||||
|
fontSizeMin: 14,
|
||||||
|
fontSizeMax: 28,
|
||||||
|
heroFontSizeMin: 36,
|
||||||
|
heroFontSizeMax: 48,
|
||||||
|
companions: ['🎆', '✨', '⭐', '💥'],
|
||||||
|
companionChance: 0.5,
|
||||||
|
twinkle: true,
|
||||||
|
},
|
||||||
'🐱': {
|
'🐱': {
|
||||||
style: 'bounce',
|
style: 'bounce',
|
||||||
particleCount: 20,
|
particleCount: 20,
|
||||||
@@ -369,6 +411,10 @@ export function getEmojiBurstProfile(emoji: string): EmojiBurstProfile {
|
|||||||
return { ...DEFAULT_PROFILE, ...override };
|
return { ...DEFAULT_PROFILE, ...override };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isFullscreenBurstEmoji(emoji: string): boolean {
|
||||||
|
return getEmojiBurstProfile(emoji).fullscreen === true;
|
||||||
|
}
|
||||||
|
|
||||||
export function pickParticleEmoji(profile: EmojiBurstProfile, primaryEmoji: string): string {
|
export function pickParticleEmoji(profile: EmojiBurstProfile, primaryEmoji: string): string {
|
||||||
if (!profile.companions?.length || !profile.companionChance) {
|
if (!profile.companions?.length || !profile.companionChance) {
|
||||||
return primaryEmoji;
|
return primaryEmoji;
|
||||||
|
|||||||
336
src/app/features/room/emoji-confetti/fireworkParticleEngine.ts
Normal file
336
src/app/features/room/emoji-confetti/fireworkParticleEngine.ts
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
/**
|
||||||
|
* Old-school fullscreen firework fountain — no physics engine.
|
||||||
|
* Position/velocity arrays, gravity, floor bounce, settle into a pile.
|
||||||
|
* Same vibe as late-90s/MSN page effects, just with emoji.
|
||||||
|
*/
|
||||||
|
import { BurstPoint } from './burstOrigin';
|
||||||
|
import { pickParticleEmoji, type EmojiBurstProfile } from './emojiParticleProfiles';
|
||||||
|
|
||||||
|
const EMOJI_CACHE_PX = 40;
|
||||||
|
const EMOJI_CACHE_SCALE = 2;
|
||||||
|
|
||||||
|
const SPAWN_WINDOW_MS = 2200;
|
||||||
|
const SETTLE_HOLD_MS = 7800;
|
||||||
|
const FADE_MS = 800;
|
||||||
|
export const FIREWORK_TOTAL_MS = SETTLE_HOLD_MS + FADE_MS;
|
||||||
|
|
||||||
|
const SPARKLE_IN_MS = 120;
|
||||||
|
const MAX_SPAWNS_PER_FRAME = 6;
|
||||||
|
const FLOOR_PAD = 8;
|
||||||
|
const BOUNCE = 0.38;
|
||||||
|
const DRAG = 0.992;
|
||||||
|
const SETTLE_SPEED = 55;
|
||||||
|
const PILE_CELL = 22;
|
||||||
|
|
||||||
|
type SpawnItem = {
|
||||||
|
atMs: number;
|
||||||
|
emoji: string;
|
||||||
|
fontSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FireworkParticle = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
spin: number;
|
||||||
|
angle: number;
|
||||||
|
emoji: string;
|
||||||
|
emojiCanvas: HTMLCanvasElement;
|
||||||
|
drawSize: number;
|
||||||
|
halfSize: number;
|
||||||
|
bornAt: number;
|
||||||
|
settled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FireworkSim = {
|
||||||
|
particles: FireworkParticle[];
|
||||||
|
profile: EmojiBurstProfile;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
origin: BurstPoint;
|
||||||
|
startMs: number;
|
||||||
|
spawnPlan: SpawnItem[];
|
||||||
|
spawnCursor: number;
|
||||||
|
floorY: number;
|
||||||
|
/** Column stack heights for the settled pile (in cells). */
|
||||||
|
pileCols: Uint16Array;
|
||||||
|
pileCanvas: HTMLCanvasElement | null;
|
||||||
|
pileCtx: CanvasRenderingContext2D | null;
|
||||||
|
flyingCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emojiCanvasCache = new Map<string, HTMLCanvasElement>();
|
||||||
|
|
||||||
|
function lerp(min: number, max: number): number {
|
||||||
|
return min + Math.random() * (max - min);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmojiCanvas(emoji: string): HTMLCanvasElement {
|
||||||
|
const cached = emojiCanvasCache.get(emoji);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const size = EMOJI_CACHE_PX * EMOJI_CACHE_SCALE;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
ctx.font = `${EMOJI_CACHE_PX}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
|
||||||
|
ctx.fillText(emoji, size / 2, size / 2);
|
||||||
|
}
|
||||||
|
emojiCanvasCache.set(emoji, canvas);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSpawnPlan(
|
||||||
|
profile: EmojiBurstProfile,
|
||||||
|
primaryEmoji: string,
|
||||||
|
startMs: number
|
||||||
|
): SpawnItem[] {
|
||||||
|
const plan: SpawnItem[] = [
|
||||||
|
{
|
||||||
|
atMs: startMs,
|
||||||
|
emoji: primaryEmoji,
|
||||||
|
fontSize: lerp(profile.heroFontSizeMin, profile.heroFontSizeMax),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const count = profile.particleCount;
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
const slot = (i + 0.5) / count;
|
||||||
|
const eased = slot * slot;
|
||||||
|
plan.push({
|
||||||
|
atMs:
|
||||||
|
startMs +
|
||||||
|
80 +
|
||||||
|
eased * SPAWN_WINDOW_MS +
|
||||||
|
(Math.random() - 0.5) * 70,
|
||||||
|
emoji: pickParticleEmoji(profile, primaryEmoji),
|
||||||
|
fontSize: lerp(profile.fontSizeMin, profile.fontSizeMax),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.sort((a, b) => a.atMs - b.atMs);
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePile(sim: FireworkSim): CanvasRenderingContext2D {
|
||||||
|
if (sim.pileCanvas && sim.pileCtx) return sim.pileCtx;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = sim.width;
|
||||||
|
canvas.height = sim.height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('firework pile unsupported');
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
sim.pileCanvas = canvas;
|
||||||
|
sim.pileCtx = ctx;
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stampSettled(sim: FireworkSim, p: FireworkParticle) {
|
||||||
|
const ctx = ensurePile(sim);
|
||||||
|
const rad = (p.angle * Math.PI) / 180;
|
||||||
|
const cos = Math.cos(rad);
|
||||||
|
const sin = Math.sin(rad);
|
||||||
|
ctx.setTransform(cos, sin, -sin, cos, p.x, p.y);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.drawImage(p.emojiCanvas, -p.halfSize, -p.halfSize, p.drawSize, p.drawSize);
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnOne(sim: FireworkSim, item: SpawnItem, now: number) {
|
||||||
|
const { profile, origin } = sim;
|
||||||
|
const jitter = Math.max(4, (origin.maskRadius ?? 28) * 0.25);
|
||||||
|
const angle = Math.random() * Math.PI * 2;
|
||||||
|
const speed = lerp(profile.speedMin, profile.speedMax);
|
||||||
|
const launchUp = lerp(profile.launchUpMin, profile.launchUpMax);
|
||||||
|
const drawSize = item.fontSize * EMOJI_CACHE_SCALE * 0.85;
|
||||||
|
|
||||||
|
sim.particles.push({
|
||||||
|
x: origin.x + (Math.random() - 0.5) * jitter,
|
||||||
|
y: origin.y + (Math.random() - 0.5) * jitter,
|
||||||
|
vx: Math.cos(angle) * speed + (Math.random() - 0.5) * 40,
|
||||||
|
vy: Math.sin(angle) * speed - launchUp,
|
||||||
|
spin: lerp(profile.spinMin, profile.spinMax) * 0.35,
|
||||||
|
angle: (Math.random() - 0.5) * 40,
|
||||||
|
emoji: item.emoji,
|
||||||
|
emojiCanvas: getEmojiCanvas(item.emoji),
|
||||||
|
drawSize,
|
||||||
|
halfSize: drawSize / 2,
|
||||||
|
bornAt: now,
|
||||||
|
settled: false,
|
||||||
|
});
|
||||||
|
sim.flyingCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function settleParticle(sim: FireworkSim, p: FireworkParticle) {
|
||||||
|
const cols = sim.pileCols.length;
|
||||||
|
let col = Math.floor(p.x / PILE_CELL);
|
||||||
|
if (col < 0) col = 0;
|
||||||
|
if (col >= cols) col = cols - 1;
|
||||||
|
|
||||||
|
// Prefer the intended column; spill to neighbors if that stack is already tall.
|
||||||
|
let bestCol = col;
|
||||||
|
let bestH = sim.pileCols[col];
|
||||||
|
for (const delta of [0, -1, 1, -2, 2]) {
|
||||||
|
const c = col + delta;
|
||||||
|
if (c < 0 || c >= cols) continue;
|
||||||
|
if (sim.pileCols[c] < bestH) {
|
||||||
|
bestH = sim.pileCols[c];
|
||||||
|
bestCol = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = sim.pileCols[bestCol];
|
||||||
|
sim.pileCols[bestCol] = stack + 1;
|
||||||
|
p.x = bestCol * PILE_CELL + PILE_CELL * 0.5 + (Math.random() - 0.5) * 6;
|
||||||
|
p.y = sim.floorY - stack * (PILE_CELL * 0.72) - p.halfSize;
|
||||||
|
p.vx = 0;
|
||||||
|
p.vy = 0;
|
||||||
|
p.spin = 0;
|
||||||
|
p.settled = true;
|
||||||
|
sim.flyingCount = Math.max(0, sim.flyingCount - 1);
|
||||||
|
stampSettled(sim, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFireworkSim(
|
||||||
|
widthPx: number,
|
||||||
|
heightPx: number,
|
||||||
|
originPx: BurstPoint,
|
||||||
|
primaryEmoji: string,
|
||||||
|
profile: EmojiBurstProfile,
|
||||||
|
startMs = performance.now()
|
||||||
|
): FireworkSim {
|
||||||
|
const colCount = Math.max(8, Math.ceil(widthPx / PILE_CELL));
|
||||||
|
return {
|
||||||
|
particles: [],
|
||||||
|
profile,
|
||||||
|
width: widthPx,
|
||||||
|
height: heightPx,
|
||||||
|
origin: originPx,
|
||||||
|
startMs,
|
||||||
|
spawnPlan: buildSpawnPlan(profile, primaryEmoji, startMs),
|
||||||
|
spawnCursor: 0,
|
||||||
|
floorY: heightPx - FLOOR_PAD,
|
||||||
|
pileCols: new Uint16Array(colCount),
|
||||||
|
pileCanvas: null,
|
||||||
|
pileCtx: null,
|
||||||
|
flyingCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns false when the burst should be removed. */
|
||||||
|
export function stepFireworkSim(sim: FireworkSim, now: number, dtSeconds: number): boolean {
|
||||||
|
if (now - sim.startMs > FIREWORK_TOTAL_MS) return false;
|
||||||
|
|
||||||
|
const dt = Math.min(dtSeconds, 0.05);
|
||||||
|
const g = sim.profile.gravity;
|
||||||
|
|
||||||
|
let spawned = 0;
|
||||||
|
while (
|
||||||
|
spawned < MAX_SPAWNS_PER_FRAME &&
|
||||||
|
sim.spawnCursor < sim.spawnPlan.length &&
|
||||||
|
sim.spawnPlan[sim.spawnCursor].atMs <= now
|
||||||
|
) {
|
||||||
|
spawnOne(sim, sim.spawnPlan[sim.spawnCursor], now);
|
||||||
|
sim.spawnCursor += 1;
|
||||||
|
spawned += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const floor = sim.floorY;
|
||||||
|
const left = 4;
|
||||||
|
const right = sim.width - 4;
|
||||||
|
|
||||||
|
for (let i = 0; i < sim.particles.length; i += 1) {
|
||||||
|
const p = sim.particles[i];
|
||||||
|
if (p.settled) continue;
|
||||||
|
|
||||||
|
p.vy += g * dt;
|
||||||
|
p.vx *= DRAG;
|
||||||
|
p.vy *= DRAG;
|
||||||
|
p.x += p.vx * dt;
|
||||||
|
p.y += p.vy * dt;
|
||||||
|
p.angle += p.spin * dt;
|
||||||
|
|
||||||
|
if (p.x < left) {
|
||||||
|
p.x = left;
|
||||||
|
p.vx = Math.abs(p.vx) * BOUNCE;
|
||||||
|
} else if (p.x > right) {
|
||||||
|
p.x = right;
|
||||||
|
p.vx = -Math.abs(p.vx) * BOUNCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.y >= floor - p.halfSize) {
|
||||||
|
p.y = floor - p.halfSize;
|
||||||
|
const speed = Math.hypot(p.vx, p.vy);
|
||||||
|
if (speed < SETTLE_SPEED || Math.abs(p.vy) < SETTLE_SPEED * 0.55) {
|
||||||
|
settleParticle(sim, p);
|
||||||
|
} else {
|
||||||
|
p.vy = -Math.abs(p.vy) * BOUNCE;
|
||||||
|
p.vx *= 0.85;
|
||||||
|
p.spin *= 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawFireworkSim(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
sim: FireworkSim,
|
||||||
|
now: number
|
||||||
|
) {
|
||||||
|
const elapsed = now - sim.startMs;
|
||||||
|
let fade = 1;
|
||||||
|
if (elapsed >= SETTLE_HOLD_MS) {
|
||||||
|
fade = 1 - Math.min(1, (elapsed - SETTLE_HOLD_MS) / FADE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
context.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
if (sim.pileCanvas) {
|
||||||
|
context.globalAlpha = fade;
|
||||||
|
context.drawImage(sim.pileCanvas, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < sim.particles.length; i += 1) {
|
||||||
|
const p = sim.particles[i];
|
||||||
|
if (p.settled) continue;
|
||||||
|
|
||||||
|
const ageMs = now - p.bornAt;
|
||||||
|
if (ageMs < 0) continue;
|
||||||
|
|
||||||
|
let opacity = fade;
|
||||||
|
let scale = 1;
|
||||||
|
if (ageMs < SPARKLE_IN_MS) {
|
||||||
|
const t = ageMs / SPARKLE_IN_MS;
|
||||||
|
const appear = t * t * (3 - 2 * t);
|
||||||
|
scale = 0.5 + 0.65 * appear;
|
||||||
|
opacity *= appear;
|
||||||
|
}
|
||||||
|
if (opacity <= 0.02) continue;
|
||||||
|
|
||||||
|
const rad = (p.angle * Math.PI) / 180;
|
||||||
|
const size = p.drawSize * scale;
|
||||||
|
const half = size / 2;
|
||||||
|
const cos = Math.cos(rad);
|
||||||
|
const sin = Math.sin(rad);
|
||||||
|
|
||||||
|
context.globalAlpha = opacity;
|
||||||
|
context.setTransform(cos, sin, -sin, cos, p.x, p.y);
|
||||||
|
context.drawImage(p.emojiCanvas, -half, -half, size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.globalAlpha = 1;
|
||||||
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFireworkDpr(): number {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
@@ -4,21 +4,30 @@ import { readClipboardImage, isTauri, isLinux } from '../utils/tauri';
|
|||||||
|
|
||||||
export const useFilePasteHandler = (onPaste: (file: File[]) => void): ClipboardEventHandler =>
|
export const useFilePasteHandler = (onPaste: (file: File[]) => void): ClipboardEventHandler =>
|
||||||
useCallback(
|
useCallback(
|
||||||
async (evt) => {
|
(evt) => {
|
||||||
const files = getDataTransferFiles(evt.clipboardData);
|
const files = getDataTransferFiles(evt.clipboardData);
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
|
evt.preventDefault();
|
||||||
onPaste(files);
|
onPaste(files);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// On Linux with Tauri, browser clipboard API doesn't work for images
|
// Address-bar / plain-text copies still win. On Linux the native clipboard
|
||||||
// Use our custom Tauri command with arboard/Wayland support
|
// often still has a previous image (or a junk bitmap) alongside text/plain;
|
||||||
if (isTauri() && isLinux()) {
|
// don't treat those as an image paste.
|
||||||
const clipboardImage = await readClipboardImage();
|
const text = evt.clipboardData?.getData('text/plain')?.trim() ?? '';
|
||||||
if (clipboardImage) {
|
if (text.length > 0) {
|
||||||
onPaste([clipboardImage]);
|
return;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Browser clipboardData.files is empty for raw image copies on Linux Tauri/Electron.
|
||||||
|
if (!(isTauri() && isLinux())) return;
|
||||||
|
|
||||||
|
// preventDefault before the async read, or the editor also inserts text.
|
||||||
|
evt.preventDefault();
|
||||||
|
void readClipboardImage().then((clipboardImage) => {
|
||||||
|
if (clipboardImage) onPaste([clipboardImage]);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[onPaste]
|
[onPaste]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ import { MatrixClient } from 'matrix-js-sdk';
|
|||||||
import { getHomeRoomPath, getDirectRoomPath, getSpaceRoomPath } from './pages/pathUtils';
|
import { getHomeRoomPath, getDirectRoomPath, getSpaceRoomPath } from './pages/pathUtils';
|
||||||
import { getCanonicalAliasOrRoomId, isRoomId } from './utils/matrix';
|
import { getCanonicalAliasOrRoomId, isRoomId } from './utils/matrix';
|
||||||
import { getCallService } from './features/call/useCall';
|
import { getCallService } from './features/call/useCall';
|
||||||
import { RoomType, StateEvent } from '../types/matrix/room';
|
import {
|
||||||
|
getNotificationType,
|
||||||
|
getUnreadInfo,
|
||||||
|
reactionOrEditEvent,
|
||||||
|
roomHaveNotification,
|
||||||
|
roomHaveUnread,
|
||||||
|
} from './utils/room';
|
||||||
|
import { MessageEvent, NotificationType, RoomType, StateEvent } from '../types/matrix/room';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global reference to the router navigate function
|
* Global reference to the router navigate function
|
||||||
@@ -120,6 +127,30 @@ export function initPaarrotAPI(matrixClient: MatrixClient) {
|
|||||||
result = await getCurrentRoom(matrixClient);
|
result = await getCurrentRoom(matrixClient);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'get-messages-groups':
|
||||||
|
result = await getMessagesByScope(matrixClient, 'groups', action.params.limit);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'get-messages-dms':
|
||||||
|
result = await getMessagesByScope(matrixClient, 'dms', action.params.limit);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'get-messages-combined':
|
||||||
|
result = await getMessagesByScope(matrixClient, 'combined', action.params.limit);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'get-unreads-groups':
|
||||||
|
result = await getUnreadsByScope(matrixClient, 'groups');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'get-unreads-dms':
|
||||||
|
result = await getUnreadsByScope(matrixClient, 'dms');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'get-unreads-combined':
|
||||||
|
result = await getUnreadsByScope(matrixClient, 'combined');
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown action: ${action.action}`);
|
throw new Error(`Unknown action: ${action.action}`);
|
||||||
}
|
}
|
||||||
@@ -410,6 +441,207 @@ async function getCurrentRoom(matrixClient: any) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MESSAGE_LIMIT = 10;
|
||||||
|
const MAX_MESSAGE_LIMIT = 100;
|
||||||
|
|
||||||
|
type MessageScope = 'groups' | 'dms' | 'combined';
|
||||||
|
|
||||||
|
type ApiMessage = {
|
||||||
|
eventId: string;
|
||||||
|
roomId: string;
|
||||||
|
roomName: string;
|
||||||
|
isDirect: boolean;
|
||||||
|
sender: string;
|
||||||
|
senderName: string;
|
||||||
|
timestamp: number;
|
||||||
|
msgtype: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isDirectRoom(room: any): boolean {
|
||||||
|
return room?.guessDMUserId?.() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMessageLimit(limit?: number | string): number {
|
||||||
|
const parsedLimit = Number(limit);
|
||||||
|
return Math.min(
|
||||||
|
Math.max(Number.isFinite(parsedLimit) ? Math.floor(parsedLimit) : DEFAULT_MESSAGE_LIMIT, 1),
|
||||||
|
MAX_MESSAGE_LIMIT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect recent room messages from the local live timeline (newest last).
|
||||||
|
* Skips reactions, edits, and redacted events.
|
||||||
|
*/
|
||||||
|
function collectRecentMessages(room: any, limit: number): ApiMessage[] {
|
||||||
|
const liveEvents = room.getLiveTimeline?.()?.getEvents?.() ?? [];
|
||||||
|
const isDirect = isDirectRoom(room);
|
||||||
|
const roomId = room.roomId;
|
||||||
|
const roomName = room.name || 'Unnamed Room';
|
||||||
|
const messages: ApiMessage[] = [];
|
||||||
|
|
||||||
|
for (let i = liveEvents.length - 1; i >= 0 && messages.length < limit; i -= 1) {
|
||||||
|
const evt = liveEvents[i];
|
||||||
|
if (!evt) continue;
|
||||||
|
if (typeof evt.isRedacted === 'function' && evt.isRedacted()) continue;
|
||||||
|
if (reactionOrEditEvent(evt)) continue;
|
||||||
|
|
||||||
|
const type = evt.getType?.();
|
||||||
|
if (
|
||||||
|
type !== MessageEvent.RoomMessage &&
|
||||||
|
type !== MessageEvent.RoomMessageEncrypted &&
|
||||||
|
type !== MessageEvent.Sticker
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = evt.getContent?.() ?? {};
|
||||||
|
const sender = evt.getSender?.() ?? '';
|
||||||
|
const member = sender ? room.getMember?.(sender) : null;
|
||||||
|
|
||||||
|
if (type === MessageEvent.RoomMessageEncrypted && !content.msgtype) {
|
||||||
|
messages.push({
|
||||||
|
eventId: evt.getId?.() ?? '',
|
||||||
|
roomId,
|
||||||
|
roomName,
|
||||||
|
isDirect,
|
||||||
|
sender,
|
||||||
|
senderName: member?.name ?? sender,
|
||||||
|
timestamp: evt.getTs?.() ?? 0,
|
||||||
|
msgtype: 'm.encrypted',
|
||||||
|
body: '[encrypted]',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
eventId: evt.getId?.() ?? '',
|
||||||
|
roomId,
|
||||||
|
roomName,
|
||||||
|
isDirect,
|
||||||
|
sender,
|
||||||
|
senderName: member?.name ?? sender,
|
||||||
|
timestamp: evt.getTs?.() ?? 0,
|
||||||
|
msgtype: content.msgtype || (type === MessageEvent.Sticker ? 'm.sticker' : 'm.text'),
|
||||||
|
body: content.body || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoomsForScope(matrixClient: any, scope: MessageScope) {
|
||||||
|
const rooms = (matrixClient?.getRooms?.() || []).filter(
|
||||||
|
(room: any) => !isSpaceLikeRoom(room) && room.getMyMembership?.() === 'join'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (scope === 'groups') {
|
||||||
|
return rooms.filter((room: any) => !isDirectRoom(room));
|
||||||
|
}
|
||||||
|
if (scope === 'dms') {
|
||||||
|
return rooms.filter((room: any) => isDirectRoom(room));
|
||||||
|
}
|
||||||
|
return rooms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent messages across groups, DMs, or both (combined).
|
||||||
|
*/
|
||||||
|
async function getMessagesByScope(
|
||||||
|
matrixClient: any,
|
||||||
|
scope: MessageScope,
|
||||||
|
limit?: number | string
|
||||||
|
) {
|
||||||
|
const limitNum = parseMessageLimit(limit);
|
||||||
|
const rooms = getRoomsForScope(matrixClient, scope);
|
||||||
|
|
||||||
|
const candidates: ApiMessage[] = [];
|
||||||
|
for (const room of rooms) {
|
||||||
|
candidates.push(...collectRecentMessages(room, limitNum));
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
const messages = candidates.slice(-limitNum);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scope,
|
||||||
|
limit: limitNum,
|
||||||
|
count: messages.length,
|
||||||
|
messages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List unread conversations across groups, DMs, or both (combined).
|
||||||
|
* Uses the same unread rules as the app UI (notification counts + read receipts, muted excluded).
|
||||||
|
*/
|
||||||
|
async function getUnreadsByScope(matrixClient: any, scope: MessageScope) {
|
||||||
|
const rooms = getRoomsForScope(matrixClient, scope);
|
||||||
|
const unreads: Array<{
|
||||||
|
roomId: string;
|
||||||
|
name: string;
|
||||||
|
isDirect: boolean;
|
||||||
|
avatar: string | null;
|
||||||
|
total: number;
|
||||||
|
highlight: number;
|
||||||
|
latest?: {
|
||||||
|
eventId: string;
|
||||||
|
sender: string;
|
||||||
|
senderName: string;
|
||||||
|
timestamp: number;
|
||||||
|
msgtype: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const room of rooms) {
|
||||||
|
if (getNotificationType(matrixClient, room.roomId) === NotificationType.Mute) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!roomHaveNotification(room) && !roomHaveUnread(matrixClient, room)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unreadInfo = getUnreadInfo(room);
|
||||||
|
const latestMessages = collectRecentMessages(room, 1);
|
||||||
|
const latest = latestMessages[0];
|
||||||
|
|
||||||
|
unreads.push({
|
||||||
|
roomId: room.roomId,
|
||||||
|
name: room.name || 'Unnamed Room',
|
||||||
|
isDirect: isDirectRoom(room),
|
||||||
|
avatar: room.getMxcAvatarUrl?.() || null,
|
||||||
|
total: unreadInfo.total,
|
||||||
|
highlight: unreadInfo.highlight,
|
||||||
|
latest: latest
|
||||||
|
? {
|
||||||
|
eventId: latest.eventId,
|
||||||
|
sender: latest.sender,
|
||||||
|
senderName: latest.senderName,
|
||||||
|
timestamp: latest.timestamp,
|
||||||
|
msgtype: latest.msgtype,
|
||||||
|
body: latest.body,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
unreads.sort((a, b) => {
|
||||||
|
const aTs = a.latest?.timestamp ?? 0;
|
||||||
|
const bTs = b.latest?.timestamp ?? 0;
|
||||||
|
if (bTs !== aTs) return bTs - aTs;
|
||||||
|
if (b.highlight !== a.highlight) return b.highlight - a.highlight;
|
||||||
|
return b.total - a.total;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
scope,
|
||||||
|
count: unreads.length,
|
||||||
|
unreads,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the currently active room ID
|
* Get the currently active room ID
|
||||||
* Extracts room ID from the current URL pathname
|
* Extracts room ID from the current URL pathname
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
Membership,
|
Membership,
|
||||||
MessageEvent,
|
MessageEvent,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
|
PaarrotUiContent,
|
||||||
RoomToParents,
|
RoomToParents,
|
||||||
RoomType,
|
RoomType,
|
||||||
StateEvent,
|
StateEvent,
|
||||||
@@ -125,6 +126,33 @@ export const getPaarrotRoomKind = (room: Room | null): string | undefined => {
|
|||||||
return typeof kind === 'string' ? kind : undefined;
|
return typeof kind === 'string' ? kind : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valid bot-driven room UI state (im.paarrot.ui with version + root node).
|
||||||
|
*/
|
||||||
|
export const parsePaarrotUiContent = (raw: unknown): PaarrotUiContent | undefined => {
|
||||||
|
if (!raw || typeof raw !== 'object') return undefined;
|
||||||
|
const content = raw as PaarrotUiContent;
|
||||||
|
if (
|
||||||
|
typeof content.version !== 'number' ||
|
||||||
|
!content.root ||
|
||||||
|
typeof content.root !== 'object' ||
|
||||||
|
typeof content.root.type !== 'string' ||
|
||||||
|
typeof content.root.id !== 'string'
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPaarrotUiContent = (room: Room | null): PaarrotUiContent | undefined => {
|
||||||
|
if (!room) return undefined;
|
||||||
|
const event = room.currentState?.getStateEvents(StateEvent.PaarrotUi, '');
|
||||||
|
if (!event) return undefined;
|
||||||
|
return parsePaarrotUiContent(event.getContent());
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hasRoomAppUi = (room: Room | null): boolean => getPaarrotUiContent(room) !== undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* m.forum space root (forum board lobby), not a plain m.space or a lone m.forum topic channel.
|
* m.forum space root (forum board lobby), not a plain m.space or a lone m.forum topic channel.
|
||||||
* Matrix sets create type m.forum on forum spaces but SDK isSpaceRoom() is often still false;
|
* Matrix sets create type m.forum on forum spaces but SDK isSpaceRoom() is often still false;
|
||||||
|
|||||||
@@ -534,12 +534,20 @@ export const readClipboardImage = async (): Promise<File | null> => {
|
|||||||
try {
|
try {
|
||||||
const electron = (window as any).electron;
|
const electron = (window as any).electron;
|
||||||
if (electron?.clipboard?.readImage) {
|
if (electron?.clipboard?.readImage) {
|
||||||
const dataUrl = await electron.clipboard.readImage();
|
const result = await electron.clipboard.readImage();
|
||||||
|
// IPC may return a data URL string or { success, data } depending on path.
|
||||||
|
const dataUrl =
|
||||||
|
typeof result === 'string'
|
||||||
|
? result
|
||||||
|
: result && typeof result === 'object' && typeof result.data === 'string'
|
||||||
|
? result.data
|
||||||
|
: null;
|
||||||
if (!dataUrl) return null;
|
if (!dataUrl) return null;
|
||||||
|
|
||||||
// Convert data URL to File
|
// Convert data URL to File
|
||||||
const response = await fetch(dataUrl);
|
const response = await fetch(dataUrl);
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
|
if (blob.size < 32) return null;
|
||||||
return new File([blob], 'clipboard-image.png', { type: 'image/png' });
|
return new File([blob], 'clipboard-image.png', { type: 'image/png' });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -559,6 +567,7 @@ export const readClipboardImage = async (): Promise<File | null> => {
|
|||||||
// Convert data URL to File
|
// Convert data URL to File
|
||||||
const response = await fetch(dataUrl);
|
const response = await fetch(dataUrl);
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
|
if (blob.size < 32) return null;
|
||||||
return new File([blob], 'clipboard-image.png', { type: 'image/png' });
|
return new File([blob], 'clipboard-image.png', { type: 'image/png' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to read Tauri clipboard image:', err);
|
console.warn('Failed to read Tauri clipboard image:', err);
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ export enum StateEvent {
|
|||||||
/** Paarrot room classification marker for custom room behaviors */
|
/** Paarrot room classification marker for custom room behaviors */
|
||||||
PaarrotRoomKind = 'im.paarrot.room.kind',
|
PaarrotRoomKind = 'im.paarrot.room.kind',
|
||||||
|
|
||||||
|
/** Bot-driven declarative room UI (takes over room view) */
|
||||||
|
PaarrotUi = 'im.paarrot.ui',
|
||||||
|
|
||||||
/** Matrix RTC call membership (MSC3401) */
|
/** Matrix RTC call membership (MSC3401) */
|
||||||
CallMember = 'org.matrix.msc3401.call.member',
|
CallMember = 'org.matrix.msc3401.call.member',
|
||||||
}
|
}
|
||||||
@@ -61,8 +64,114 @@ export enum MessageEvent {
|
|||||||
|
|
||||||
/** Relay emoji confetti burst tied to a target message */
|
/** Relay emoji confetti burst tied to a target message */
|
||||||
RelayEmojiConfetti = 'app.relay.emoji_confetti',
|
RelayEmojiConfetti = 'app.relay.emoji_confetti',
|
||||||
|
|
||||||
|
/** User interaction with bot-driven room UI (im.paarrot.ui) */
|
||||||
|
PaarrotUiAction = 'im.paarrot.ui.action',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Declarative UI node types for im.paarrot.ui */
|
||||||
|
export type PaarrotUiNodeType =
|
||||||
|
| 'panel'
|
||||||
|
| 'row'
|
||||||
|
| 'text'
|
||||||
|
| 'button'
|
||||||
|
| 'input'
|
||||||
|
| 'select'
|
||||||
|
| 'image'
|
||||||
|
| 'spacer';
|
||||||
|
|
||||||
|
export type PaarrotUiNodeBase = {
|
||||||
|
type: PaarrotUiNodeType;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaarrotUiPanelNode = PaarrotUiNodeBase & {
|
||||||
|
type: 'panel';
|
||||||
|
children?: PaarrotUiNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaarrotUiRowNode = PaarrotUiNodeBase & {
|
||||||
|
type: 'row';
|
||||||
|
children?: PaarrotUiNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaarrotUiTextNode = PaarrotUiNodeBase & {
|
||||||
|
type: 'text';
|
||||||
|
text?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaarrotUiButtonNode = PaarrotUiNodeBase & {
|
||||||
|
type: 'button';
|
||||||
|
label: string;
|
||||||
|
action: string;
|
||||||
|
value?: string | number | boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
variant?: 'Primary' | 'Secondary' | 'Success' | 'Warning' | 'Critical';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaarrotUiInputNode = PaarrotUiNodeBase & {
|
||||||
|
type: 'input';
|
||||||
|
name?: string;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
value?: string;
|
||||||
|
inputType?: 'text' | 'number' | 'password';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaarrotUiSelectOption = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaarrotUiSelectNode = PaarrotUiNodeBase & {
|
||||||
|
type: 'select';
|
||||||
|
name?: string;
|
||||||
|
label?: string;
|
||||||
|
options?: PaarrotUiSelectOption[];
|
||||||
|
value?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaarrotUiImageNode = PaarrotUiNodeBase & {
|
||||||
|
type: 'image';
|
||||||
|
src: string;
|
||||||
|
alt?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaarrotUiSpacerNode = PaarrotUiNodeBase & {
|
||||||
|
type: 'spacer';
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaarrotUiNode =
|
||||||
|
| PaarrotUiPanelNode
|
||||||
|
| PaarrotUiRowNode
|
||||||
|
| PaarrotUiTextNode
|
||||||
|
| PaarrotUiButtonNode
|
||||||
|
| PaarrotUiInputNode
|
||||||
|
| PaarrotUiSelectNode
|
||||||
|
| PaarrotUiImageNode
|
||||||
|
| PaarrotUiSpacerNode;
|
||||||
|
|
||||||
|
/** Content of state event im.paarrot.ui */
|
||||||
|
export type PaarrotUiContent = {
|
||||||
|
version: number;
|
||||||
|
title?: string;
|
||||||
|
css?: string;
|
||||||
|
root: PaarrotUiNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Content of timeline event im.paarrot.ui.action */
|
||||||
|
export type PaarrotUiActionContent = {
|
||||||
|
action: string;
|
||||||
|
component_id: string;
|
||||||
|
value?: string | number | boolean;
|
||||||
|
values?: Record<string, string>;
|
||||||
|
ui_event_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export enum RoomType {
|
export enum RoomType {
|
||||||
Space = 'm.space',
|
Space = 'm.space',
|
||||||
Forum = 'm.forum',
|
Forum = 'm.forum',
|
||||||
|
|||||||
Reference in New Issue
Block a user