Compare commits
39 Commits
9c7f71896c
...
7d866404b3
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d866404b3 | |||
| c264de53b5 | |||
| b6f3f5c0aa | |||
| 25be638c5e | |||
| a20c871726 | |||
| e65a516350 | |||
| 9887903f49 | |||
| 898d217451 | |||
| e08b4ec22e | |||
| 13523fea2b | |||
| 8a68a1e30a | |||
| 32bf2cbed5 | |||
| 17706ae019 | |||
| 82b22fa739 | |||
| 0da4f0d6cb | |||
| ea7642f0bb | |||
| 0fb3da20b9 | |||
| e460dbd34e | |||
| 0de2fd942f | |||
| 2603ca8e5c | |||
| 99a294791e | |||
| f62200ecd1 | |||
| b52926f7d8 | |||
| d338e1c35e | |||
| 9589680a81 | |||
| 27f1357fc0 | |||
| 9509a9705e | |||
| 61d41900cc | |||
| e7777f42d8 | |||
| ae70eae0fc | |||
| c286501be8 | |||
| 154f4dfdb0 | |||
| c7067021ba | |||
| 687f139719 | |||
| b711646b58 | |||
| bd6d753f47 | |||
| 3de3058ca9 | |||
| 02d96a9758 | |||
| 5374c10c61 |
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
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,4 +4,5 @@ node_modules
|
|||||||
devAssets
|
devAssets
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.idea
|
.idea
|
||||||
|
.cache
|
||||||
|
|||||||
3
.npmrc
3
.npmrc
@@ -1,2 +1,3 @@
|
|||||||
legacy-peer-deps=true
|
legacy-peer-deps=true
|
||||||
save-exact=true
|
save-exact=true
|
||||||
|
allow-git=all
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ tauri.ts / electron main
|
|||||||
- Multiple runtimes: `isTauri`, `isElectron`, `isCapacitorNative` branch logic
|
- Multiple runtimes: `isTauri`, `isElectron`, `isCapacitorNative` branch logic
|
||||||
- Pusher registration races on fast login/logout
|
- Pusher registration races on fast login/logout
|
||||||
- Android small icon: `ic_stat_paarrot` must exist in Android resources
|
- Android small icon: `ic_stat_paarrot` must exist in Android resources
|
||||||
|
- Background wake (`MatrixSyncService`) must filter by push rules / unread_notifications; a push for one room used to notify for every new message in the sync batch
|
||||||
|
|
||||||
## Future work
|
## Future work
|
||||||
|
|
||||||
@@ -91,3 +92,4 @@ tauri.ts / electron main
|
|||||||
|
|
||||||
- Constants: `PUSHER_APP_ID_BASE`, `PUSHER_STORAGE_PREFIX` in `backgroundSync.ts`
|
- Constants: `PUSHER_APP_ID_BASE`, `PUSHER_STORAGE_PREFIX` in `backgroundSync.ts`
|
||||||
- Logo assets: `paarrot.svg`, `paarrot-unread.svg`, `paarrot-highlight.svg` in `public/res/svg/`
|
- Logo assets: `paarrot.svg`, `paarrot-unread.svg`, `paarrot-highlight.svg` in `public/res/svg/`
|
||||||
|
- Android notify filter: mute / mentions / default-room behavior lives in `MatrixSyncService.resolveRoomNotifyMode`
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -96,6 +96,12 @@
|
|||||||
</script>
|
</script>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<div id="portalContainer"></div>
|
<div id="portalContainer"></div>
|
||||||
|
<style>
|
||||||
|
#portalContainer {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
<script type="module" src="./src/index.tsx"></script>
|
<script type="module" src="./src/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
1515
package-lock.json
generated
1515
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -8,13 +8,14 @@
|
|||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite --open false",
|
"start": "vite",
|
||||||
"build": "vite build",
|
"build": "node scripts/generate-update-manifest.mjs && vite build",
|
||||||
"lint": "yarn check:eslint && yarn check:prettier",
|
"lint": "yarn check:eslint && yarn check:prettier",
|
||||||
"check:eslint": "eslint src/*",
|
"check:eslint": "eslint src/*",
|
||||||
"check:prettier": "prettier --check .",
|
"check:prettier": "prettier --check .",
|
||||||
"fix:prettier": "prettier --write .",
|
"fix:prettier": "prettier --write .",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"playground": "vite"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Ajay Bura",
|
"author": "Ajay Bura",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "3.0.0",
|
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "3.0.0",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "2.0.0",
|
"@atlaskit/pragmatic-drag-and-drop-hitbox": "2.0.0",
|
||||||
"@fontsource-variable/inter": "5.2.8",
|
"@fontsource-variable/inter": "5.2.8",
|
||||||
|
"@fontsource/caveat": "5.3.0",
|
||||||
"@fontsource/inter": "5.2.8",
|
"@fontsource/inter": "5.2.8",
|
||||||
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
|
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
|
||||||
"@tanstack/react-query": "5.101.2",
|
"@tanstack/react-query": "5.101.2",
|
||||||
@@ -82,8 +84,10 @@
|
|||||||
"react-error-boundary": "6.1.2",
|
"react-error-boundary": "6.1.2",
|
||||||
"react-google-recaptcha": "3.1.0",
|
"react-google-recaptcha": "3.1.0",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
|
"react-markdown": "10.1.0",
|
||||||
"react-range": "1.10.0",
|
"react-range": "1.10.0",
|
||||||
"react-router-dom": "7.18.1",
|
"react-router-dom": "7.18.1",
|
||||||
|
"remark-gfm": "4.0.1",
|
||||||
"sanitize-html": "2.17.5",
|
"sanitize-html": "2.17.5",
|
||||||
"slate": "0.124.1",
|
"slate": "0.124.1",
|
||||||
"slate-dom": "0.124.1",
|
"slate-dom": "0.124.1",
|
||||||
@@ -93,6 +97,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||||
|
"@monaco-editor/react": "4.7.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
"@types/chroma-js": "3.1.2",
|
"@types/chroma-js": "3.1.2",
|
||||||
@@ -116,11 +121,16 @@
|
|||||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"eslint-plugin-react-hooks": "7.1.1",
|
"eslint-plugin-react-hooks": "7.1.1",
|
||||||
|
"monaco-editor": "0.52.2",
|
||||||
"prettier": "3.9.4",
|
"prettier": "3.9.4",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"vite": "8.1.3",
|
"vite": "8.1.3",
|
||||||
"vite-plugin-pwa": "1.3.0",
|
"vite-plugin-pwa": "1.3.0",
|
||||||
"vite-plugin-static-copy": "4.1.1",
|
"vite-plugin-static-copy": "4.1.1",
|
||||||
"vite-plugin-top-level-await": "1.6.0"
|
"vite-plugin-top-level-await": "1.6.0"
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"@swc/core@1.15.43": true,
|
||||||
|
"esbuild@0.28.1": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
149
playground-liveTsxPlugin.ts
Normal file
149
playground-liveTsxPlugin.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import type { Plugin, ViteDevServer } from 'vite';
|
||||||
|
|
||||||
|
export type LiveTsxApi = {
|
||||||
|
getSource: () => string;
|
||||||
|
setSource: (source: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALLOWED_PREFIXES = ['src/app/components/', 'src/app/features/'];
|
||||||
|
|
||||||
|
function resolveAppSourceFile(projectRoot: string, relPath: string): string | null {
|
||||||
|
const normalized = relPath.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||||
|
if (normalized.includes('..') || path.isAbsolute(normalized)) return null;
|
||||||
|
if (!ALLOWED_PREFIXES.some((p) => normalized.startsWith(p))) return null;
|
||||||
|
if (!/\.(tsx|ts)$/.test(normalized)) return null;
|
||||||
|
const abs = path.resolve(projectRoot, normalized);
|
||||||
|
const root = path.resolve(projectRoot);
|
||||||
|
if (!abs.startsWith(root + path.sep) && abs !== root) return null;
|
||||||
|
return abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req: NodeJS.ReadableStream): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on('data', (c) => chunks.push(c));
|
||||||
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bumpLivePreview(server: ViteDevServer | undefined) {
|
||||||
|
if (!server) return;
|
||||||
|
server.ws.send({
|
||||||
|
type: 'custom',
|
||||||
|
event: 'playground:live-updated',
|
||||||
|
data: { revision: Date.now() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function liveTsxPlugin(projectRoot: string, initialSource: string): Plugin {
|
||||||
|
const liveFile = path.resolve(projectRoot, '.cache/playground-live.tsx');
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(liveFile), { recursive: true });
|
||||||
|
fs.writeFileSync(liveFile, initialSource, 'utf8');
|
||||||
|
|
||||||
|
let source = initialSource;
|
||||||
|
let server: ViteDevServer | undefined;
|
||||||
|
|
||||||
|
const api: LiveTsxApi = {
|
||||||
|
getSource: () => source,
|
||||||
|
setSource: (next) => {
|
||||||
|
source = next;
|
||||||
|
fs.writeFileSync(liveFile, next, 'utf8');
|
||||||
|
if (!server) return;
|
||||||
|
for (const m of server.moduleGraph.urlToModuleMap.values()) {
|
||||||
|
if (m.file === liveFile) server.moduleGraph.invalidateModule(m);
|
||||||
|
}
|
||||||
|
const byId = server.moduleGraph.getModuleById(liveFile);
|
||||||
|
if (byId) server.moduleGraph.invalidateModule(byId);
|
||||||
|
bumpLivePreview(server);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'playground-live-tsx',
|
||||||
|
configureServer(devServer) {
|
||||||
|
server = devServer;
|
||||||
|
(devServer as ViteDevServer & { playgroundLive?: LiveTsxApi }).playgroundLive = api;
|
||||||
|
|
||||||
|
devServer.middlewares.use('/__playground/live', (req, res, next) => {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||||
|
res.end(source);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
void readBody(req).then((body) => {
|
||||||
|
api.setSource(body);
|
||||||
|
res.statusCode = 204;
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read/write real Cinny component sources (strings, markup, etc.).
|
||||||
|
devServer.middlewares.use('/__playground/source', (req, res, next) => {
|
||||||
|
const url = new URL(req.url ?? '', 'http://playground.local');
|
||||||
|
const rel = url.searchParams.get('path') ?? '';
|
||||||
|
const abs = resolveAppSourceFile(projectRoot, rel);
|
||||||
|
if (!abs) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.end('Invalid or disallowed path');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
if (!fs.existsSync(abs)) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end('Not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||||
|
res.end(fs.readFileSync(abs, 'utf8'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
void readBody(req).then((body) => {
|
||||||
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||||
|
fs.writeFileSync(abs, body, 'utf8');
|
||||||
|
const mod =
|
||||||
|
server?.moduleGraph.getModuleById(abs) ??
|
||||||
|
[...(server?.moduleGraph.urlToModuleMap.values() ?? [])].find((m) => m.file === abs);
|
||||||
|
if (mod && server) server.moduleGraph.invalidateModule(mod);
|
||||||
|
bumpLivePreview(server);
|
||||||
|
res.statusCode = 204;
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
resolveId(id) {
|
||||||
|
const clean = id.split('?')[0];
|
||||||
|
if (
|
||||||
|
clean === '/@playground/live.tsx' ||
|
||||||
|
clean === 'virtual:playground-live' ||
|
||||||
|
clean.endsWith('/.cache/playground-live.tsx')
|
||||||
|
) {
|
||||||
|
return liveFile;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readDefaultLiveSource(projectRoot: string): string {
|
||||||
|
return fs.readFileSync(path.join(projectRoot, 'src/playground/defaultLive.tsx'), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectRootDir(): string {
|
||||||
|
return path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
}
|
||||||
12
playground.html
Normal file
12
playground.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Component Playground</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;background:#0f1115;color:#e8eaed">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./src/playground/main.tsx?v=react-dedupe-1"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
16
public/update/4.11.175.md
Normal file
16
public/update/4.11.175.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
version: 4.11.175
|
||||||
|
title: Stability & polish
|
||||||
|
date: 2026-08-10
|
||||||
|
summary: Desktop update flow improvements and UI refinements.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Desktop updates
|
||||||
|
|
||||||
|
- Smoother download and install flow in the title bar updater.
|
||||||
|
- Better handling when an update is already downloading.
|
||||||
|
|
||||||
|
## UI polish
|
||||||
|
|
||||||
|
- Refined spacing and contrast in settings panels.
|
||||||
|
- Improved mobile layout for room headers and notifications.
|
||||||
22
public/update/currentupdate.md
Normal file
22
public/update/currentupdate.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
version: 4.11.177
|
||||||
|
title: Profile colors & banners
|
||||||
|
date: 2026-08-23
|
||||||
|
summary: Username colors now follow MSC4522 with separate dark and light theme pickers.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Profile colors (MSC4522)
|
||||||
|
|
||||||
|
Username colors are now stored on your Matrix profile instead of inside avatar images. In turn **will need to be updated in your Account settings**, this is now cross compatible with Element + Cinny
|
||||||
|
|
||||||
|
- Set **separate colors for dark and light themes** in Settings → Account → Profile.
|
||||||
|
- Per-room username overrides are read from member profile data when available.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Profile banners (MSC4133)
|
||||||
|
|
||||||
|
Profile banners now live on your Matrix profile via `m.banner_url` (MSC4133), not embedded in avatar metadata.
|
||||||
|
|
||||||
|
- Upload or remove a banner independently from your avatar.
|
||||||
|
- Other clients that support MSC4133 can read your banner directly.
|
||||||
5634
public/update/images/paarrot.svg
Normal file
5634
public/update/images/paarrot.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 889 KiB |
BIN
public/update/images/usernamecolors.png
Normal file
BIN
public/update/images/usernamecolors.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
11
public/update/manifest.json
Normal file
11
public/update/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"current": "currentupdate.md",
|
||||||
|
"older": [
|
||||||
|
{
|
||||||
|
"file": "4.11.175.md",
|
||||||
|
"version": "4.11.175",
|
||||||
|
"title": "Stability & polish",
|
||||||
|
"date": "2026-08-10"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
114
scripts/generate-update-manifest.mjs
Normal file
114
scripts/generate-update-manifest.mjs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Regenerates public/update/manifest.json from markdown files.
|
||||||
|
*
|
||||||
|
* - currentupdate.md is always the "current" entry
|
||||||
|
* - Other *.md files matching X.Y.Z.md become "older" (sorted newest first)
|
||||||
|
* - title, date, version come from YAML frontmatter when present
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
const UPDATE_DIR = path.join(ROOT, 'public', 'update');
|
||||||
|
const MANIFEST_PATH = path.join(UPDATE_DIR, 'manifest.json');
|
||||||
|
const CURRENT_FILE = 'currentupdate.md';
|
||||||
|
const VERSION_FILE_RE = /^(\d+\.\d+\.\d+)\.md$/;
|
||||||
|
|
||||||
|
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
||||||
|
|
||||||
|
function parseFrontmatter(source) {
|
||||||
|
const match = FRONTMATTER_RE.exec(source.trimStart());
|
||||||
|
if (!match) return {};
|
||||||
|
|
||||||
|
const frontmatter = {};
|
||||||
|
match[1].split('\n').forEach((line) => {
|
||||||
|
const colon = line.indexOf(':');
|
||||||
|
if (colon <= 0) return;
|
||||||
|
const key = line.slice(0, colon).trim();
|
||||||
|
const raw = line.slice(colon + 1).trim();
|
||||||
|
frontmatter[key] = raw.replace(/^['"]|['"]$/g, '');
|
||||||
|
});
|
||||||
|
return frontmatter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareVersions(a, b) {
|
||||||
|
const pa = a.split('.').map((part) => Number.parseInt(part, 10));
|
||||||
|
const pb = b.split('.').map((part) => Number.parseInt(part, 10));
|
||||||
|
const len = Math.max(pa.length, pb.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < len; i += 1) {
|
||||||
|
const na = pa[i] ?? 0;
|
||||||
|
const nb = pb[i] ?? 0;
|
||||||
|
if (na !== nb) return nb - na;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMarkdownMeta(fileName) {
|
||||||
|
const filePath = path.join(UPDATE_DIR, fileName);
|
||||||
|
const source = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const frontmatter = parseFrontmatter(source);
|
||||||
|
const versionFromName = VERSION_FILE_RE.exec(fileName)?.[1];
|
||||||
|
|
||||||
|
return {
|
||||||
|
file: fileName,
|
||||||
|
version: frontmatter.version ?? versionFromName,
|
||||||
|
title: frontmatter.title,
|
||||||
|
date: frontmatter.date,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildManifest() {
|
||||||
|
if (!fs.existsSync(UPDATE_DIR)) {
|
||||||
|
throw new Error(`Update directory not found: ${UPDATE_DIR}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPath = path.join(UPDATE_DIR, CURRENT_FILE);
|
||||||
|
if (!fs.existsSync(currentPath)) {
|
||||||
|
throw new Error(`Missing ${CURRENT_FILE} in ${UPDATE_DIR}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const older = fs
|
||||||
|
.readdirSync(UPDATE_DIR, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isFile() && VERSION_FILE_RE.test(entry.name))
|
||||||
|
.map((entry) => readMarkdownMeta(entry.name))
|
||||||
|
.filter((entry) => entry.version)
|
||||||
|
.sort((a, b) => compareVersions(a.version, b.version));
|
||||||
|
|
||||||
|
return {
|
||||||
|
current: CURRENT_FILE,
|
||||||
|
older: older.map(({ file, version, title, date }) => {
|
||||||
|
const item = { file };
|
||||||
|
if (version) item.version = version;
|
||||||
|
if (title) item.title = title;
|
||||||
|
if (date) item.date = date;
|
||||||
|
return item;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const manifest = buildManifest();
|
||||||
|
const json = `${JSON.stringify(manifest, null, 2)}\n`;
|
||||||
|
|
||||||
|
let previous = null;
|
||||||
|
if (fs.existsSync(MANIFEST_PATH)) {
|
||||||
|
previous = fs.readFileSync(MANIFEST_PATH, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previous === json) {
|
||||||
|
console.log('[update-manifest] manifest.json is already up to date');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(MANIFEST_PATH, json, 'utf8');
|
||||||
|
console.log(
|
||||||
|
`[update-manifest] wrote manifest.json (${manifest.older.length} older version(s))`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Box, Text, IconButton, Input, Button, TextAreaComponent as TextArea, color, Spinner, Chip, Scroll, config } from 'folds';
|
import { Box, Text, IconButton, Input, Button, TextArea, color, Spinner, Chip, Scroll, config } from 'folds';
|
||||||
import { Icon, Icons } from './icons';
|
import { Icon, Icons } from './icons';
|
||||||
import { MatrixError } from 'matrix-js-sdk';
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
import { Cursor } from '../plugins/text-area';
|
import { Cursor } from '../plugins/text-area';
|
||||||
@@ -154,7 +154,7 @@ function AccountDataEdit({
|
|||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<Text size="L400">JSON Content</Text>
|
<Text size="L400">JSON Content</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<TextAreaComponent
|
<TextArea
|
||||||
ref={textAreaRef}
|
ref={textAreaRef}
|
||||||
name="contentTextArea"
|
name="contentTextArea"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,16 +1,53 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Outlet, useLocation } from 'react-router-dom';
|
import { Outlet, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
const decodeSegment = (segment: string): string => {
|
||||||
|
let decoded = segment;
|
||||||
|
try {
|
||||||
|
let next = decodeURIComponent(decoded);
|
||||||
|
while (next !== decoded) {
|
||||||
|
decoded = next;
|
||||||
|
next = decodeURIComponent(decoded);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// keep partially decoded value
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper for Outlet that adds route-based animation
|
* Room routes are `:roomIdOrAlias/:eventId?/`. Jumping to an event (or clearing it)
|
||||||
* Forces remount on route change by using location as key
|
* changes the pathname but not the room — keep the outlet mounted so we don't replay
|
||||||
|
* the route enter animation or remount drawers like Shared Media.
|
||||||
|
*
|
||||||
|
* Path segments are decoded so `!room:server` and `%21room%3Aserver` share a key.
|
||||||
|
*/
|
||||||
|
const getOutletTransitionKey = (pathname: string): string => {
|
||||||
|
const segments = pathname.split('/').filter(Boolean).map(decodeSegment);
|
||||||
|
if (segments.length === 0) return pathname;
|
||||||
|
|
||||||
|
// Matrix event IDs start with `$`
|
||||||
|
if (segments[segments.length - 1].startsWith('$')) {
|
||||||
|
segments.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/${segments.join('/')}/`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for Outlet that adds route-based animation.
|
||||||
|
* Remounts (and animates) when leaving a room / switching rooms, not on same-room event hops.
|
||||||
*/
|
*/
|
||||||
export function AnimatedOutlet() {
|
export function AnimatedOutlet() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const transitionKey = useMemo(
|
||||||
|
() => getOutletTransitionKey(location.pathname),
|
||||||
|
[location.pathname]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={location.pathname}
|
key={transitionKey}
|
||||||
data-route-transition="true"
|
data-route-transition="true"
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React, { ReactNode, useCallback, useState } from 'react';
|
import React, { ReactNode, useCallback, useState } from 'react';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
import { Modal, Overlay, OverlayBackdrop, OverlayCenter, PopOutContainerProvider } from 'folds';
|
import { Modal, Overlay, OverlayBackdrop, OverlayCenter, PopOutContainerProvider } from 'folds';
|
||||||
import { stopPropagation } from '../utils/keyboard';
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
import { releaseNotesDialogAtom } from '../state/releaseNotes';
|
||||||
|
|
||||||
type Modal500Props = {
|
type Modal500Props = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
@@ -10,11 +12,13 @@ type Modal500Props = {
|
|||||||
export function Modal500({ requestClose, children }: Modal500Props) {
|
export function Modal500({ requestClose, children }: Modal500Props) {
|
||||||
const [modalEl, setModalEl] = useState<HTMLDivElement | null>(null);
|
const [modalEl, setModalEl] = useState<HTMLDivElement | null>(null);
|
||||||
const modalRef = useCallback((el: HTMLDivElement | null) => setModalEl(el), []);
|
const modalRef = useCallback((el: HTMLDivElement | null) => setModalEl(el), []);
|
||||||
|
const releaseNotesOpen = useAtomValue(releaseNotesDialogAtom).open;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
|
active={!releaseNotesOpen}
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import classNames from 'classnames';
|
|||||||
import { Box, Button, Chip, Header, IconButton, Input, Menu, PopOut, RectCords, Scroll, Spinner, Text, as, config } from 'folds';
|
import { Box, Button, Chip, Header, IconButton, Input, Menu, PopOut, RectCords, Scroll, Spinner, Text, as, config } from 'folds';
|
||||||
import { Icon, Icons } from '../icons';
|
import { Icon, Icons } from '../icons';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import FileSaver from 'file-saver';
|
import { saveMediaBlob } from '../../utils/saveMedia';
|
||||||
import * as css from './PdfViewer.css';
|
import * as css from './PdfViewer.css';
|
||||||
import { AsyncStatus } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus } from '../../hooks/useAsyncCallback';
|
||||||
import { useZoom } from '../../hooks/useZoom';
|
import { useZoom } from '../../hooks/useZoom';
|
||||||
@@ -61,8 +61,14 @@ export const PdfViewer = as<'div', PdfViewerProps>(
|
|||||||
}
|
}
|
||||||
}, [docState, pageNo, zoom]);
|
}, [docState, pageNo, zoom]);
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = async () => {
|
||||||
FileSaver.saveAs(src, name);
|
try {
|
||||||
|
const res = await fetch(src);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch PDF');
|
||||||
|
await saveMediaBlob(await res.blob(), name);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[PdfViewer] Failed to download:', error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { KeyboardEvent, MouseEvent } from 'react';
|
||||||
import { MsgType } from 'matrix-js-sdk';
|
import { MsgType } from 'matrix-js-sdk';
|
||||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||||
import { Opts } from 'linkifyjs';
|
import { Opts } from 'linkifyjs';
|
||||||
@@ -49,6 +49,12 @@ type RenderMessageContentProps = {
|
|||||||
linkifyOpts: Opts;
|
linkifyOpts: Opts;
|
||||||
outlineAttachment?: boolean;
|
outlineAttachment?: boolean;
|
||||||
disabledEmbedPatterns?: string[];
|
disabledEmbedPatterns?: string[];
|
||||||
|
targetEventId?: string;
|
||||||
|
onJumboEmojiClick?: (
|
||||||
|
targetEventId: string,
|
||||||
|
body: string,
|
||||||
|
event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>
|
||||||
|
) => void;
|
||||||
};
|
};
|
||||||
export function RenderMessageContent({
|
export function RenderMessageContent({
|
||||||
displayName,
|
displayName,
|
||||||
@@ -63,6 +69,8 @@ export function RenderMessageContent({
|
|||||||
linkifyOpts,
|
linkifyOpts,
|
||||||
outlineAttachment,
|
outlineAttachment,
|
||||||
disabledEmbedPatterns,
|
disabledEmbedPatterns,
|
||||||
|
targetEventId,
|
||||||
|
onJumboEmojiClick,
|
||||||
}: RenderMessageContentProps) {
|
}: RenderMessageContentProps) {
|
||||||
const renderUrlsPreview = (urls: string[]) => {
|
const renderUrlsPreview = (urls: string[]) => {
|
||||||
let filteredUrls = urls.filter((url) => !testMatrixTo(url));
|
let filteredUrls = urls.filter((url) => !testMatrixTo(url));
|
||||||
@@ -100,6 +108,11 @@ export function RenderMessageContent({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
const handleJumboEmojiClick =
|
||||||
|
targetEventId && onJumboEmojiClick
|
||||||
|
? (body: string, event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) =>
|
||||||
|
onJumboEmojiClick(targetEventId, body, event)
|
||||||
|
: undefined;
|
||||||
const renderCaption = () => {
|
const renderCaption = () => {
|
||||||
const content: IImageContent = getContent();
|
const content: IImageContent = getContent();
|
||||||
if (content.filename && content.filename !== content.body) {
|
if (content.filename && content.filename !== content.body) {
|
||||||
@@ -182,6 +195,7 @@ export function RenderMessageContent({
|
|||||||
<MText
|
<MText
|
||||||
edited={edited}
|
edited={edited}
|
||||||
content={getContent()}
|
content={getContent()}
|
||||||
|
onJumboEmojiClick={handleJumboEmojiClick}
|
||||||
renderBody={(props) => (
|
renderBody={(props) => (
|
||||||
<RenderBody
|
<RenderBody
|
||||||
{...props}
|
{...props}
|
||||||
@@ -201,6 +215,7 @@ export function RenderMessageContent({
|
|||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
edited={edited}
|
edited={edited}
|
||||||
content={getContent()}
|
content={getContent()}
|
||||||
|
onJumboEmojiClick={handleJumboEmojiClick}
|
||||||
renderBody={(props) => (
|
renderBody={(props) => (
|
||||||
<RenderBody
|
<RenderBody
|
||||||
{...props}
|
{...props}
|
||||||
@@ -219,6 +234,7 @@ export function RenderMessageContent({
|
|||||||
<MNotice
|
<MNotice
|
||||||
edited={edited}
|
edited={edited}
|
||||||
content={getContent()}
|
content={getContent()}
|
||||||
|
onJumboEmojiClick={handleJumboEmojiClick}
|
||||||
renderBody={(props) => (
|
renderBody={(props) => (
|
||||||
<RenderBody
|
<RenderBody
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ export const createRoomEncryptionState = () => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partial power levels for MatrixRTC call membership (MSC3401).
|
||||||
|
* Do NOT put this in createRoom initial_state — homeservers reject incomplete
|
||||||
|
* m.room.power_levels with 403. Apply after create via sendStateEvent instead,
|
||||||
|
* or rely on TrustedPrivateChat (both users PL 100) for DMs.
|
||||||
|
*/
|
||||||
export const createRoomPowerLevelsState = () => ({
|
export const createRoomPowerLevelsState = () => ({
|
||||||
type: StateEvent.RoomPowerLevels,
|
type: StateEvent.RoomPowerLevels,
|
||||||
state_key: '',
|
state_key: '',
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export const EditorOptions = style([
|
|||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
padding: config.space.S200,
|
padding: config.space.S200,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -26,7 +28,7 @@ export const EditorTextarea = style([
|
|||||||
{
|
{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
height: '100%',
|
height: '100%',
|
||||||
padding: `${toRem(13)} ${toRem(1)}`,
|
padding: `${toRem(10)} ${toRem(1)}`,
|
||||||
selectors: {
|
selectors: {
|
||||||
[`${EditorTextareaScroll}:first-child &`]: {
|
[`${EditorTextareaScroll}:first-child &`]: {
|
||||||
paddingLeft: toRem(13),
|
paddingLeft: toRem(13),
|
||||||
@@ -54,7 +56,7 @@ export const EditorPlaceholderTextVisual = style([
|
|||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
paddingTop: toRem(13),
|
paddingTop: toRem(10),
|
||||||
paddingLeft: toRem(1),
|
paddingLeft: toRem(1),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import React, {
|
|||||||
forwardRef,
|
forwardRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Box, Scroll, Text } from 'folds';
|
import { Box, Scroll, Text } from 'folds';
|
||||||
import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate';
|
import { Descendant, Editor, createEditor, Element as SlateElement, Text as SlateText } from 'slate';
|
||||||
import {
|
import {
|
||||||
Slate,
|
Slate,
|
||||||
Editable,
|
Editable,
|
||||||
@@ -172,48 +170,6 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
[editor, onKeyDown]
|
[editor, onKeyDown]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleBeforeInput = useCallback(
|
|
||||||
(event: Event) => {
|
|
||||||
const inputEvent = event as InputEvent;
|
|
||||||
|
|
||||||
// Handle autocorrect replacement that causes text duplication
|
|
||||||
if (inputEvent.inputType === 'insertReplacementText' ||
|
|
||||||
inputEvent.inputType === 'insertFromComposition') {
|
|
||||||
const { selection } = editor;
|
|
||||||
if (!selection) return;
|
|
||||||
|
|
||||||
// Get the data being inserted
|
|
||||||
const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain');
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
// If there's selected text, delete it first
|
|
||||||
if (selection && !Range.isCollapsed(selection)) {
|
|
||||||
Transforms.delete(editor, { at: selection });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert the replacement text
|
|
||||||
editor.insertText(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[editor]
|
|
||||||
);
|
|
||||||
|
|
||||||
const editableRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const editableElement = editableRef.current?.querySelector('[data-slate-editor="true"]');
|
|
||||||
if (!editableElement) return;
|
|
||||||
|
|
||||||
editableElement.addEventListener('beforeinput', handleBeforeInput, { capture: true });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
editableElement.removeEventListener('beforeinput', handleBeforeInput, { capture: true });
|
|
||||||
};
|
|
||||||
}, [handleBeforeInput]);
|
|
||||||
|
|
||||||
const renderPlaceholder = useCallback(
|
const renderPlaceholder = useCallback(
|
||||||
({ attributes, children }: RenderPlaceholderProps) => (
|
({ attributes, children }: RenderPlaceholderProps) => (
|
||||||
<span {...attributes} className={css.EditorPlaceholderContainer}>
|
<span {...attributes} className={css.EditorPlaceholderContainer}>
|
||||||
@@ -243,7 +199,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
|
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
|
||||||
{top}
|
{top}
|
||||||
<Box
|
<Box
|
||||||
alignItems="Start"
|
alignItems={fillHeight ? 'Start' : 'Center'}
|
||||||
grow={fillHeight ? 'Yes' : undefined}
|
grow={fillHeight ? 'Yes' : undefined}
|
||||||
style={fillHeight ? { width: '100%', minHeight: 0, flex: '1 1 auto' } : undefined}
|
style={fillHeight ? { width: '100%', minHeight: 0, flex: '1 1 auto' } : undefined}
|
||||||
>
|
>
|
||||||
@@ -256,10 +212,9 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
className={css.EditorTextareaScroll}
|
className={css.EditorTextareaScroll}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
style={scrollStyle}
|
style={scrollStyle}
|
||||||
size="300"
|
size="0"
|
||||||
visibility="Hover"
|
visibility="Hover"
|
||||||
hideTrack
|
hideTrack
|
||||||
ref={editableRef}
|
|
||||||
>
|
>
|
||||||
<Editable
|
<Editable
|
||||||
data-editable-name={editableName}
|
data-editable-name={editableName}
|
||||||
|
|||||||
@@ -82,13 +82,21 @@ function RenderEmoticonElement({
|
|||||||
const selected = useSelected();
|
const selected = useSelected();
|
||||||
const focused = useFocused();
|
const focused = useFocused();
|
||||||
|
|
||||||
|
// Void inline: attributes + contentEditable={false} on the same root, children
|
||||||
|
// as a sibling of the visual (not nested inside another non-editable span).
|
||||||
|
// Nesting {children} under an inner contentEditable={false} breaks Slate's
|
||||||
|
// DOM↔node map and crashes ReactEditor.focus after insert.
|
||||||
return (
|
return (
|
||||||
<span className={css.EmoticonBase} {...attributes}>
|
<span
|
||||||
|
{...attributes}
|
||||||
|
contentEditable={false}
|
||||||
|
className={css.EmoticonBase}
|
||||||
|
style={{ userSelect: 'none' }}
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
className={css.Emoticon({
|
className={css.Emoticon({
|
||||||
focus: selected && focused,
|
focus: selected && focused,
|
||||||
})}
|
})}
|
||||||
contentEditable={false}
|
|
||||||
>
|
>
|
||||||
{element.key.startsWith('mxc://') ? (
|
{element.key.startsWith('mxc://') ? (
|
||||||
<img
|
<img
|
||||||
@@ -99,8 +107,8 @@ function RenderEmoticonElement({
|
|||||||
) : (
|
) : (
|
||||||
element.key
|
element.key
|
||||||
)}
|
)}
|
||||||
{children}
|
|
||||||
</span>
|
</span>
|
||||||
|
{children}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) {
|
|||||||
radii="300"
|
radii="300"
|
||||||
disabled={disableInline}
|
disabled={disableInline}
|
||||||
>
|
>
|
||||||
<Icon size="200" src={icon} />
|
<Icon size="100" src={icon} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -95,7 +95,7 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
|
|||||||
size="400"
|
size="400"
|
||||||
radii="300"
|
radii="300"
|
||||||
>
|
>
|
||||||
<Icon size="200" src={icon} />
|
<Icon size="100" src={icon} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -152,7 +152,7 @@ export function HeadingBlockButton() {
|
|||||||
size="400"
|
size="400"
|
||||||
radii="300"
|
radii="300"
|
||||||
>
|
>
|
||||||
<Icon size="200" src={Icons.Heading1} />
|
<Icon size="100" src={Icons.Heading1} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -167,7 +167,7 @@ export function HeadingBlockButton() {
|
|||||||
size="400"
|
size="400"
|
||||||
radii="300"
|
radii="300"
|
||||||
>
|
>
|
||||||
<Icon size="200" src={Icons.Heading2} />
|
<Icon size="100" src={Icons.Heading2} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -182,7 +182,7 @@ export function HeadingBlockButton() {
|
|||||||
size="400"
|
size="400"
|
||||||
radii="300"
|
radii="300"
|
||||||
>
|
>
|
||||||
<Icon size="200" src={Icons.Heading3} />
|
<Icon size="100" src={Icons.Heading3} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -199,8 +199,8 @@ export function HeadingBlockButton() {
|
|||||||
size="400"
|
size="400"
|
||||||
radii="300"
|
radii="300"
|
||||||
>
|
>
|
||||||
<Icon size="200" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
|
<Icon size="100" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
|
||||||
<Icon size="200" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
|
<Icon size="100" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</PopOut>
|
</PopOut>
|
||||||
);
|
);
|
||||||
@@ -336,7 +336,7 @@ export function Toolbar() {
|
|||||||
radii="300"
|
radii="300"
|
||||||
disabled={disableInline || !!isAnyMarkActive(editor)}
|
disabled={disableInline || !!isAnyMarkActive(editor)}
|
||||||
>
|
>
|
||||||
<Icon size="200" src={Icons.Markdown} filled={isMarkdown} />
|
<Icon size="100" src={Icons.Markdown} filled={isMarkdown} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BasePoint, BaseRange, Editor, Element, Point, Range, Text, Transforms } from 'slate';
|
import { BasePoint, BaseRange, Editor, Element, Point, Range, Text, Transforms } from 'slate';
|
||||||
|
import { ReactEditor } from 'slate-react';
|
||||||
import { BlockType, MarkType } from './types';
|
import { BlockType, MarkType } from './types';
|
||||||
import {
|
import {
|
||||||
CommandElement,
|
CommandElement,
|
||||||
@@ -206,6 +207,29 @@ export const moveCursor = (editor: Editor, withSpace?: boolean) => {
|
|||||||
if (withSpace) editor.insertText(' ');
|
if (withSpace) editor.insertText(' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focus the editor after React has committed DOM for recent transforms.
|
||||||
|
* Calling ReactEditor.focus immediately after insertNode/insertText (e.g. emoji +
|
||||||
|
* trailing space) races Slate's DOM map and throws:
|
||||||
|
* "Cannot resolve a DOM node from Slate node: {"text":" "}"
|
||||||
|
*/
|
||||||
|
export const safeFocusEditor = (editor: Editor) => {
|
||||||
|
const tryFocus = () => {
|
||||||
|
try {
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Defer past the current React commit; retry once if the DOM map isn't ready yet.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (tryFocus()) return;
|
||||||
|
setTimeout(tryFocus, 0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
interface PointUntilCharOptions {
|
interface PointUntilCharOptions {
|
||||||
match: (char: string) => boolean;
|
match: (char: string) => boolean;
|
||||||
reverse?: boolean;
|
reverse?: boolean;
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ export const Icon = forwardRef<SVGSVGElement, IconProps>(
|
|||||||
? { width: pixelSize, height: pixelSize, ...style }
|
? { width: pixelSize, height: pixelSize, ...style }
|
||||||
: style
|
: style
|
||||||
}
|
}
|
||||||
strokeWidth={2}
|
// Lucide icons are stroke-based; solid fill turns faces (e.g. Smile) into blobs.
|
||||||
fill={fill ?? (filled ? 'currentColor' : 'none')}
|
// Emphasize selection with a heavier stroke instead.
|
||||||
|
strokeWidth={filled ? 2.75 : 2}
|
||||||
|
fill={fill ?? 'none'}
|
||||||
aria-hidden={props['aria-hidden'] ?? props['aria-label'] ? undefined : true}
|
aria-hidden={props['aria-hidden'] ?? props['aria-label'] ? undefined : true}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { IconSize } from './types';
|
import type { IconSize } from './types';
|
||||||
|
|
||||||
|
/** App chrome icons; ~4% above the original folds scale after +30% then -20%. */
|
||||||
export const ICON_PIXEL_SIZES: Record<IconSize, number | undefined> = {
|
export const ICON_PIXEL_SIZES: Record<IconSize, number | undefined> = {
|
||||||
'50': 10,
|
'50': 10,
|
||||||
'100': 12,
|
'100': 13,
|
||||||
'200': 16,
|
'200': 17,
|
||||||
'300': 20,
|
'300': 21,
|
||||||
'400': 24,
|
'400': 25,
|
||||||
'500': 28,
|
'500': 29,
|
||||||
'600': 32,
|
'600': 34,
|
||||||
Inherit: undefined,
|
Inherit: undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import { config } from 'folds';
|
|||||||
|
|
||||||
export const Icon = recipe({
|
export const Icon = recipe({
|
||||||
base: {
|
base: {
|
||||||
display: 'inline-block',
|
display: 'block',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
verticalAlign: 'middle',
|
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -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,61 @@
|
|||||||
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
||||||
import React from 'react';
|
import React, {
|
||||||
import FileSaver from 'file-saver';
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
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 { downloadAndSaveMedia } from '../../utils/saveMedia';
|
||||||
import { usePan } from '../../hooks/usePan';
|
|
||||||
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,100 +64,382 @@ 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
|
await downloadAndSaveMedia(src, alt, getCurrentAccessToken());
|
||||||
const fileContent = await downloadMedia(src, getCurrentAccessToken());
|
|
||||||
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);
|
|
||||||
if (response.ok) {
|
|
||||||
const blob = await response.blob();
|
|
||||||
FileSaver.saveAs(blob, alt);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// If all else fails, open in new tab to let browser handle it
|
|
||||||
window.open(src, '_blank');
|
window.open(src, '_blank');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Badge, Box, IconButton, Spinner, Text, as, toRem } from 'folds';
|
|||||||
import { Icon, Icons } from '../icons';
|
import { Icon, Icons } from '../icons';
|
||||||
import React, { ReactNode, useCallback } from 'react';
|
import React, { ReactNode, useCallback } from 'react';
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
@@ -14,6 +13,7 @@ import {
|
|||||||
mxcUrlToHttp,
|
mxcUrlToHttp,
|
||||||
} from '../../utils/matrix';
|
} from '../../utils/matrix';
|
||||||
import { getCurrentAccessToken } from '../../utils/auth';
|
import { getCurrentAccessToken } from '../../utils/auth';
|
||||||
|
import { saveMediaBlob } from '../../utils/saveMedia';
|
||||||
|
|
||||||
const badgeStyles = { maxWidth: toRem(100) };
|
const badgeStyles = { maxWidth: toRem(100) };
|
||||||
|
|
||||||
@@ -36,9 +36,8 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
|
|||||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
||||||
: await downloadMedia(mediaUrl, accessToken);
|
: await downloadMedia(mediaUrl, accessToken);
|
||||||
|
|
||||||
const fileURL = URL.createObjectURL(fileContent);
|
await saveMediaBlob(fileContent, filename);
|
||||||
FileSaver.saveAs(fileURL, filename);
|
return true;
|
||||||
return fileURL;
|
|
||||||
}, [mx, url, useAuthentication, mimeType, encInfo, filename])
|
}, [mx, url, useAuthentication, mimeType, encInfo, filename])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,11 @@ import {
|
|||||||
MATRIX_SPOILER_PROPERTY_NAME,
|
MATRIX_SPOILER_PROPERTY_NAME,
|
||||||
MATRIX_SPOILER_REASON_PROPERTY_NAME,
|
MATRIX_SPOILER_REASON_PROPERTY_NAME,
|
||||||
} from '../../../types/matrix/common';
|
} from '../../../types/matrix/common';
|
||||||
|
import { StationeryMedia, hashStationerySeed, stationeryMediaRot } from './StationeryMedia';
|
||||||
|
import { getJumboEmojiInteractionProps, JumboEmojiClickHandler } from '../../features/room/emoji-confetti/jumboEmojiInteraction';
|
||||||
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
|
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
|
||||||
import { parseGeoUri, scaleYDimension } from '../../utils/common';
|
import { parseGeoUri, scaleYDimension } from '../../utils/common';
|
||||||
|
import { resolveAttachmentBoxSize } from '../../state/mediaDimensionCache';
|
||||||
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
|
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
|
||||||
import { FileHeader, FileDownloadButton } from './FileHeader';
|
import { FileHeader, FileDownloadButton } from './FileHeader';
|
||||||
|
|
||||||
@@ -76,8 +79,9 @@ type MTextProps = {
|
|||||||
renderBody: (props: RenderBodyProps) => ReactNode;
|
renderBody: (props: RenderBodyProps) => ReactNode;
|
||||||
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
onJumboEmojiClick?: JumboEmojiClickHandler;
|
||||||
};
|
};
|
||||||
export function MText({ edited, content, renderBody, renderUrlsPreview, style }: MTextProps) {
|
export function MText({ edited, content, renderBody, renderUrlsPreview, style, onJumboEmojiClick }: MTextProps) {
|
||||||
const { body, formatted_body: customBody } = content;
|
const { body, formatted_body: customBody } = content;
|
||||||
|
|
||||||
if (typeof body !== 'string') return <BrokenContent />;
|
if (typeof body !== 'string') return <BrokenContent />;
|
||||||
@@ -87,13 +91,20 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
|
|||||||
: undefined;
|
: undefined;
|
||||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
||||||
|
const isJumboEmoji = JUMBO_EMOJI_REG.test(trimmedBody);
|
||||||
|
const jumboEmojiInteraction = getJumboEmojiInteractionProps({
|
||||||
|
body: trimmedBody,
|
||||||
|
isJumboEmoji,
|
||||||
|
onJumboEmojiClick,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MessageTextBody
|
<MessageTextBody
|
||||||
preWrap={typeof customBody !== 'string'}
|
preWrap={typeof customBody !== 'string'}
|
||||||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
jumboEmoji={isJumboEmoji}
|
||||||
style={style}
|
style={style}
|
||||||
|
{...jumboEmojiInteraction}
|
||||||
>
|
>
|
||||||
{renderBody({
|
{renderBody({
|
||||||
body: trimmedBody,
|
body: trimmedBody,
|
||||||
@@ -112,6 +123,7 @@ type MEmoteProps = {
|
|||||||
content: Record<string, unknown>;
|
content: Record<string, unknown>;
|
||||||
renderBody: (props: RenderBodyProps) => ReactNode;
|
renderBody: (props: RenderBodyProps) => ReactNode;
|
||||||
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
||||||
|
onJumboEmojiClick?: JumboEmojiClickHandler;
|
||||||
};
|
};
|
||||||
export function MEmote({
|
export function MEmote({
|
||||||
displayName,
|
displayName,
|
||||||
@@ -119,6 +131,7 @@ export function MEmote({
|
|||||||
content,
|
content,
|
||||||
renderBody,
|
renderBody,
|
||||||
renderUrlsPreview,
|
renderUrlsPreview,
|
||||||
|
onJumboEmojiClick,
|
||||||
}: MEmoteProps) {
|
}: MEmoteProps) {
|
||||||
const { body, formatted_body: customBody } = content;
|
const { body, formatted_body: customBody } = content;
|
||||||
|
|
||||||
@@ -129,20 +142,28 @@ export function MEmote({
|
|||||||
: undefined;
|
: undefined;
|
||||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
||||||
|
const isJumboEmoji = JUMBO_EMOJI_REG.test(trimmedBody);
|
||||||
|
const jumboEmojiInteraction = getJumboEmojiInteractionProps({
|
||||||
|
body: trimmedBody,
|
||||||
|
isJumboEmoji,
|
||||||
|
onJumboEmojiClick,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MessageTextBody
|
<MessageTextBody
|
||||||
emote
|
emote
|
||||||
preWrap={typeof customBody !== 'string'}
|
preWrap={typeof customBody !== 'string'}
|
||||||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
jumboEmoji={isJumboEmoji}
|
||||||
>
|
>
|
||||||
<b>{`${displayName} `}</b>
|
<b>{`${displayName} `}</b>
|
||||||
{renderBody({
|
<span {...jumboEmojiInteraction}>
|
||||||
body: trimmedBody,
|
{renderBody({
|
||||||
customBody: trimmedCustomBody,
|
body: trimmedBody,
|
||||||
})}
|
customBody: trimmedCustomBody,
|
||||||
{edited && <MessageEditedContent />}
|
})}
|
||||||
|
{edited && <MessageEditedContent />}
|
||||||
|
</span>
|
||||||
</MessageTextBody>
|
</MessageTextBody>
|
||||||
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
||||||
</>
|
</>
|
||||||
@@ -154,8 +175,9 @@ type MNoticeProps = {
|
|||||||
content: Record<string, unknown>;
|
content: Record<string, unknown>;
|
||||||
renderBody: (props: RenderBodyProps) => ReactNode;
|
renderBody: (props: RenderBodyProps) => ReactNode;
|
||||||
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
||||||
|
onJumboEmojiClick?: JumboEmojiClickHandler;
|
||||||
};
|
};
|
||||||
export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNoticeProps) {
|
export function MNotice({ edited, content, renderBody, renderUrlsPreview, onJumboEmojiClick }: MNoticeProps) {
|
||||||
const { body, formatted_body: customBody } = content;
|
const { body, formatted_body: customBody } = content;
|
||||||
|
|
||||||
if (typeof body !== 'string') return <BrokenContent />;
|
if (typeof body !== 'string') return <BrokenContent />;
|
||||||
@@ -165,13 +187,20 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
|
|||||||
: undefined;
|
: undefined;
|
||||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
||||||
|
const isJumboEmoji = JUMBO_EMOJI_REG.test(trimmedBody);
|
||||||
|
const jumboEmojiInteraction = getJumboEmojiInteractionProps({
|
||||||
|
body: trimmedBody,
|
||||||
|
isJumboEmoji,
|
||||||
|
onJumboEmojiClick,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MessageTextBody
|
<MessageTextBody
|
||||||
notice
|
notice
|
||||||
preWrap={typeof customBody !== 'string'}
|
preWrap={typeof customBody !== 'string'}
|
||||||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
jumboEmoji={isJumboEmoji}
|
||||||
|
{...jumboEmojiInteraction}
|
||||||
>
|
>
|
||||||
{renderBody({
|
{renderBody({
|
||||||
body: trimmedBody,
|
body: trimmedBody,
|
||||||
@@ -199,6 +228,20 @@ type MImageProps = {
|
|||||||
renderImageContent: (props: RenderImageContentProps) => ReactNode;
|
renderImageContent: (props: RenderImageContentProps) => ReactNode;
|
||||||
outlined?: boolean;
|
outlined?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveMediaBoxSize = (
|
||||||
|
mxcUrl: string,
|
||||||
|
w?: number,
|
||||||
|
h?: number
|
||||||
|
): { width: number; height: number } => resolveAttachmentBoxSize(mxcUrl, w, h);
|
||||||
|
|
||||||
|
/** Extra space after media so following text stays on the 24px ruled grid — applied outside the photo chrome */
|
||||||
|
const STATIONERY_LINE_PX = 24;
|
||||||
|
const snapStationeryGridPad = (height: number): number => {
|
||||||
|
const rem = height % STATIONERY_LINE_PX;
|
||||||
|
return rem === 0 ? 0 : STATIONERY_LINE_PX - rem;
|
||||||
|
};
|
||||||
|
|
||||||
export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
||||||
const imgInfo = content?.info;
|
const imgInfo = content?.info;
|
||||||
const mxcUrl = content.file?.url ?? content.url;
|
const mxcUrl = content.file?.url ?? content.url;
|
||||||
@@ -206,9 +249,24 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
|||||||
return <BrokenContent />;
|
return <BrokenContent />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { width, height } = resolveMediaBoxSize(mxcUrl, imgInfo?.w, imgInfo?.h);
|
||||||
|
const gridPad = snapStationeryGridPad(height);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Attachment outlined={outlined} transparent>
|
<StationeryMedia
|
||||||
<AttachmentBox>
|
outlined={outlined}
|
||||||
|
transparent
|
||||||
|
tiltSeed={mxcUrl}
|
||||||
|
gridPad={gridPad}
|
||||||
|
>
|
||||||
|
<AttachmentBox
|
||||||
|
style={{
|
||||||
|
width: toRem(width),
|
||||||
|
height: toRem(height),
|
||||||
|
['--media-h' as string]: `${height}px`,
|
||||||
|
}}
|
||||||
|
data-paarrot-media-height={height}
|
||||||
|
>
|
||||||
{renderImageContent({
|
{renderImageContent({
|
||||||
body: content.body || 'Image',
|
body: content.body || 'Image',
|
||||||
info: imgInfo,
|
info: imgInfo,
|
||||||
@@ -219,7 +277,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
|||||||
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
|
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
|
||||||
})}
|
})}
|
||||||
</AttachmentBox>
|
</AttachmentBox>
|
||||||
</Attachment>
|
</StationeryMedia>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,9 +309,20 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filename = content.filename ?? content.body ?? 'Video';
|
const filename = content.filename ?? content.body ?? 'Video';
|
||||||
|
const { width, height } = resolveMediaBoxSize(
|
||||||
|
mxcUrl,
|
||||||
|
videoInfo.w ?? videoInfo.thumbnail_info?.w,
|
||||||
|
videoInfo.h ?? videoInfo.thumbnail_info?.h
|
||||||
|
);
|
||||||
|
const gridPad = snapStationeryGridPad(height);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Attachment outlined={outlined} transparent>
|
<StationeryMedia
|
||||||
|
outlined={outlined}
|
||||||
|
transparent
|
||||||
|
tiltSeed={mxcUrl}
|
||||||
|
gridPad={gridPad}
|
||||||
|
>
|
||||||
<AttachmentHeader>
|
<AttachmentHeader>
|
||||||
<FileHeader
|
<FileHeader
|
||||||
body={filename}
|
body={filename}
|
||||||
@@ -268,7 +337,13 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</AttachmentHeader>
|
</AttachmentHeader>
|
||||||
<AttachmentBox>
|
<AttachmentBox
|
||||||
|
style={{
|
||||||
|
width: toRem(width),
|
||||||
|
height: toRem(height),
|
||||||
|
}}
|
||||||
|
data-paarrot-media-height={height}
|
||||||
|
>
|
||||||
{renderVideoContent({
|
{renderVideoContent({
|
||||||
body: content.body || 'Video',
|
body: content.body || 'Video',
|
||||||
info: videoInfo,
|
info: videoInfo,
|
||||||
@@ -279,7 +354,7 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
|||||||
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
|
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
|
||||||
})}
|
})}
|
||||||
</AttachmentBox>
|
</AttachmentBox>
|
||||||
</Attachment>
|
</StationeryMedia>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,12 +493,16 @@ export function MSticker({ content, renderImageContent }: MStickerProps) {
|
|||||||
return <MessageBrokenContent />;
|
return <MessageBrokenContent />;
|
||||||
}
|
}
|
||||||
const height = scaleYDimension(imgInfo?.w || 152, 152, imgInfo?.h || 152);
|
const height = scaleYDimension(imgInfo?.w || 152, 152, imgInfo?.h || 152);
|
||||||
|
const tapeAlt = hashStationerySeed(mxcUrl) % 2 === 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AttachmentBox
|
<AttachmentBox
|
||||||
|
data-stationery-media=""
|
||||||
|
data-tape-alt={tapeAlt ? '' : undefined}
|
||||||
style={{
|
style={{
|
||||||
height: toRem(height < 48 ? 48 : height),
|
height: toRem(height < 48 ? 48 : height),
|
||||||
width: toRem(152),
|
width: toRem(152),
|
||||||
|
['--media-rot' as string]: stationeryMediaRot(mxcUrl),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderImageContent({
|
{renderImageContent({
|
||||||
|
|||||||
@@ -64,6 +64,33 @@ export const ReactionText = style([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const ReactionStack = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'inline-grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
gridTemplateColumns: '1fr',
|
||||||
|
gridTemplateRows: '1fr',
|
||||||
|
lineHeight: toRem(20),
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ReactionSticker = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
minWidth: 0,
|
||||||
|
maxWidth: toRem(150),
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
lineHeight: toRem(20),
|
||||||
|
gridArea: '1 / 1',
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
export const ReactionImg = style([
|
export const ReactionImg = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import * as css from './Reaction.css';
|
|||||||
import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji';
|
import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji';
|
||||||
import { getMemberDisplayName } from '../../utils/room';
|
import { getMemberDisplayName } from '../../utils/room';
|
||||||
import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
|
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||||
|
|
||||||
|
const MAX_STICKER_STACK = 4;
|
||||||
|
|
||||||
export const Reaction = as<
|
export const Reaction = as<
|
||||||
'button',
|
'button',
|
||||||
@@ -15,35 +18,101 @@ export const Reaction = as<
|
|||||||
reaction: string;
|
reaction: string;
|
||||||
useAuthentication?: boolean;
|
useAuthentication?: boolean;
|
||||||
}
|
}
|
||||||
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => (
|
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => {
|
||||||
<Box
|
const theme = useTheme();
|
||||||
as="button"
|
const stationery = isStationeryTheme(theme);
|
||||||
className={classNames(css.Reaction, className)}
|
const isCustomEmoji = reaction.startsWith('mxc://');
|
||||||
alignItems="Center"
|
const customSrc = isCustomEmoji
|
||||||
shrink="No"
|
? mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
|
||||||
gap="200"
|
: undefined;
|
||||||
{...props}
|
|
||||||
ref={ref}
|
const emoji = isCustomEmoji ? (
|
||||||
>
|
<img className={css.ReactionImg} src={customSrc} alt={reaction} />
|
||||||
<Text className={css.ReactionText} as="span" size="T400">
|
) : (
|
||||||
{reaction.startsWith('mxc://') ? (
|
<Text as="span" size="Inherit" truncate>
|
||||||
<img
|
{reaction}
|
||||||
className={css.ReactionImg}
|
</Text>
|
||||||
src={mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
|
);
|
||||||
}
|
|
||||||
alt={reaction}
|
// Stationery: fanned sticker stack. Everywhere else: compact emoji + count.
|
||||||
/>
|
if (!stationery) {
|
||||||
) : (
|
return (
|
||||||
<Text as="span" size="Inherit" truncate>
|
<Box
|
||||||
{reaction}
|
as="button"
|
||||||
|
className={classNames(css.Reaction, className)}
|
||||||
|
alignItems="Center"
|
||||||
|
shrink="No"
|
||||||
|
gap="200"
|
||||||
|
data-reaction=""
|
||||||
|
aria-label={`${reaction}, ${count}`}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Text className={css.ReactionText} as="span" size="T400">
|
||||||
|
{emoji}
|
||||||
|
</Text>
|
||||||
|
<Text as="span" size="T300" data-reaction-count="">
|
||||||
|
{count}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stackCount = Math.min(Math.max(count, 1), MAX_STICKER_STACK);
|
||||||
|
const showCount = count > 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
as="button"
|
||||||
|
className={classNames(css.Reaction, className)}
|
||||||
|
alignItems="Center"
|
||||||
|
shrink="No"
|
||||||
|
gap="200"
|
||||||
|
data-reaction=""
|
||||||
|
data-reaction-stack={stackCount}
|
||||||
|
aria-label={`${reaction}, ${count}`}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<span className={css.ReactionStack} data-reaction-stack-layer="">
|
||||||
|
{Array.from({ length: stackCount }, (_, i) => {
|
||||||
|
const layer = stackCount - 1 - i;
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
key={layer}
|
||||||
|
className={css.ReactionSticker}
|
||||||
|
as="span"
|
||||||
|
size="T400"
|
||||||
|
data-reaction-sticker=""
|
||||||
|
style={{
|
||||||
|
['--sticker-i' as string]: layer,
|
||||||
|
zIndex: i + 1,
|
||||||
|
}}
|
||||||
|
aria-hidden={i < stackCount - 1 ? true : undefined}
|
||||||
|
>
|
||||||
|
{isCustomEmoji ? (
|
||||||
|
<img
|
||||||
|
className={css.ReactionImg}
|
||||||
|
src={customSrc}
|
||||||
|
alt={i === stackCount - 1 ? reaction : ''}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text as="span" size="Inherit" truncate>
|
||||||
|
{reaction}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{showCount && (
|
||||||
|
<Text as="span" size="T300" data-reaction-count="">
|
||||||
|
{count}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Box>
|
||||||
<Text as="span" size="T300">
|
);
|
||||||
{count}
|
});
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
));
|
|
||||||
|
|
||||||
type ReactionTooltipMsgProps = {
|
type ReactionTooltipMsgProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
|||||||
@@ -14,29 +14,54 @@ import { useRoomEvent } from '../../hooks/useRoomEvent';
|
|||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||||
import { useOtherUserColor } from '../../hooks/useUserColor';
|
import { useOtherUserColor } from '../../hooks/useUserColor';
|
||||||
|
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||||
|
import { STATIONERY_NAME_INK } from '../../utils/paperSafeInk';
|
||||||
|
|
||||||
type ReplyLayoutProps = {
|
type ReplyLayoutProps = {
|
||||||
userColor?: string;
|
userColor?: string;
|
||||||
username?: ReactNode;
|
username?: ReactNode;
|
||||||
};
|
};
|
||||||
export const ReplyLayout = as<'div', ReplyLayoutProps>(
|
export const ReplyLayout = as<'div', ReplyLayoutProps>(
|
||||||
({ username, userColor, className, children, ...props }, ref) => (
|
({ username, userColor, className, children, ...props }, ref) => {
|
||||||
<Box
|
const theme = useTheme();
|
||||||
className={classNames(css.Reply, className)}
|
const isStationery = isStationeryTheme(theme);
|
||||||
alignItems="Center"
|
|
||||||
gap="100"
|
return (
|
||||||
{...props}
|
<Box
|
||||||
ref={ref}
|
className={classNames(css.Reply, className)}
|
||||||
>
|
alignItems="Center"
|
||||||
<Box style={{ color: userColor, maxWidth: toRem(200) }} alignItems="Center" shrink="No">
|
gap="100"
|
||||||
<Icon size="100" src={Icons.ReplyArrow} />
|
{...props}
|
||||||
{username}
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={
|
||||||
|
isStationery
|
||||||
|
? {
|
||||||
|
color: STATIONERY_NAME_INK,
|
||||||
|
maxWidth: toRem(200),
|
||||||
|
...(userColor
|
||||||
|
? {
|
||||||
|
['--username-accent' as string]: userColor,
|
||||||
|
textDecorationColor: userColor,
|
||||||
|
}
|
||||||
|
: null),
|
||||||
|
}
|
||||||
|
: { color: userColor, maxWidth: toRem(200) }
|
||||||
|
}
|
||||||
|
data-username-accent={isStationery && userColor ? '' : undefined}
|
||||||
|
alignItems="Center"
|
||||||
|
shrink="No"
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.ReplyArrow} />
|
||||||
|
{username}
|
||||||
|
</Box>
|
||||||
|
<Box grow="Yes" className={css.ReplyContent}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box grow="Yes" className={css.ReplyContent}>
|
);
|
||||||
{children}
|
}
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
|
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
|
||||||
@@ -92,7 +117,7 @@ export const Reply = as<'div', ReplyProps>(
|
|||||||
const { body } = replyEvent?.getContent() ?? {};
|
const { body } = replyEvent?.getContent() ?? {};
|
||||||
const sender = replyEvent?.getSender();
|
const sender = replyEvent?.getSender();
|
||||||
const senderAvatarMxc = sender ? getMemberAvatarMxc(room, sender) : undefined;
|
const senderAvatarMxc = sender ? getMemberAvatarMxc(room, sender) : undefined;
|
||||||
const customUserColor = useOtherUserColor(sender ?? '', senderAvatarMxc);
|
const customUserColor = useOtherUserColor(sender ?? '', room);
|
||||||
const powerTag = sender ? getMemberPowerTag?.(sender) : undefined;
|
const powerTag = sender ? getMemberPowerTag?.(sender) : undefined;
|
||||||
const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined;
|
const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined;
|
||||||
|
|
||||||
|
|||||||
293
src/app/components/message/StationeryMedia.tsx
Normal file
293
src/app/components/message/StationeryMedia.tsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import React, {
|
||||||
|
ComponentProps,
|
||||||
|
CSSProperties,
|
||||||
|
MouseEventHandler,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
import { Attachment } from './attachment';
|
||||||
|
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||||
|
|
||||||
|
type Corner = 'tl' | 'tr' | 'bl' | 'br';
|
||||||
|
|
||||||
|
type StationeryMediaProps = ComponentProps<typeof Attachment> & {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Stable id (e.g. mxc URL) so tilt/tape don't reshuffle on timeline re-renders */
|
||||||
|
tiltSeed?: string;
|
||||||
|
/** Invisible bottom spacer (px) so following text stays on the ruled grid */
|
||||||
|
gridPad?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CORNERS: { id: Corner; x: 0 | 1; y: 0 | 1 }[] = [
|
||||||
|
{ id: 'tl', x: 0, y: 0 },
|
||||||
|
{ id: 'tr', x: 1, y: 0 },
|
||||||
|
{ id: 'bl', x: 0, y: 1 },
|
||||||
|
{ id: 'br', x: 1, y: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MEDIA_ROTS = [-0.9, 0.7, -0.5, 1.0, -1.1, 0.8, -0.4, 0.9] as const;
|
||||||
|
|
||||||
|
export function hashStationerySeed(seed: string): number {
|
||||||
|
let h = 2166136261;
|
||||||
|
for (let i = 0; i < seed.length; i += 1) {
|
||||||
|
h ^= seed.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 16777619);
|
||||||
|
}
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stationeryMediaRot(seed: string): string {
|
||||||
|
return `${MEDIA_ROTS[hashStationerySeed(seed) % MEDIA_ROTS.length]}deg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold sits halfway between corner and cursor; flap hinges on that fold.
|
||||||
|
* `keep` = remaining photo; `flap` = lifted triangle.
|
||||||
|
*/
|
||||||
|
function cornerPeelPaths(
|
||||||
|
id: Corner,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
ux: number,
|
||||||
|
uy: number,
|
||||||
|
size: number
|
||||||
|
): {
|
||||||
|
keep: string;
|
||||||
|
flap: string;
|
||||||
|
origin: string;
|
||||||
|
axisX: number;
|
||||||
|
axisY: number;
|
||||||
|
hingeSign: number;
|
||||||
|
} {
|
||||||
|
const eps = 0.0001;
|
||||||
|
// Fold line through the midpoint on corner→cursor, ⊥ to that ray
|
||||||
|
const px = cx + ux * size;
|
||||||
|
const py = cy + uy * size;
|
||||||
|
|
||||||
|
let ax = 0;
|
||||||
|
let ay = 0;
|
||||||
|
let bx = 0;
|
||||||
|
let by = 0;
|
||||||
|
|
||||||
|
if (id === 'tr') {
|
||||||
|
ax = clamp(Math.abs(ux) > eps ? px + (py * uy) / ux : px, 0, w);
|
||||||
|
ay = 0;
|
||||||
|
bx = w;
|
||||||
|
by = clamp(Math.abs(uy) > eps ? py - ((w - px) * ux) / uy : py, 0, h);
|
||||||
|
} else if (id === 'tl') {
|
||||||
|
ax = clamp(Math.abs(ux) > eps ? px + (py * uy) / ux : px, 0, w);
|
||||||
|
ay = 0;
|
||||||
|
bx = 0;
|
||||||
|
by = clamp(Math.abs(uy) > eps ? py - ((0 - px) * ux) / uy : py, 0, h);
|
||||||
|
} else if (id === 'br') {
|
||||||
|
ax = clamp(Math.abs(ux) > eps ? px + ((py - h) * uy) / ux : px, 0, w);
|
||||||
|
ay = h;
|
||||||
|
bx = w;
|
||||||
|
by = clamp(Math.abs(uy) > eps ? py - ((w - px) * ux) / uy : py, 0, h);
|
||||||
|
} else {
|
||||||
|
ax = clamp(Math.abs(ux) > eps ? px + ((py - h) * uy) / ux : px, 0, w);
|
||||||
|
ay = h;
|
||||||
|
bx = 0;
|
||||||
|
by = clamp(Math.abs(uy) > eps ? py - ((0 - px) * ux) / uy : py, 0, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
let keep: string;
|
||||||
|
if (id === 'tr') {
|
||||||
|
keep = `polygon(0 0, ${ax}px 0, ${w}px ${by}px, ${w}px ${h}px, 0 ${h}px)`;
|
||||||
|
} else if (id === 'tl') {
|
||||||
|
keep = `polygon(${ax}px 0, ${w}px 0, ${w}px ${h}px, 0 ${h}px, 0 ${by}px)`;
|
||||||
|
} else if (id === 'br') {
|
||||||
|
keep = `polygon(0 0, ${w}px 0, ${w}px ${by}px, ${ax}px ${h}px, 0 ${h}px)`;
|
||||||
|
} else {
|
||||||
|
keep = `polygon(0 0, ${w}px 0, ${w}px ${h}px, ${ax}px ${h}px, 0 ${by}px)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flap = `polygon(${cx}px ${cy}px, ${ax}px ${ay}px, ${bx}px ${by}px)`;
|
||||||
|
|
||||||
|
// Hinge on the fold (midpoint), axis along the fold edge
|
||||||
|
const midX = (ax + bx) / 2;
|
||||||
|
const midY = (ay + by) / 2;
|
||||||
|
const fdx = bx - ax;
|
||||||
|
const fdy = by - ay;
|
||||||
|
const flen = Math.hypot(fdx, fdy) || 1;
|
||||||
|
const axisX = fdx / flen;
|
||||||
|
const axisY = fdy / flen;
|
||||||
|
// Sign so the corner swings up over the fold (right-hand rule)
|
||||||
|
const cross = fdx * (cy - midY) - fdy * (cx - midX);
|
||||||
|
const hingeSign = cross >= 0 ? 1 : -1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
keep,
|
||||||
|
flap,
|
||||||
|
origin: `${midX}px ${midY}px`,
|
||||||
|
axisX,
|
||||||
|
axisY,
|
||||||
|
hingeSign,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stationery-theme image chrome: clear corner tape + a mouse-driven peel
|
||||||
|
* that shows a duplicated slice of the photo lifting toward the cursor.
|
||||||
|
*/
|
||||||
|
export function StationeryMedia({
|
||||||
|
children,
|
||||||
|
tiltSeed = 'stationery',
|
||||||
|
gridPad = 0,
|
||||||
|
style,
|
||||||
|
...attachmentProps
|
||||||
|
}: StationeryMediaProps) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isStationery = isStationeryTheme(theme);
|
||||||
|
const peelRef = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
const tiltStyle = useMemo((): CSSProperties => {
|
||||||
|
return {
|
||||||
|
...(style as CSSProperties | undefined),
|
||||||
|
// Margin sits outside the paper chrome — snaps the grid without a white bar
|
||||||
|
...(isStationery && gridPad > 0 ? { marginBottom: `${gridPad}px` } : null),
|
||||||
|
['--media-rot' as string]: stationeryMediaRot(tiltSeed),
|
||||||
|
};
|
||||||
|
}, [tiltSeed, style, isStationery, gridPad]);
|
||||||
|
|
||||||
|
const tapeAlt = useMemo(() => hashStationerySeed(tiltSeed) % 2 === 1, [tiltSeed]);
|
||||||
|
// Default tape covers TL; alt covers TR — peel only the free top corner
|
||||||
|
const peelCorner = useMemo(
|
||||||
|
() => (tapeAlt ? CORNERS.find((c) => c.id === 'tl')! : CORNERS.find((c) => c.id === 'tr')!),
|
||||||
|
[tapeAlt]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearPeel = useCallback((host: HTMLElement) => {
|
||||||
|
const peel = peelRef.current;
|
||||||
|
const target = host.querySelector('[data-paarrot-media-height]') as HTMLElement | null;
|
||||||
|
if (peel) {
|
||||||
|
// Hide flap first (no opacity tween) so restoring the photo can't double-draw
|
||||||
|
peel.style.transition = 'none';
|
||||||
|
peel.style.opacity = '0';
|
||||||
|
peel.style.setProperty('--peel-hinge', '0deg');
|
||||||
|
peel.style.clipPath = '';
|
||||||
|
}
|
||||||
|
if (target) target.style.clipPath = '';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const syncPeel = useCallback(
|
||||||
|
(host: HTMLElement, clientX: number, clientY: number) => {
|
||||||
|
const peel = peelRef.current;
|
||||||
|
if (!peel) return;
|
||||||
|
|
||||||
|
const rect = host.getBoundingClientRect();
|
||||||
|
const x = clientX - rect.left;
|
||||||
|
const y = clientY - rect.top;
|
||||||
|
const w = rect.width;
|
||||||
|
const h = rect.height;
|
||||||
|
|
||||||
|
const best = peelCorner;
|
||||||
|
const cx = best.x * w;
|
||||||
|
const cy = best.y * h;
|
||||||
|
const dist = Math.hypot(cx - x, cy - y);
|
||||||
|
const maxReach = Math.min(w, h) * 0.55;
|
||||||
|
|
||||||
|
// Fold halfway between corner and cursor; fade when leaving the corner zone
|
||||||
|
let size = 0;
|
||||||
|
let amount = 0;
|
||||||
|
if (dist > 8 && dist <= maxReach) {
|
||||||
|
size = dist * 0.5;
|
||||||
|
amount = Math.min(1, size / (Math.min(w, h) * 0.22));
|
||||||
|
} else if (dist > maxReach && dist < maxReach * 1.35) {
|
||||||
|
const fade = 1 - (dist - maxReach) / (maxReach * 0.35);
|
||||||
|
size = maxReach * 0.5 * Math.max(0, fade);
|
||||||
|
amount = Math.max(0, fade);
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = host.querySelector('img');
|
||||||
|
const url = img?.currentSrc || img?.src || '';
|
||||||
|
|
||||||
|
// Unit vector from corner → cursor (triangle aims this way)
|
||||||
|
let ux = x - cx;
|
||||||
|
let uy = y - cy;
|
||||||
|
if (best.id === 'tr') {
|
||||||
|
ux = Math.min(-0.05, ux);
|
||||||
|
uy = Math.max(0.05, uy);
|
||||||
|
} else {
|
||||||
|
ux = Math.max(0.05, ux);
|
||||||
|
uy = Math.max(0.05, uy);
|
||||||
|
}
|
||||||
|
const len = Math.hypot(ux, uy) || 1;
|
||||||
|
ux /= len;
|
||||||
|
uy /= len;
|
||||||
|
|
||||||
|
peel.dataset.corner = best.id;
|
||||||
|
if (url) {
|
||||||
|
peel.style.setProperty('--peel-img', `url(${JSON.stringify(url)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = host.querySelector('[data-paarrot-media-height]') as HTMLElement | null;
|
||||||
|
if (target && amount > 0.06 && size > 6) {
|
||||||
|
const { keep, flap, origin, axisX, axisY, hingeSign } = cornerPeelPaths(
|
||||||
|
best.id,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
ux,
|
||||||
|
uy,
|
||||||
|
size
|
||||||
|
);
|
||||||
|
target.style.clipPath = keep;
|
||||||
|
peel.style.clipPath = flap;
|
||||||
|
peel.style.transformOrigin = origin;
|
||||||
|
peel.style.setProperty('--peel-axis-x', String(axisX));
|
||||||
|
peel.style.setProperty('--peel-axis-y', String(axisY));
|
||||||
|
peel.style.setProperty('--peel-hinge', `${hingeSign * amount * 52}deg`);
|
||||||
|
peel.style.setProperty('--peel-tip-x', `${cx}px`);
|
||||||
|
peel.style.setProperty('--peel-tip-y', `${cy}px`);
|
||||||
|
peel.style.setProperty('--peel-grad-r', `${Math.max(size * 1.15, 12)}px`);
|
||||||
|
peel.style.opacity = '1';
|
||||||
|
} else {
|
||||||
|
clearPeel(host);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[peelCorner, clearPeel]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMove: MouseEventHandler<HTMLElement> = (evt) => {
|
||||||
|
syncPeel(evt.currentTarget, evt.clientX, evt.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnter: MouseEventHandler<HTMLElement> = (evt) => {
|
||||||
|
syncPeel(evt.currentTarget, evt.clientX, evt.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLeave: MouseEventHandler<HTMLElement> = (evt) => {
|
||||||
|
clearPeel(evt.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isStationery) {
|
||||||
|
return (
|
||||||
|
<Attachment style={style} {...attachmentProps}>
|
||||||
|
{children}
|
||||||
|
</Attachment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Attachment
|
||||||
|
{...attachmentProps}
|
||||||
|
style={tiltStyle}
|
||||||
|
data-stationery-media=""
|
||||||
|
data-tape-alt={tapeAlt ? '' : undefined}
|
||||||
|
onMouseEnter={handleEnter}
|
||||||
|
onMouseMove={handleMove}
|
||||||
|
onMouseLeave={handleLeave}
|
||||||
|
>
|
||||||
|
<span ref={peelRef} data-stationery-peel="" aria-hidden="true" />
|
||||||
|
{children}
|
||||||
|
</Attachment>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { ReactNode, useCallback, useState } from 'react';
|
import React, { ReactNode, useCallback, useState } from 'react';
|
||||||
import { Box, Button, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
|
import { Box, Button, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
|
||||||
import { Icon, Icons } from '../../icons';
|
import { Icon, Icons } from '../../icons';
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
|
import { saveMediaBlob } from '../../../utils/saveMedia';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { IFileInfo } from '../../../../types/matrix/common';
|
import { IFileInfo } from '../../../../types/matrix/common';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
@@ -252,9 +252,8 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
|
|||||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
||||||
: await downloadMedia(mediaUrl, accessToken);
|
: await downloadMedia(mediaUrl, accessToken);
|
||||||
|
|
||||||
const fileURL = URL.createObjectURL(fileContent);
|
await saveMediaBlob(fileContent, body);
|
||||||
FileSaver.saveAs(fileURL, body);
|
return fileContent;
|
||||||
return fileURL;
|
|
||||||
}, [mx, url, useAuthentication, mimeType, encInfo, body])
|
}, [mx, url, useAuthentication, mimeType, encInfo, body])
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -268,7 +267,7 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
|
|||||||
size="400"
|
size="400"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
downloadState.status === AsyncStatus.Success
|
downloadState.status === AsyncStatus.Success
|
||||||
? FileSaver.saveAs(downloadState.data, body)
|
? saveMediaBlob(downloadState.data, body)
|
||||||
: download()
|
: download()
|
||||||
}
|
}
|
||||||
disabled={downloadState.status === AsyncStatus.Loading}
|
disabled={downloadState.status === AsyncStatus.Loading}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
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 { BlurhashCanvas } from 'react-blurhash';
|
import { Blurhash } from 'react-blurhash';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
|
import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
|
||||||
@@ -14,9 +14,9 @@ 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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches media with authentication headers and returns a blob URL.
|
* Fetches media with authentication headers and returns a blob URL.
|
||||||
@@ -68,7 +68,7 @@ type RenderImageProps = {
|
|||||||
alt: string;
|
alt: string;
|
||||||
title: string;
|
title: string;
|
||||||
src: string;
|
src: string;
|
||||||
onLoad: () => void;
|
onLoad: (event?: React.SyntheticEvent<HTMLImageElement>) => void;
|
||||||
onError: () => void;
|
onError: () => void;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
tabIndex: number;
|
tabIndex: number;
|
||||||
@@ -105,12 +105,23 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
) => {
|
) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
const eventBlurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
||||||
|
const cachedBlurHash = getMediaBlurHash(url);
|
||||||
|
const blurHash = eventBlurHash ?? cachedBlurHash;
|
||||||
|
const cachedDims = getMediaDimensions(url);
|
||||||
|
const blurResolutionX = 32;
|
||||||
|
const ratioW = info?.w || cachedDims?.w;
|
||||||
|
const ratioH = info?.h || cachedDims?.h;
|
||||||
|
const blurResolutionY =
|
||||||
|
ratioW && ratioH && ratioW > 0
|
||||||
|
? Math.max(1, Math.round(blurResolutionX * (ratioH / ratioW)))
|
||||||
|
: 32;
|
||||||
|
|
||||||
const [load, setLoad] = useState(false);
|
const [load, setLoad] = useState(false);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [viewer, setViewer] = useState(false);
|
const [viewer, setViewer] = useState(false);
|
||||||
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
||||||
|
const [showPlaceholder, setShowPlaceholder] = useState(true);
|
||||||
|
|
||||||
const [srcState, loadSrc] = useAsyncCallback(
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
@@ -139,6 +150,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
const handleError = () => {
|
const handleError = () => {
|
||||||
setLoad(false);
|
setLoad(false);
|
||||||
setError(true);
|
setError(true);
|
||||||
|
setShowPlaceholder(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRetry = () => {
|
const handleRetry = () => {
|
||||||
@@ -150,42 +162,52 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
if (autoPlay) loadSrc();
|
if (autoPlay) loadSrc();
|
||||||
}, [autoPlay, loadSrc]);
|
}, [autoPlay, loadSrc]);
|
||||||
|
|
||||||
|
// Keep blurhash under the fade, then remove it once the image is fully opaque.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!load) {
|
||||||
|
setShowPlaceholder(true);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const timer = window.setTimeout(() => setShowPlaceholder(false), 540);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||||
|
{showPlaceholder &&
|
||||||
|
(typeof blurHash === 'string' ? (
|
||||||
|
<div className={css.BlurhashPlaceholder}>
|
||||||
|
<Blurhash
|
||||||
|
hash={blurHash}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
resolutionX={blurResolutionX}
|
||||||
|
resolutionY={blurResolutionY}
|
||||||
|
punch={1.2}
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
)}
|
)}
|
||||||
{typeof blurHash === 'string' && !load && (
|
|
||||||
<BlurhashCanvas
|
|
||||||
style={{ width: '100%', height: '100%' }}
|
|
||||||
width={32}
|
|
||||||
height={32}
|
|
||||||
hash={blurHash}
|
|
||||||
punch={1}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (
|
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (
|
||||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||||
<Button
|
<Button
|
||||||
@@ -201,12 +223,26 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{srcState.status === AsyncStatus.Success && (
|
{srcState.status === AsyncStatus.Success && (
|
||||||
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
<Box
|
||||||
|
className={classNames(
|
||||||
|
css.AbsoluteContainer,
|
||||||
|
css.MediaFadeIn,
|
||||||
|
load && css.MediaFadeInLoaded,
|
||||||
|
blurred && css.Blur
|
||||||
|
)}
|
||||||
|
>
|
||||||
{renderImage({
|
{renderImage({
|
||||||
alt: body,
|
alt: body,
|
||||||
title: body,
|
title: body,
|
||||||
src: srcState.data,
|
src: srcState.data,
|
||||||
onLoad: handleLoad,
|
onLoad: (e?: React.SyntheticEvent<HTMLImageElement>) => {
|
||||||
|
const img = e?.currentTarget;
|
||||||
|
if (img?.naturalWidth && img?.naturalHeight) {
|
||||||
|
setMediaDimensions(url, img.naturalWidth, img.naturalHeight);
|
||||||
|
}
|
||||||
|
if (img) rememberMediaBlurHash(url, img);
|
||||||
|
handleLoad();
|
||||||
|
},
|
||||||
onError: handleError,
|
onError: handleError,
|
||||||
onClick: () => setViewer(true),
|
onClick: () => setViewer(true),
|
||||||
tabIndex: 0,
|
tabIndex: 0,
|
||||||
@@ -248,7 +284,8 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
)}
|
)}
|
||||||
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
|
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
|
||||||
!load &&
|
!load &&
|
||||||
!blurred && (
|
!blurred &&
|
||||||
|
typeof blurHash !== 'string' && (
|
||||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||||
<Spinner variant="Secondary" />
|
<Spinner variant="Secondary" />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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, Modal, Overlay, OverlayBackdrop, OverlayCenter, 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 { BlurhashCanvas } from 'react-blurhash';
|
import { Blurhash } from 'react-blurhash';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +25,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
|||||||
import { validBlurHash } from '../../../utils/blurHash';
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
import { ModalWide } from '../../../styles/Modal.css';
|
import { ModalWide } from '../../../styles/Modal.css';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
|
import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
|
||||||
|
|
||||||
type RenderViewerProps = {
|
type RenderViewerProps = {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -34,7 +35,7 @@ type RenderViewerProps = {
|
|||||||
type RenderVideoProps = {
|
type RenderVideoProps = {
|
||||||
title: string;
|
title: string;
|
||||||
src: string;
|
src: string;
|
||||||
onLoadedMetadata: () => void;
|
onLoadedMetadata: (event?: React.SyntheticEvent<HTMLVideoElement>) => void;
|
||||||
onError: () => void;
|
onError: () => void;
|
||||||
autoPlay: boolean;
|
autoPlay: boolean;
|
||||||
controls: boolean;
|
controls: boolean;
|
||||||
@@ -74,12 +75,23 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
) => {
|
) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const blurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
const eventBlurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
||||||
|
const cachedBlurHash = getMediaBlurHash(url);
|
||||||
|
const blurHash = eventBlurHash ?? cachedBlurHash;
|
||||||
|
const cachedDims = getMediaDimensions(url);
|
||||||
|
const blurResolutionX = 32;
|
||||||
|
const blurW = info.w ?? info.thumbnail_info?.w ?? cachedDims?.w;
|
||||||
|
const blurH = info.h ?? info.thumbnail_info?.h ?? cachedDims?.h;
|
||||||
|
const blurResolutionY =
|
||||||
|
blurW && blurH && blurW > 0
|
||||||
|
? Math.max(1, Math.round(blurResolutionX * (blurH / blurW)))
|
||||||
|
: 32;
|
||||||
|
|
||||||
const [load, setLoad] = useState(false);
|
const [load, setLoad] = useState(false);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [viewer, setViewer] = useState(false);
|
const [viewer, setViewer] = useState(false);
|
||||||
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
||||||
|
const [showPlaceholder, setShowPlaceholder] = useState(true);
|
||||||
|
|
||||||
const [srcState, loadSrc] = useAsyncCallback(
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
@@ -95,12 +107,17 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
}, [mx, url, useAuthentication, mimeType, encInfo])
|
}, [mx, url, useAuthentication, mimeType, encInfo])
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleLoad = () => {
|
const handleLoad = (video?: HTMLVideoElement) => {
|
||||||
|
if (video?.videoWidth && video?.videoHeight) {
|
||||||
|
setMediaDimensions(url, video.videoWidth, video.videoHeight);
|
||||||
|
}
|
||||||
|
if (video) rememberMediaBlurHash(url, video);
|
||||||
setLoad(true);
|
setLoad(true);
|
||||||
};
|
};
|
||||||
const handleError = () => {
|
const handleError = () => {
|
||||||
setLoad(false);
|
setLoad(false);
|
||||||
setError(true);
|
setError(true);
|
||||||
|
setShowPlaceholder(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRetry = () => {
|
const handleRetry = () => {
|
||||||
@@ -112,8 +129,33 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
if (autoPlay) loadSrc();
|
if (autoPlay) loadSrc();
|
||||||
}, [autoPlay, loadSrc]);
|
}, [autoPlay, loadSrc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!load) {
|
||||||
|
setShowPlaceholder(true);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const timer = window.setTimeout(() => setShowPlaceholder(false), 540);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||||
|
{showPlaceholder &&
|
||||||
|
(typeof blurHash === 'string' ? (
|
||||||
|
<div className={css.BlurhashPlaceholder}>
|
||||||
|
<Blurhash
|
||||||
|
hash={blurHash}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
resolutionX={blurResolutionX}
|
||||||
|
resolutionY={blurResolutionY}
|
||||||
|
punch={1.2}
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={css.MediaSkeleton} />
|
||||||
|
))}
|
||||||
{renderViewer && srcState.status === AsyncStatus.Success && (
|
{renderViewer && srcState.status === AsyncStatus.Success && (
|
||||||
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
@@ -136,15 +178,6 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
</OverlayCenter>
|
</OverlayCenter>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
)}
|
)}
|
||||||
{typeof blurHash === 'string' && !load && (
|
|
||||||
<BlurhashCanvas
|
|
||||||
style={{ width: '100%', height: '100%' }}
|
|
||||||
width={32}
|
|
||||||
height={32}
|
|
||||||
hash={blurHash}
|
|
||||||
punch={1}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{renderThumbnail && !load && (
|
{renderThumbnail && !load && (
|
||||||
<Box
|
<Box
|
||||||
className={classNames(css.AbsoluteContainer, blurred && css.Blur)}
|
className={classNames(css.AbsoluteContainer, blurred && css.Blur)}
|
||||||
@@ -169,11 +202,20 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{srcState.status === AsyncStatus.Success && (
|
{srcState.status === AsyncStatus.Success && (
|
||||||
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
<Box
|
||||||
|
className={classNames(
|
||||||
|
css.AbsoluteContainer,
|
||||||
|
css.MediaFadeIn,
|
||||||
|
load && css.MediaFadeInLoaded,
|
||||||
|
blurred && css.Blur
|
||||||
|
)}
|
||||||
|
>
|
||||||
{renderVideo({
|
{renderVideo({
|
||||||
title: body,
|
title: body,
|
||||||
src: srcState.data,
|
src: srcState.data,
|
||||||
onLoadedMetadata: handleLoad,
|
onLoadedMetadata: (e?: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
handleLoad(e?.currentTarget);
|
||||||
|
},
|
||||||
onError: handleError,
|
onError: handleError,
|
||||||
autoPlay: true,
|
autoPlay: true,
|
||||||
controls: true,
|
controls: true,
|
||||||
@@ -213,7 +255,8 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
)}
|
)}
|
||||||
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
|
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
|
||||||
!load &&
|
!load &&
|
||||||
!blurred && (
|
!blurred &&
|
||||||
|
typeof blurHash !== 'string' && (
|
||||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||||
<Spinner variant="Secondary" />
|
<Spinner variant="Secondary" />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { keyframes, style } from '@vanilla-extract/css';
|
||||||
import { DefaultReset, config } from 'folds';
|
import { DefaultReset, color, config } from 'folds';
|
||||||
|
|
||||||
export const RelativeBase = style([
|
export const RelativeBase = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
borderRadius: '7px',
|
borderRadius: '7px',
|
||||||
@@ -16,9 +18,13 @@ export const RelativeBase = style([
|
|||||||
export const AbsoluteContainer = style([
|
export const AbsoluteContainer = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
borderRadius: 'inherit',
|
borderRadius: 'inherit',
|
||||||
@@ -34,6 +40,7 @@ export const AbsoluteFooter = style([
|
|||||||
bottom: config.space.S100,
|
bottom: config.space.S100,
|
||||||
left: config.space.S100,
|
left: config.space.S100,
|
||||||
right: config.space.S100,
|
right: config.space.S100,
|
||||||
|
zIndex: 1,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -43,3 +50,67 @@ export const Blur = style([
|
|||||||
filter: 'blur(44px)',
|
filter: 'blur(44px)',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const shimmer = keyframes({
|
||||||
|
'0%': {
|
||||||
|
backgroundPosition: '200% 0',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
backgroundPosition: '-200% 0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Soft fallback when no blurhash is available. */
|
||||||
|
export const MediaSkeleton = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
backgroundImage: `linear-gradient(
|
||||||
|
90deg,
|
||||||
|
${color.SurfaceVariant.Container} 0%,
|
||||||
|
${color.SurfaceVariant.ContainerHover} 40%,
|
||||||
|
${color.SurfaceVariant.Container} 80%
|
||||||
|
)`,
|
||||||
|
backgroundSize: '200% 100%',
|
||||||
|
animation: `${shimmer} 1.4s ease-in-out infinite`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const blurhashFadeIn = keyframes({
|
||||||
|
from: {
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Soft blurhash layer that fills the reserved media box. */
|
||||||
|
export const BlurhashPlaceholder = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
zIndex: 0,
|
||||||
|
// Soften the decode so it reads as color blobs; slight scale hides blur edges.
|
||||||
|
filter: 'blur(14px)',
|
||||||
|
transform: 'scale(1.06)',
|
||||||
|
opacity: 0,
|
||||||
|
animation: `${blurhashFadeIn} 560ms cubic-bezier(0.22, 1, 0.36, 1) forwards`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Media starts invisible and fades in once loaded over the placeholder. */
|
||||||
|
export const MediaFadeIn = style({
|
||||||
|
zIndex: 1,
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 520ms cubic-bezier(0.22, 1, 0.36, 1)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaFadeInLoaded = style({
|
||||||
|
opacity: 1,
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React from 'react';
|
import React, { CSSProperties, useMemo } from 'react';
|
||||||
import { Text, as } from 'folds';
|
import { Text, as } from 'folds';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import * as css from './layout.css';
|
import * as css from './layout.css';
|
||||||
|
import { isStationeryTheme, useTheme } from '../../../hooks/useTheme';
|
||||||
|
import { STATIONERY_NAME_INK } from '../../../utils/paperSafeInk';
|
||||||
|
|
||||||
export const MessageBase = as<'div', css.MessageBaseVariants>(
|
export const MessageBase = as<'div', css.MessageBaseVariants>(
|
||||||
({ className, highlight, selected, collapse, autoCollapse, space, newMessage, ...props }, ref) => (
|
({ className, highlight, selected, collapse, autoCollapse, space, newMessage, ...props }, ref) => (
|
||||||
@@ -10,6 +12,7 @@ export const MessageBase = as<'div', css.MessageBaseVariants>(
|
|||||||
css.MessageBase({ highlight, selected, collapse, autoCollapse, space, newMessage }),
|
css.MessageBase({ highlight, selected, collapse, autoCollapse, space, newMessage }),
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
data-message-collapsed={collapse ? '' : undefined}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
@@ -17,12 +20,45 @@ export const MessageBase = as<'div', css.MessageBaseVariants>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const AvatarBase = as<'span'>(({ className, ...props }, ref) => (
|
export const AvatarBase = as<'span'>(({ className, ...props }, ref) => (
|
||||||
<span className={classNames(css.AvatarBase, className)} {...props} ref={ref} />
|
<span
|
||||||
|
className={classNames(css.AvatarBase, className)}
|
||||||
|
data-message-avatar=""
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
));
|
));
|
||||||
|
|
||||||
export const Username = as<'span'>(({ as: AsUsername = 'span', className, ...props }, ref) => (
|
export const Username = as<'span'>(({ as: AsUsername = 'span', className, style, ...props }, ref) => {
|
||||||
<AsUsername className={classNames(css.Username, className)} {...props} ref={ref} />
|
const theme = useTheme();
|
||||||
));
|
const isStationery = isStationeryTheme(theme);
|
||||||
|
const userColor =
|
||||||
|
typeof (style as CSSProperties | undefined)?.color === 'string'
|
||||||
|
? ((style as CSSProperties).color as string)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const safeStyle = useMemo((): CSSProperties | undefined => {
|
||||||
|
if (!isStationery) return style as CSSProperties | undefined;
|
||||||
|
return {
|
||||||
|
...(style as CSSProperties | undefined),
|
||||||
|
// Name in ink; keep their color as an accent underline
|
||||||
|
color: STATIONERY_NAME_INK,
|
||||||
|
...(userColor
|
||||||
|
? { ['--username-accent' as string]: userColor, textDecorationColor: userColor }
|
||||||
|
: null),
|
||||||
|
};
|
||||||
|
}, [style, isStationery, userColor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AsUsername
|
||||||
|
className={classNames(css.Username, className)}
|
||||||
|
data-message-username=""
|
||||||
|
data-username-accent={isStationery && userColor ? '' : undefined}
|
||||||
|
style={safeStyle}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export const UsernameBold = as<'b'>(({ as: AsUsernameBold = 'b', className, ...props }, ref) => (
|
export const UsernameBold = as<'b'>(({ as: AsUsernameBold = 'b', className, ...props }, ref) => (
|
||||||
<AsUsernameBold className={classNames(css.UsernameBold, className)} {...props} ref={ref} />
|
<AsUsernameBold className={classNames(css.UsernameBold, className)} {...props} ref={ref} />
|
||||||
@@ -32,10 +68,12 @@ export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?
|
|||||||
({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => (
|
({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => (
|
||||||
<Text
|
<Text
|
||||||
as={asComp}
|
as={asComp}
|
||||||
size="T400"
|
size={jumboEmoji ? 'Inherit' : 'T400'}
|
||||||
priority={notice ? '300' : '400'}
|
priority={notice ? '300' : '400'}
|
||||||
className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)}
|
className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)}
|
||||||
data-allow-text-selection="true"
|
data-allow-text-selection="true"
|
||||||
|
data-message-body=""
|
||||||
|
data-jumbo-emoji={jumboEmoji ? '' : undefined}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createVar, keyframes, style, styleVariants } from '@vanilla-extract/css';
|
import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css';
|
||||||
|
import * as htmlCss from '../../../styles/CustomHtml.css';
|
||||||
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
|
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
|
||||||
import { DefaultReset, color, config, toRem } from 'folds';
|
import { DefaultReset, color, config, toRem } from 'folds';
|
||||||
|
|
||||||
@@ -117,6 +118,7 @@ export const MessageBase = recipe({
|
|||||||
collapse: {
|
collapse: {
|
||||||
true: {
|
true: {
|
||||||
marginTop: 0,
|
marginTop: 0,
|
||||||
|
paddingTop: config.space.S0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
autoCollapse: {
|
autoCollapse: {
|
||||||
@@ -202,6 +204,14 @@ export const Username = style({
|
|||||||
'button&:hover, button&:focus-visible': {
|
'button&:hover, button&:focus-visible': {
|
||||||
textDecoration: 'underline',
|
textDecoration: 'underline',
|
||||||
},
|
},
|
||||||
|
// Stationery uses a colored border-bottom accent instead
|
||||||
|
'html.stationery &, body.stationery &': {
|
||||||
|
overflow: 'visible',
|
||||||
|
overflowY: 'visible',
|
||||||
|
},
|
||||||
|
'body.stationery button&:hover, body.stationery button&:focus-visible': {
|
||||||
|
textDecoration: 'none',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -209,6 +219,8 @@ export const UsernameBold = style({
|
|||||||
fontWeight: 550,
|
fontWeight: 550,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const jumboEmojiSize = toRem(70);
|
||||||
|
|
||||||
export const MessageTextBody = recipe({
|
export const MessageTextBody = recipe({
|
||||||
base: {
|
base: {
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
@@ -226,8 +238,14 @@ export const MessageTextBody = recipe({
|
|||||||
},
|
},
|
||||||
jumboEmoji: {
|
jumboEmoji: {
|
||||||
true: {
|
true: {
|
||||||
fontSize: '1.504em',
|
fontSize: jumboEmojiSize,
|
||||||
lineHeight: '1.4962em',
|
lineHeight: 1,
|
||||||
|
overflow: 'visible',
|
||||||
|
overflowY: 'visible',
|
||||||
|
paddingBottom: config.space.S200,
|
||||||
|
cursor: 'pointer',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 5,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
emote: {
|
emote: {
|
||||||
@@ -240,3 +258,31 @@ export const MessageTextBody = recipe({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type MessageTextBodyVariants = RecipeVariants<typeof MessageTextBody>;
|
export type MessageTextBodyVariants = RecipeVariants<typeof MessageTextBody>;
|
||||||
|
|
||||||
|
const jumboEmojiClass = MessageTextBody.classNames.variants.jumboEmoji.true;
|
||||||
|
|
||||||
|
globalStyle(`${jumboEmojiClass} .${htmlCss.EmoticonBase}`, {
|
||||||
|
height: '1em',
|
||||||
|
padding: 0,
|
||||||
|
overflow: 'visible',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${jumboEmojiClass} .${htmlCss.Emoticon.classNames.base}`, {
|
||||||
|
fontSize: '1em',
|
||||||
|
height: '1em',
|
||||||
|
minWidth: '1em',
|
||||||
|
lineHeight: 1,
|
||||||
|
position: 'static',
|
||||||
|
top: 0,
|
||||||
|
overflow: 'visible',
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${jumboEmojiClass} .${htmlCss.EmoticonImg}`, {
|
||||||
|
height: '1em',
|
||||||
|
width: '1em',
|
||||||
|
maxHeight: 'none',
|
||||||
|
objectFit: 'contain',
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,5 +7,10 @@ type NavCategoryProps = {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
export const NavCategory = as<'div', NavCategoryProps>(({ className, ...props }, ref) => (
|
export const NavCategory = as<'div', NavCategoryProps>(({ className, ...props }, ref) => (
|
||||||
<div className={classNames(css.NavCategory, className)} {...props} ref={ref} />
|
<div
|
||||||
|
className={classNames(css.NavCategory, className)}
|
||||||
|
data-nav-category=""
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const NavItem = as<
|
|||||||
<AsNavItem
|
<AsNavItem
|
||||||
className={classNames(css.NavItem({ variant, radii }), className)}
|
className={classNames(css.NavItem({ variant, radii }), className)}
|
||||||
data-highlight={highlight}
|
data-highlight={highlight}
|
||||||
|
data-nav-item=""
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import * as css from './style.css';
|
|||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
|
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
|
||||||
import { useShowCompactMasterView } from '../../hooks/useCompactNav';
|
import { useShowCompactMasterView } from '../../hooks/useCompactNav';
|
||||||
|
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||||
|
|
||||||
type PageRootProps = {
|
type PageRootProps = {
|
||||||
nav: ReactNode;
|
nav: ReactNode;
|
||||||
@@ -15,6 +16,7 @@ type PageRootProps = {
|
|||||||
export function PageRoot({ nav, children }: PageRootProps) {
|
export function PageRoot({ nav, children }: PageRootProps) {
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const showCompactMaster = useShowCompactMasterView();
|
const showCompactMaster = useShowCompactMasterView();
|
||||||
|
const showDetail = !showCompactMaster || nav == null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
|
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
|
||||||
@@ -22,7 +24,7 @@ export function PageRoot({ nav, children }: PageRootProps) {
|
|||||||
{screenSize !== ScreenSize.Mobile && (
|
{screenSize !== ScreenSize.Mobile && (
|
||||||
<Line variant="Background" size="300" direction="Vertical" />
|
<Line variant="Background" size="300" direction="Vertical" />
|
||||||
)}
|
)}
|
||||||
{!showCompactMaster && children}
|
{showDetail && children}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -43,6 +45,7 @@ export function PageNav({
|
|||||||
grow={isMobile ? 'Yes' : undefined}
|
grow={isMobile ? 'Yes' : undefined}
|
||||||
className={classNames(css.PageNav({ size }), className)}
|
className={classNames(css.PageNav({ size }), className)}
|
||||||
shrink={isMobile ? 'Yes' : 'No'}
|
shrink={isMobile ? 'Yes' : 'No'}
|
||||||
|
data-page-nav=""
|
||||||
>
|
>
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
{children}
|
{children}
|
||||||
@@ -58,6 +61,7 @@ export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
|
|||||||
className={classNames(css.PageNavHeader({ outlined }), className)}
|
className={classNames(css.PageNavHeader({ outlined }), className)}
|
||||||
variant="Background"
|
variant="Background"
|
||||||
size="600"
|
size="600"
|
||||||
|
data-folder-tab="nav"
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
@@ -73,13 +77,16 @@ export function PageNavContent({
|
|||||||
scrollRef?: MutableRefObject<HTMLDivElement | null>;
|
scrollRef?: MutableRefObject<HTMLDivElement | null>;
|
||||||
scrollProps?: React.ComponentProps<typeof Scroll>;
|
scrollProps?: React.ComponentProps<typeof Scroll>;
|
||||||
}) {
|
}) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const hideScrollbar = isStationeryTheme(theme);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
<Scroll
|
<Scroll
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
variant="Background"
|
variant="Background"
|
||||||
direction="Vertical"
|
direction="Vertical"
|
||||||
size="300"
|
size={hideScrollbar ? '0' : '300'}
|
||||||
hideTrack
|
hideTrack
|
||||||
visibility="Hover"
|
visibility="Hover"
|
||||||
{...scrollProps}
|
{...scrollProps}
|
||||||
@@ -95,6 +102,7 @@ export const Page = as<'div'>(({ className, ...props }, ref) => (
|
|||||||
grow="Yes"
|
grow="Yes"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
className={classNames(ContainerColor({ variant: 'Surface' }), className)}
|
className={classNames(ContainerColor({ variant: 'Surface' }), className)}
|
||||||
|
data-page=""
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
@@ -106,6 +114,7 @@ export const PageHeader = as<'div', css.PageHeaderVariants>(
|
|||||||
as="header"
|
as="header"
|
||||||
size="600"
|
size="600"
|
||||||
className={classNames(css.PageHeader({ balance, outlined }), className)}
|
className={classNames(css.PageHeader({ balance, outlined }), className)}
|
||||||
|
data-folder-tab-bar=""
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,35 +1,76 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { CSSProperties, ReactElement, cloneElement, isValidElement } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import * as css from './styles.css';
|
import { config } from 'folds';
|
||||||
import { Presence } from '../../hooks/useUserPresence';
|
import { Presence } from '../../hooks/useUserPresence';
|
||||||
|
|
||||||
type PresenceAvatarProps = {
|
type PresenceAvatarProps = {
|
||||||
/** The presence state to display */
|
|
||||||
presence?: Presence;
|
presence?: Presence;
|
||||||
/** The avatar element to wrap */
|
children: ReactElement;
|
||||||
children: ReactNode;
|
|
||||||
/** Additional className */
|
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PRESENCE_COLOR: Record<Presence, string> = {
|
||||||
|
[Presence.Online]: '#38842b',
|
||||||
|
[Presence.Unavailable]: '#959e30',
|
||||||
|
[Presence.Offline]: '#454545',
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowStyle: CSSProperties = {
|
||||||
|
display: 'inline-flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
flexShrink: 0,
|
||||||
|
lineHeight: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const faceStyle: CSSProperties = {
|
||||||
|
borderTopLeftRadius: 0,
|
||||||
|
borderBottomLeftRadius: 0,
|
||||||
|
// Keep the right side rounded to match size-200 nav avatars.
|
||||||
|
borderTopRightRadius: config.radii.R400,
|
||||||
|
borderBottomRightRadius: config.radii.R400,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps an avatar with a left-side presence indicator border.
|
* Presence strip as a real sibling (not ::before).
|
||||||
* Shows green for online, yellow/orange for unavailable/away, grey for offline.
|
* Folds Avatar uses overflow:hidden, which clips outside ::before bars.
|
||||||
*/
|
*/
|
||||||
export function PresenceAvatar({ presence, children, className }: PresenceAvatarProps) {
|
export function PresenceAvatar({
|
||||||
if (!presence) {
|
presence = Presence.Offline,
|
||||||
return <>{children}</>;
|
children,
|
||||||
}
|
className,
|
||||||
|
}: PresenceAvatarProps) {
|
||||||
|
const stripColor = PRESENCE_COLOR[presence] ?? PRESENCE_COLOR[Presence.Offline];
|
||||||
|
|
||||||
|
const face = isValidElement(children)
|
||||||
|
? cloneElement(children, {
|
||||||
|
className: classNames((children.props as { className?: string }).className),
|
||||||
|
style: {
|
||||||
|
...((children.props as { style?: CSSProperties }).style ?? {}),
|
||||||
|
...faceStyle,
|
||||||
|
},
|
||||||
|
// Avoid the folds radii shorthand fighting the square-left corners.
|
||||||
|
radii: '0',
|
||||||
|
} as Partial<unknown>)
|
||||||
|
: children;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={className} style={rowStyle} data-presence={presence}>
|
||||||
className={classNames(css.PresenceAvatarContainer, css.PresenceIndicator, className, {
|
<span
|
||||||
[css.PresenceOnline]: presence === Presence.Online,
|
aria-hidden
|
||||||
[css.PresenceUnavailable]: presence === Presence.Unavailable,
|
style={{
|
||||||
[css.PresenceOffline]: presence === Presence.Offline,
|
display: 'block',
|
||||||
})}
|
width: 4,
|
||||||
>
|
minWidth: 4,
|
||||||
{children}
|
flex: '0 0 4px',
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
// Match folds Avatar size="200" so the strip can't collapse to 0 height.
|
||||||
|
minHeight: config.size.X200,
|
||||||
|
borderRadius: '5px 0 0 5px',
|
||||||
|
backgroundColor: stripColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{face}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { config, color } from 'folds';
|
import { config } from 'folds';
|
||||||
|
|
||||||
export const AvatarPresence = style({
|
export const AvatarPresence = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -20,41 +20,3 @@ export const AvatarPresenceBadge = style({
|
|||||||
borderRadius: config.radii.Pill,
|
borderRadius: config.radii.Pill,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const PresenceAvatarContainer = style({
|
|
||||||
display: 'flex',
|
|
||||||
position: 'relative',
|
|
||||||
flexShrink: 0,
|
|
||||||
alignSelf: 'flex-start',
|
|
||||||
'::before': {
|
|
||||||
content: '""',
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
top: '10%',
|
|
||||||
height: '80%',
|
|
||||||
width: '3px',
|
|
||||||
borderTopRightRadius: '2px',
|
|
||||||
borderBottomRightRadius: '2px',
|
|
||||||
zIndex: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const PresenceIndicator = style({});
|
|
||||||
|
|
||||||
export const PresenceOnline = style({
|
|
||||||
'::before': {
|
|
||||||
backgroundColor: color.Success.Main,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const PresenceUnavailable = style({
|
|
||||||
'::before': {
|
|
||||||
backgroundColor: color.Warning.Main,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const PresenceOffline = style({
|
|
||||||
'::before': {
|
|
||||||
backgroundColor: color.Secondary.Main,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
Overlay,
|
||||||
|
OverlayCenter,
|
||||||
|
OverlayBackdrop,
|
||||||
|
Header,
|
||||||
|
config,
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
IconButton,
|
||||||
|
color,
|
||||||
|
Button,
|
||||||
|
Spinner,
|
||||||
|
toRem,
|
||||||
|
} from 'folds';
|
||||||
|
import { MatrixClient, MatrixError, Room } from 'matrix-js-sdk';
|
||||||
|
import { Icon, Icons } from '../icons';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { getSpaceChildren, getStateEvent, isSpace } from '../../utils/room';
|
||||||
|
import { Membership, StateEvent } from '../../../types/matrix/room';
|
||||||
|
import { fetchAllHierarchyRooms } from '../../hooks/useSpaceHierarchy';
|
||||||
|
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
|
||||||
|
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
|
||||||
|
import { IPowerLevels } from '../../hooks/usePowerLevels';
|
||||||
|
|
||||||
|
const REMOVE_DELAY_MS = 100;
|
||||||
|
const PREVIEW_LIMIT = 20;
|
||||||
|
|
||||||
|
export type InaccessibleSpaceChild = {
|
||||||
|
parentId: string;
|
||||||
|
roomId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function canEditSpaceChildren(mx: MatrixClient, spaceRoom: Room): boolean {
|
||||||
|
const creators = getRoomCreatorsForRoomId(mx, spaceRoom.roomId);
|
||||||
|
const powerLevelsEvent = getStateEvent(spaceRoom, StateEvent.RoomPowerLevels);
|
||||||
|
const powerLevels = (powerLevelsEvent?.getContent() ?? {}) as IPowerLevels;
|
||||||
|
const permissions = getRoomPermissionsAPI(creators, powerLevels);
|
||||||
|
return permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId());
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InaccessibleScanResult = {
|
||||||
|
inaccessible: InaccessibleSpaceChild[];
|
||||||
|
/** Total valid m.space.child entries walked (editable spaces only). */
|
||||||
|
childCount: number;
|
||||||
|
/** Children skipped because the user is joined to them. */
|
||||||
|
joinedCount: number;
|
||||||
|
/** Unjoined children that hierarchy can still summarize (Joinable / not Inaccessible). */
|
||||||
|
joinableCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects space children that appear as Unknown/Inaccessible in the lobby.
|
||||||
|
* Matches lobby getRoom (joined-only): not joined + no hierarchy summary.
|
||||||
|
* Walks nested subspaces the user can edit.
|
||||||
|
*/
|
||||||
|
export async function collectInaccessibleSpaceChildren(
|
||||||
|
mx: MatrixClient,
|
||||||
|
rootSpaceId: string
|
||||||
|
): Promise<InaccessibleScanResult> {
|
||||||
|
let hierarchySummaries = new Map<string, { room_id: string }>();
|
||||||
|
try {
|
||||||
|
const hierarchyRooms = await fetchAllHierarchyRooms(mx, rootSpaceId, 3);
|
||||||
|
hierarchySummaries = new Map(hierarchyRooms.map((room) => [room.room_id, room]));
|
||||||
|
} catch {
|
||||||
|
// Hierarchy often fails for restricted spaces; treat missing summaries as inaccessible.
|
||||||
|
hierarchySummaries = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const inaccessible: InaccessibleSpaceChild[] = [];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
let childCount = 0;
|
||||||
|
let joinedCount = 0;
|
||||||
|
let joinableCount = 0;
|
||||||
|
|
||||||
|
const walk = (spaceId: string) => {
|
||||||
|
if (visited.has(spaceId)) return;
|
||||||
|
visited.add(spaceId);
|
||||||
|
|
||||||
|
const spaceRoom = mx.getRoom(spaceId);
|
||||||
|
if (!spaceRoom || spaceRoom.getMyMembership() !== Membership.Join) return;
|
||||||
|
if (!canEditSpaceChildren(mx, spaceRoom)) return;
|
||||||
|
|
||||||
|
for (const childId of getSpaceChildren(spaceRoom)) {
|
||||||
|
childCount += 1;
|
||||||
|
const childRoom = mx.getRoom(childId);
|
||||||
|
// Lobby uses joined-only getRoom; left/invite rooms still render as Inaccessible.
|
||||||
|
const joined = childRoom?.getMyMembership() === Membership.Join;
|
||||||
|
|
||||||
|
if (joined && childRoom && isSpace(childRoom)) {
|
||||||
|
joinedCount += 1;
|
||||||
|
walk(childId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (joined) {
|
||||||
|
joinedCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasHierarchySummary = hierarchySummaries.has(childId);
|
||||||
|
if (hasHierarchySummary) {
|
||||||
|
joinableCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
inaccessible.push({ parentId: spaceId, roomId: childId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(rootSpaceId);
|
||||||
|
return { inaccessible, childCount, joinedCount, joinableCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSpaceChildrenSequentially(
|
||||||
|
mx: MatrixClient,
|
||||||
|
children: InaccessibleSpaceChild[],
|
||||||
|
onProgress?: (current: number, total: number) => void
|
||||||
|
): Promise<void> {
|
||||||
|
const total = children.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < children.length; i += 1) {
|
||||||
|
const { parentId, roomId } = children[i];
|
||||||
|
onProgress?.(i, total);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mx.sendStateEvent(parentId, StateEvent.SpaceChild as any, {}, roomId);
|
||||||
|
await delay(REMOVE_DELAY_MS);
|
||||||
|
} catch {
|
||||||
|
// Continue removing others even if one fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.(total, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RemoveInaccessiblePromptProps = {
|
||||||
|
roomId: string;
|
||||||
|
onDone: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RemoveInaccessiblePrompt({
|
||||||
|
roomId,
|
||||||
|
onDone,
|
||||||
|
onCancel,
|
||||||
|
}: RemoveInaccessiblePromptProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [progress, setProgress] = useState<{ current: number; total: number } | null>(null);
|
||||||
|
const [scanResult, setScanResult] = useState<InaccessibleScanResult | null>(null);
|
||||||
|
|
||||||
|
const [scanState, scan] = useAsyncCallback<InaccessibleScanResult, MatrixError | Error, []>(
|
||||||
|
useCallback(async () => collectInaccessibleSpaceChildren(mx, roomId), [mx, roomId])
|
||||||
|
);
|
||||||
|
|
||||||
|
const children = scanResult?.inaccessible ?? null;
|
||||||
|
|
||||||
|
const [removeState, removeChildren] = useAsyncCallback<undefined, MatrixError | Error, []>(
|
||||||
|
useCallback(async () => {
|
||||||
|
if (!children || children.length === 0) return;
|
||||||
|
await removeSpaceChildrenSequentially(mx, children, (current, total) => {
|
||||||
|
setProgress({ current, total });
|
||||||
|
});
|
||||||
|
setProgress(null);
|
||||||
|
}, [mx, children])
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scan();
|
||||||
|
}, [scan]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scanState.status === AsyncStatus.Success) {
|
||||||
|
setScanResult(scanState.data);
|
||||||
|
}
|
||||||
|
}, [scanState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (removeState.status === AsyncStatus.Success) {
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
}, [removeState, onDone]);
|
||||||
|
|
||||||
|
const previewIds = useMemo(() => (children ?? []).slice(0, PREVIEW_LIMIT).map((c) => c.roomId), [children]);
|
||||||
|
const isScanning = scanState.status === AsyncStatus.Loading || scanState.status === AsyncStatus.Idle;
|
||||||
|
const isRemoving = removeState.status === AsyncStatus.Loading;
|
||||||
|
const count = children?.length ?? 0;
|
||||||
|
|
||||||
|
const getButtonText = () => {
|
||||||
|
if (isRemoving && progress) return `Removing: ${progress.current}/${progress.total}`;
|
||||||
|
if (isRemoving) return 'Removing...';
|
||||||
|
if (count === 0) return 'Nothing to Remove';
|
||||||
|
return `Remove ${count} Room${count === 1 ? '' : 's'}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: onCancel,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog variant="Surface">
|
||||||
|
<Header
|
||||||
|
style={{
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
}}
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="H4">Remove Inaccessible</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" onClick={onCancel} radii="300" disabled={isRemoving}>
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
{isScanning && (
|
||||||
|
<Box alignItems="Center" gap="200">
|
||||||
|
<Spinner size="200" />
|
||||||
|
<Text priority="400">Scanning space hierarchy for inaccessible rooms…</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{scanState.status === AsyncStatus.Error && (
|
||||||
|
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||||
|
Failed to scan space. {scanState.error.message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{scanState.status === AsyncStatus.Success && count === 0 && (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text priority="400">No inaccessible rooms found in this space.</Text>
|
||||||
|
{scanResult && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Scanned {scanResult.childCount} child
|
||||||
|
{scanResult.childCount === 1 ? '' : 'ren'}: {scanResult.joinedCount} joined,{' '}
|
||||||
|
{scanResult.joinableCount} joinable via hierarchy.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{scanState.status === AsyncStatus.Success && count > 0 && (
|
||||||
|
<>
|
||||||
|
<Text priority="400">
|
||||||
|
Remove {count} inaccessible room{count === 1 ? '' : 's'} from this space? This
|
||||||
|
only unlinks them from the space; it does not leave or delete rooms.
|
||||||
|
</Text>
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
style={{
|
||||||
|
maxHeight: toRem(200),
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: config.space.S200,
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{previewIds.map((id) => (
|
||||||
|
<Text key={id} size="T200" style={{ wordBreak: 'break-all' }}>
|
||||||
|
{id}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
{count > PREVIEW_LIMIT && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
…and {count - PREVIEW_LIMIT} more
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{removeState.status === AsyncStatus.Error && (
|
||||||
|
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||||
|
Failed to remove some rooms. {removeState.error.message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="Critical"
|
||||||
|
onClick={() => removeChildren()}
|
||||||
|
before={
|
||||||
|
isRemoving || isScanning ? (
|
||||||
|
<Spinner fill="Solid" variant="Critical" size="200" />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
isScanning ||
|
||||||
|
isRemoving ||
|
||||||
|
count === 0 ||
|
||||||
|
removeState.status === AsyncStatus.Success
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="B400">{getButtonText()}</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/components/remove-inaccessible-prompt/index.ts
Normal file
1
src/app/components/remove-inaccessible-prompt/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './RemoveInaccessiblePrompt';
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
import { JoinRule } from 'matrix-js-sdk';
|
import { JoinRule } from 'matrix-js-sdk';
|
||||||
import { AvatarFallback, AvatarImage, color } from 'folds';
|
import { AvatarFallback, AvatarImage, color } from 'folds';
|
||||||
import { Icon, Icons } from '../icons';
|
import { Icon, Icons } from '../icons';
|
||||||
import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react';
|
import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useEffect, useState } from 'react';
|
||||||
|
import { Blurhash } from 'react-blurhash';
|
||||||
import * as css from './RoomAvatar.css';
|
import * as css from './RoomAvatar.css';
|
||||||
import { joinRuleToIconSrc } from '../../utils/room';
|
import { joinRuleToIconSrc } from '../../utils/room';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
|
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
|
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
|
||||||
|
import {
|
||||||
|
getCachedAuthenticatedMediaUrl,
|
||||||
|
isAuthenticatedMediaUrl,
|
||||||
|
} from '../../utils/authenticatedMediaCache';
|
||||||
|
import {
|
||||||
|
getMediaBlurHash,
|
||||||
|
getMediaDimensions,
|
||||||
|
rememberMediaBlurHash,
|
||||||
|
} from '../../state/mediaDimensionCache';
|
||||||
|
|
||||||
type RoomAvatarProps = {
|
type RoomAvatarProps = {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@@ -17,19 +27,36 @@ type RoomAvatarProps = {
|
|||||||
};
|
};
|
||||||
export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps) {
|
export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps) {
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [loaded, setLoaded] = useState(() => isAvatarCached(src));
|
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
||||||
|
const blurHash = getMediaBlurHash(src ?? '');
|
||||||
|
const dims = getMediaDimensions(src ?? '');
|
||||||
|
const blobCached =
|
||||||
|
!!src &&
|
||||||
|
(!useAuthentication ||
|
||||||
|
!isAuthenticatedMediaUrl(src) ||
|
||||||
|
!!getCachedAuthenticatedMediaUrl(src));
|
||||||
|
const [loaded, setLoaded] = useState(() => isAvatarCached(src) || blobCached);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
authenticatedSrc &&
|
||||||
|
(isAvatarCached(src) ||
|
||||||
|
(src && (!useAuthentication || !isAuthenticatedMediaUrl(src) || getCachedAuthenticatedMediaUrl(src))))
|
||||||
|
) {
|
||||||
|
setLoaded(true);
|
||||||
|
}
|
||||||
|
}, [authenticatedSrc, src, useAuthentication]);
|
||||||
|
|
||||||
const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
|
const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
|
||||||
evt.currentTarget.setAttribute('data-image-loaded', 'true');
|
evt.currentTarget.setAttribute('data-image-loaded', 'true');
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
cacheAvatar(src);
|
cacheAvatar(src);
|
||||||
pruneAvatarCache();
|
pruneAvatarCache();
|
||||||
|
rememberMediaBlurHash(src ?? '', evt.currentTarget);
|
||||||
};
|
};
|
||||||
|
|
||||||
// No src or error - show fallback only
|
if (!src || error) {
|
||||||
if (!authenticatedSrc || error) {
|
|
||||||
return (
|
return (
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
style={{ backgroundColor: colorMXID(roomId ?? ''), color: color.Surface.Container }}
|
style={{ backgroundColor: colorMXID(roomId ?? ''), color: color.Surface.Container }}
|
||||||
@@ -40,8 +67,7 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If already cached, show image directly without fallback flash
|
if (authenticatedSrc && loaded) {
|
||||||
if (loaded) {
|
|
||||||
return (
|
return (
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
className={css.RoomAvatar}
|
className={css.RoomAvatar}
|
||||||
@@ -55,12 +81,17 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading state - render fallback with image overlay
|
const blurResolutionX = 32;
|
||||||
|
const blurResolutionY =
|
||||||
|
dims?.w && dims?.h && dims.w > 0
|
||||||
|
? Math.max(1, Math.round(blurResolutionX * (dims.h / dims.w)))
|
||||||
|
: 32;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: colorMXID(roomId ?? ''),
|
backgroundColor: colorMXID(roomId ?? ''),
|
||||||
color: color.Surface.Container,
|
color: color.Surface.Container,
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -69,15 +100,39 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
|
|||||||
>
|
>
|
||||||
{renderFallback()}
|
{renderFallback()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
<AvatarImage
|
{blurHash && (
|
||||||
className={css.RoomAvatar}
|
<div
|
||||||
style={{ position: 'relative', zIndex: 1 }}
|
style={{
|
||||||
src={authenticatedSrc}
|
position: 'absolute',
|
||||||
alt={alt}
|
inset: 0,
|
||||||
onError={() => setError(true)}
|
overflow: 'hidden',
|
||||||
onLoad={handleLoad}
|
borderRadius: 'inherit',
|
||||||
draggable={false}
|
filter: 'blur(6px)',
|
||||||
/>
|
transform: 'scale(1.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Blurhash
|
||||||
|
hash={blurHash}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
resolutionX={blurResolutionX}
|
||||||
|
resolutionY={blurResolutionY}
|
||||||
|
punch={1.1}
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{authenticatedSrc && (
|
||||||
|
<AvatarImage
|
||||||
|
className={css.RoomAvatar}
|
||||||
|
style={{ position: 'relative', zIndex: 1, opacity: loaded ? 1 : 0 }}
|
||||||
|
src={authenticatedSrc}
|
||||||
|
alt={alt}
|
||||||
|
onError={() => setError(true)}
|
||||||
|
onLoad={handleLoad}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/app/components/setting-tile/SettingTile.css.ts
Normal file
18
src/app/components/setting-tile/SettingTile.css.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { keyframes, style } from '@vanilla-extract/css';
|
||||||
|
import { color, config } from 'folds';
|
||||||
|
|
||||||
|
const focusPulse = keyframes({
|
||||||
|
'0%': {
|
||||||
|
outlineColor: color.Primary.Main,
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
outlineColor: 'transparent',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SettingTileFocus = style({
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
outline: `${config.borderWidth.B300} solid ${color.Primary.Main}`,
|
||||||
|
outlineOffset: config.space.S100,
|
||||||
|
animation: `${focusPulse} 1.2s ease-out forwards`,
|
||||||
|
});
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode, useEffect, useRef, useState } from 'react';
|
||||||
import { Box, Text } from 'folds';
|
import { Box, Text } from 'folds';
|
||||||
|
import classNames from 'classnames';
|
||||||
import { BreakWord } from '../../styles/Text.css';
|
import { BreakWord } from '../../styles/Text.css';
|
||||||
|
import { settingAnchorId, useSettingsFocus } from './SettingsFocus';
|
||||||
|
import { SettingTileFocus } from './SettingTile.css';
|
||||||
|
|
||||||
type SettingTileProps = {
|
type SettingTileProps = {
|
||||||
title?: ReactNode;
|
title?: ReactNode;
|
||||||
@@ -8,10 +11,65 @@ type SettingTileProps = {
|
|||||||
before?: ReactNode;
|
before?: ReactNode;
|
||||||
after?: ReactNode;
|
after?: ReactNode;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
/** Override auto-generated scroll anchor. */
|
||||||
|
anchorId?: string;
|
||||||
};
|
};
|
||||||
export function SettingTile({ title, description, before, after, children }: SettingTileProps) {
|
export function SettingTile({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
children,
|
||||||
|
anchorId: anchorIdProp,
|
||||||
|
}: SettingTileProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const { anchorId: focusAnchorId, setAnchorId } = useSettingsFocus();
|
||||||
|
const [highlighted, setHighlighted] = useState(false);
|
||||||
|
|
||||||
|
const autoAnchorId = typeof title === 'string' ? settingAnchorId(title) : undefined;
|
||||||
|
const anchorId = anchorIdProp ?? autoAnchorId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!anchorId || !focusAnchorId || anchorId !== focusAnchorId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const node = ref.current;
|
||||||
|
if (node) {
|
||||||
|
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
setHighlighted(true);
|
||||||
|
setAnchorId(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attempts < 40) {
|
||||||
|
attempts += 1;
|
||||||
|
window.requestAnimationFrame(run);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = window.setTimeout(run, 50);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [anchorId, focusAnchorId, setAnchorId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlighted) return;
|
||||||
|
const timer = window.setTimeout(() => setHighlighted(false), 1400);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [highlighted]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box alignItems="Center" gap="300">
|
<Box
|
||||||
|
ref={ref}
|
||||||
|
id={anchorId}
|
||||||
|
alignItems="Center"
|
||||||
|
gap="300"
|
||||||
|
className={classNames(highlighted && SettingTileFocus)}
|
||||||
|
>
|
||||||
{before && <Box shrink="No">{before}</Box>}
|
{before && <Box shrink="No">{before}</Box>}
|
||||||
<Box grow="Yes" direction="Column" gap="100">
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
{title && (
|
{title && (
|
||||||
|
|||||||
32
src/app/components/setting-tile/SettingsFocus.tsx
Normal file
32
src/app/components/setting-tile/SettingsFocus.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
export type SettingsFocus = {
|
||||||
|
/** Stable anchor id for a setting tile (from settingAnchorId). */
|
||||||
|
anchorId?: string;
|
||||||
|
setAnchorId: (anchorId: string | undefined) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SettingsFocusContext = createContext<SettingsFocus | null>(null);
|
||||||
|
|
||||||
|
export const SettingsFocusProvider = SettingsFocusContext.Provider;
|
||||||
|
|
||||||
|
export const useSettingsFocus = (): SettingsFocus => {
|
||||||
|
const ctx = useContext(SettingsFocusContext);
|
||||||
|
if (!ctx) {
|
||||||
|
return {
|
||||||
|
anchorId: undefined,
|
||||||
|
setAnchorId: () => undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stable DOM id for a settings tile title. */
|
||||||
|
export function settingAnchorId(title: string): string {
|
||||||
|
const slug = title
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
return `settings-item-${slug || 'untitled'}`;
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from './SettingTile';
|
export * from './SettingTile';
|
||||||
|
export * from './SettingsFocus';
|
||||||
|
|||||||
@@ -4,5 +4,10 @@ import React from 'react';
|
|||||||
import * as css from './Sidebar.css';
|
import * as css from './Sidebar.css';
|
||||||
|
|
||||||
export const Sidebar = as<'div'>(({ as: AsSidebar = 'div', className, ...props }, ref) => (
|
export const Sidebar = as<'div'>(({ as: AsSidebar = 'div', className, ...props }, ref) => (
|
||||||
<AsSidebar className={classNames(css.Sidebar, className)} {...props} ref={ref} />
|
<AsSidebar
|
||||||
|
className={classNames(css.Sidebar, className)}
|
||||||
|
data-sidebar=""
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ type SidebarContentProps = {
|
|||||||
export function SidebarContent({ scrollable, sticky }: SidebarContentProps) {
|
export function SidebarContent({ scrollable, sticky }: SidebarContentProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box direction="Column" grow="Yes">
|
<Box direction="Column" grow="Yes" data-sidebar-scroll-region="" style={{ overflow: 'visible', minWidth: 0 }}>
|
||||||
{scrollable}
|
{scrollable}
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Column" shrink="No">
|
<Box direction="Column" shrink="No" data-sidebar-sticky="" style={{ overflow: 'visible' }}>
|
||||||
{sticky}
|
{sticky}
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const SidebarItem = as<'div', css.SidebarItemVariants>(
|
|||||||
({ as: AsSidebarAvatarBox = 'div', className, active, ...props }, ref) => (
|
({ as: AsSidebarAvatarBox = 'div', className, active, ...props }, ref) => (
|
||||||
<AsSidebarAvatarBox
|
<AsSidebarAvatarBox
|
||||||
className={classNames(css.SidebarItem({ active }), className)}
|
className={classNames(css.SidebarItem({ active }), className)}
|
||||||
|
data-sidebar-item=""
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
@@ -54,6 +55,7 @@ export const SidebarAvatar = as<'div', css.SidebarAvatarVariants & ComponentProp
|
|||||||
<Avatar
|
<Avatar
|
||||||
className={classNames(css.SidebarAvatar({ size, outlined }), className)}
|
className={classNames(css.SidebarAvatar({ size, outlined }), className)}
|
||||||
radii={radii}
|
radii={radii}
|
||||||
|
data-sidebar-avatar=""
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
@@ -64,6 +66,7 @@ export const SidebarFolder = as<'div', css.SidebarFolderVariants>(
|
|||||||
({ as: AsSidebarFolder = 'div', className, state, ...props }, ref) => (
|
({ as: AsSidebarFolder = 'div', className, state, ...props }, ref) => (
|
||||||
<AsSidebarFolder
|
<AsSidebarFolder
|
||||||
className={classNames(css.SidebarFolder({ state }), className)}
|
className={classNames(css.SidebarFolder({ state }), className)}
|
||||||
|
data-sidebar-folder=""
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,95 +1,149 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { keyframes, style } from '@vanilla-extract/css';
|
||||||
import { color, config, toRem } from 'folds';
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
export const CheckButtonContainer = style({
|
const indeterminate = keyframes({
|
||||||
|
'0%': { transform: 'translateX(-100%)' },
|
||||||
|
'100%': { transform: 'translateX(250%)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const IdleSlot = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
height: '32px',
|
height: '100%',
|
||||||
width: '32px',
|
WebkitAppRegion: 'no-drag',
|
||||||
|
flexShrink: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CheckButton = style({
|
export const GhostCheck = style({
|
||||||
all: 'unset',
|
all: 'unset',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: 0,
|
width: toRem(28),
|
||||||
|
height: toRem(28),
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
cursor: 'pointer',
|
color: color.Surface.OnContainer,
|
||||||
color: color.Secondary.Main,
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
transition: 'opacity 0.2s, background-color 0.15s',
|
|
||||||
height: '32px',
|
|
||||||
width: '32px',
|
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
WebkitAppRegion: 'no-drag',
|
cursor: 'pointer',
|
||||||
flexShrink: 0,
|
transition: 'opacity 0.15s ease, background-color 0.15s ease',
|
||||||
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'&[data-visible="true"]': {
|
'&[data-visible="true"]': {
|
||||||
opacity: 0.7,
|
opacity: 0.65,
|
||||||
|
},
|
||||||
|
'&:hover': {
|
||||||
|
opacity: 1,
|
||||||
|
backgroundColor: color.Surface.ContainerHover,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
':hover': {
|
|
||||||
backgroundColor: color.Surface.ContainerHover,
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
|
|
||||||
':active': {
|
|
||||||
backgroundColor: color.Surface.ContainerActive,
|
|
||||||
},
|
|
||||||
|
|
||||||
':focus-visible': {
|
|
||||||
outline: `2px solid ${color.Secondary.Main}`,
|
|
||||||
outlineOffset: '2px',
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateButton = style({
|
export const Bar = style({
|
||||||
all: 'unset',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
gap: config.space.S200,
|
||||||
padding: 0,
|
height: toRem(22),
|
||||||
|
minWidth: toRem(120),
|
||||||
|
maxWidth: toRem(200),
|
||||||
|
padding: `0 ${config.space.S200}`,
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
cursor: 'pointer',
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
color: color.Success.Main,
|
color: color.SurfaceVariant.OnContainer,
|
||||||
backgroundColor: 'transparent',
|
|
||||||
transition: 'background-color 0.15s',
|
|
||||||
height: '32px',
|
|
||||||
width: '32px',
|
|
||||||
WebkitAppRegion: 'no-drag',
|
WebkitAppRegion: 'no-drag',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
});
|
||||||
|
|
||||||
':hover': {
|
export const BarTrack = style({
|
||||||
backgroundColor: color.Surface.ContainerHover,
|
position: 'relative',
|
||||||
},
|
flex: 1,
|
||||||
|
height: toRem(4),
|
||||||
|
borderRadius: toRem(2),
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: color.Surface.ContainerLine,
|
||||||
|
minWidth: toRem(64),
|
||||||
|
});
|
||||||
|
|
||||||
':active': {
|
export const BarFill = style({
|
||||||
backgroundColor: color.Surface.ContainerActive,
|
position: 'absolute',
|
||||||
},
|
inset: '0 auto 0 0',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
backgroundColor: color.Success.Main,
|
||||||
|
transition: 'width 120ms linear',
|
||||||
|
});
|
||||||
|
|
||||||
':focus-visible': {
|
export const BarIndeterminate = style({
|
||||||
outline: `2px solid ${color.Success.Main}`,
|
position: 'absolute',
|
||||||
outlineOffset: '2px',
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '40%',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
backgroundColor: color.Secondary.Main,
|
||||||
|
animation: `${indeterminate} 1s ease-in-out infinite`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BarLabel = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
opacity: 0.9,
|
||||||
|
minWidth: toRem(28),
|
||||||
|
textAlign: 'right',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Chip = style({
|
||||||
|
height: toRem(22),
|
||||||
|
maxWidth: toRem(260),
|
||||||
|
padding: `0 ${config.space.S100} 0 ${config.space.S200}`,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
backgroundColor: color.Success.Container,
|
||||||
|
color: color.Success.OnContainer,
|
||||||
|
WebkitAppRegion: 'no-drag',
|
||||||
|
flexShrink: 0,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ChipText = style({
|
||||||
|
maxWidth: toRem(140),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ChipAction = style({
|
||||||
|
all: 'unset',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: toRem(18),
|
||||||
|
padding: `0 ${config.space.S200}`,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
backgroundColor: color.Success.Main,
|
||||||
|
color: color.Success.OnMain,
|
||||||
|
fontSize: toRem(11),
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
filter: 'brightness(1.05)',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateMenu = style({
|
export const ChipDismiss = style({
|
||||||
minWidth: toRem(280),
|
all: 'unset',
|
||||||
maxWidth: toRem(320),
|
display: 'inline-flex',
|
||||||
backgroundColor: color.Surface.Container,
|
alignItems: 'center',
|
||||||
borderRadius: config.radii.R400,
|
justifyContent: 'center',
|
||||||
boxShadow: config.shadow.E400,
|
width: toRem(18),
|
||||||
border: `1px solid ${color.Surface.ContainerLine}`,
|
height: toRem(18),
|
||||||
});
|
borderRadius: config.radii.R300,
|
||||||
|
cursor: 'pointer',
|
||||||
export const ProgressText = style({
|
opacity: 0.7,
|
||||||
color: color.Success.Main,
|
fontSize: toRem(14),
|
||||||
fontWeight: 500,
|
lineHeight: 1,
|
||||||
minWidth: toRem(35),
|
selectors: {
|
||||||
textAlign: 'center',
|
'&:hover': {
|
||||||
|
opacity: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.08)',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,138 +1,136 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { Box, Spinner, Text, Menu, PopOut, Button, config } from 'folds';
|
import { Box, Text } from 'folds';
|
||||||
import * as css from './UpdateNotification.css';
|
import * as css from './UpdateNotification.css';
|
||||||
|
|
||||||
interface UpdateInfo {
|
interface UpdateInfo {
|
||||||
version: string;
|
version: string;
|
||||||
releaseNotes?: string;
|
releaseNotes?: string;
|
||||||
releaseDate?: string;
|
releaseDate?: string;
|
||||||
|
mock?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DownloadProgress {
|
interface DownloadProgress {
|
||||||
percent: number;
|
percent: number;
|
||||||
transferred: number;
|
transferred: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
mock?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UpdaterPhase = 'idle' | 'checking' | 'available' | 'downloading' | 'ready';
|
||||||
|
|
||||||
export function UpdateNotification() {
|
export function UpdateNotification() {
|
||||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
const [phase, setPhase] = useState<UpdaterPhase>('idle');
|
||||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||||
const [downloading, setDownloading] = useState(false);
|
|
||||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||||
const [updateReady, setUpdateReady] = useState(false);
|
const [isMock, setIsMock] = useState(false);
|
||||||
const [checking, setChecking] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
|
||||||
const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number; width: number; height: number } | undefined>(undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if we're in Electron environment
|
const electron = (window as any).electron;
|
||||||
if (typeof window === 'undefined' || !(window as any).electron?.updater) {
|
if (!electron?.updater) return undefined;
|
||||||
return;
|
|
||||||
|
const { updater } = electron;
|
||||||
|
|
||||||
|
updater.isMock?.().then((result: { success?: boolean; data?: { mock?: boolean } }) => {
|
||||||
|
if (result?.data?.mock) setIsMock(true);
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
updater.onUpdateAvailable((info: UpdateInfo) => {
|
||||||
|
setUpdateInfo(info);
|
||||||
|
if (info.mock) setIsMock(true);
|
||||||
|
setPhase('available');
|
||||||
|
setDownloadProgress(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
updater.onUpdateDownloadProgress((progress: DownloadProgress) => {
|
||||||
|
setPhase('downloading');
|
||||||
|
setDownloadProgress(Math.min(100, Math.round(progress.percent)));
|
||||||
|
});
|
||||||
|
|
||||||
|
updater.onUpdateDownloaded((info: UpdateInfo) => {
|
||||||
|
setUpdateInfo(info);
|
||||||
|
setPhase('ready');
|
||||||
|
setDownloadProgress(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onNotAvailable = () => {
|
||||||
|
setPhase('idle');
|
||||||
|
setUpdateInfo(null);
|
||||||
|
setDownloadProgress(0);
|
||||||
|
};
|
||||||
|
// Optional channel — ignore if preload doesn't expose a dedicated listener
|
||||||
|
if (electron.updater.onUpdateNotAvailable) {
|
||||||
|
electron.updater.onUpdateNotAvailable(onNotAvailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { updater } = (window as any).electron;
|
return undefined;
|
||||||
|
|
||||||
// Listen for update available
|
|
||||||
updater.onUpdateAvailable((info: UpdateInfo) => {
|
|
||||||
console.log('Update available:', info.version);
|
|
||||||
setUpdateAvailable(true);
|
|
||||||
setUpdateInfo(info);
|
|
||||||
setDownloading(false);
|
|
||||||
setUpdateReady(false);
|
|
||||||
setChecking(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for download progress
|
|
||||||
updater.onUpdateDownloadProgress((progress: DownloadProgress) => {
|
|
||||||
setDownloadProgress(Math.round(progress.percent));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for update downloaded
|
|
||||||
updater.onUpdateDownloaded((info: UpdateInfo) => {
|
|
||||||
console.log('Update downloaded:', info.version);
|
|
||||||
setDownloading(false);
|
|
||||||
setUpdateReady(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup - IPC listeners don't need manual cleanup in this case
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCheckForUpdates = async () => {
|
const handleCheck = useCallback(async () => {
|
||||||
setChecking(true);
|
setPhase('checking');
|
||||||
try {
|
try {
|
||||||
const result = await (window as any).electron.updater.checkForUpdates();
|
const result = await (window as any).electron.updater.checkForUpdates();
|
||||||
console.log('Update check result:', result);
|
if (!result?.success) {
|
||||||
|
setPhase('idle');
|
||||||
// Handle error response (including dev mode error)
|
|
||||||
if (!result.success) {
|
|
||||||
console.warn('Update check failed:', result.error);
|
|
||||||
setChecking(false);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
// available event will advance phase; if nothing comes, fall back
|
||||||
// If no update found, show feedback briefly
|
window.setTimeout(() => {
|
||||||
setTimeout(() => {
|
setPhase((current) => (current === 'checking' ? 'idle' : current));
|
||||||
if (!updateAvailable) {
|
}, 4000);
|
||||||
setChecking(false);
|
} catch {
|
||||||
}
|
setPhase('idle');
|
||||||
}, 2000);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to check for updates:', error);
|
|
||||||
setChecking(false);
|
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = useCallback(async () => {
|
||||||
setDownloading(true);
|
setPhase('downloading');
|
||||||
setDownloadProgress(0);
|
setDownloadProgress(0);
|
||||||
try {
|
try {
|
||||||
await (window as any).electron.updater.downloadUpdate();
|
await (window as any).electron.updater.downloadUpdate();
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Failed to download update:', error);
|
setPhase('available');
|
||||||
setDownloading(false);
|
|
||||||
}
|
}
|
||||||
setMenuAnchor(undefined);
|
}, []);
|
||||||
};
|
|
||||||
|
|
||||||
const handleInstall = async () => {
|
const handleInstall = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await (window as any).electron.updater.installUpdate();
|
await (window as any).electron.updater.installUpdate();
|
||||||
} catch (error) {
|
if (isMock) {
|
||||||
console.error('Failed to install update:', error);
|
setPhase('idle');
|
||||||
|
setUpdateInfo(null);
|
||||||
|
setDownloadProgress(0);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// keep ready state
|
||||||
}
|
}
|
||||||
};
|
}, [isMock]);
|
||||||
|
|
||||||
const handleMenuToggle = (event: React.MouseEvent<HTMLButtonElement>) => {
|
const handleDismiss = useCallback(() => {
|
||||||
if (menuAnchor) {
|
setPhase('idle');
|
||||||
setMenuAnchor(undefined);
|
setUpdateInfo(null);
|
||||||
} else {
|
setDownloadProgress(0);
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
}, []);
|
||||||
setMenuAnchor({ x: rect.x, y: rect.y, width: rect.width, height: rect.height });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Don't render anything if not in Electron
|
|
||||||
if (typeof window === 'undefined' || !(window as any).electron?.updater) {
|
if (typeof window === 'undefined' || !(window as any).electron?.updater) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show check button if no update status
|
if (phase === 'idle') {
|
||||||
if (!updateAvailable && !updateReady && !checking) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={css.CheckButtonContainer}
|
className={css.IdleSlot}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className={css.CheckButton}
|
|
||||||
data-visible={isHovered}
|
|
||||||
onClick={handleCheckForUpdates}
|
|
||||||
aria-label="Check for updates"
|
|
||||||
title="Check for updates"
|
|
||||||
type="button"
|
type="button"
|
||||||
|
className={css.GhostCheck}
|
||||||
|
data-visible={hovered || isMock ? 'true' : undefined}
|
||||||
|
onClick={handleCheck}
|
||||||
|
title={isMock ? 'Check for updates (mock)' : 'Check for updates'}
|
||||||
|
aria-label="Check for updates"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||||
<path
|
<path
|
||||||
d="M8 2V10M8 10L5 7M8 10L11 7"
|
d="M8 2V10M8 10L5 7M8 10L11 7"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -140,109 +138,75 @@ export function UpdateNotification() {
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
<path
|
<path d="M3 14H13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
d="M3 14H13"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show checking state
|
if (phase === 'checking') {
|
||||||
if (checking) {
|
|
||||||
return (
|
return (
|
||||||
<button className={css.UpdateButton} disabled type="button">
|
<div className={css.Bar} title="Checking for updates…">
|
||||||
<Spinner variant="Secondary" size="50" />
|
<div className={css.BarTrack}>
|
||||||
</button>
|
<div className={css.BarIndeterminate} />
|
||||||
|
</div>
|
||||||
|
<Text className={css.BarLabel} size="L400">
|
||||||
|
Checking…
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show update available/ready state
|
if (phase === 'downloading') {
|
||||||
if (!updateAvailable && !updateReady) {
|
return (
|
||||||
return null;
|
<div
|
||||||
|
className={css.Bar}
|
||||||
|
title={`Downloading update${updateInfo ? ` ${updateInfo.version}` : ''}… ${downloadProgress}%`}
|
||||||
|
>
|
||||||
|
<div className={css.BarTrack}>
|
||||||
|
<div className={css.BarFill} style={{ width: `${downloadProgress}%` }} />
|
||||||
|
</div>
|
||||||
|
<Text className={css.BarLabel} size="L400">
|
||||||
|
{downloadProgress}%
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (phase === 'ready') {
|
||||||
<>
|
return (
|
||||||
<button
|
<Box className={css.Chip} alignItems="Center" gap="100">
|
||||||
className={css.UpdateButton}
|
<Text className={css.ChipText} size="L400" truncate>
|
||||||
onClick={handleMenuToggle}
|
{isMock ? 'Mock ready' : 'Update ready'}
|
||||||
aria-label={updateReady ? 'Update ready' : 'Update available'}
|
{updateInfo ? ` · ${updateInfo.version}` : ''}
|
||||||
title={updateReady ? 'Update downloaded - click to install' : 'New version available'}
|
</Text>
|
||||||
type="button"
|
<button type="button" className={css.ChipAction} onClick={handleInstall}>
|
||||||
>
|
Restart
|
||||||
{downloading ? (
|
</button>
|
||||||
<Spinner variant="Secondary" size="50" />
|
</Box>
|
||||||
) : (
|
);
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
}
|
||||||
<path
|
|
||||||
d="M8 2V10M8 10L5 7M8 10L11 7"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M3 14H13"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<PopOut
|
// available
|
||||||
anchor={menuAnchor}
|
return (
|
||||||
position="Bottom"
|
<Box className={css.Chip} alignItems="Center" gap="100">
|
||||||
align="End"
|
<Text className={css.ChipText} size="L400" truncate>
|
||||||
offset={8}
|
{isMock ? 'Mock update' : 'Update'}
|
||||||
content={
|
{updateInfo ? ` ${updateInfo.version}` : ''}
|
||||||
<Menu className={css.UpdateMenu}>
|
</Text>
|
||||||
<Box direction="Column" gap="200" style={{ padding: config.space.S300 }}>
|
<button type="button" className={css.ChipAction} onClick={handleDownload}>
|
||||||
<Box direction="Column" gap="100">
|
Download
|
||||||
<Text size="H5" priority="400">
|
</button>
|
||||||
{updateReady ? 'Update Ready' : 'Update Available'}
|
<button
|
||||||
</Text>
|
type="button"
|
||||||
{updateInfo && (
|
className={css.ChipDismiss}
|
||||||
<Text size="T200" priority="300">
|
onClick={handleDismiss}
|
||||||
Version {updateInfo.version}
|
aria-label="Dismiss update"
|
||||||
</Text>
|
title="Dismiss"
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{updateReady ? (
|
|
||||||
<Button
|
|
||||||
variant="Primary"
|
|
||||||
size="400"
|
|
||||||
onClick={handleInstall}
|
|
||||||
fill="Solid"
|
|
||||||
>
|
|
||||||
<Text size="B400">Install and Restart</Text>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="Primary"
|
|
||||||
size="400"
|
|
||||||
onClick={handleDownload}
|
|
||||||
disabled={downloading}
|
|
||||||
fill="Solid"
|
|
||||||
>
|
|
||||||
<Text size="B400">
|
|
||||||
{downloading ? `Downloading ${downloadProgress}%` : 'Download Update'}
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Menu>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{null}
|
×
|
||||||
</PopOut>
|
</button>
|
||||||
</>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/app/components/updates-dialog/UpdateMarkdownViewer.tsx
Normal file
35
src/app/components/updates-dialog/UpdateMarkdownViewer.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { resolveUpdateAssetUrl } from '../../data/updateNotes';
|
||||||
|
import * as css from './UpdatesDialog.css';
|
||||||
|
|
||||||
|
type UpdateMarkdownViewerProps = {
|
||||||
|
markdown: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UpdateMarkdownViewer({ markdown }: UpdateMarkdownViewerProps) {
|
||||||
|
if (!markdown.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.Markdown}>
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
a: ({ href, children }) => (
|
||||||
|
<a href={href} target="_blank" rel="noreferrer noopener">
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
img: ({ src, alt }) => (
|
||||||
|
<img src={resolveUpdateAssetUrl(src) ?? src} alt={alt ?? ''} loading="lazy" />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{markdown}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
303
src/app/components/updates-dialog/UpdatesDialog.css.ts
Normal file
303
src/app/components/updates-dialog/UpdatesDialog.css.ts
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = '480px';
|
||||||
|
|
||||||
|
export const PortalLayer = style({
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: config.zIndex.Max,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Dialog + overlay padding must never exceed the viewport height. */
|
||||||
|
const DIALOG_MAX_HEIGHT =
|
||||||
|
'calc(85vh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))';
|
||||||
|
|
||||||
|
export const OverlayFrame = style({
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
maxHeight: '100vh',
|
||||||
|
maxWidth: '100vw',
|
||||||
|
padding:
|
||||||
|
'env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DialogShell = style({
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: `min(${toRem(560)}, calc(100vw - ${config.space.S800}))`,
|
||||||
|
maxHeight: DIALOG_MAX_HEIGHT,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
'@media': {
|
||||||
|
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||||
|
maxWidth: `calc(100vw - ${config.space.S400})`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Hero = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: config.space.S200,
|
||||||
|
padding: config.space.S500,
|
||||||
|
paddingBottom: config.space.S400,
|
||||||
|
backgroundColor: color.Primary.Container,
|
||||||
|
color: color.Primary.OnContainer,
|
||||||
|
borderBottom: `${config.borderWidth.B300} solid ${color.Primary.ContainerLine}`,
|
||||||
|
'@media': {
|
||||||
|
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||||
|
padding: config.space.S400,
|
||||||
|
paddingBottom: config.space.S300,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const HeroRow = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S300,
|
||||||
|
minWidth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const HeroLogo = style({
|
||||||
|
width: toRem(48),
|
||||||
|
height: toRem(48),
|
||||||
|
flexShrink: 0,
|
||||||
|
'@media': {
|
||||||
|
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||||
|
width: toRem(40),
|
||||||
|
height: toRem(40),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const HeroTitleWrap = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: config.space.S100,
|
||||||
|
minWidth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BodyScroll = style({
|
||||||
|
flex: '1 1 auto',
|
||||||
|
minHeight: 0,
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BodyContent = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
padding: config.space.S500,
|
||||||
|
paddingTop: config.space.S400,
|
||||||
|
'@media': {
|
||||||
|
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||||
|
padding: config.space.S400,
|
||||||
|
paddingTop: config.space.S300,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BodyLoading = style({
|
||||||
|
padding: config.space.S800,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Markdown = style({
|
||||||
|
width: '100%',
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
fontSize: toRem(14),
|
||||||
|
lineHeight: 1.55,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} h1, ${Markdown} h2, ${Markdown} h3, ${Markdown} h4`, {
|
||||||
|
margin: 0,
|
||||||
|
marginTop: config.space.S400,
|
||||||
|
marginBottom: config.space.S200,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} h1:first-child, ${Markdown} h2:first-child, ${Markdown} h3:first-child, ${Markdown} h4:first-child`, {
|
||||||
|
marginTop: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} h2`, { fontSize: toRem(18) });
|
||||||
|
globalStyle(`${Markdown} h3`, { fontSize: toRem(16) });
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} p`, {
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} p:last-child`, { marginBottom: 0 });
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} ul, ${Markdown} ol`, {
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
paddingLeft: config.space.S500,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} li`, { marginBottom: config.space.S100 });
|
||||||
|
globalStyle(`${Markdown} li:last-child`, { marginBottom: 0 });
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} a`, {
|
||||||
|
color: color.Primary.Main,
|
||||||
|
textDecoration: 'underline',
|
||||||
|
textUnderlineOffset: toRem(2),
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} a:hover`, { color: color.Primary.MainHover });
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} img`, {
|
||||||
|
display: 'block',
|
||||||
|
maxWidth: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
marginTop: config.space.S200,
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} code`, {
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||||
|
fontSize: toRem(13),
|
||||||
|
padding: `0 ${config.space.S100}`,
|
||||||
|
borderRadius: config.radii.R200,
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} pre`, {
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
padding: config.space.S300,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
overflow: 'auto',
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} pre code`, {
|
||||||
|
padding: 0,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} blockquote`, {
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
paddingLeft: config.space.S300,
|
||||||
|
borderLeft: `${toRem(3)} solid ${color.Primary.Main}`,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
opacity: 0.9,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} hr`, {
|
||||||
|
border: 'none',
|
||||||
|
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
margin: `${config.space.S400} 0`,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} table`, {
|
||||||
|
width: '100%',
|
||||||
|
borderCollapse: 'collapse',
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
fontSize: toRem(13),
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} th, ${Markdown} td`, {
|
||||||
|
padding: config.space.S200,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
textAlign: 'left',
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} th`, {
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OlderSection = style({
|
||||||
|
width: '100%',
|
||||||
|
marginTop: config.space.S500,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OlderToggle = style({
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OlderList = style({
|
||||||
|
listStyle: 'none',
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: config.space.S100,
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OlderListItem = style({
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OlderItem = style({
|
||||||
|
all: 'unset',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: config.space.S100,
|
||||||
|
width: '100%',
|
||||||
|
minHeight: toRem(48),
|
||||||
|
padding: config.space.S300,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: color.SurfaceVariant.ContainerHover,
|
||||||
|
},
|
||||||
|
'&[data-active="true"]': {
|
||||||
|
borderColor: color.Primary.Main,
|
||||||
|
backgroundColor: color.Primary.Container,
|
||||||
|
color: color.Primary.OnContainer,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Footer = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: config.space.S200,
|
||||||
|
padding: config.space.S400,
|
||||||
|
paddingTop: config.space.S300,
|
||||||
|
paddingBottom: `calc(${config.space.S400} + env(safe-area-inset-bottom, 0px))`,
|
||||||
|
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
'@media': {
|
||||||
|
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||||
|
padding: config.space.S300,
|
||||||
|
paddingTop: config.space.S300,
|
||||||
|
paddingBottom: `calc(${config.space.S300} + env(safe-area-inset-bottom, 0px))`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FooterButton = style({
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CloseButton = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: config.space.S300,
|
||||||
|
right: config.space.S300,
|
||||||
|
'@media': {
|
||||||
|
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||||
|
top: config.space.S200,
|
||||||
|
right: config.space.S200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
191
src/app/components/updates-dialog/UpdatesDialog.tsx
Normal file
191
src/app/components/updates-dialog/UpdatesDialog.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
IconButton,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Scroll,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
usePopOutContainer,
|
||||||
|
} from 'folds';
|
||||||
|
import { Icon, Icons } from '../icons';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import type { ParsedUpdateDoc, UpdateManifest } from '../../data/updateNotes';
|
||||||
|
import PaarrotSVG from '../../../../public/res/svg/paarrot.svg';
|
||||||
|
import { UpdateMarkdownViewer } from './UpdateMarkdownViewer';
|
||||||
|
import * as css from './UpdatesDialog.css';
|
||||||
|
|
||||||
|
type UpdatesDialogProps = {
|
||||||
|
manifest: UpdateManifest | null;
|
||||||
|
activeDoc: ParsedUpdateDoc | null;
|
||||||
|
loading: boolean;
|
||||||
|
onSelectFile: (file: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UpdatesDialog({
|
||||||
|
manifest,
|
||||||
|
activeDoc,
|
||||||
|
loading,
|
||||||
|
onSelectFile,
|
||||||
|
onClose,
|
||||||
|
}: UpdatesDialogProps) {
|
||||||
|
const [showOlderList, setShowOlderList] = useState(false);
|
||||||
|
const popOutContainer = usePopOutContainer();
|
||||||
|
|
||||||
|
const displayVersion = activeDoc?.version;
|
||||||
|
const displayTitle = activeDoc?.title ?? (displayVersion ? `Version ${displayVersion}` : 'Release notes');
|
||||||
|
const isCurrent = manifest && activeDoc?.file === manifest.current;
|
||||||
|
const olderEntries = manifest?.older ?? [];
|
||||||
|
|
||||||
|
const handleSelectOlder = (file: string) => {
|
||||||
|
onSelectFile(file);
|
||||||
|
setShowOlderList(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const portalTarget =
|
||||||
|
popOutContainer ??
|
||||||
|
document.getElementById('portalContainer') ??
|
||||||
|
document.body;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className={css.PortalLayer}>
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter className={css.OverlayFrame}>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
clickOutsideDeactivates: false,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog variant="Surface" className={css.DialogShell}>
|
||||||
|
<Box className={css.Hero}>
|
||||||
|
<Box className={css.HeroRow}>
|
||||||
|
<img className={css.HeroLogo} src={PaarrotSVG} alt="" draggable={false} />
|
||||||
|
<Box className={css.HeroTitleWrap}>
|
||||||
|
<Text size="L400" priority="300">What's new</Text>
|
||||||
|
<Text size="H4" truncate>
|
||||||
|
{displayVersion ? `Paarrot ${displayVersion}` : 'Paarrot updates'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Text size="H6">{displayTitle}</Text>
|
||||||
|
{activeDoc?.date && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
{activeDoc.date}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!loading && !isCurrent && manifest?.current && (
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => onSelectFile(manifest.current)}
|
||||||
|
>
|
||||||
|
<Text size="B300">Back to current update</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Scroll className={css.BodyScroll} visibility="Hover">
|
||||||
|
<Box className={css.BodyContent} direction="Column">
|
||||||
|
{loading && (
|
||||||
|
<Box justifyContent="Center" alignItems="Center" className={css.BodyLoading}>
|
||||||
|
<Spinner size="400" variant="Secondary" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && activeDoc?.markdown && (
|
||||||
|
<UpdateMarkdownViewer markdown={activeDoc.markdown} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !activeDoc?.markdown && (
|
||||||
|
<Text size="T300" priority="400">
|
||||||
|
No update notes are available right now.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && olderEntries.length > 0 && (
|
||||||
|
<Box className={css.OlderSection} direction="Column" gap="200">
|
||||||
|
<Button
|
||||||
|
className={css.OlderToggle}
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
aria-expanded={showOlderList}
|
||||||
|
onClick={() => setShowOlderList((open) => !open)}
|
||||||
|
>
|
||||||
|
<Text size="B300">
|
||||||
|
{showOlderList
|
||||||
|
? 'Hide older versions'
|
||||||
|
: `Older versions (${olderEntries.length})`}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{showOlderList && (
|
||||||
|
<ul className={css.OlderList}>
|
||||||
|
{olderEntries.map((entry) => {
|
||||||
|
const isActive = activeDoc?.file === entry.file;
|
||||||
|
const label = entry.version ?? entry.file.replace(/\.md$/i, '');
|
||||||
|
return (
|
||||||
|
<li key={entry.file} className={css.OlderListItem}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={css.OlderItem}
|
||||||
|
data-active={isActive ? 'true' : 'false'}
|
||||||
|
onClick={() => handleSelectOlder(entry.file)}
|
||||||
|
>
|
||||||
|
<Text size="B300">{label}</Text>
|
||||||
|
{entry.title && (
|
||||||
|
<Text size="T200" priority="400">
|
||||||
|
{entry.title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{entry.date && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
{entry.date}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
|
||||||
|
<Box className={css.Footer}>
|
||||||
|
<Button
|
||||||
|
className={css.FooterButton}
|
||||||
|
variant="Primary"
|
||||||
|
fill="Solid"
|
||||||
|
radii="300"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<Text size="B400">Got it</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box className={css.CloseButton}>
|
||||||
|
<IconButton variant="Surface" onClick={onClose} aria-label="Close">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
</div>,
|
||||||
|
portalTarget
|
||||||
|
);
|
||||||
|
}
|
||||||
153
src/app/components/updates-dialog/UpdatesDialogHost.tsx
Normal file
153
src/app/components/updates-dialog/UpdatesDialogHost.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useAtom, useSetAtom } from 'jotai';
|
||||||
|
import {
|
||||||
|
loadCurrentUpdateDocument,
|
||||||
|
loadUpdateDocument,
|
||||||
|
loadUpdateManifest,
|
||||||
|
type ParsedUpdateDoc,
|
||||||
|
type UpdateManifest,
|
||||||
|
} from '../../data/updateNotes';
|
||||||
|
import {
|
||||||
|
clearPendingReleaseNotes,
|
||||||
|
getLastSeenAppVersion,
|
||||||
|
releaseNotesDialogAtom,
|
||||||
|
setLastSeenAppVersion,
|
||||||
|
storePendingReleaseNotes,
|
||||||
|
} from '../../state/releaseNotes';
|
||||||
|
import { getAppVersion } from '../../utils/appVersion';
|
||||||
|
import { UpdatesDialog } from './UpdatesDialog';
|
||||||
|
|
||||||
|
type UpdaterInfo = {
|
||||||
|
version: string;
|
||||||
|
releaseNotes?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Open the What's New dialog from Settings → About (or anywhere else). */
|
||||||
|
export function useOpenReleaseNotesDialog(): () => void {
|
||||||
|
const setDialogState = useSetAtom(releaseNotesDialogAtom);
|
||||||
|
return useCallback(() => {
|
||||||
|
// Defer so the opening tap does not trip FocusTrap click-outside on Android.
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
setDialogState({ open: true, manual: true });
|
||||||
|
});
|
||||||
|
}, [setDialogState]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpdatesDialogHost() {
|
||||||
|
const [dialogState, setDialogState] = useAtom(releaseNotesDialogAtom);
|
||||||
|
const [manifest, setManifest] = useState<UpdateManifest | null>(null);
|
||||||
|
const [activeDoc, setActiveDoc] = useState<ParsedUpdateDoc | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const closeDialog = useCallback(() => {
|
||||||
|
setDialogState({ open: false, manual: false });
|
||||||
|
setManifest(null);
|
||||||
|
setActiveDoc(null);
|
||||||
|
setLoading(false);
|
||||||
|
getAppVersion().then((version) => {
|
||||||
|
if (version !== 'unknown') {
|
||||||
|
setLastSeenAppVersion(version);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [setDialogState]);
|
||||||
|
|
||||||
|
const selectFile = useCallback(async (file: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
const doc = await loadUpdateDocument(file);
|
||||||
|
setActiveDoc(doc);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Persist updater metadata when a download finishes (version tracking after restart).
|
||||||
|
useEffect(() => {
|
||||||
|
const electron = window.electron;
|
||||||
|
if (!electron?.updater?.onUpdateDownloaded) return undefined;
|
||||||
|
|
||||||
|
const onDownloaded = (info: UpdaterInfo) => {
|
||||||
|
if (!info?.version) return;
|
||||||
|
storePendingReleaseNotes(info.version, info.releaseNotes);
|
||||||
|
};
|
||||||
|
|
||||||
|
electron.updater.onUpdateDownloaded(onDownloaded);
|
||||||
|
return undefined;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Show What's New after the app version changes (post-update restart).
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const checkVersion = async () => {
|
||||||
|
const currentVersion = await getAppVersion();
|
||||||
|
if (cancelled || currentVersion === 'unknown') return;
|
||||||
|
|
||||||
|
const lastSeen = getLastSeenAppVersion();
|
||||||
|
const shouldAutoShow = lastSeen !== currentVersion;
|
||||||
|
|
||||||
|
if (shouldAutoShow) {
|
||||||
|
const loadedManifest = await loadUpdateManifest();
|
||||||
|
if (cancelled || !loadedManifest) return;
|
||||||
|
|
||||||
|
const doc = await loadUpdateDocument(loadedManifest.current);
|
||||||
|
if (cancelled || !doc) return;
|
||||||
|
|
||||||
|
setManifest(loadedManifest);
|
||||||
|
setActiveDoc(doc);
|
||||||
|
setDialogState({ open: true, manual: false });
|
||||||
|
clearPendingReleaseNotes();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = window.setTimeout(checkVersion, 1500);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [setDialogState]);
|
||||||
|
|
||||||
|
// Load markdown when the dialog opens (manual open from About, etc.).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dialogState.open) return undefined;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const loadedManifest = await loadUpdateManifest();
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (!loadedManifest) {
|
||||||
|
setManifest(null);
|
||||||
|
setActiveDoc(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = await loadUpdateDocument(loadedManifest.current);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
setManifest(loadedManifest);
|
||||||
|
setActiveDoc(doc);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [dialogState.open]);
|
||||||
|
|
||||||
|
if (!dialogState.open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UpdatesDialog
|
||||||
|
manifest={manifest}
|
||||||
|
activeDoc={activeDoc}
|
||||||
|
loading={loading}
|
||||||
|
onSelectFile={selectFile}
|
||||||
|
onClose={closeDialog}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/app/components/updates-dialog/index.ts
Normal file
3
src/app/components/updates-dialog/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './UpdatesDialog';
|
||||||
|
export * from './UpdatesDialogHost';
|
||||||
|
export * from './UpdateMarkdownViewer';
|
||||||
@@ -1,18 +1,21 @@
|
|||||||
import { AvatarFallback, AvatarImage, color } from 'folds';
|
import { AvatarFallback, AvatarImage, color } from 'folds';
|
||||||
import React, { ReactEventHandler, ReactNode, useEffect, useRef, useState } from 'react';
|
import React, { ReactEventHandler, ReactNode, useEffect, useRef, useState } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { Blurhash } from 'react-blurhash';
|
||||||
import * as css from './UserAvatar.css';
|
import * as css from './UserAvatar.css';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
|
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
|
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
|
||||||
|
import {
|
||||||
// Check if image is already in browser cache
|
getCachedAuthenticatedMediaUrl,
|
||||||
function isImageInBrowserCache(url: string): boolean {
|
isAuthenticatedMediaUrl,
|
||||||
const img = new Image();
|
} from '../../utils/authenticatedMediaCache';
|
||||||
img.src = url;
|
import {
|
||||||
return img.complete && img.naturalWidth > 0;
|
getMediaBlurHash,
|
||||||
}
|
getMediaDimensions,
|
||||||
|
rememberMediaBlurHash,
|
||||||
|
} from '../../state/mediaDimensionCache';
|
||||||
|
|
||||||
type UserAvatarProps = {
|
type UserAvatarProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -25,20 +28,28 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
|||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
||||||
|
const blurHash = getMediaBlurHash(src ?? '');
|
||||||
// Check both our cache and browser's native cache
|
const dims = getMediaDimensions(src ?? '');
|
||||||
const [loaded, setLoaded] = useState(() => {
|
const blobCached =
|
||||||
if (isAvatarCached(src)) return true;
|
!!src &&
|
||||||
if (authenticatedSrc && isImageInBrowserCache(authenticatedSrc)) {
|
(!useAuthentication ||
|
||||||
cacheAvatar(src);
|
!isAuthenticatedMediaUrl(src) ||
|
||||||
return true;
|
!!getCachedAuthenticatedMediaUrl(src));
|
||||||
|
|
||||||
|
const [loaded, setLoaded] = useState(() => isAvatarCached(src) || blobCached);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
authenticatedSrc &&
|
||||||
|
(isAvatarCached(src) ||
|
||||||
|
(src && (!useAuthentication || !isAuthenticatedMediaUrl(src) || getCachedAuthenticatedMediaUrl(src))))
|
||||||
|
) {
|
||||||
|
setLoaded(true);
|
||||||
}
|
}
|
||||||
return false;
|
}, [authenticatedSrc, src, useAuthentication]);
|
||||||
});
|
|
||||||
|
|
||||||
const imgRef = useRef<HTMLImageElement>(null);
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
|
|
||||||
// Also check on mount if image is already complete (browser cached)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loaded && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
|
if (!loaded && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
@@ -51,10 +62,10 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
|||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
cacheAvatar(src);
|
cacheAvatar(src);
|
||||||
pruneAvatarCache();
|
pruneAvatarCache();
|
||||||
|
rememberMediaBlurHash(src ?? '', evt.currentTarget);
|
||||||
};
|
};
|
||||||
|
|
||||||
// No src or error - show fallback only
|
if (!src || error) {
|
||||||
if (!authenticatedSrc || error) {
|
|
||||||
return (
|
return (
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
style={{ backgroundColor: colorMXID(userId), color: color.Surface.Container }}
|
style={{ backgroundColor: colorMXID(userId), color: color.Surface.Container }}
|
||||||
@@ -65,8 +76,7 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If already cached, show image directly without fallback flash
|
if (authenticatedSrc && loaded) {
|
||||||
if (loaded) {
|
|
||||||
return (
|
return (
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
className={classNames(css.UserAvatar, className)}
|
className={classNames(css.UserAvatar, className)}
|
||||||
@@ -80,13 +90,17 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading state - render image with hidden fallback behind it
|
const blurResolutionX = 32;
|
||||||
// Image loads invisibly, then we show it once complete
|
const blurResolutionY =
|
||||||
|
dims?.w && dims?.h && dims.w > 0
|
||||||
|
? Math.max(1, Math.round(blurResolutionX * (dims.h / dims.w)))
|
||||||
|
: 32;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: colorMXID(userId),
|
backgroundColor: colorMXID(userId),
|
||||||
color: color.Surface.Container,
|
color: color.Surface.Container,
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -95,16 +109,40 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
|||||||
>
|
>
|
||||||
{renderFallback()}
|
{renderFallback()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
<AvatarImage
|
{blurHash && (
|
||||||
ref={imgRef}
|
<div
|
||||||
className={classNames(css.UserAvatar, className)}
|
style={{
|
||||||
style={{ position: 'relative', zIndex: 1 }}
|
position: 'absolute',
|
||||||
src={authenticatedSrc}
|
inset: 0,
|
||||||
alt={alt}
|
overflow: 'hidden',
|
||||||
onError={() => setError(true)}
|
borderRadius: 'inherit',
|
||||||
onLoad={handleLoad}
|
filter: 'blur(6px)',
|
||||||
draggable={false}
|
transform: 'scale(1.08)',
|
||||||
/>
|
}}
|
||||||
|
>
|
||||||
|
<Blurhash
|
||||||
|
hash={blurHash}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
resolutionX={blurResolutionX}
|
||||||
|
resolutionY={blurResolutionY}
|
||||||
|
punch={1.1}
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{authenticatedSrc && (
|
||||||
|
<AvatarImage
|
||||||
|
ref={imgRef}
|
||||||
|
className={classNames(css.UserAvatar, className)}
|
||||||
|
style={{ position: 'relative', zIndex: 1, opacity: loaded ? 1 : 0 }}
|
||||||
|
src={authenticatedSrc}
|
||||||
|
alt={alt}
|
||||||
|
onError={() => setError(true)}
|
||||||
|
onLoad={handleLoad}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -14,7 +14,6 @@ import { ImageViewer } from '../image-viewer';
|
|||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { useOtherUserColor } from '../../hooks/useUserColor';
|
import { useOtherUserColor } from '../../hooks/useUserColor';
|
||||||
import { useOtherUserBanner } from '../../hooks/useUserBanner';
|
import { useOtherUserBanner } from '../../hooks/useUserBanner';
|
||||||
import { useOtherUserProfileStyle } from '../../hooks/useUserProfileStyle';
|
|
||||||
|
|
||||||
type UserHeroProps = {
|
type UserHeroProps = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -24,15 +23,7 @@ type UserHeroProps = {
|
|||||||
};
|
};
|
||||||
export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroProps) {
|
export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroProps) {
|
||||||
const [viewAvatar, setViewAvatar] = useState<string>();
|
const [viewAvatar, setViewAvatar] = useState<string>();
|
||||||
const bannerUrl = useOtherUserBanner(userId, avatarMxc); // Now returns blob URL directly
|
const bannerUrl = useOtherUserBanner(userId);
|
||||||
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
|
|
||||||
|
|
||||||
console.log('[UserHero]', {
|
|
||||||
userId,
|
|
||||||
avatarMxc,
|
|
||||||
bannerUrl,
|
|
||||||
profileStyle,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -57,8 +48,6 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
|
|||||||
<div className={css.UserHeroAvatarContainer}>
|
<div className={css.UserHeroAvatarContainer}>
|
||||||
<AvatarPresence
|
<AvatarPresence
|
||||||
className={css.UserAvatarContainer}
|
className={css.UserAvatarContainer}
|
||||||
style={profileStyle.avatarBorderColor ? { backgroundColor: profileStyle.avatarBorderColor } : undefined}
|
|
||||||
badgeBackgroundColor={profileStyle.avatarBorderColor}
|
|
||||||
badge={
|
badge={
|
||||||
presence && <PresenceBadge presence={presence.presence} status={presence.status} />
|
presence && <PresenceBadge presence={presence.presence} status={presence.status} />
|
||||||
}
|
}
|
||||||
@@ -71,10 +60,6 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
|
|||||||
style={{
|
style={{
|
||||||
width: toRem(72),
|
width: toRem(72),
|
||||||
height: toRem(72),
|
height: toRem(72),
|
||||||
...(profileStyle.avatarBorderColor ? {
|
|
||||||
outline: `${toRem(4)} solid ${profileStyle.avatarBorderColor}`,
|
|
||||||
outlineOffset: toRem(-1),
|
|
||||||
} : {}),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
@@ -87,25 +72,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>
|
||||||
@@ -118,16 +99,9 @@ type UserHeroNameProps = {
|
|||||||
userId: string;
|
userId: string;
|
||||||
avatarMxc?: string;
|
avatarMxc?: string;
|
||||||
};
|
};
|
||||||
export function UserHeroName({ displayName, userId, avatarMxc }: UserHeroNameProps) {
|
export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
|
||||||
const username = getMxIdLocalPart(userId);
|
const username = getMxIdLocalPart(userId);
|
||||||
const userColor = useOtherUserColor(userId, avatarMxc);
|
const userColor = useOtherUserColor(userId);
|
||||||
|
|
||||||
console.log('[UserHeroName]', {
|
|
||||||
userId,
|
|
||||||
avatarMxc,
|
|
||||||
userColor,
|
|
||||||
fallbackColor: colorMXID(userId),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" direction="Column" gap="100">
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|||||||
import { usePowerLevels } from '../../hooks/usePowerLevels';
|
import { usePowerLevels } from '../../hooks/usePowerLevels';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
import { useUserPresence } from '../../hooks/useUserPresence';
|
import { useUserPresence } from '../../hooks/useUserPresence';
|
||||||
import { useOtherUserProfileStyle } from '../../hooks/useUserProfileStyle';
|
|
||||||
import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips';
|
import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips';
|
||||||
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
import { PowerChip } from './PowerChip';
|
import { PowerChip } from './PowerChip';
|
||||||
@@ -24,7 +23,7 @@ import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
|
|||||||
import { CreatorChip } from './CreatorChip';
|
import { CreatorChip } from './CreatorChip';
|
||||||
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
|
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
|
||||||
import { DirectCreateSearchParams } from '../../pages/paths';
|
import { DirectCreateSearchParams } from '../../pages/paths';
|
||||||
import { getContrastingTextColor, stripAlphaFromColor, getTextShadowColor } from '../../utils/common';
|
import { getTextShadowColor } from '../../utils/common';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { BreakWord, LineClamp3 } from '../../styles/Text.css';
|
import { BreakWord, LineClamp3 } from '../../styles/Text.css';
|
||||||
import colorMXID, { getColorMXIDValue } from '../../../util/colorMXID';
|
import colorMXID, { getColorMXIDValue } from '../../../util/colorMXID';
|
||||||
@@ -65,13 +64,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
|
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
|
||||||
|
|
||||||
const presence = useUserPresence(userId);
|
const presence = useUserPresence(userId);
|
||||||
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
|
const userColor = useOtherUserColor(userId);
|
||||||
const userColor = useOtherUserColor(userId, avatarMxc);
|
|
||||||
|
|
||||||
// Build gradient CSS if configured
|
|
||||||
const gradientStyle = profileStyle.gradient
|
|
||||||
? `linear-gradient(${profileStyle.gradient.direction}, ${profileStyle.gradient.startColor}, ${profileStyle.gradient.stopColor})`
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const handleMessage = () => {
|
const handleMessage = () => {
|
||||||
closeUserRoomProfile();
|
closeUserRoomProfile();
|
||||||
@@ -93,22 +86,6 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
// Use userColor for text, or fall back to colorMXID
|
// Use userColor for text, or fall back to colorMXID
|
||||||
const profileColor = userColor ? userColor : getColorMXIDValue(userId);
|
const profileColor = userColor ? userColor : getColorMXIDValue(userId);
|
||||||
const profileTextShadow = `0 1px 4px ${getTextShadowColor(profileColor)}`;
|
const profileTextShadow = `0 1px 4px ${getTextShadowColor(profileColor)}`;
|
||||||
|
|
||||||
// Use avatarBorderColor for pill background only if it has visible alpha (not fully transparent)
|
|
||||||
const hasVisibleBorder = profileStyle.avatarBorderColor && !profileStyle.avatarBorderColor.endsWith('00');
|
|
||||||
const pillBgColor = hasVisibleBorder ? stripAlphaFromColor(profileStyle.avatarBorderColor!) : undefined;
|
|
||||||
|
|
||||||
console.log('[UserRoomProfile]', {
|
|
||||||
userId,
|
|
||||||
avatarMxc,
|
|
||||||
userColor,
|
|
||||||
profileStyle,
|
|
||||||
profileColor,
|
|
||||||
profileTextShadow,
|
|
||||||
hasVisibleBorder,
|
|
||||||
pillBgColor,
|
|
||||||
fallbackColorMXID: getColorMXIDValue(userId),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column">
|
<Box direction="Column">
|
||||||
@@ -122,12 +99,11 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
direction="Column"
|
direction="Column"
|
||||||
gap="200"
|
gap="200"
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
style={{
|
style={{
|
||||||
padding: config.space.S400,
|
padding: config.space.S400,
|
||||||
paddingTop: `calc(${config.space.S200} + ${toRem(36)})`,
|
paddingTop: `calc(${config.space.S200} + ${toRem(36)})`,
|
||||||
marginTop: toRem(-36),
|
marginTop: toRem(-36),
|
||||||
background: gradientStyle,
|
textAlign: 'center',
|
||||||
textAlign: 'center'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Display Name */}
|
{/* Display Name */}
|
||||||
@@ -152,20 +128,14 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
{presence?.status && (
|
{presence?.status && (
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: pillBgColor || color.Surface.Container,
|
backgroundColor: color.Surface.Container,
|
||||||
border: pillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
padding: `${toRem(6)} ${toRem(10)}`,
|
padding: `${toRem(6)} ${toRem(10)}`,
|
||||||
borderRadius: toRem(16),
|
borderRadius: toRem(16),
|
||||||
maxWidth: toRem(250),
|
maxWidth: toRem(250),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text size="T300" className={BreakWord}>
|
||||||
size="T300"
|
|
||||||
className={BreakWord}
|
|
||||||
style={{
|
|
||||||
color: pillBgColor ? getContrastingTextColor(pillBgColor) : undefined
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{presence.status}
|
{presence.status}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -173,9 +143,9 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
|
|
||||||
{/* Chips Row */}
|
{/* Chips Row */}
|
||||||
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
|
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
|
||||||
{server && <ServerChip server={server} borderColor={pillBgColor} />}
|
{server && <ServerChip server={server} />}
|
||||||
<ShareChip userId={userId} borderColor={pillBgColor} />
|
<ShareChip userId={userId} />
|
||||||
{creator ? <CreatorChip borderColor={pillBgColor} /> : <PowerChip userId={userId} borderColor={pillBgColor} />}
|
{creator ? <CreatorChip /> : <PowerChip userId={userId} />}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Message Button */}
|
{/* Message Button */}
|
||||||
@@ -195,8 +165,8 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
{/* Additional Chips if needed */}
|
{/* Additional Chips if needed */}
|
||||||
{userId !== myUserId && (
|
{userId !== myUserId && (
|
||||||
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
|
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
|
||||||
<MutualRoomsChip userId={userId} borderColor={pillBgColor} />
|
<MutualRoomsChip userId={userId} />
|
||||||
<OptionsChip userId={userId} borderColor={pillBgColor} />
|
<OptionsChip userId={userId} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Box, Chip, Header, IconButton, Text, as } from 'folds';
|
import { Box, Chip, Header, IconButton, Text, as } from 'folds';
|
||||||
import { Icon, Icons } from '../icons';
|
import { Icon, Icons } from '../icons';
|
||||||
import * as css from './VideoViewer.css';
|
import * as css from './VideoViewer.css';
|
||||||
import { downloadMedia } from '../../utils/matrix';
|
import { downloadAndSaveMedia } from '../../utils/saveMedia';
|
||||||
import { getCurrentAccessToken } from '../../utils/auth';
|
import { getCurrentAccessToken } from '../../utils/auth';
|
||||||
|
|
||||||
export type VideoViewerProps = {
|
export type VideoViewerProps = {
|
||||||
@@ -14,20 +13,16 @@ export type VideoViewerProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const VideoViewer = as<'div', VideoViewerProps>(
|
export const VideoViewer = as<'div', VideoViewerProps>(
|
||||||
({ className, alt, src, requestClose, ...props }, ref) => { const handleDownload = async () => {
|
({ className, alt, src, requestClose, ...props }, ref) => {
|
||||||
|
const handleDownload = async () => {
|
||||||
try {
|
try {
|
||||||
const fileContent = await downloadMedia(src, getCurrentAccessToken());
|
await downloadAndSaveMedia(src, alt, getCurrentAccessToken());
|
||||||
FileSaver.saveAs(fileContent, alt);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[VideoViewer] Failed to download media:', error);
|
console.warn('[VideoViewer] Failed to download media:', error);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(src);
|
|
||||||
if (response.ok) {
|
|
||||||
const blob = await response.blob();
|
|
||||||
FileSaver.saveAs(blob, alt);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
window.open(src, '_blank');
|
window.open(src, '_blank');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const VirtualTile = as<'div', VirtualTileProps>(
|
|||||||
({ className, virtualItem, style, ...props }, ref) => (
|
({ className, virtualItem, style, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
className={classNames(css.VirtualTile, className)}
|
className={classNames(css.VirtualTile, className)}
|
||||||
style={{ top: virtualItem.start, ...style }}
|
style={{ top: virtualItem.start, zIndex: virtualItem.index, ...style }}
|
||||||
data-index={virtualItem.index}
|
data-index={virtualItem.index}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
166
src/app/data/updateNotes.ts
Normal file
166
src/app/data/updateNotes.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import bundledManifest from '../../../public/update/manifest.json';
|
||||||
|
import { trimTrailingSlash } from '../utils/common';
|
||||||
|
|
||||||
|
const bundledMarkdownByFile = import.meta.glob('../../../public/update/*.md', {
|
||||||
|
query: '?raw',
|
||||||
|
import: 'default',
|
||||||
|
eager: true,
|
||||||
|
}) as Record<string, string>;
|
||||||
|
|
||||||
|
function getUpdateBaseUrl(): string {
|
||||||
|
const basePath = trimTrailingSlash(import.meta.env.BASE_URL || './');
|
||||||
|
const relative =
|
||||||
|
basePath === '.' || basePath === '' ? 'update/' : `${basePath}/update/`;
|
||||||
|
return new URL(relative, window.location.href).href.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUpdateFileUrl(file: string): string {
|
||||||
|
return new URL(file, `${getUpdateBaseUrl()}/`).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateManifestEntry = {
|
||||||
|
file: string;
|
||||||
|
version?: string;
|
||||||
|
title?: string;
|
||||||
|
date?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateManifest = {
|
||||||
|
current: string;
|
||||||
|
older: UpdateManifestEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParsedUpdateDoc = {
|
||||||
|
file: string;
|
||||||
|
version?: string;
|
||||||
|
title?: string;
|
||||||
|
date?: string;
|
||||||
|
summary?: string;
|
||||||
|
markdown: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
||||||
|
|
||||||
|
function parseFrontmatter(source: string): { frontmatter: Record<string, string>; body: string } {
|
||||||
|
const match = FRONTMATTER_RE.exec(source.trimStart());
|
||||||
|
if (!match) {
|
||||||
|
return { frontmatter: {}, body: source };
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontmatter: Record<string, string> = {};
|
||||||
|
match[1].split('\n').forEach((line) => {
|
||||||
|
const colon = line.indexOf(':');
|
||||||
|
if (colon <= 0) return;
|
||||||
|
const key = line.slice(0, colon).trim();
|
||||||
|
const raw = line.slice(colon + 1).trim();
|
||||||
|
frontmatter[key] = raw.replace(/^['"]|['"]$/g, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
return { frontmatter, body: match[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUpdateMarkdown(file: string, source: string): ParsedUpdateDoc {
|
||||||
|
const { frontmatter, body } = parseFrontmatter(source);
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
version: frontmatter.version,
|
||||||
|
title: frontmatter.title,
|
||||||
|
date: frontmatter.date,
|
||||||
|
summary: frontmatter.summary,
|
||||||
|
markdown: body.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBundledManifest(): UpdateManifest {
|
||||||
|
const manifest = bundledManifest as UpdateManifest;
|
||||||
|
return {
|
||||||
|
current: manifest.current,
|
||||||
|
older: (manifest.older ?? []).filter((entry) => entry?.file),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBundledDocument(file: string): ParsedUpdateDoc | null {
|
||||||
|
const safeFile = file.replace(/^\/+/, '');
|
||||||
|
const entry = Object.entries(bundledMarkdownByFile).find(([path]) =>
|
||||||
|
path.endsWith(`/${safeFile}`)
|
||||||
|
);
|
||||||
|
if (!entry) return null;
|
||||||
|
return parseUpdateMarkdown(safeFile, entry[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveUpdateAssetUrl(src: string | undefined): string | undefined {
|
||||||
|
if (!src) return undefined;
|
||||||
|
if (/^(https?:|data:|blob:|capacitor:)/i.test(src)) return src;
|
||||||
|
if (src.startsWith('/')) return src;
|
||||||
|
|
||||||
|
const normalized = src.replace(/^\.\//, '');
|
||||||
|
return new URL(normalized, `${getUpdateBaseUrl()}/`).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadUpdateManifest(): Promise<UpdateManifest | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(getUpdateFileUrl('manifest.json'), { cache: 'no-cache' });
|
||||||
|
if (response.ok) {
|
||||||
|
const data = (await response.json()) as UpdateManifest;
|
||||||
|
if (data?.current && Array.isArray(data.older)) {
|
||||||
|
return {
|
||||||
|
current: data.current,
|
||||||
|
older: data.older.filter((entry) => entry?.file),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to bundled copy (Capacitor / offline)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return loadBundledManifest();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadUpdateDocument(file: string): Promise<ParsedUpdateDoc | null> {
|
||||||
|
const safeFile = file.replace(/^\/+/, '');
|
||||||
|
if (!safeFile || safeFile.includes('..')) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(getUpdateFileUrl(safeFile), { cache: 'no-cache' });
|
||||||
|
if (response.ok) {
|
||||||
|
const source = await response.text();
|
||||||
|
return parseUpdateMarkdown(safeFile, source);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to bundled copy
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadBundledDocument(safeFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCurrentUpdateDocument(): Promise<ParsedUpdateDoc | null> {
|
||||||
|
const manifest = await loadUpdateManifest();
|
||||||
|
if (!manifest) return null;
|
||||||
|
return loadUpdateDocument(manifest.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentUpdatePreview(): Promise<{
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
} | null> {
|
||||||
|
const doc = await loadCurrentUpdateDocument();
|
||||||
|
if (!doc) return null;
|
||||||
|
|
||||||
|
const title = doc.title ?? (doc.version ? `Paarrot ${doc.version}` : 'Release notes');
|
||||||
|
const description =
|
||||||
|
doc.summary ??
|
||||||
|
doc.markdown
|
||||||
|
.replace(/^#+\s+/gm, '')
|
||||||
|
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.replace(/^[-*•]\s*/, '').trim())
|
||||||
|
.find(Boolean) ??
|
||||||
|
'See what changed in this version.';
|
||||||
|
|
||||||
|
return { title, description };
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ParticipantEvent,
|
ParticipantEvent,
|
||||||
Participant,
|
Participant,
|
||||||
LocalTrackPublication,
|
LocalTrackPublication,
|
||||||
|
LocalAudioTrack,
|
||||||
Track,
|
Track,
|
||||||
} from 'livekit-client';
|
} from 'livekit-client';
|
||||||
import { MatrixClient } from 'matrix-js-sdk';
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
@@ -31,7 +32,7 @@ import {
|
|||||||
getLiveKitHomeserverPriority,
|
getLiveKitHomeserverPriority,
|
||||||
} from './useRtcConfig';
|
} from './useRtcConfig';
|
||||||
import { getActiveCallMembers } from './useRoomCallMembers';
|
import { getActiveCallMembers } from './useRoomCallMembers';
|
||||||
import { getAudioSettings, SCREEN_SHARE_RESOLUTIONS, SCREEN_SHARE_BITRATES } from '../settings/audio/Audio';
|
import { getAudioCaptureOptions, getAudioSettings, AUDIO_SETTINGS_CHANGED_EVENT, SCREEN_SHARE_RESOLUTIONS, SCREEN_SHARE_BITRATES } from '../settings/audio/Audio';
|
||||||
import { getCallSounds, CallSoundType } from './CallSounds';
|
import { getCallSounds, CallSoundType } from './CallSounds';
|
||||||
|
|
||||||
type CallEventListener = (event: CallEvent) => void;
|
type CallEventListener = (event: CallEvent) => void;
|
||||||
@@ -102,6 +103,10 @@ export class CallService {
|
|||||||
|
|
||||||
private wasMutedBeforeDeafen = false;
|
private wasMutedBeforeDeafen = false;
|
||||||
|
|
||||||
|
private readonly onAudioSettingsChanged = () => {
|
||||||
|
void this.applyAudioCaptureSettings();
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new CallService instance
|
* Creates a new CallService instance
|
||||||
* @param config - Service configuration including homeserver and LiveKit URLs
|
* @param config - Service configuration including homeserver and LiveKit URLs
|
||||||
@@ -110,6 +115,7 @@ export class CallService {
|
|||||||
constructor(config: CallServiceConfig, matrixClient: MatrixClient) {
|
constructor(config: CallServiceConfig, matrixClient: MatrixClient) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.matrixClient = matrixClient;
|
this.matrixClient = matrixClient;
|
||||||
|
window.addEventListener(AUDIO_SETTINGS_CHANGED_EVENT, this.onAudioSettingsChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -386,9 +392,13 @@ export class CallService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Create and connect to LiveKit room
|
// Step 4: Create and connect to LiveKit room
|
||||||
|
// Pin audioCaptureDefaults so mute/unmute republish doesn't re-enable LiveKit's
|
||||||
|
// voiceIsolation:true default (which ignores noiseSuppression).
|
||||||
|
const audioCaptureOptions = getAudioCaptureOptions();
|
||||||
this.livekitRoom = new Room({
|
this.livekitRoom = new Room({
|
||||||
adaptiveStream: true,
|
adaptiveStream: true,
|
||||||
dynacast: true,
|
dynacast: true,
|
||||||
|
audioCaptureDefaults: audioCaptureOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.setupLiveKitEventHandlers();
|
this.setupLiveKitEventHandlers();
|
||||||
@@ -400,19 +410,7 @@ export class CallService {
|
|||||||
console.log('Local participant identity:', this.livekitRoom.localParticipant.identity);
|
console.log('Local participant identity:', this.livekitRoom.localParticipant.identity);
|
||||||
console.log('Remote participants:', this.livekitRoom.remoteParticipants.size);
|
console.log('Remote participants:', this.livekitRoom.remoteParticipants.size);
|
||||||
|
|
||||||
// Get user's audio device preferences
|
console.log('Using audio capture options:', audioCaptureOptions);
|
||||||
const audioSettings = getAudioSettings();
|
|
||||||
console.log('Using audio settings:', audioSettings);
|
|
||||||
|
|
||||||
// Build audio capture options with processing settings
|
|
||||||
const audioCaptureOptions = {
|
|
||||||
deviceId: audioSettings.microphoneId || undefined,
|
|
||||||
noiseSuppression: audioSettings.noiseSuppression,
|
|
||||||
echoCancellation: audioSettings.echoCancellation,
|
|
||||||
autoGainControl: audioSettings.autoGainControl,
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('Audio capture options:', audioCaptureOptions);
|
|
||||||
|
|
||||||
// Step 5: Publish local tracks based on call type with selected devices and processing
|
// Step 5: Publish local tracks based on call type with selected devices and processing
|
||||||
if (callType === CallType.Video) {
|
if (callType === CallType.Video) {
|
||||||
@@ -881,7 +879,10 @@ export class CallService {
|
|||||||
getCallSounds().playSound(CallSoundType.Undeafen);
|
getCallSounds().playSound(CallSoundType.Undeafen);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.livekitRoom.localParticipant.setMicrophoneEnabled(!newMuteState);
|
this.livekitRoom.localParticipant.setMicrophoneEnabled(
|
||||||
|
!newMuteState,
|
||||||
|
getAudioCaptureOptions()
|
||||||
|
);
|
||||||
this.activeCall.isMuted = newMuteState;
|
this.activeCall.isMuted = newMuteState;
|
||||||
|
|
||||||
// Play mute/unmute sound
|
// Play mute/unmute sound
|
||||||
@@ -929,7 +930,7 @@ export class CallService {
|
|||||||
} else {
|
} else {
|
||||||
// Undeafening - only unmute if user wasn't muted before deafening
|
// Undeafening - only unmute if user wasn't muted before deafening
|
||||||
if (!this.wasMutedBeforeDeafen && this.activeCall.isMuted) {
|
if (!this.wasMutedBeforeDeafen && this.activeCall.isMuted) {
|
||||||
this.livekitRoom.localParticipant.setMicrophoneEnabled(true);
|
this.livekitRoom.localParticipant.setMicrophoneEnabled(true, getAudioCaptureOptions());
|
||||||
this.activeCall.isMuted = false;
|
this.activeCall.isMuted = false;
|
||||||
}
|
}
|
||||||
this.wasMutedBeforeDeafen = false;
|
this.wasMutedBeforeDeafen = false;
|
||||||
@@ -1129,10 +1130,39 @@ export class CallService {
|
|||||||
return this.activeCall?.isScreenSharing ?? false;
|
return this.activeCall?.isScreenSharing ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-apply stored mic processing settings to the live published track.
|
||||||
|
* Needed because LiveKit merges voiceIsolation:true by default, and because
|
||||||
|
* settings changes mid-call previously only wrote localStorage.
|
||||||
|
*/
|
||||||
|
async applyAudioCaptureSettings(): Promise<void> {
|
||||||
|
if (!this.livekitRoom || !this.activeCall) return;
|
||||||
|
|
||||||
|
const options = getAudioCaptureOptions();
|
||||||
|
this.livekitRoom.options.audioCaptureDefaults = {
|
||||||
|
...this.livekitRoom.options.audioCaptureDefaults,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
const publication = this.livekitRoom.localParticipant.getTrackPublication(
|
||||||
|
Track.Source.Microphone
|
||||||
|
);
|
||||||
|
const track = publication?.track;
|
||||||
|
if (!(track instanceof LocalAudioTrack)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await track.restartTrack(options);
|
||||||
|
console.log('Reapplied audio capture settings:', options);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reapply audio capture settings:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleans up resources
|
* Cleans up resources
|
||||||
*/
|
*/
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
|
window.removeEventListener(AUDIO_SETTINGS_CHANGED_EVENT, this.onAudioSettingsChanged);
|
||||||
this.stopMembershipRefresh();
|
this.stopMembershipRefresh();
|
||||||
this.endCall();
|
this.endCall();
|
||||||
this.listeners.clear();
|
this.listeners.clear();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
|
import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
|
||||||
import { MatrixError } from 'matrix-js-sdk';
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
import { Box, Chip, IconButton, Text, config, Button, Spinner, color, TextAreaComponent as TextArea, Input } from 'folds';
|
import { Box, Chip, IconButton, Text, config, Button, Spinner, color, TextArea, Input } from 'folds';
|
||||||
import { Icon, Icons } from '../../../components/icons';
|
import { Icon, Icons } from '../../../components/icons';
|
||||||
import { Page, PageHeader } from '../../../components/page';
|
import { Page, PageHeader } from '../../../components/page';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
@@ -171,7 +171,7 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro
|
|||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<Text size="L400">JSON Content</Text>
|
<Text size="L400">JSON Content</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<TextAreaComponent
|
<TextArea
|
||||||
ref={textAreaRef}
|
ref={textAreaRef}
|
||||||
name="contentTextArea"
|
name="contentTextArea"
|
||||||
style={{ fontFamily: 'monospace' }}
|
style={{ fontFamily: 'monospace' }}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Box, Text, IconButton, Chip, Scroll, config, TextAreaComponent as TextArea, color, Spinner, Button } from 'folds';
|
import { Box, Text, IconButton, Chip, Scroll, config, TextArea, color, Spinner, Button } from 'folds';
|
||||||
import { Icon, Icons } from '../../../components/icons';
|
import { Icon, Icons } from '../../../components/icons';
|
||||||
import { MatrixError } from 'matrix-js-sdk';
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
import { Page, PageHeader } from '../../../components/page';
|
import { Page, PageHeader } from '../../../components/page';
|
||||||
@@ -153,7 +153,7 @@ function StateEventEdit({ type, stateKey, content, requestClose }: StateEventEdi
|
|||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<Text size="L400">JSON Content</Text>
|
<Text size="L400">JSON Content</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<TextAreaComponent
|
<TextArea
|
||||||
ref={textAreaRef}
|
ref={textAreaRef}
|
||||||
name="contentTextArea"
|
name="contentTextArea"
|
||||||
style={{ fontFamily: 'monospace' }}
|
style={{ fontFamily: 'monospace' }}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { ErrorCode } from '../../cs-errorcode';
|
import { ErrorCode } from '../../cs-errorcode';
|
||||||
import { millisecondsToMinutes } from '../../utils/common';
|
import { millisecondsToMinutes } from '../../utils/common';
|
||||||
import { createRoomEncryptionState, createRoomPowerLevelsState } from '../../components/create-room';
|
import { createRoomEncryptionState } from '../../components/create-room';
|
||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
import { getDirectRoomPath } from '../../pages/pathUtils';
|
import { getDirectRoomPath } from '../../pages/pathUtils';
|
||||||
|
|
||||||
@@ -28,9 +28,10 @@ export function CreateChat({ defaultUserId }: CreateChatProps) {
|
|||||||
const [createState, create] = useAsyncCallback<string, Error | MatrixError, [string, boolean]>(
|
const [createState, create] = useAsyncCallback<string, Error | MatrixError, [string, boolean]>(
|
||||||
useCallback(
|
useCallback(
|
||||||
async (userId, encrypted) => {
|
async (userId, encrypted) => {
|
||||||
|
// Do not put m.room.power_levels in initial_state — incomplete PL events
|
||||||
|
// (used for org.matrix.msc3401.call.member) cause M_FORBIDDEN / 403 on create.
|
||||||
|
// TrustedPrivateChat already gives both users PL 100, so call membership works.
|
||||||
const initialState: ICreateRoomStateEvent[] = [];
|
const initialState: ICreateRoomStateEvent[] = [];
|
||||||
|
|
||||||
initialState.push(createRoomPowerLevelsState());
|
|
||||||
if (encrypted) initialState.push(createRoomEncryptionState());
|
if (encrypted) initialState.push(createRoomEncryptionState());
|
||||||
|
|
||||||
const result = await mx.createRoom({
|
const result = await mx.createRoom({
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { KeyboardEventHandler, useCallback, useRef, useState } from 'react';
|
import React, { KeyboardEventHandler, useCallback, useRef, useState } from 'react';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { Editor } from 'slate';
|
import { Editor } from 'slate';
|
||||||
import { ReactEditor } from 'slate-react';
|
|
||||||
import { IconButton, Line, PopOut } from 'folds';
|
import { IconButton, Line, PopOut } from 'folds';
|
||||||
import { Icon, Icons } from '../../components/icons';
|
import { Icon, Icons } from '../../components/icons';
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +12,7 @@ import {
|
|||||||
getAutocompleteQuery,
|
getAutocompleteQuery,
|
||||||
createEmoticonElement,
|
createEmoticonElement,
|
||||||
moveCursor,
|
moveCursor,
|
||||||
|
safeFocusEditor,
|
||||||
} from '../../components/editor';
|
} from '../../components/editor';
|
||||||
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
@@ -84,7 +84,7 @@ export function ForumChatComposer({
|
|||||||
|
|
||||||
const handleCloseAutocomplete = useCallback(() => {
|
const handleCloseAutocomplete = useCallback(() => {
|
||||||
setAutocompleteQuery(undefined);
|
setAutocompleteQuery(undefined);
|
||||||
ReactEditor.focus(editor);
|
safeFocusEditor(editor);
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -114,7 +114,7 @@ export function ForumChatComposer({
|
|||||||
onClick={() => setToolbar(!toolbar)}
|
onClick={() => setToolbar(!toolbar)}
|
||||||
aria-label="Formatting"
|
aria-label="Formatting"
|
||||||
>
|
>
|
||||||
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} size="200" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<UseStateProvider initial={undefined}>
|
<UseStateProvider initial={undefined}>
|
||||||
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
|
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
|
||||||
@@ -136,15 +136,15 @@ export function ForumChatComposer({
|
|||||||
returnFocusOnDeactivate={false}
|
returnFocusOnDeactivate={false}
|
||||||
onEmojiSelect={handleEmoticonSelect}
|
onEmojiSelect={handleEmoticonSelect}
|
||||||
onCustomEmojiSelect={handleEmoticonSelect}
|
onCustomEmojiSelect={handleEmoticonSelect}
|
||||||
requestClose={() => {
|
requestClose={() => {
|
||||||
setEmojiBoardTab((t) => {
|
setEmojiBoardTab((t) => {
|
||||||
if (t) {
|
if (t) {
|
||||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
if (!mobileOrTablet()) safeFocusEditor(editor);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return t;
|
return t;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -158,7 +158,11 @@ export function ForumChatComposer({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="Emoji"
|
aria-label="Emoji"
|
||||||
>
|
>
|
||||||
<Icon src={Icons.Smile} filled={emojiBoardTab === EmojiBoardTab.Emoji} />
|
<Icon
|
||||||
|
src={Icons.Smile}
|
||||||
|
size="200"
|
||||||
|
filled={emojiBoardTab === EmojiBoardTab.Emoji}
|
||||||
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</PopOut>
|
</PopOut>
|
||||||
)}
|
)}
|
||||||
@@ -173,7 +177,7 @@ export function ForumChatComposer({
|
|||||||
radii="300"
|
radii="300"
|
||||||
aria-label="Send"
|
aria-label="Send"
|
||||||
>
|
>
|
||||||
<Icon src={Icons.Send} />
|
<Icon src={Icons.Send} size="200" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import * as css from './LobbyHeader.css';
|
|||||||
import { IPowerLevels } from '../../hooks/usePowerLevels';
|
import { IPowerLevels } from '../../hooks/usePowerLevels';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
|
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
|
||||||
|
import { RemoveInaccessiblePrompt } from '../../components/remove-inaccessible-prompt';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { useBackRoute } from '../../hooks/useBackRoute';
|
import { useBackRoute } from '../../hooks/useBackRoute';
|
||||||
@@ -48,6 +49,7 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
|
|||||||
|
|
||||||
const permissions = useRoomPermissions(creators, powerLevels);
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
||||||
|
const canEditChildren = permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId());
|
||||||
const openSpaceSettings = useOpenSpaceSettings();
|
const openSpaceSettings = useOpenSpaceSettings();
|
||||||
|
|
||||||
const [invitePrompt, setInvitePrompt] = useState(false);
|
const [invitePrompt, setInvitePrompt] = useState(false);
|
||||||
@@ -117,6 +119,34 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
|
|||||||
</Box>
|
</Box>
|
||||||
<Line variant="Surface" size="300" />
|
<Line variant="Surface" size="300" />
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{canEditChildren && (
|
||||||
|
<UseStateProvider initial={false}>
|
||||||
|
{(promptRemove, setPromptRemove) => (
|
||||||
|
<>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => setPromptRemove(true)}
|
||||||
|
variant="Critical"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.Delete} />}
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={promptRemove}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
Remove Inaccessible
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
{promptRemove && (
|
||||||
|
<RemoveInaccessiblePrompt
|
||||||
|
roomId={space.roomId}
|
||||||
|
onDone={requestClose}
|
||||||
|
onCancel={() => setPromptRemove(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</UseStateProvider>
|
||||||
|
)}
|
||||||
<UseStateProvider initial={false}>
|
<UseStateProvider initial={false}>
|
||||||
{(promptLeave, setPromptLeave) => (
|
{(promptLeave, setPromptLeave) => (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -133,7 +133,11 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
|
|||||||
}, [mx, roomItems]);
|
}, [mx, roomItems]);
|
||||||
|
|
||||||
let childItems = roomItems?.filter((i) => !subspaces.has(i.roomId) && !globalSubRoomIds.has(i.roomId));
|
let childItems = roomItems?.filter((i) => !subspaces.has(i.roomId) && !globalSubRoomIds.has(i.roomId));
|
||||||
if (!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())) {
|
// Only hide when we know the user cannot edit children. If power levels are
|
||||||
|
// still loading (spacePermissions undefined), keep inaccessible rows visible
|
||||||
|
// so admins can clean them up.
|
||||||
|
const canEditChild = spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId());
|
||||||
|
if (spacePermissions && !canEditChild) {
|
||||||
// hide unknown rooms for normal user
|
// hide unknown rooms for normal user
|
||||||
childItems = childItems?.filter((i) => {
|
childItems = childItems?.filter((i) => {
|
||||||
const forbidden = error instanceof MatrixError ? error.errcode === 'M_FORBIDDEN' : false;
|
const forbidden = error instanceof MatrixError ? error.errcode === 'M_FORBIDDEN' : false;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
|||||||
import { ScrollTopContainer } from '../../components/scroll-top-container';
|
import { ScrollTopContainer } from '../../components/scroll-top-container';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../pages/pathUtils';
|
import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../pages/pathUtils';
|
||||||
import { useRooms } from '../../state/hooks/roomList';
|
import { useRooms, useDirects } from '../../state/hooks/roomList';
|
||||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
|
import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
|
||||||
@@ -53,7 +53,13 @@ export function MessageSearch({
|
|||||||
}: MessageSearchProps) {
|
}: MessageSearchProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
const allRooms = useRooms(mx, allRoomsAtom, mDirects);
|
const nonDirectRooms = useRooms(mx, allRoomsAtom, mDirects);
|
||||||
|
const directRooms = useDirects(mx, allRoomsAtom, mDirects);
|
||||||
|
// Include DMs — previously useRooms alone stripped them and broke DM search
|
||||||
|
const allRooms = useMemo(
|
||||||
|
() => [...nonDirectRooms, ...directRooms],
|
||||||
|
[nonDirectRooms, directRooms]
|
||||||
|
);
|
||||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ function SearchResultItem({
|
|||||||
relation?.rel_type === RelationType.Thread ? relation.event_id : undefined;
|
relation?.rel_type === RelationType.Thread ? relation.event_id : undefined;
|
||||||
|
|
||||||
// Get custom user color from avatar metadata
|
// Get custom user color from avatar metadata
|
||||||
const customUserColor = useOtherUserColor(event.sender, senderAvatarMxc);
|
const customUserColor = useOtherUserColor(event.sender, room);
|
||||||
|
|
||||||
const memberPowerTag = getMemberPowerTag(event.sender);
|
const memberPowerTag = getMemberPowerTag(event.sender);
|
||||||
const tagColor = memberPowerTag?.color
|
const tagColor = memberPowerTag?.color
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import {
|
|||||||
ISearchRequestBody,
|
ISearchRequestBody,
|
||||||
ISearchResponse,
|
ISearchResponse,
|
||||||
ISearchResult,
|
ISearchResult,
|
||||||
|
MatrixClient,
|
||||||
|
MatrixEvent,
|
||||||
|
Room,
|
||||||
SearchOrderBy,
|
SearchOrderBy,
|
||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
@@ -26,6 +29,12 @@ export type SearchResult = {
|
|||||||
groups: ResultGroup[];
|
groups: ResultGroup[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EMPTY_CONTEXT: IResultContext = {
|
||||||
|
events_before: [],
|
||||||
|
events_after: [],
|
||||||
|
profile_info: {},
|
||||||
|
};
|
||||||
|
|
||||||
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
||||||
const groups: ResultGroup[] = [];
|
const groups: ResultGroup[] = [];
|
||||||
|
|
||||||
@@ -54,13 +63,108 @@ const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
|||||||
const parseSearchResult = (result: ISearchResponse): SearchResult => {
|
const parseSearchResult = (result: ISearchResponse): SearchResult => {
|
||||||
const roomEvents = result.search_categories.room_events;
|
const roomEvents = result.search_categories.room_events;
|
||||||
|
|
||||||
const searchResult: SearchResult = {
|
return {
|
||||||
nextToken: roomEvents?.next_batch,
|
nextToken: roomEvents?.next_batch,
|
||||||
highlights: roomEvents?.highlights ?? [],
|
highlights: roomEvents?.highlights ?? [],
|
||||||
groups: groupSearchResult(roomEvents?.results ?? []),
|
groups: groupSearchResult(roomEvents?.results ?? []),
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return searchResult;
|
const eventToSearchEvent = (event: MatrixEvent, roomId: string): IEventWithRoomId | undefined => {
|
||||||
|
const eventId = event.getId();
|
||||||
|
if (!eventId) return undefined;
|
||||||
|
|
||||||
|
const content = event.getClearContent() ?? event.getContent();
|
||||||
|
return {
|
||||||
|
event_id: eventId,
|
||||||
|
type: event.getWireType() === 'm.room.encrypted' ? 'm.room.message' : event.getType(),
|
||||||
|
sender: event.getSender() ?? '',
|
||||||
|
origin_server_ts: event.getTs(),
|
||||||
|
content,
|
||||||
|
room_id: roomId,
|
||||||
|
unsigned: event.getUnsigned(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSearchableBody = (event: MatrixEvent): string | undefined => {
|
||||||
|
if (event.isRedacted()) return undefined;
|
||||||
|
|
||||||
|
// After decryption, clear content is available even if wire type was encrypted
|
||||||
|
const content = event.getClearContent() ?? event.getContent();
|
||||||
|
if (!content || typeof content !== 'object') return undefined;
|
||||||
|
|
||||||
|
// Skip still-encrypted payloads
|
||||||
|
if (event.isEncrypted() && !event.isDecryptionFailure() && !event.getClearContent()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msgType = content.msgtype;
|
||||||
|
if (msgType && msgType !== 'm.text' && msgType !== 'm.notice' && msgType !== 'm.emote') {
|
||||||
|
// Still allow filename / body on media
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = typeof content.body === 'string' ? content.body : undefined;
|
||||||
|
const formatted =
|
||||||
|
typeof content.formatted_body === 'string' ? content.formatted_body : undefined;
|
||||||
|
return [body, formatted].filter(Boolean).join('\n') || undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const highlightsFromTerm = (term: string): string[] =>
|
||||||
|
term
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter((part) => part.length > 1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search decrypted timeline events already loaded for a room.
|
||||||
|
* Server-side search cannot match E2EE message bodies.
|
||||||
|
*/
|
||||||
|
const searchLocalRoomTimeline = (room: Room, term: string): ResultItem[] => {
|
||||||
|
const needle = term.trim().toLowerCase();
|
||||||
|
if (!needle) return [];
|
||||||
|
|
||||||
|
const events = room.getLiveTimeline().getEvents();
|
||||||
|
const matches: ResultItem[] = [];
|
||||||
|
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const event = events[i];
|
||||||
|
const haystack = getSearchableBody(event);
|
||||||
|
if (!haystack || !haystack.toLowerCase().includes(needle)) continue;
|
||||||
|
|
||||||
|
const searchEvent = eventToSearchEvent(event, room.roomId);
|
||||||
|
if (!searchEvent) continue;
|
||||||
|
|
||||||
|
matches.push({
|
||||||
|
rank: 1,
|
||||||
|
event: searchEvent,
|
||||||
|
context: EMPTY_CONTEXT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEncryptedRoom = (mx: MatrixClient, roomId: string): boolean => {
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
return !!room?.hasEncryptionStateEvent();
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchLocalRooms = (mx: MatrixClient, roomIds: string[], term: string): SearchResult => {
|
||||||
|
const groups: ResultGroup[] = [];
|
||||||
|
|
||||||
|
roomIds.forEach((roomId) => {
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) return;
|
||||||
|
const items = searchLocalRoomTimeline(room, term);
|
||||||
|
if (items.length > 0) {
|
||||||
|
groups.push({ roomId, items });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
highlights: highlightsFromTerm(term),
|
||||||
|
groups,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MessageSearchParams = {
|
export type MessageSearchParams = {
|
||||||
@@ -69,19 +173,30 @@ export type MessageSearchParams = {
|
|||||||
rooms?: string[];
|
rooms?: string[];
|
||||||
senders?: string[];
|
senders?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useMessageSearch = (params: MessageSearchParams) => {
|
export const useMessageSearch = (params: MessageSearchParams) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const { term, order, rooms, senders } = params;
|
const { term, order, rooms, senders } = params;
|
||||||
|
|
||||||
const searchMessages = useCallback(
|
const searchMessages = useCallback(
|
||||||
async (nextBatch?: string) => {
|
async (nextBatch?: string) => {
|
||||||
if (!term)
|
if (!term) {
|
||||||
return {
|
return {
|
||||||
highlights: [],
|
highlights: [],
|
||||||
groups: [],
|
groups: [],
|
||||||
};
|
};
|
||||||
const limit = 20;
|
}
|
||||||
|
|
||||||
|
const scopedRooms = rooms?.filter(Boolean) ?? [];
|
||||||
|
const encryptedScoped =
|
||||||
|
scopedRooms.length > 0 && scopedRooms.every((roomId) => isEncryptedRoom(mx, roomId));
|
||||||
|
|
||||||
|
// Encrypted-only scope: server cannot see bodies — search loaded timeline locally
|
||||||
|
if (encryptedScoped && !nextBatch) {
|
||||||
|
return searchLocalRooms(mx, scopedRooms, term);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = 20;
|
||||||
const requestBody: ISearchRequestBody = {
|
const requestBody: ISearchRequestBody = {
|
||||||
search_categories: {
|
search_categories: {
|
||||||
room_events: {
|
room_events: {
|
||||||
@@ -102,11 +217,33 @@ export const useMessageSearch = (params: MessageSearchParams) => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const r = await mx.search({
|
try {
|
||||||
body: requestBody,
|
const r = await mx.search({
|
||||||
next_batch: nextBatch === '' ? undefined : nextBatch,
|
body: requestBody,
|
||||||
});
|
next_batch: nextBatch === '' ? undefined : nextBatch,
|
||||||
return parseSearchResult(r);
|
});
|
||||||
|
const parsed = parseSearchResult(r);
|
||||||
|
|
||||||
|
if (
|
||||||
|
parsed.groups.length === 0 &&
|
||||||
|
!nextBatch &&
|
||||||
|
scopedRooms.length > 0 &&
|
||||||
|
scopedRooms.some((roomId) => isEncryptedRoom(mx, roomId))
|
||||||
|
) {
|
||||||
|
return searchLocalRooms(
|
||||||
|
mx,
|
||||||
|
scopedRooms.filter((id) => isEncryptedRoom(mx, id)),
|
||||||
|
term
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
if (!nextBatch && scopedRooms.length > 0) {
|
||||||
|
return searchLocalRooms(mx, scopedRooms, term);
|
||||||
|
}
|
||||||
|
throw new Error('Message search failed');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[mx, term, order, rooms, senders]
|
[mx, term, order, rooms, senders]
|
||||||
);
|
);
|
||||||
|
|||||||
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://')
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export const RoomNavCategoryButton = as<'button', { closed?: boolean }>(
|
|||||||
className={classNames(css.CategoryButton, className)}
|
className={classNames(css.CategoryButton, className)}
|
||||||
variant="Background"
|
variant="Background"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
|
data-nav-category-btn=""
|
||||||
before={
|
before={
|
||||||
<Icon
|
<Icon
|
||||||
className={css.CategoryButtonIcon}
|
className={css.CategoryButtonIcon}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
|
|||||||
import { TypingIndicator } from '../../components/typing-indicator';
|
import { TypingIndicator } from '../../components/typing-indicator';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
||||||
import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
|
import { getCanonicalAliasOrRoomId, guessDmRoomUserId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { getViaServers } from '../../plugins/via-servers';
|
import { getViaServers } from '../../plugins/via-servers';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
@@ -46,6 +46,8 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
|
|||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
import { useOpenCreateSubRoomModal } from '../../state/hooks/createRoomModal';
|
import { useOpenCreateSubRoomModal } from '../../state/hooks/createRoomModal';
|
||||||
|
import { PresenceAvatar } from '../../components/presence';
|
||||||
|
import { Presence, useUserPresence } from '../../hooks/useUserPresence';
|
||||||
|
|
||||||
type RoomNavItemMenuProps = {
|
type RoomNavItemMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -289,13 +291,17 @@ export function RoomNavItem({
|
|||||||
const avatarUrl = direct
|
const avatarUrl = direct
|
||||||
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||||
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
|
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
|
||||||
|
|
||||||
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
const isDirect = Boolean(direct || mDirects.has(room.roomId));
|
||||||
|
const dmUserId = isDirect ? guessDmRoomUserId(room, mx.getSafeUserId()) : undefined;
|
||||||
|
const dmPresence = useUserPresence(dmUserId ?? '');
|
||||||
|
|
||||||
const shouldShowIcon = !hideIcon || (hideIcon && avatarUrl);
|
const shouldShowIcon = !hideIcon || (hideIcon && avatarUrl);
|
||||||
const typingMember = useRoomTypingMember(room.roomId).filter(
|
const typingMember = useRoomTypingMember(room.roomId).filter(
|
||||||
(receipt) => receipt.userId !== mx.getUserId()
|
(receipt) => receipt.userId !== mx.getUserId()
|
||||||
);
|
);
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
|
||||||
|
|
||||||
// Get first participated thread for preview
|
// Get first participated thread for preview
|
||||||
const firstThreadPreview = useMemo(() => {
|
const firstThreadPreview = useMemo(() => {
|
||||||
@@ -317,7 +323,7 @@ export function RoomNavItem({
|
|||||||
|
|
||||||
// Get parent space for DMs
|
// Get parent space for DMs
|
||||||
const parentSpaceInfo = (() => {
|
const parentSpaceInfo = (() => {
|
||||||
if (!direct || !mDirects.has(room.roomId)) return undefined;
|
if (!isDirect || !mDirects.has(room.roomId)) return undefined;
|
||||||
|
|
||||||
const orphanParents = getOrphanParents(roomToParents, room.roomId);
|
const orphanParents = getOrphanParents(roomToParents, room.roomId);
|
||||||
if (orphanParents.length === 0) return undefined;
|
if (orphanParents.length === 0) return undefined;
|
||||||
@@ -383,29 +389,60 @@ export function RoomNavItem({
|
|||||||
<NavLink to={linkPath} onClick={handleRoomClick}>
|
<NavLink to={linkPath} onClick={handleRoomClick}>
|
||||||
<NavItemContent>
|
<NavItemContent>
|
||||||
<Box as="span" grow="Yes" alignItems="Center" gap={shouldShowIcon ? "200" : "100"}>
|
<Box as="span" grow="Yes" alignItems="Center" gap={shouldShowIcon ? "200" : "100"}>
|
||||||
{shouldShowIcon && (
|
{shouldShowIcon &&
|
||||||
<Avatar size="200" radii="400">
|
(isDirect ? (
|
||||||
{showAvatar || avatarUrl ? (
|
<PresenceAvatar
|
||||||
<RoomAvatar
|
presence={
|
||||||
roomId={room.roomId}
|
dmUserId && dmUserId !== mx.getSafeUserId()
|
||||||
src={avatarUrl}
|
? dmPresence?.presence ?? Presence.Offline
|
||||||
alt={room.name}
|
: Presence.Offline
|
||||||
renderFallback={() => (
|
}
|
||||||
<Text as="span" size="H6">
|
>
|
||||||
{nameInitials(room.name)}
|
<Avatar size="200" radii="400">
|
||||||
</Text>
|
{showAvatar || avatarUrl ? (
|
||||||
)}
|
<RoomAvatar
|
||||||
/>
|
roomId={room.roomId}
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={room.name}
|
||||||
|
renderFallback={() => (
|
||||||
|
<Text as="span" size="H6">
|
||||||
|
{nameInitials(room.name)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RoomIcon
|
||||||
|
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
|
||||||
|
filled={selected}
|
||||||
|
size="100"
|
||||||
|
joinRule={room.getJoinRule()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Avatar>
|
||||||
|
</PresenceAvatar>
|
||||||
) : (
|
) : (
|
||||||
<RoomIcon
|
<Avatar size="200" radii="400">
|
||||||
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
|
{showAvatar || avatarUrl ? (
|
||||||
filled={selected}
|
<RoomAvatar
|
||||||
size="100"
|
roomId={room.roomId}
|
||||||
joinRule={room.getJoinRule()}
|
src={avatarUrl}
|
||||||
/>
|
alt={room.name}
|
||||||
)}
|
renderFallback={() => (
|
||||||
</Avatar>
|
<Text as="span" size="H6">
|
||||||
)}
|
{nameInitials(room.name)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RoomIcon
|
||||||
|
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
|
||||||
|
filled={selected}
|
||||||
|
size="100"
|
||||||
|
joinRule={room.getJoinRule()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Avatar>
|
||||||
|
))}
|
||||||
<Box as="span" grow="Yes" direction="Column" style={{ overflow: 'hidden', minWidth: 0 }}>
|
<Box as="span" grow="Yes" direction="Column" style={{ overflow: 'hidden', minWidth: 0 }}>
|
||||||
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
|
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
|
||||||
{room.name}
|
{room.name}
|
||||||
|
|||||||
@@ -41,7 +41,13 @@ export function UnjoinedSubRoomItem({ roomId, depth, isLast = false }: UnjoinedS
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
style={{ paddingLeft, padding: `${config.space.S100} ${config.space.S200}`, minHeight: '1.5rem' }}
|
data-nav-depth={depth}
|
||||||
|
style={{
|
||||||
|
paddingLeft,
|
||||||
|
padding: `${config.space.S100} ${config.space.S200}`,
|
||||||
|
minHeight: '1.5rem',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
gap="200"
|
gap="200"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,11 +1,12 @@
|
|||||||
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';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useAtom, useSetAtom } from 'jotai';
|
||||||
import { RoomView } from './RoomView';
|
import { RoomView } from './RoomView';
|
||||||
import { MembersDrawer } from './MembersDrawer';
|
import { MembersDrawer } from './MembersDrawer';
|
||||||
|
import { MediaDrawer } from './room-media-menu';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
@@ -16,8 +17,12 @@ import { useMarkAsRead } from '../../hooks/useMarkAsRead';
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
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 { isForum } from '../../utils/room';
|
import { isMediaDrawerAtom } from '../../state/mediaDrawer';
|
||||||
|
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();
|
||||||
@@ -26,13 +31,28 @@ export function Room() {
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const setActiveRoomId = useSetAtom(activeRoomIdAtom);
|
const setActiveRoomId = useSetAtom(activeRoomIdAtom);
|
||||||
|
|
||||||
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
const [isPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||||
|
const [isMediaDrawer] = useAtom(isMediaDrawerAtom);
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const powerLevels = usePowerLevels(room);
|
const powerLevels = usePowerLevels(room);
|
||||||
const members = useRoomMembers(mx, room.roomId);
|
const members = useRoomMembers(mx, room.roomId);
|
||||||
const markAsRead = useMarkAsRead(mx);
|
const markAsRead = useMarkAsRead(mx);
|
||||||
const forumRoom = isForum(room);
|
const forumRoom = isForum(room);
|
||||||
|
const skinny = screenSize !== ScreenSize.Desktop;
|
||||||
|
const showMediaSolo = skinny && isMediaDrawer;
|
||||||
|
const showRightDrawer =
|
||||||
|
!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(() => {
|
||||||
@@ -55,11 +75,49 @@ export function Room() {
|
|||||||
return (
|
return (
|
||||||
<PowerLevelsContextProvider value={powerLevels}>
|
<PowerLevelsContextProvider value={powerLevels}>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
{forumRoom ? <ForumRoomView room={room} eventId={eventId} /> : <RoomView room={room} eventId={eventId} />}
|
{!showMediaSolo &&
|
||||||
{screenSize === ScreenSize.Desktop && isDrawer && (
|
(showAppView ? (
|
||||||
|
<RoomAppView room={room} onShowChat={() => setPreferChat(true)} />
|
||||||
|
) : forumRoom ? (
|
||||||
|
<ForumRoomView 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 && showRightDrawer && (
|
||||||
<>
|
<>
|
||||||
<Line variant="Background" direction="Vertical" size="300" />
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
<MembersDrawer key={room.roomId} room={room} members={members} />
|
{isMediaDrawer ? (
|
||||||
|
<MediaDrawer room={room} />
|
||||||
|
) : (
|
||||||
|
<MembersDrawer key={room.roomId} room={room} members={members} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
17
src/app/features/room/RoomInput.css.ts
Normal file
17
src/app/features/room/RoomInput.css.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
|
import { color, config } from 'folds';
|
||||||
|
import * as editorCss from '../../components/editor/Editor.css';
|
||||||
|
|
||||||
|
export const RoomInputWrap = style({
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Notebook-flat composer chrome — Stationery only (other themes keep Editor inset border) */
|
||||||
|
globalStyle(`.stationery ${RoomInputWrap} .${editorCss.Editor}`, {
|
||||||
|
borderRadius: 0,
|
||||||
|
boxShadow: 'none',
|
||||||
|
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
|
borderLeft: 'none',
|
||||||
|
borderRight: 'none',
|
||||||
|
borderBottom: 'none',
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user