Compare commits
22 Commits
61d41900cc
...
e65a516350
| Author | SHA1 | Date | |
|---|---|---|---|
| e65a516350 | |||
| 9887903f49 | |||
| 898d217451 | |||
| e08b4ec22e | |||
| 13523fea2b | |||
| 8a68a1e30a | |||
| 32bf2cbed5 | |||
| 17706ae019 | |||
| 82b22fa739 | |||
| 0da4f0d6cb | |||
| ea7642f0bb | |||
| 0fb3da20b9 | |||
| e460dbd34e | |||
| 0de2fd942f | |||
| 2603ca8e5c | |||
| 99a294791e | |||
| f62200ecd1 | |||
| b52926f7d8 | |||
| d338e1c35e | |||
| 9589680a81 | |||
| 27f1357fc0 | |||
| 9509a9705e |
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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
1505
package-lock.json
generated
1505
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,8 @@
|
|||||||
"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",
|
||||||
@@ -83,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",
|
||||||
@@ -94,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",
|
||||||
@@ -117,6 +121,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}>
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
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 { 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 { resolveAttachmentBoxSize } from '../../state/mediaDimensionCache';
|
||||||
@@ -78,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 />;
|
||||||
@@ -89,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,
|
||||||
@@ -114,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,
|
||||||
@@ -121,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;
|
||||||
|
|
||||||
@@ -131,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)}
|
||||||
</>
|
</>
|
||||||
@@ -156,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 />;
|
||||||
@@ -167,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,
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ export const Reaction = style([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const ReactionText = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
minWidth: 0,
|
||||||
|
maxWidth: toRem(150),
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
lineHeight: toRem(20),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
export const ReactionStack = style([
|
export const ReactionStack = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
@@ -66,7 +77,7 @@ export const ReactionStack = style([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ReactionText = style([
|
export const ReactionSticker = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
@@ -77,11 +88,6 @@ export const ReactionText = style([
|
|||||||
lineHeight: toRem(20),
|
lineHeight: toRem(20),
|
||||||
gridArea: '1 / 1',
|
gridArea: '1 / 1',
|
||||||
transformOrigin: 'center center',
|
transformOrigin: 'center center',
|
||||||
// Fan each stacked copy so count>1 reads as multiple stickers
|
|
||||||
transform: `translate(
|
|
||||||
calc(var(--sticker-i, 0) * 7px - 2px),
|
|
||||||
calc(var(--sticker-i, 0) * -5px)
|
|
||||||
) rotate(calc((var(--sticker-i, 0) - 1) * 12deg))`,
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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;
|
const MAX_STICKER_STACK = 4;
|
||||||
|
|
||||||
@@ -18,13 +19,48 @@ export const Reaction = as<
|
|||||||
useAuthentication?: boolean;
|
useAuthentication?: boolean;
|
||||||
}
|
}
|
||||||
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => {
|
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => {
|
||||||
const stackCount = Math.min(Math.max(count, 1), MAX_STICKER_STACK);
|
const theme = useTheme();
|
||||||
const showCount = count > 2;
|
const stationery = isStationeryTheme(theme);
|
||||||
const isCustomEmoji = reaction.startsWith('mxc://');
|
const isCustomEmoji = reaction.startsWith('mxc://');
|
||||||
const customSrc = isCustomEmoji
|
const customSrc = isCustomEmoji
|
||||||
? mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
|
? mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const emoji = isCustomEmoji ? (
|
||||||
|
<img className={css.ReactionImg} src={customSrc} alt={reaction} />
|
||||||
|
) : (
|
||||||
|
<Text as="span" size="Inherit" truncate>
|
||||||
|
{reaction}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stationery: fanned sticker stack. Everywhere else: compact emoji + count.
|
||||||
|
if (!stationery) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
as="button"
|
as="button"
|
||||||
@@ -40,12 +76,11 @@ export const Reaction = as<
|
|||||||
>
|
>
|
||||||
<span className={css.ReactionStack} data-reaction-stack-layer="">
|
<span className={css.ReactionStack} data-reaction-stack-layer="">
|
||||||
{Array.from({ length: stackCount }, (_, i) => {
|
{Array.from({ length: stackCount }, (_, i) => {
|
||||||
// Draw back-to-front so the top sticker is the last layer
|
|
||||||
const layer = stackCount - 1 - i;
|
const layer = stackCount - 1 - i;
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
key={layer}
|
key={layer}
|
||||||
className={css.ReactionText}
|
className={css.ReactionSticker}
|
||||||
as="span"
|
as="span"
|
||||||
size="T400"
|
size="T400"
|
||||||
data-reaction-sticker=""
|
data-reaction-sticker=""
|
||||||
|
|||||||
@@ -117,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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||||
import { Badge, Box, Button, Chip, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
|
import { Badge, Box, Button, Chip, Overlay, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
|
||||||
import { Icon, Icons } from '../../icons';
|
import { Icon, Icons } from '../../icons';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Blurhash } from 'react-blurhash';
|
import { Blurhash } from 'react-blurhash';
|
||||||
@@ -14,7 +14,6 @@ import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
|
|||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
|
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { ModalWide } from '../../../styles/Modal.css';
|
|
||||||
import { validBlurHash } from '../../../utils/blurHash';
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||||
import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
|
import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
|
||||||
@@ -192,28 +191,21 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
<div className={css.MediaSkeleton} />
|
<div className={css.MediaSkeleton} />
|
||||||
))}
|
))}
|
||||||
{srcState.status === AsyncStatus.Success && (
|
{srcState.status === AsyncStatus.Success && (
|
||||||
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
<Overlay open={viewer} backdrop={null}>
|
||||||
<OverlayCenter>
|
<FocusTrap
|
||||||
<FocusTrap
|
focusTrapOptions={{
|
||||||
focusTrapOptions={{
|
initialFocus: false,
|
||||||
initialFocus: false,
|
onDeactivate: () => setViewer(false),
|
||||||
onDeactivate: () => setViewer(false),
|
clickOutsideDeactivates: true,
|
||||||
clickOutsideDeactivates: true,
|
escapeDeactivates: stopPropagation,
|
||||||
escapeDeactivates: stopPropagation,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{renderViewer({
|
||||||
<Modal
|
src: srcState.data,
|
||||||
className={ModalWide}
|
alt: body,
|
||||||
size="500"
|
requestClose: () => setViewer(false),
|
||||||
>
|
})}
|
||||||
{renderViewer({
|
</FocusTrap>
|
||||||
src: srcState.data,
|
|
||||||
alt: body,
|
|
||||||
requestClose: () => setViewer(false),
|
|
||||||
})}
|
|
||||||
</Modal>
|
|
||||||
</FocusTrap>
|
|
||||||
</OverlayCenter>
|
|
||||||
</Overlay>
|
</Overlay>
|
||||||
)}
|
)}
|
||||||
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (
|
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (
|
||||||
|
|||||||
@@ -242,6 +242,10 @@ export const MessageTextBody = recipe({
|
|||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
overflow: 'visible',
|
overflow: 'visible',
|
||||||
overflowY: 'visible',
|
overflowY: 'visible',
|
||||||
|
paddingBottom: config.space.S200,
|
||||||
|
cursor: 'pointer',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 5,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
emote: {
|
emote: {
|
||||||
@@ -262,6 +266,8 @@ globalStyle(`${jumboEmojiClass} .${htmlCss.EmoticonBase}`, {
|
|||||||
padding: 0,
|
padding: 0,
|
||||||
overflow: 'visible',
|
overflow: 'visible',
|
||||||
verticalAlign: 'middle',
|
verticalAlign: 'middle',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
globalStyle(`${jumboEmojiClass} .${htmlCss.Emoticon.classNames.base}`, {
|
globalStyle(`${jumboEmojiClass} .${htmlCss.Emoticon.classNames.base}`, {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
296
src/app/components/updates-dialog/UpdatesDialog.css.ts
Normal file
296
src/app/components/updates-dialog/UpdatesDialog.css.ts
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = '480px';
|
||||||
|
|
||||||
|
/** Dialog height cap — 85% of the viewport, minus safe areas. */
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
181
src/app/components/updates-dialog/UpdatesDialog.tsx
Normal file
181
src/app/components/updates-dialog/UpdatesDialog.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
IconButton,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Scroll,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
} 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 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter className={css.OverlayFrame}>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: onClose,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
src/app/components/updates-dialog/UpdatesDialogHost.tsx
Normal file
149
src/app/components/updates-dialog/UpdatesDialogHost.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
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(() => {
|
||||||
|
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);
|
||||||
|
}, [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 = Boolean(lastSeen && 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastSeen || lastSeen !== currentVersion) {
|
||||||
|
setLastSeenAppVersion(currentVersion);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
125
src/app/data/updateNotes.ts
Normal file
125
src/app/data/updateNotes.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { trimTrailingSlash } from '../utils/common';
|
||||||
|
|
||||||
|
const UPDATE_BASE_PATH = `${trimTrailingSlash(import.meta.env.BASE_URL)}/update`;
|
||||||
|
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveUpdateAssetUrl(src: string | undefined): string | undefined {
|
||||||
|
if (!src) return undefined;
|
||||||
|
if (/^(https?:|data:|blob:)/i.test(src)) return src;
|
||||||
|
if (src.startsWith('/')) return src;
|
||||||
|
|
||||||
|
const normalized = src.replace(/^\.\//, '');
|
||||||
|
return `${UPDATE_BASE_PATH}/${normalized}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadUpdateManifest(): Promise<UpdateManifest | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${UPDATE_BASE_PATH}/manifest.json`, { cache: 'no-cache' });
|
||||||
|
if (!response.ok) return null;
|
||||||
|
|
||||||
|
const data = (await response.json()) as UpdateManifest;
|
||||||
|
if (!data?.current || !Array.isArray(data.older)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
current: data.current,
|
||||||
|
older: data.older.filter((entry) => entry?.file),
|
||||||
|
};
|
||||||
|
} 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(`${UPDATE_BASE_PATH}/${safeFile}`, { cache: 'no-cache' });
|
||||||
|
if (!response.ok) return null;
|
||||||
|
|
||||||
|
const source = await response.text();
|
||||||
|
return parseUpdateMarkdown(safeFile, source);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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' }}
|
||||||
|
|||||||
@@ -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://')
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ export const RoomInputWrap = style({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
});
|
});
|
||||||
|
|
||||||
globalStyle(`${RoomInputWrap} .${editorCss.Editor}`, {
|
/* Notebook-flat composer chrome — Stationery only (other themes keep Editor inset border) */
|
||||||
|
globalStyle(`.stationery ${RoomInputWrap} .${editorCss.Editor}`, {
|
||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
boxShadow: 'none',
|
boxShadow: 'none',
|
||||||
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
|||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
import {
|
import {
|
||||||
TUploadContent,
|
TUploadContent,
|
||||||
encryptFile,
|
|
||||||
getImageInfo,
|
getImageInfo,
|
||||||
getMxIdLocalPart,
|
getMxIdLocalPart,
|
||||||
mxcUrlToHttp,
|
mxcUrlToHttp,
|
||||||
@@ -54,6 +53,7 @@ import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
|
|||||||
import { useFilePicker } from '../../hooks/useFilePicker';
|
import { useFilePicker } from '../../hooks/useFilePicker';
|
||||||
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
|
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
|
||||||
import { useFileDropZone } from '../../hooks/useFileDrop';
|
import { useFileDropZone } from '../../hooks/useFileDrop';
|
||||||
|
import { useRoomUploadFiles } from '../../hooks/useRoomUploadFiles';
|
||||||
import {
|
import {
|
||||||
TUploadItem,
|
TUploadItem,
|
||||||
TUploadMetadata,
|
TUploadMetadata,
|
||||||
@@ -76,7 +76,6 @@ import {
|
|||||||
createUploadFamilyObserverAtom,
|
createUploadFamilyObserverAtom,
|
||||||
} from '../../state/upload';
|
} from '../../state/upload';
|
||||||
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
|
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
|
||||||
import { safeFile } from '../../utils/mimeTypes';
|
|
||||||
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
@@ -101,7 +100,7 @@ import colorMXID from '../../../util/colorMXID';
|
|||||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||||
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { useTheme } from '../../hooks/useTheme';
|
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||||
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||||
import { useComposingCheck } from '../../hooks/useComposingCheck';
|
import { useComposingCheck } from '../../hooks/useComposingCheck';
|
||||||
@@ -184,7 +183,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Get custom user color from avatar metadata
|
// Get custom user color from avatar metadata
|
||||||
const replyCustomColor = useOtherUserColor(replyUserID ?? '', replyAvatarMxc);
|
const replyCustomColor = useOtherUserColor(replyUserID ?? '', room);
|
||||||
|
|
||||||
const replyPowerTag = replyUserID ? getMemberPowerTag(replyUserID) : undefined;
|
const replyPowerTag = replyUserID ? getMemberPowerTag(replyUserID) : undefined;
|
||||||
const replyPowerColor = replyPowerTag?.color
|
const replyPowerColor = replyPowerTag?.color
|
||||||
@@ -224,42 +223,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
});
|
});
|
||||||
}, [commands]);
|
}, [commands]);
|
||||||
|
|
||||||
|
const { handleFiles: enqueueFiles } = useRoomUploadFiles(room);
|
||||||
const handleFiles = useCallback(
|
const handleFiles = useCallback(
|
||||||
async (files: File[]) => {
|
async (files: File[]) => {
|
||||||
setUploadBoard(true);
|
setUploadBoard(true);
|
||||||
const safeFiles = files.map(safeFile);
|
await enqueueFiles(files);
|
||||||
const fileItems: TUploadItem[] = [];
|
|
||||||
|
|
||||||
if (room.hasEncryptionStateEvent()) {
|
|
||||||
const encryptFiles = fulfilledPromiseSettledResult(
|
|
||||||
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
|
|
||||||
);
|
|
||||||
encryptFiles.forEach((ef) =>
|
|
||||||
fileItems.push({
|
|
||||||
...ef,
|
|
||||||
metadata: {
|
|
||||||
markedAsSpoiler: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
safeFiles.forEach((f) =>
|
|
||||||
fileItems.push({
|
|
||||||
file: f,
|
|
||||||
originalFile: f,
|
|
||||||
encInfo: undefined,
|
|
||||||
metadata: {
|
|
||||||
markedAsSpoiler: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setSelectedFiles({
|
|
||||||
type: 'PUT',
|
|
||||||
item: fileItems,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[setSelectedFiles, room]
|
[enqueueFiles]
|
||||||
);
|
);
|
||||||
const pickFile = useFilePicker(handleFiles, true);
|
const pickFile = useFilePicker(handleFiles, true);
|
||||||
const handlePaste = useFilePasteHandler(handleFiles);
|
const handlePaste = useFilePasteHandler(handleFiles);
|
||||||
@@ -724,7 +694,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
backgroundColor: color.SurfaceVariant.Container,
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
borderBottom: 'none',
|
borderBottom: 'none',
|
||||||
borderRadius: 0,
|
borderRadius: isStationeryTheme(theme) ? 0 : undefined,
|
||||||
|
borderTopLeftRadius: isStationeryTheme(theme) ? undefined : config.radii.R400,
|
||||||
|
borderTopRightRadius: isStationeryTheme(theme) ? undefined : config.radii.R400,
|
||||||
marginBottom: config.space.S100,
|
marginBottom: config.space.S100,
|
||||||
}}
|
}}
|
||||||
direction="Column"
|
direction="Column"
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export const TimelineFloat = recipe({
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
zIndex: 1,
|
// Above timeline media carousels / sticky message chrome
|
||||||
|
zIndex: 10,
|
||||||
minWidth: 'max-content',
|
minWidth: 'max-content',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ import {
|
|||||||
useIntersectionObserver,
|
useIntersectionObserver,
|
||||||
} from '../../hooks/useIntersectionObserver';
|
} from '../../hooks/useIntersectionObserver';
|
||||||
import { useMarkAsRead } from '../../hooks/useMarkAsRead';
|
import { useMarkAsRead } from '../../hooks/useMarkAsRead';
|
||||||
import { useDebounce } from '../../hooks/useDebounce';
|
|
||||||
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
||||||
import * as css from './RoomTimeline.css';
|
import * as css from './RoomTimeline.css';
|
||||||
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
||||||
@@ -104,9 +103,12 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
|
|||||||
import { useRoomUnread } from '../../state/hooks/unread';
|
import { useRoomUnread } from '../../state/hooks/unread';
|
||||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||||
import {
|
import {
|
||||||
|
clearRoomReturnAnchor,
|
||||||
clearRoomScrollState,
|
clearRoomScrollState,
|
||||||
|
getRoomReturnAnchor,
|
||||||
getRoomScrollState,
|
getRoomScrollState,
|
||||||
saveRoomScrollState,
|
saveRoomScrollState,
|
||||||
|
setRoomReturnAnchor,
|
||||||
} from '../../state/roomScrollCache';
|
} from '../../state/roomScrollCache';
|
||||||
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
|
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
|
||||||
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
|
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
|
||||||
@@ -135,6 +137,8 @@ import {
|
|||||||
PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME,
|
PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME,
|
||||||
PAARROT_CAROUSEL_UUID_PROPERTY_NAME,
|
PAARROT_CAROUSEL_UUID_PROPERTY_NAME,
|
||||||
} from '../../../types/matrix/common';
|
} from '../../../types/matrix/common';
|
||||||
|
import { EmojiConfettiOverlay } from './emoji-confetti/EmojiConfettiOverlay';
|
||||||
|
import { useJumboEmojiConfetti } from './emoji-confetti/useJumboEmojiConfetti';
|
||||||
|
|
||||||
/** Information about a call member event for grouping */
|
/** Information about a call member event for grouping */
|
||||||
interface CallEventInfo {
|
interface CallEventInfo {
|
||||||
@@ -580,6 +584,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
);
|
);
|
||||||
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
||||||
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
|
const { bursts: emojiConfettiBursts, removeBurst: removeEmojiConfettiBurst, handleJumboEmojiClick } =
|
||||||
|
useJumboEmojiConfetti(room);
|
||||||
|
|
||||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
@@ -650,6 +656,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
sender: string;
|
sender: string;
|
||||||
content: string;
|
content: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [returnToEventId, setReturnToEventIdState] = useState<string | null>(
|
||||||
|
() => getRoomReturnAnchor(room.roomId) ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
const setReturnToEventId = useCallback(
|
||||||
|
(eventId: string | null) => {
|
||||||
|
if (eventId) setRoomReturnAnchor(room.roomId, eventId);
|
||||||
|
else clearRoomReturnAnchor(room.roomId);
|
||||||
|
setReturnToEventIdState(eventId);
|
||||||
|
},
|
||||||
|
[room.roomId]
|
||||||
|
);
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const timelineContentRef = useRef<HTMLDivElement>(null);
|
const timelineContentRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -661,6 +679,31 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getViewportAnchorEventId = useCallback((): string | undefined => {
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
if (!scrollEl) return undefined;
|
||||||
|
|
||||||
|
const scrollRect = scrollEl.getBoundingClientRect();
|
||||||
|
const centerY = scrollRect.top + scrollRect.height / 2;
|
||||||
|
const items = scrollEl.querySelectorAll<HTMLElement>('[data-message-id]');
|
||||||
|
|
||||||
|
let bestId: string | undefined;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
|
||||||
|
items.forEach((el) => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (rect.bottom < scrollRect.top || rect.top > scrollRect.bottom) return;
|
||||||
|
const mid = (rect.top + rect.bottom) / 2;
|
||||||
|
const dist = Math.abs(mid - centerY);
|
||||||
|
if (dist < bestDist) {
|
||||||
|
bestDist = dist;
|
||||||
|
bestId = el.getAttribute('data-message-id') ?? undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return bestId;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [focusItem, setFocusItem] = useState<
|
const [focusItem, setFocusItem] = useState<
|
||||||
| {
|
| {
|
||||||
index: number;
|
index: number;
|
||||||
@@ -709,6 +752,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const timelineRef = useRef(timeline);
|
const timelineRef = useRef(timeline);
|
||||||
timelineRef.current = timeline;
|
timelineRef.current = timeline;
|
||||||
|
|
||||||
|
const getRangeCenterEventId = useCallback((): string | undefined => {
|
||||||
|
const { linkedTimelines, range } = timelineRef.current;
|
||||||
|
if (range.end <= range.start) return undefined;
|
||||||
|
const mid = Math.floor((range.start + range.end - 1) / 2);
|
||||||
|
const [tm, baseIndex] = getTimelineAndBaseIndex(linkedTimelines, mid);
|
||||||
|
if (!tm) return undefined;
|
||||||
|
return getTimelineEvent(tm, getTimelineRelativeIndex(mid, baseIndex))?.getId();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const persistTimelineScroll = useCallback(
|
const persistTimelineScroll = useCallback(
|
||||||
(roomId: string) => {
|
(roomId: string) => {
|
||||||
if (eventId) return;
|
if (eventId) return;
|
||||||
@@ -736,6 +788,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const atLiveEndRef = useRef(liveTimelineLinked && rangeAtEnd);
|
const atLiveEndRef = useRef(liveTimelineLinked && rangeAtEnd);
|
||||||
atLiveEndRef.current = liveTimelineLinked && rangeAtEnd;
|
atLiveEndRef.current = liveTimelineLinked && rangeAtEnd;
|
||||||
|
|
||||||
|
// Historical / non-live windows are never "at bottom" of the room.
|
||||||
|
// When we return to the live end, re-check scroll — IntersectionObserver may
|
||||||
|
// not re-fire if the anchor stayed intersecting across the transition.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!liveTimelineLinked || !rangeAtEnd) {
|
||||||
|
setAtBottom(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
if (scrollEl && isScrolledToBottom(scrollEl, 100)) {
|
||||||
|
setAtBottom(true);
|
||||||
|
}
|
||||||
|
}, [liveTimelineLinked, rangeAtEnd]);
|
||||||
|
|
||||||
const handleTimelinePagination = useTimelinePagination(
|
const handleTimelinePagination = useTimelinePagination(
|
||||||
mx,
|
mx,
|
||||||
timeline,
|
timeline,
|
||||||
@@ -800,6 +866,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const isOwnMessage = mEvt.getSender() === mx.getUserId();
|
const isOwnMessage = mEvt.getSender() === mx.getUserId();
|
||||||
const isBottomPinnedEvent =
|
const isBottomPinnedEvent =
|
||||||
mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker;
|
mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker;
|
||||||
|
const wasAwayFromBottom = !atBottomRef.current;
|
||||||
const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent);
|
const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent);
|
||||||
|
|
||||||
// Always update timeline with new message
|
// Always update timeline with new message
|
||||||
@@ -820,6 +887,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
requestAnimationFrame(() => markAsRead(roomId, hideActivity));
|
requestAnimationFrame(() => markAsRead(roomId, hideActivity));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Sending while scrolled up jumps to latest — offer return to previous spot
|
||||||
|
if (wasAwayFromBottom && isBottomPinnedEvent) {
|
||||||
|
const anchorEventId =
|
||||||
|
eventId ?? getViewportAnchorEventId() ?? getRangeCenterEventId();
|
||||||
|
if (anchorEventId) {
|
||||||
|
setReturnToEventId(anchorEventId);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (!document.hasFocus() && !unreadInfo) {
|
} else if (!document.hasFocus() && !unreadInfo) {
|
||||||
// Show unread notification for other users' messages when unfocused
|
// Show unread notification for other users' messages when unfocused
|
||||||
setUnreadInfo(getRoomUnreadInfo(room));
|
setUnreadInfo(getRoomUnreadInfo(room));
|
||||||
@@ -846,7 +921,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mx, room, unreadInfo, hideActivity, markAsRead]
|
[mx, room, unreadInfo, hideActivity, markAsRead, eventId, getViewportAnchorEventId, getRangeCenterEventId, setReturnToEventId]
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -961,30 +1036,32 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
}
|
}
|
||||||
}, [room, hideActivity, markAsRead]);
|
}, [room, hideActivity, markAsRead]);
|
||||||
|
|
||||||
const debounceSetAtBottom = useDebounce(
|
|
||||||
useCallback((entry: IntersectionObserverEntry) => {
|
|
||||||
if (!entry.isIntersecting) {
|
|
||||||
setAtBottom(false);
|
|
||||||
}
|
|
||||||
}, []),
|
|
||||||
{ wait: 1000 }
|
|
||||||
);
|
|
||||||
useIntersectionObserver(
|
useIntersectionObserver(
|
||||||
useCallback(
|
useCallback(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
const target = atBottomAnchorRef.current;
|
const target = atBottomAnchorRef.current;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
const targetEntry = getIntersectionObserverEntry(target, entries);
|
const targetEntry = getIntersectionObserverEntry(target, entries);
|
||||||
if (targetEntry) debounceSetAtBottom(targetEntry);
|
if (!targetEntry) return;
|
||||||
if (targetEntry?.isIntersecting && atLiveEndRef.current) {
|
|
||||||
setAtBottom(true);
|
// Leave the live bottom immediately so Jump to Latest stays available.
|
||||||
setLatestUnreadMessage(null);
|
// Only intersection clears atBottom here; !atLiveEnd is handled by the
|
||||||
if (document.hasFocus()) {
|
// liveTimelineLinked/rangeAtEnd effect so we don't stick false when the
|
||||||
tryAutoMarkAsRead();
|
// live end reconnects without a new intersection event.
|
||||||
}
|
if (!targetEntry.isIntersecting) {
|
||||||
|
setAtBottom(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!atLiveEndRef.current) return;
|
||||||
|
|
||||||
|
setAtBottom(true);
|
||||||
|
setLatestUnreadMessage(null);
|
||||||
|
if (document.hasFocus()) {
|
||||||
|
tryAutoMarkAsRead();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[debounceSetAtBottom, tryAutoMarkAsRead]
|
[tryAutoMarkAsRead]
|
||||||
),
|
),
|
||||||
useCallback(
|
useCallback(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -1048,6 +1125,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
}
|
}
|
||||||
}, [eventId, loadEventTimeline]);
|
}, [eventId, loadEventTimeline]);
|
||||||
|
|
||||||
|
// Restore return-to-previous when switching rooms (survives remount via cache).
|
||||||
|
useEffect(() => {
|
||||||
|
setReturnToEventIdState(getRoomReturnAnchor(room.roomId) ?? null);
|
||||||
|
}, [room.roomId]);
|
||||||
|
|
||||||
// Scroll to latest when clicking on already-selected room
|
// Scroll to latest when clicking on already-selected room
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollToLatest) {
|
if (scrollToLatest) {
|
||||||
@@ -1079,7 +1161,16 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
if (restoringScrollRef.current) return;
|
if (restoringScrollRef.current) return;
|
||||||
window.cancelAnimationFrame(raf);
|
window.cancelAnimationFrame(raf);
|
||||||
raf = window.requestAnimationFrame(() => persistTimelineScroll(roomId));
|
raf = window.requestAnimationFrame(() => {
|
||||||
|
persistTimelineScroll(roomId);
|
||||||
|
// Keep Jump to Latest in sync with real scroll position when on the live end.
|
||||||
|
// IntersectionObserver alone can miss transitions and leave atBottom stuck.
|
||||||
|
if (atLiveEndRef.current) {
|
||||||
|
const at = isScrolledToBottom(scrollEl, 100);
|
||||||
|
setAtBottom(at);
|
||||||
|
if (at) setLatestUnreadMessage(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
scrollEl.addEventListener('scroll', onScroll, { passive: true });
|
scrollEl.addEventListener('scroll', onScroll, { passive: true });
|
||||||
@@ -1184,6 +1275,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
scrollToBottomRef.current.smooth,
|
scrollToBottomRef.current.smooth,
|
||||||
scrollToBottomRef.current.force
|
scrollToBottomRef.current.force
|
||||||
);
|
);
|
||||||
|
if (atLiveEndRef.current) {
|
||||||
|
setAtBottom(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [scrollToBottomCount]);
|
}, [scrollToBottomCount]);
|
||||||
@@ -1234,17 +1328,51 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
}, [room, hour24Clock]);
|
}, [room, hour24Clock]);
|
||||||
|
|
||||||
const handleJumpToLatest = () => {
|
const handleJumpToLatest = () => {
|
||||||
|
const anchorEventId =
|
||||||
|
eventId ?? getViewportAnchorEventId() ?? getRangeCenterEventId();
|
||||||
|
if (anchorEventId) {
|
||||||
|
setReturnToEventId(anchorEventId);
|
||||||
|
}
|
||||||
|
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
navigateRoom(room.roomId, undefined, { replace: true });
|
navigateRoom(room.roomId, undefined, { replace: true });
|
||||||
}
|
}
|
||||||
clearRoomScrollState(room.roomId);
|
clearRoomScrollState(room.roomId);
|
||||||
setLatestUnreadMessage(null);
|
setLatestUnreadMessage(null);
|
||||||
setTimeline(getInitialTimeline(room));
|
|
||||||
|
// Already on the live timeline: only snap the virtual window to the end.
|
||||||
|
// A full getInitialTimeline() rebuild feels like a chat reload when far back.
|
||||||
|
if (!eventId && liveTimelineLinked) {
|
||||||
|
setTimeline((ct) => {
|
||||||
|
const evLength = getTimelinesEventsCount(ct.linkedTimelines);
|
||||||
|
return {
|
||||||
|
linkedTimelines: ct.linkedTimelines,
|
||||||
|
range: {
|
||||||
|
start: Math.max(evLength - PAGINATION_LIMIT, 0),
|
||||||
|
end: evLength,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setTimeline(getInitialTimeline(room));
|
||||||
|
}
|
||||||
|
|
||||||
scrollToBottomRef.current.count += 1;
|
scrollToBottomRef.current.count += 1;
|
||||||
scrollToBottomRef.current.smooth = false;
|
scrollToBottomRef.current.smooth = false;
|
||||||
scrollToBottomRef.current.force = true;
|
scrollToBottomRef.current.force = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReturnToPrevious = () => {
|
||||||
|
if (!returnToEventId) return;
|
||||||
|
const targetId = returnToEventId;
|
||||||
|
setReturnToEventId(null);
|
||||||
|
handleOpenEvent(targetId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearReturnToPrevious = () => {
|
||||||
|
setReturnToEventId(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleJumpToUnread = () => {
|
const handleJumpToUnread = () => {
|
||||||
if (unreadInfo?.readUptoEventId) {
|
if (unreadInfo?.readUptoEventId) {
|
||||||
setTimeline(getEmptyTimeline());
|
setTimeline(getEmptyTimeline());
|
||||||
@@ -1473,6 +1601,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
linkifyOpts={linkifyOpts}
|
linkifyOpts={linkifyOpts}
|
||||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||||
disabledEmbedPatterns={combinedEmbedFilters}
|
disabledEmbedPatterns={combinedEmbedFilters}
|
||||||
|
targetEventId={mEventId}
|
||||||
|
onJumboEmojiClick={handleJumboEmojiClick}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Message>
|
</Message>
|
||||||
@@ -1581,6 +1711,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
linkifyOpts={linkifyOpts}
|
linkifyOpts={linkifyOpts}
|
||||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||||
disabledEmbedPatterns={combinedEmbedFilters}
|
disabledEmbedPatterns={combinedEmbedFilters}
|
||||||
|
targetEventId={mEventId}
|
||||||
|
onJumboEmojiClick={handleJumboEmojiClick}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1878,6 +2010,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
// Return special marker for call event (will be handled by eventRenderer)
|
// Return special marker for call event (will be handled by eventRenderer)
|
||||||
return 'CALL_EVENT_PENDING' as unknown as React.ReactNode;
|
return 'CALL_EVENT_PENDING' as unknown as React.ReactNode;
|
||||||
},
|
},
|
||||||
|
[MessageEvent.RelayEmojiConfetti]: () => null,
|
||||||
|
[MessageEvent.PaarrotUiAction]: () => null,
|
||||||
},
|
},
|
||||||
(mEventId, mEvent, item) => {
|
(mEventId, mEvent, item) => {
|
||||||
if (!showHiddenEvents) return null;
|
if (!showHiddenEvents) return null;
|
||||||
@@ -2391,31 +2525,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
return callSummaryJSX || eventJSX;
|
return callSummaryJSX || eventJSX;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showUnreadTop =
|
||||||
|
!!unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline;
|
||||||
|
const atLiveBottom = atBottom && liveTimelineLinked && rangeAtEnd;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" style={{ position: 'relative' }}>
|
<Box grow="Yes" style={{ position: 'relative' }}>
|
||||||
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
|
|
||||||
<TimelineFloat position="Top">
|
|
||||||
<Chip
|
|
||||||
variant="Primary"
|
|
||||||
radii="Pill"
|
|
||||||
outlined
|
|
||||||
before={<Icon size="50" src={Icons.MessageUnread} />}
|
|
||||||
onClick={handleJumpToUnread}
|
|
||||||
>
|
|
||||||
<Text size="L400">Jump to Unread</Text>
|
|
||||||
</Chip>
|
|
||||||
|
|
||||||
<Chip
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
radii="Pill"
|
|
||||||
outlined
|
|
||||||
before={<Icon size="50" src={Icons.CheckTwice} />}
|
|
||||||
onClick={handleMarkAsRead}
|
|
||||||
>
|
|
||||||
<Text size="L400">Mark as Read</Text>
|
|
||||||
</Chip>
|
|
||||||
</TimelineFloat>
|
|
||||||
)}
|
|
||||||
<Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll="">
|
<Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll="">
|
||||||
<Box
|
<Box
|
||||||
ref={timelineContentRef}
|
ref={timelineContentRef}
|
||||||
@@ -2507,10 +2622,65 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
</MessageBase>
|
</MessageBase>
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
<span ref={atBottomAnchorRef} />
|
{/* 1×1 so IntersectionObserver reliably detects the live bottom */}
|
||||||
|
<span
|
||||||
|
ref={atBottomAnchorRef}
|
||||||
|
aria-hidden
|
||||||
|
style={{ display: 'block', width: 1, height: 1 }}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
{!atBottom && (
|
{(showUnreadTop || returnToEventId) && (
|
||||||
|
<TimelineFloat position="Top">
|
||||||
|
{returnToEventId && (
|
||||||
|
<>
|
||||||
|
<Chip
|
||||||
|
variant="Primary"
|
||||||
|
radii="Pill"
|
||||||
|
outlined
|
||||||
|
before={<Icon size="50" src={Icons.ArrowTop} />}
|
||||||
|
onClick={handleReturnToPrevious}
|
||||||
|
>
|
||||||
|
<Text size="L400">Return to Previous</Text>
|
||||||
|
</Chip>
|
||||||
|
<IconButton
|
||||||
|
title="Dismiss"
|
||||||
|
aria-label="Dismiss return to previous"
|
||||||
|
size="300"
|
||||||
|
radii="Pill"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
onClick={handleClearReturnToPrevious}
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{showUnreadTop && (
|
||||||
|
<>
|
||||||
|
<Chip
|
||||||
|
variant="Primary"
|
||||||
|
radii="Pill"
|
||||||
|
outlined
|
||||||
|
before={<Icon size="50" src={Icons.MessageUnread} />}
|
||||||
|
onClick={handleJumpToUnread}
|
||||||
|
>
|
||||||
|
<Text size="L400">Jump to Unread</Text>
|
||||||
|
</Chip>
|
||||||
|
|
||||||
|
<Chip
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="Pill"
|
||||||
|
outlined
|
||||||
|
before={<Icon size="50" src={Icons.CheckTwice} />}
|
||||||
|
onClick={handleMarkAsRead}
|
||||||
|
>
|
||||||
|
<Text size="L400">Mark as Read</Text>
|
||||||
|
</Chip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TimelineFloat>
|
||||||
|
)}
|
||||||
|
{!atLiveBottom && (
|
||||||
<TimelineFloat position="Bottom">
|
<TimelineFloat position="Bottom">
|
||||||
<Box direction="Column" gap="200" alignItems="Center">
|
<Box direction="Column" gap="200" alignItems="Center">
|
||||||
{latestUnreadMessage && (
|
{latestUnreadMessage && (
|
||||||
@@ -2544,6 +2714,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
</Box>
|
</Box>
|
||||||
</TimelineFloat>
|
</TimelineFloat>
|
||||||
)}
|
)}
|
||||||
|
<EmojiConfettiOverlay bursts={emojiConfettiBursts} onBurstComplete={removeEmojiConfettiBurst} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,18 +7,16 @@ import { JoinRule, Room } from 'matrix-js-sdk';
|
|||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
||||||
|
|
||||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
|
||||||
import { PageHeader } from '../../components/page';
|
import { PageHeader } from '../../components/page';
|
||||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
|
import { getHomeSearchPath, getSpaceSearchPath, getDirectSearchPath, withSearchParam } from '../../pages/pathUtils';
|
||||||
import { getCanonicalAliasOrRoomId, guessDmRoomUserId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
|
import { getCanonicalAliasOrRoomId, guessDmRoomUserId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
import { useOtherUserColor } from '../../hooks/useUserColor';
|
import { useOtherUserColor } from '../../hooks/useUserColor';
|
||||||
@@ -43,6 +41,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|||||||
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
|
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
|
||||||
import { RoomPinMenu } from './room-pin-menu';
|
import { RoomPinMenu } from './room-pin-menu';
|
||||||
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
||||||
|
import { isMediaDrawerAtom } from '../../state/mediaDrawer';
|
||||||
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
||||||
import {
|
import {
|
||||||
getRoomNotificationMode,
|
getRoomNotificationMode,
|
||||||
@@ -58,6 +57,8 @@ import { useRoomCall, useRoomCallMembers } from '../call';
|
|||||||
import { CallState, CallType } from '../call/types';
|
import { CallState, CallType } from '../call/types';
|
||||||
import { UserAvatar } from '../../components/user-avatar';
|
import { UserAvatar } from '../../components/user-avatar';
|
||||||
import { getMemberDisplayName, getMemberAvatarMxc } from '../../utils/room';
|
import { getMemberDisplayName, getMemberAvatarMxc } from '../../utils/room';
|
||||||
|
import { useDirectSelected } from '../../hooks/router/useDirectSelected';
|
||||||
|
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||||
|
|
||||||
type RoomMenuProps = {
|
type RoomMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -471,10 +472,11 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||||
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
const onDirectPath = useDirectSelected();
|
||||||
|
const theme = useTheme();
|
||||||
|
const stationery = isStationeryTheme(theme);
|
||||||
|
|
||||||
const pinnedEvents = useRoomPinnedEvents(room);
|
const pinnedEvents = useRoomPinnedEvents(room);
|
||||||
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
|
|
||||||
const ecryptedRoom = !!encryptionEvent;
|
|
||||||
const isDirect = mDirects.has(room.roomId);
|
const isDirect = mDirects.has(room.roomId);
|
||||||
const avatarMxc = useRoomAvatar(room, isDirect);
|
const avatarMxc = useRoomAvatar(room, isDirect);
|
||||||
const name = useRoomName(room);
|
const name = useRoomName(room);
|
||||||
@@ -487,12 +489,14 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
const dmAvatarMxc =
|
const dmAvatarMxc =
|
||||||
(dmUserId ? room.getMember(dmUserId)?.getMxcAvatarUrl() : undefined) ||
|
(dmUserId ? room.getMember(dmUserId)?.getMxcAvatarUrl() : undefined) ||
|
||||||
(isDirect ? avatarMxc : undefined);
|
(isDirect ? avatarMxc : undefined);
|
||||||
const dmCustomColor = useOtherUserColor(dmUserId ?? '', dmAvatarMxc);
|
const dmCustomColor = useOtherUserColor(dmUserId ?? '');
|
||||||
const dmFolderTabColor = isDirect
|
const dmFolderTabColor =
|
||||||
? `color-mix(in srgb, ${dmCustomColor ?? colorMXID(dmUserId ?? room.roomId)} 50%, var(--folder-tab-room, #f3e6c8))`
|
stationery && isDirect
|
||||||
: undefined;
|
? `color-mix(in srgb, ${dmCustomColor ?? colorMXID(dmUserId ?? room.roomId)} 50%, var(--folder-tab-room, #f3e6c8))`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||||
|
const [isMediaDrawer, setMediaDrawer] = useAtom(isMediaDrawerAtom);
|
||||||
const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId));
|
const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId));
|
||||||
|
|
||||||
const handleSearchClick = () => {
|
const handleSearchClick = () => {
|
||||||
@@ -501,7 +505,9 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
};
|
};
|
||||||
const path = space
|
const path = space
|
||||||
? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))
|
? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))
|
||||||
: getHomeSearchPath();
|
: isDirect || onDirectPath
|
||||||
|
? getDirectSearchPath()
|
||||||
|
: getHomeSearchPath();
|
||||||
navigate(withSearchParam(path, searchParams));
|
navigate(withSearchParam(path, searchParams));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -513,6 +519,22 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTogglePeopleDrawer = () => {
|
||||||
|
setPeopleDrawer((drawer) => {
|
||||||
|
const next = !drawer;
|
||||||
|
if (next) setMediaDrawer(false);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleMediaDrawer = () => {
|
||||||
|
setMediaDrawer((open) => {
|
||||||
|
const next = !open;
|
||||||
|
if (next) setPeopleDrawer(false);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageHeader balance={showInPageHeader}>
|
<PageHeader balance={showInPageHeader}>
|
||||||
<Box grow="Yes" gap="300">
|
<Box grow="Yes" gap="300">
|
||||||
@@ -611,23 +633,21 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
<Box shrink="No" alignItems="Center" gap="100">
|
<Box shrink="No" alignItems="Center" gap="100">
|
||||||
<CallIndicator roomId={room.roomId} />
|
<CallIndicator roomId={room.roomId} />
|
||||||
<RoomCallButtons roomId={room.roomId} />
|
<RoomCallButtons roomId={room.roomId} />
|
||||||
{!ecryptedRoom && (
|
<TooltipProvider
|
||||||
<TooltipProvider
|
position="Bottom"
|
||||||
position="Bottom"
|
offset={4}
|
||||||
offset={4}
|
tooltip={
|
||||||
tooltip={
|
<Tooltip>
|
||||||
<Tooltip>
|
<Text>Search</Text>
|
||||||
<Text>Search</Text>
|
</Tooltip>
|
||||||
</Tooltip>
|
}
|
||||||
}
|
>
|
||||||
>
|
{(triggerRef) => (
|
||||||
{(triggerRef) => (
|
<IconButton ref={triggerRef} onClick={handleSearchClick}>
|
||||||
<IconButton ref={triggerRef} onClick={handleSearchClick}>
|
<Icon size="300" src={Icons.Search} />
|
||||||
<Icon size="300" src={Icons.Search} />
|
</IconButton>
|
||||||
</IconButton>
|
)}
|
||||||
)}
|
</TooltipProvider>
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
offset={4}
|
offset={4}
|
||||||
@@ -695,8 +715,12 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
|
<IconButton
|
||||||
<Icon size="300" src={Icons.User} />
|
ref={triggerRef}
|
||||||
|
onClick={handleTogglePeopleDrawer}
|
||||||
|
aria-pressed={peopleDrawer}
|
||||||
|
>
|
||||||
|
<Icon size="300" src={Icons.User} filled={peopleDrawer} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -720,6 +744,26 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
<TooltipProvider
|
||||||
|
position="Bottom"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>{isMediaDrawer ? 'Hide Shared Media' : 'Show Shared Media'}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={triggerRef}
|
||||||
|
onClick={handleToggleMediaDrawer}
|
||||||
|
aria-pressed={isMediaDrawer}
|
||||||
|
aria-label="Shared Media"
|
||||||
|
>
|
||||||
|
<Icon size="300" src={Icons.Photo} filled={isMediaDrawer} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
<PluginButtonSlot location="room-header" />
|
<PluginButtonSlot location="room-header" />
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ import * as roomViewCss from './RoomViewFollowing.css';
|
|||||||
import { Message, Reactions, EncryptedContent } from './message';
|
import { Message, Reactions, EncryptedContent } from './message';
|
||||||
import { Reply } from '../../components/message';
|
import { Reply } from '../../components/message';
|
||||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||||
|
import { EmojiConfettiOverlay } from './emoji-confetti/EmojiConfettiOverlay';
|
||||||
|
import { useJumboEmojiConfetti } from './emoji-confetti/useJumboEmojiConfetti';
|
||||||
import {
|
import {
|
||||||
LINKIFY_OPTS,
|
LINKIFY_OPTS,
|
||||||
factoryRenderLinkifyWithMention,
|
factoryRenderLinkifyWithMention,
|
||||||
@@ -156,6 +158,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
|||||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
|
const { bursts: emojiConfettiBursts, removeBurst: removeEmojiConfettiBurst, handleJumboEmojiClick } =
|
||||||
|
useJumboEmojiConfetti(room);
|
||||||
|
|
||||||
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
||||||
const direct = useIsDirectRoom();
|
const direct = useIsDirectRoom();
|
||||||
@@ -638,6 +642,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
|||||||
linkifyOpts={linkifyOpts}
|
linkifyOpts={linkifyOpts}
|
||||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||||
disabledEmbedPatterns={combinedEmbedFilters}
|
disabledEmbedPatterns={combinedEmbedFilters}
|
||||||
|
targetEventId={mEventId}
|
||||||
|
onJumboEmojiClick={handleJumboEmojiClick}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</EncryptedContent>
|
</EncryptedContent>
|
||||||
@@ -654,6 +660,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
|||||||
linkifyOpts={linkifyOpts}
|
linkifyOpts={linkifyOpts}
|
||||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||||
disabledEmbedPatterns={combinedEmbedFilters}
|
disabledEmbedPatterns={combinedEmbedFilters}
|
||||||
|
targetEventId={mEventId}
|
||||||
|
onJumboEmojiClick={handleJumboEmojiClick}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Message>
|
</Message>
|
||||||
@@ -759,6 +767,10 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
|||||||
{events.map((evt, idx, arr) => renderEvent(evt, idx, arr))}
|
{events.map((evt, idx, arr) => renderEvent(evt, idx, arr))}
|
||||||
</Box>
|
</Box>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
|
<EmojiConfettiOverlay
|
||||||
|
bursts={emojiConfettiBursts}
|
||||||
|
onBurstComplete={removeEmojiConfettiBurst}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<div className={roomViewCss.RoomViewBottomFloat}>
|
<div className={roomViewCss.RoomViewBottomFloat}>
|
||||||
<RoomViewTyping room={room} />
|
<RoomViewTyping room={room} />
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { EmojiConfettiBurst } from './types';
|
||||||
|
import { findJumboEmojiElement, getLocalBurstCanvasSize, measureJumboGlyph, setJumboEmojiHidden } from './findJumboMount';
|
||||||
|
import {
|
||||||
|
BurstParticle,
|
||||||
|
drawBurstParticles,
|
||||||
|
spawnEmojiBurst,
|
||||||
|
stepBurstParticles,
|
||||||
|
} from './emojiBurstEngine';
|
||||||
|
import { getEmojiBurstProfile, isFullscreenBurstEmoji } from './emojiParticleProfiles';
|
||||||
|
import {
|
||||||
|
createFireworkSim,
|
||||||
|
drawFireworkSim,
|
||||||
|
FireworkSim,
|
||||||
|
getFireworkDpr,
|
||||||
|
stepFireworkSim,
|
||||||
|
syncFireworkHero,
|
||||||
|
} from './fireworkParticleEngine';
|
||||||
|
|
||||||
|
const MAX_DPR = 2;
|
||||||
|
|
||||||
|
type EmojiConfettiBurstCanvasProps = {
|
||||||
|
burst: EmojiConfettiBurst;
|
||||||
|
onComplete: (burstId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBurstCanvasProps) {
|
||||||
|
const primaryEmoji = burst.emojis[0] ?? '🎉';
|
||||||
|
const fullscreen = isFullscreenBurstEmoji(primaryEmoji);
|
||||||
|
|
||||||
|
if (fullscreen) {
|
||||||
|
return <FireworkBurstCanvas burst={burst} primaryEmoji={primaryEmoji} onComplete={onComplete} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LocalBurstCanvas burst={burst} primaryEmoji={primaryEmoji} onComplete={onComplete} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BurstCanvasProps = {
|
||||||
|
burst: EmojiConfettiBurst;
|
||||||
|
primaryEmoji: string;
|
||||||
|
onComplete: (burstId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function FireworkBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const simRef = useRef<FireworkSim | null>(null);
|
||||||
|
const frameRef = useRef<number | null>(null);
|
||||||
|
const lastFrameTimeRef = useRef<number | null>(null);
|
||||||
|
const spawnedRef = useRef(false);
|
||||||
|
const onCompleteRef = useRef(onComplete);
|
||||||
|
onCompleteRef.current = onComplete;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current || spawnedRef.current) return undefined;
|
||||||
|
|
||||||
|
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
if (reducedMotion) {
|
||||||
|
onCompleteRef.current(burst.id);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const context = canvas.getContext('2d', { alpha: true });
|
||||||
|
if (!context) {
|
||||||
|
onCompleteRef.current(burst.id);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
context.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
spawnedRef.current = true;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const dpr = getFireworkDpr();
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
canvas.width = Math.round(width * dpr);
|
||||||
|
canvas.height = Math.round(height * dpr);
|
||||||
|
canvas.style.width = `${width}px`;
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
return { width, height, dpr };
|
||||||
|
};
|
||||||
|
|
||||||
|
const { width, height } = resize();
|
||||||
|
const profile = getEmojiBurstProfile(primaryEmoji);
|
||||||
|
const glyph = measureJumboGlyph(burst.targetEventId);
|
||||||
|
const pinToJumbo = Boolean(profile.morphTo && glyph);
|
||||||
|
|
||||||
|
const origin = {
|
||||||
|
x: glyph ? glyph.x : burst.origin.x,
|
||||||
|
y: glyph ? glyph.y : burst.origin.y,
|
||||||
|
maskRadius: glyph ? glyph.size * 0.5 : burst.origin.maskRadius ?? 36,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep morph heroes glued to the message emoji; only clamp undirected fireworks.
|
||||||
|
if (!pinToJumbo) {
|
||||||
|
origin.x = Math.min(width - 24, Math.max(24, origin.x));
|
||||||
|
origin.y = Math.min(height - 24, Math.max(24, origin.y));
|
||||||
|
}
|
||||||
|
|
||||||
|
simRef.current = createFireworkSim(
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
origin,
|
||||||
|
primaryEmoji,
|
||||||
|
profile,
|
||||||
|
performance.now(),
|
||||||
|
glyph?.size
|
||||||
|
);
|
||||||
|
|
||||||
|
const revealJumbo = () => {
|
||||||
|
setJumboEmojiHidden(burst.targetEventId, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncHero = () => {
|
||||||
|
const sim = simRef.current;
|
||||||
|
if (!sim) return;
|
||||||
|
if (!sim.hero) {
|
||||||
|
revealJumbo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const metrics = measureJumboGlyph(burst.targetEventId);
|
||||||
|
syncFireworkHero(sim, metrics);
|
||||||
|
// Re-query every frame so React remounts still stay hidden.
|
||||||
|
setJumboEmojiHidden(burst.targetEventId, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onResize = () => {
|
||||||
|
// Extra Things: match CSS size; sim keeps its launch-time world size.
|
||||||
|
resize();
|
||||||
|
syncHero();
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
window.addEventListener('scroll', syncHero, true);
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
revealJumbo();
|
||||||
|
onCompleteRef.current(burst.id);
|
||||||
|
frameRef.current = null;
|
||||||
|
lastFrameTimeRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hide immediately before first paint of the stand-in.
|
||||||
|
if (pinToJumbo) {
|
||||||
|
setJumboEmojiHidden(burst.targetEventId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const sim = simRef.current;
|
||||||
|
if (!sim) return;
|
||||||
|
|
||||||
|
syncHero();
|
||||||
|
|
||||||
|
const last = lastFrameTimeRef.current ?? now;
|
||||||
|
const dtSeconds = Math.min((now - last) / 1000, 0.05);
|
||||||
|
lastFrameTimeRef.current = now;
|
||||||
|
|
||||||
|
const alive = stepFireworkSim(sim, now, dtSeconds);
|
||||||
|
// Hero may have just been dismissed — reveal real jumbo immediately.
|
||||||
|
if (!sim.hero) {
|
||||||
|
revealJumbo();
|
||||||
|
}
|
||||||
|
|
||||||
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
if (alive) {
|
||||||
|
drawFireworkSim(context, sim, now);
|
||||||
|
frameRef.current = requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
finish();
|
||||||
|
};
|
||||||
|
|
||||||
|
lastFrameTimeRef.current = performance.now();
|
||||||
|
frameRef.current = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
window.removeEventListener('scroll', syncHero, true);
|
||||||
|
revealJumbo();
|
||||||
|
if (frameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(frameRef.current);
|
||||||
|
frameRef.current = null;
|
||||||
|
}
|
||||||
|
simRef.current = null;
|
||||||
|
// Extra Things: allow Strict Mode remount to start a fresh burst.
|
||||||
|
spawnedRef.current = false;
|
||||||
|
};
|
||||||
|
}, [burst.id, burst.origin.maskRadius, burst.origin.x, burst.origin.y, burst.targetEventId, primaryEmoji]);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
width: '100vw',
|
||||||
|
height: '100vh',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocalBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const layerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const particlesRef = useRef<BurstParticle[]>([]);
|
||||||
|
const frameRef = useRef<number | null>(null);
|
||||||
|
const lastFrameTimeRef = useRef<number | null>(null);
|
||||||
|
const spawnedRef = useRef(false);
|
||||||
|
const onCompleteRef = useRef(onComplete);
|
||||||
|
onCompleteRef.current = onComplete;
|
||||||
|
|
||||||
|
const canvasSize = getLocalBurstCanvasSize(burst.origin.maskRadius ?? 36);
|
||||||
|
const localOrigin = useMemo(
|
||||||
|
() => ({
|
||||||
|
x: canvasSize / 2,
|
||||||
|
y: canvasSize / 2,
|
||||||
|
maskRadius: burst.origin.maskRadius ?? 36,
|
||||||
|
}),
|
||||||
|
[burst.origin.maskRadius, canvasSize]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateLayerPosition = () => {
|
||||||
|
const layer = layerRef.current;
|
||||||
|
if (!layer) return false;
|
||||||
|
|
||||||
|
const jumbo = findJumboEmojiElement(burst.targetEventId);
|
||||||
|
if (!jumbo) return false;
|
||||||
|
|
||||||
|
const rect = jumbo.getBoundingClientRect();
|
||||||
|
layer.style.left = `${rect.left + rect.width / 2 - canvasSize / 2}px`;
|
||||||
|
layer.style.top = `${rect.top + rect.height / 2 - canvasSize / 2}px`;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScrollOrResize = () => {
|
||||||
|
updateLayerPosition();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', onScrollOrResize, true);
|
||||||
|
window.addEventListener('resize', onScrollOrResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', onScrollOrResize, true);
|
||||||
|
window.removeEventListener('resize', onScrollOrResize);
|
||||||
|
};
|
||||||
|
}, [burst.targetEventId, canvasSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current || spawnedRef.current) return undefined;
|
||||||
|
|
||||||
|
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
if (reducedMotion) {
|
||||||
|
onCompleteRef.current(burst.id);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let frameCleanup: (() => void) | undefined;
|
||||||
|
|
||||||
|
const start = (): boolean => {
|
||||||
|
if (spawnedRef.current || !canvasRef.current) return false;
|
||||||
|
if (!updateLayerPosition()) return false;
|
||||||
|
|
||||||
|
spawnedRef.current = true;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) return false;
|
||||||
|
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
|
||||||
|
canvas.width = Math.round(canvasSize * dpr);
|
||||||
|
canvas.height = Math.round(canvasSize * dpr);
|
||||||
|
canvas.style.width = `${canvasSize}px`;
|
||||||
|
canvas.style.height = `${canvasSize}px`;
|
||||||
|
|
||||||
|
spawnEmojiBurst(particlesRef.current, burst.id, localOrigin, primaryEmoji, performance.now());
|
||||||
|
|
||||||
|
const tick = (now: number) => {
|
||||||
|
updateLayerPosition();
|
||||||
|
|
||||||
|
const last = lastFrameTimeRef.current ?? now;
|
||||||
|
const dtSeconds = Math.min((now - last) / 1000, 0.05);
|
||||||
|
lastFrameTimeRef.current = now;
|
||||||
|
|
||||||
|
stepBurstParticles(particlesRef.current, now, dtSeconds);
|
||||||
|
|
||||||
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
if (particlesRef.current.length > 0) {
|
||||||
|
drawBurstParticles(context, particlesRef.current, dpr, now);
|
||||||
|
frameRef.current = requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onCompleteRef.current(burst.id);
|
||||||
|
frameRef.current = null;
|
||||||
|
lastFrameTimeRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
lastFrameTimeRef.current = performance.now();
|
||||||
|
frameRef.current = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
frameCleanup = () => {
|
||||||
|
if (frameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(frameRef.current);
|
||||||
|
frameRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (start()) {
|
||||||
|
return frameCleanup;
|
||||||
|
}
|
||||||
|
|
||||||
|
let attempt = 0;
|
||||||
|
const retry = window.setInterval(() => {
|
||||||
|
attempt += 1;
|
||||||
|
if (start() || attempt >= 10) {
|
||||||
|
window.clearInterval(retry);
|
||||||
|
if (attempt >= 10 && !spawnedRef.current) {
|
||||||
|
onCompleteRef.current(burst.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(retry);
|
||||||
|
frameCleanup?.();
|
||||||
|
};
|
||||||
|
}, [burst.id, burst.targetEventId, canvasSize, localOrigin, primaryEmoji]);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={layerRef}
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
width: canvasSize,
|
||||||
|
height: canvasSize,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { EmojiConfettiBurst } from './types';
|
||||||
|
import { EmojiConfettiBurstCanvas } from './EmojiConfettiBurstCanvas';
|
||||||
|
|
||||||
|
type EmojiConfettiOverlayProps = {
|
||||||
|
bursts: EmojiConfettiBurst[];
|
||||||
|
onBurstComplete: (burstId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EmojiConfettiOverlay({ bursts, onBurstComplete }: EmojiConfettiOverlayProps) {
|
||||||
|
if (bursts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{bursts.map((burst) => (
|
||||||
|
<EmojiConfettiBurstCanvas
|
||||||
|
key={burst.id}
|
||||||
|
burst={burst}
|
||||||
|
onComplete={onBurstComplete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/app/features/room/emoji-confetti/burstOrigin.ts
Normal file
74
src/app/features/room/emoji-confetti/burstOrigin.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
export type BurstPoint = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
maskRadius?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getBurstPointFromElement(element: Element): BurstPoint {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: rect.left + rect.width / 2,
|
||||||
|
y: rect.top + rect.height / 2,
|
||||||
|
maskRadius: Math.max(rect.width, rect.height) * 0.5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getElementCenter(element: Element): BurstPoint {
|
||||||
|
return getBurstPointFromElement(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findEmojiOriginInMessage(
|
||||||
|
targetEventId: string,
|
||||||
|
emoji: string
|
||||||
|
): BurstPoint | undefined {
|
||||||
|
const message = document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
|
||||||
|
if (!message) return undefined;
|
||||||
|
|
||||||
|
const matches = message.querySelectorAll('[data-emoticon]');
|
||||||
|
for (const element of matches) {
|
||||||
|
if (element.getAttribute('data-emoticon') === emoji) {
|
||||||
|
return getBurstPointFromElement(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessageFallbackOrigin(targetEventId: string): BurstPoint | undefined {
|
||||||
|
const message = document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
|
||||||
|
if (!message) return undefined;
|
||||||
|
|
||||||
|
const jumboBody = message.querySelector('[data-jumbo-emoji]');
|
||||||
|
if (jumboBody) {
|
||||||
|
return getBurstPointFromElement(jumboBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = message.querySelector('[data-message-body]');
|
||||||
|
if (body) {
|
||||||
|
return getBurstPointFromElement(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getBurstPointFromElement(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveBurstOrigin(
|
||||||
|
targetEventId: string | undefined,
|
||||||
|
emojis: string[],
|
||||||
|
explicitOrigin?: BurstPoint
|
||||||
|
): BurstPoint {
|
||||||
|
if (explicitOrigin) return explicitOrigin;
|
||||||
|
|
||||||
|
const primaryEmoji = emojis[0];
|
||||||
|
if (targetEventId && primaryEmoji) {
|
||||||
|
const emojiOrigin = findEmojiOriginInMessage(targetEventId, primaryEmoji);
|
||||||
|
if (emojiOrigin) return emojiOrigin;
|
||||||
|
|
||||||
|
const messageOrigin = getMessageFallbackOrigin(targetEventId);
|
||||||
|
if (messageOrigin) return messageOrigin;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: window.innerWidth / 2,
|
||||||
|
y: window.innerHeight * 0.35,
|
||||||
|
};
|
||||||
|
}
|
||||||
357
src/app/features/room/emoji-confetti/emojiBurstEngine.ts
Normal file
357
src/app/features/room/emoji-confetti/emojiBurstEngine.ts
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
import { BurstPoint } from './burstOrigin';
|
||||||
|
import {
|
||||||
|
BurstMotionStyle,
|
||||||
|
EmojiBurstProfile,
|
||||||
|
getEmojiBurstProfile,
|
||||||
|
pickParticleEmoji,
|
||||||
|
sampleBurstAngle,
|
||||||
|
} from './emojiParticleProfiles';
|
||||||
|
|
||||||
|
/** Particles spawn over this window. */
|
||||||
|
export const BURST_SPAWN_MS = 500;
|
||||||
|
/** Global fade runs from 0.5s → 2s after burst start. */
|
||||||
|
export const BURST_FADE_START_MS = 500;
|
||||||
|
export const BURST_FADE_END_MS = 2000;
|
||||||
|
export const BURST_TOTAL_MS = BURST_FADE_END_MS;
|
||||||
|
|
||||||
|
export type BurstParticle = {
|
||||||
|
burstId: string;
|
||||||
|
burstStartMs: number;
|
||||||
|
emoji: string;
|
||||||
|
fontSize: number;
|
||||||
|
spawnX: number;
|
||||||
|
spawnY: number;
|
||||||
|
maskRadius: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
bornAt: number;
|
||||||
|
rotation: number;
|
||||||
|
spin: number;
|
||||||
|
peakScale: number;
|
||||||
|
style: BurstMotionStyle;
|
||||||
|
gravity: number;
|
||||||
|
drag: number;
|
||||||
|
phase: number;
|
||||||
|
twinkle?: boolean;
|
||||||
|
wobble?: boolean;
|
||||||
|
orbitRadius: number;
|
||||||
|
orbitSpeed: number;
|
||||||
|
orbitAngle: number;
|
||||||
|
bounceDamping: number;
|
||||||
|
bouncesLeft: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMOJI_CACHE_PX = 64;
|
||||||
|
const EMOJI_CACHE_SCALE = 2;
|
||||||
|
const MAX_DPR = 2;
|
||||||
|
const POP_IN_MS = 70;
|
||||||
|
|
||||||
|
const emojiCanvasCache = new Map<string, HTMLCanvasElement>();
|
||||||
|
|
||||||
|
function easeOutBack(t: number): number {
|
||||||
|
const c1 = 1.70158;
|
||||||
|
const c3 = c1 + 1;
|
||||||
|
return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function easeOutCubic(t: number): number {
|
||||||
|
return 1 - (1 - t) ** 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerp(min: number, max: number): number {
|
||||||
|
return min + Math.random() * (max - min);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmojiCanvas(emoji: string): HTMLCanvasElement {
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
|
||||||
|
const cacheKey = `${emoji}:${dpr}`;
|
||||||
|
const cached = emojiCanvasCache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const fontSize = Math.ceil(EMOJI_CACHE_PX * dpr);
|
||||||
|
const size = Math.ceil(fontSize * EMOJI_CACHE_SCALE);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (context) {
|
||||||
|
context.textAlign = 'center';
|
||||||
|
context.textBaseline = 'middle';
|
||||||
|
context.font = `${fontSize}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
|
||||||
|
context.fillText(emoji, size / 2, size / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
emojiCanvasCache.set(cacheKey, canvas);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emergeOpacity(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
spawnX: number,
|
||||||
|
spawnY: number,
|
||||||
|
maskRadius: number
|
||||||
|
): number {
|
||||||
|
const dist = Math.hypot(x - spawnX, y - spawnY);
|
||||||
|
const inner = maskRadius * 0.55;
|
||||||
|
const outer = maskRadius * 1.05;
|
||||||
|
if (dist <= inner) return 0;
|
||||||
|
if (dist >= outer) return 1;
|
||||||
|
return (dist - inner) / (outer - inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnParticle(
|
||||||
|
particles: BurstParticle[],
|
||||||
|
burstId: string,
|
||||||
|
burstStartMs: number,
|
||||||
|
point: BurstPoint,
|
||||||
|
primaryEmoji: string,
|
||||||
|
profile: EmojiBurstProfile,
|
||||||
|
bornAt: number,
|
||||||
|
options?: { hero?: boolean }
|
||||||
|
) {
|
||||||
|
const hero = options?.hero ?? false;
|
||||||
|
const angle = sampleBurstAngle(profile);
|
||||||
|
const speed = hero
|
||||||
|
? lerp(profile.speedMin * 0.65, profile.speedMax * 0.55)
|
||||||
|
: lerp(profile.speedMin, profile.speedMax);
|
||||||
|
const launchUp = lerp(profile.launchUpMin, profile.launchUpMax);
|
||||||
|
const maskRadius = point.maskRadius ?? 28;
|
||||||
|
const jitter = hero ? 3 : 12;
|
||||||
|
const emoji = pickParticleEmoji(profile, primaryEmoji);
|
||||||
|
|
||||||
|
let vx = Math.cos(angle) * speed + (Math.random() - 0.5) * 60;
|
||||||
|
let vy = Math.sin(angle) * speed - launchUp;
|
||||||
|
|
||||||
|
if (profile.style === 'spiral') {
|
||||||
|
vx *= 0.35;
|
||||||
|
vy *= 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.style === 'sparkle') {
|
||||||
|
vx *= 0.75;
|
||||||
|
vy *= 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
particles.push({
|
||||||
|
burstId,
|
||||||
|
burstStartMs,
|
||||||
|
emoji,
|
||||||
|
fontSize: hero
|
||||||
|
? lerp(profile.heroFontSizeMin, profile.heroFontSizeMax)
|
||||||
|
: lerp(profile.fontSizeMin, profile.fontSizeMax),
|
||||||
|
spawnX: point.x,
|
||||||
|
spawnY: point.y,
|
||||||
|
maskRadius,
|
||||||
|
x: point.x + (Math.random() - 0.5) * jitter,
|
||||||
|
y: point.y + (Math.random() - 0.5) * jitter,
|
||||||
|
vx,
|
||||||
|
vy,
|
||||||
|
bornAt,
|
||||||
|
rotation: (Math.random() - 0.5) * 40,
|
||||||
|
spin: lerp(profile.spinMin, profile.spinMax),
|
||||||
|
peakScale: hero ? 1.1 + Math.random() * 0.2 : 0.9 + Math.random() * 0.4,
|
||||||
|
style: profile.style,
|
||||||
|
gravity: profile.gravity,
|
||||||
|
drag: profile.drag,
|
||||||
|
phase: Math.random() * Math.PI * 2,
|
||||||
|
twinkle: profile.twinkle,
|
||||||
|
wobble: profile.wobble,
|
||||||
|
orbitRadius: hero ? 8 : 14 + Math.random() * 28,
|
||||||
|
orbitSpeed: (Math.random() < 0.5 ? -1 : 1) * (1.8 + Math.random() * 2.4),
|
||||||
|
orbitAngle: Math.random() * Math.PI * 2,
|
||||||
|
bounceDamping: 0.42 + Math.random() * 0.18,
|
||||||
|
bouncesLeft: profile.style === 'bounce' ? 2 : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fountain from behind the emoji for 0.5s, fade 0.5s→2s. */
|
||||||
|
export function spawnEmojiBurst(
|
||||||
|
particles: BurstParticle[],
|
||||||
|
burstId: string,
|
||||||
|
point: BurstPoint,
|
||||||
|
emoji: string,
|
||||||
|
now = performance.now()
|
||||||
|
) {
|
||||||
|
const profile = getEmojiBurstProfile(emoji);
|
||||||
|
const burstStartMs = now;
|
||||||
|
|
||||||
|
spawnParticle(particles, burstId, burstStartMs, point, emoji, profile, burstStartMs, {
|
||||||
|
hero: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const particleCount = profile.particleCount;
|
||||||
|
for (let i = 0; i < particleCount; i += 1) {
|
||||||
|
const slot = i / particleCount;
|
||||||
|
const bornAt =
|
||||||
|
burstStartMs + slot * BURST_SPAWN_MS + Math.random() * (BURST_SPAWN_MS / particleCount);
|
||||||
|
spawnParticle(particles, burstId, burstStartMs, point, emoji, profile, bornAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepSpiralParticle(particle: BurstParticle, dtSeconds: number) {
|
||||||
|
particle.orbitRadius += 42 * dtSeconds;
|
||||||
|
particle.orbitAngle += particle.orbitSpeed * dtSeconds;
|
||||||
|
particle.x = particle.spawnX + Math.cos(particle.orbitAngle) * particle.orbitRadius;
|
||||||
|
particle.y =
|
||||||
|
particle.spawnY + Math.sin(particle.orbitAngle) * particle.orbitRadius * 0.65 + particle.vy * dtSeconds * 8;
|
||||||
|
particle.vy += particle.gravity * dtSeconds * 0.35;
|
||||||
|
particle.rotation += particle.spin * dtSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepBounceParticle(particle: BurstParticle, dtSeconds: number, dragFactor: number) {
|
||||||
|
particle.vy += particle.gravity * dtSeconds;
|
||||||
|
particle.vx *= dragFactor;
|
||||||
|
particle.vy *= dragFactor;
|
||||||
|
particle.x += particle.vx * dtSeconds;
|
||||||
|
particle.y += particle.vy * dtSeconds;
|
||||||
|
particle.rotation += particle.spin * dtSeconds;
|
||||||
|
|
||||||
|
if (particle.bouncesLeft > 0 && particle.vy > 0 && particle.y >= particle.spawnY + 6) {
|
||||||
|
particle.y = particle.spawnY + 6;
|
||||||
|
particle.vy = -Math.abs(particle.vy) * particle.bounceDamping;
|
||||||
|
particle.vx *= 0.82;
|
||||||
|
particle.bouncesLeft -= 1;
|
||||||
|
particle.spin += (Math.random() - 0.5) * 120;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepDefaultParticle(particle: BurstParticle, dtSeconds: number, dragFactor: number) {
|
||||||
|
particle.vy += particle.gravity * dtSeconds;
|
||||||
|
particle.vx *= dragFactor;
|
||||||
|
particle.vy *= dragFactor;
|
||||||
|
particle.x += particle.vx * dtSeconds;
|
||||||
|
particle.y += particle.vy * dtSeconds;
|
||||||
|
particle.rotation += particle.spin * dtSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stepBurstParticles(
|
||||||
|
particles: BurstParticle[],
|
||||||
|
now: number,
|
||||||
|
dtSeconds: number
|
||||||
|
): void {
|
||||||
|
for (let i = particles.length - 1; i >= 0; i -= 1) {
|
||||||
|
const particle = particles[i];
|
||||||
|
const burstElapsed = now - particle.burstStartMs;
|
||||||
|
|
||||||
|
if (burstElapsed > BURST_TOTAL_MS) {
|
||||||
|
particles[i] = particles[particles.length - 1];
|
||||||
|
particles.pop();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ageMs = now - particle.bornAt;
|
||||||
|
if (ageMs < 0) continue;
|
||||||
|
|
||||||
|
const dragFactor = particle.drag ** (dtSeconds * 60);
|
||||||
|
|
||||||
|
switch (particle.style) {
|
||||||
|
case 'spiral':
|
||||||
|
stepSpiralParticle(particle, dtSeconds);
|
||||||
|
break;
|
||||||
|
case 'bounce':
|
||||||
|
stepBounceParticle(particle, dtSeconds, dragFactor);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
stepDefaultParticle(particle, dtSeconds, dragFactor);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
particle.phase += dtSeconds * (particle.twinkle ? 9 : 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type DrawState = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
scale: number;
|
||||||
|
opacity: number;
|
||||||
|
rotation: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function particleDrawState(particle: BurstParticle, now: number): DrawState | null {
|
||||||
|
const burstElapsed = now - particle.burstStartMs;
|
||||||
|
if (burstElapsed > BURST_TOTAL_MS) return null;
|
||||||
|
|
||||||
|
const ageMs = now - particle.bornAt;
|
||||||
|
if (ageMs < 0) return null;
|
||||||
|
|
||||||
|
let scale = particle.peakScale;
|
||||||
|
let opacity = emergeOpacity(
|
||||||
|
particle.x,
|
||||||
|
particle.y,
|
||||||
|
particle.spawnX,
|
||||||
|
particle.spawnY,
|
||||||
|
particle.maskRadius
|
||||||
|
);
|
||||||
|
|
||||||
|
if (opacity <= 0) return null;
|
||||||
|
|
||||||
|
if (ageMs < POP_IN_MS) {
|
||||||
|
const pop = easeOutBack(ageMs / POP_IN_MS);
|
||||||
|
scale = 0.1 + pop * particle.peakScale;
|
||||||
|
opacity *= pop;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (particle.twinkle) {
|
||||||
|
opacity *= 0.55 + 0.45 * Math.sin(particle.phase * 1.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (particle.wobble) {
|
||||||
|
scale *= 1 + 0.12 * Math.sin(particle.phase * 2.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (particle.style === 'sparkle' && burstElapsed < BURST_FADE_START_MS) {
|
||||||
|
scale *= 0.85 + 0.3 * Math.sin(particle.phase * 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (burstElapsed >= BURST_FADE_START_MS) {
|
||||||
|
const fadeT = (burstElapsed - BURST_FADE_START_MS) / (BURST_FADE_END_MS - BURST_FADE_START_MS);
|
||||||
|
opacity *= 1 - easeOutCubic(Math.min(1, fadeT));
|
||||||
|
scale *= 1 - easeOutCubic(Math.min(1, fadeT)) * 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opacity <= 0.02) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: particle.x,
|
||||||
|
y: particle.y,
|
||||||
|
scale,
|
||||||
|
opacity,
|
||||||
|
rotation: particle.rotation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawBurstParticles(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
particles: BurstParticle[],
|
||||||
|
dpr: number,
|
||||||
|
now: number
|
||||||
|
) {
|
||||||
|
for (const particle of particles) {
|
||||||
|
const state = particleDrawState(particle, now);
|
||||||
|
if (!state) continue;
|
||||||
|
|
||||||
|
context.globalAlpha = state.opacity;
|
||||||
|
|
||||||
|
const emojiCanvas = getEmojiCanvas(particle.emoji);
|
||||||
|
const drawSize = particle.fontSize * state.scale * EMOJI_CACHE_SCALE;
|
||||||
|
const halfSize = drawSize / 2;
|
||||||
|
const radians = (state.rotation * Math.PI) / 180;
|
||||||
|
const cos = Math.cos(radians) * dpr;
|
||||||
|
const sin = Math.sin(radians) * dpr;
|
||||||
|
|
||||||
|
context.setTransform(cos, sin, -sin, cos, state.x * dpr, state.y * dpr);
|
||||||
|
context.drawImage(emojiCanvas, -halfSize, -halfSize, drawSize, drawSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.globalAlpha = 1;
|
||||||
|
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasBurstParticles(particles: BurstParticle[], burstId: string): boolean {
|
||||||
|
return particles.some((particle) => particle.burstId === burstId);
|
||||||
|
}
|
||||||
500
src/app/features/room/emoji-confetti/emojiParticleProfiles.ts
Normal file
500
src/app/features/room/emoji-confetti/emojiParticleProfiles.ts
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
export type BurstMotionStyle =
|
||||||
|
| 'explode'
|
||||||
|
| 'bounce'
|
||||||
|
| 'rise'
|
||||||
|
| 'sparkle'
|
||||||
|
| 'splash'
|
||||||
|
| 'spiral'
|
||||||
|
| 'punch'
|
||||||
|
| 'scatter'
|
||||||
|
| 'shower'
|
||||||
|
| 'firework';
|
||||||
|
|
||||||
|
export type EmojiBurstProfile = {
|
||||||
|
style: BurstMotionStyle;
|
||||||
|
particleCount: number;
|
||||||
|
gravity: number;
|
||||||
|
drag: number;
|
||||||
|
speedMin: number;
|
||||||
|
speedMax: number;
|
||||||
|
launchUpMin: number;
|
||||||
|
launchUpMax: number;
|
||||||
|
spinMin: number;
|
||||||
|
spinMax: number;
|
||||||
|
fontSizeMin: number;
|
||||||
|
fontSizeMax: number;
|
||||||
|
heroFontSizeMin: number;
|
||||||
|
heroFontSizeMax: number;
|
||||||
|
companions?: string[];
|
||||||
|
companionChance?: number;
|
||||||
|
/** Bias burst angle in radians. 0 = right, -π/2 = up. */
|
||||||
|
angleBias?: number;
|
||||||
|
/** Limit spawn to a cone (radians). Omit for full 360°. */
|
||||||
|
angleSpread?: number;
|
||||||
|
twinkle?: boolean;
|
||||||
|
wobble?: boolean;
|
||||||
|
/** Full-viewport overlay (Box2D pile for fireworks). */
|
||||||
|
fullscreen?: boolean;
|
||||||
|
/** Soft color wash applied to matching particle glyphs (e.g. green spit). */
|
||||||
|
tintColor?: string;
|
||||||
|
/** Emojis that receive `tintColor` when drawn. */
|
||||||
|
tintEmojis?: string[];
|
||||||
|
/** Swap the primary face to this emoji mid-burst (e.g. 🤢 → 🤮). */
|
||||||
|
morphTo?: string;
|
||||||
|
/** Delay before `morphTo` kicks in. */
|
||||||
|
morphAfterMs?: number;
|
||||||
|
/** How long the stand-in hero face stays before the real jumbo returns.
|
||||||
|
* Omit to keep it up until the last particle spawns. */
|
||||||
|
heroDurationMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_PROFILE: EmojiBurstProfile = {
|
||||||
|
style: 'explode',
|
||||||
|
particleCount: 36,
|
||||||
|
gravity: 400,
|
||||||
|
drag: 0.935,
|
||||||
|
speedMin: 240,
|
||||||
|
speedMax: 560,
|
||||||
|
launchUpMin: 40,
|
||||||
|
launchUpMax: 130,
|
||||||
|
spinMin: -180,
|
||||||
|
spinMax: 180,
|
||||||
|
fontSizeMin: 14,
|
||||||
|
fontSizeMax: 26,
|
||||||
|
heroFontSizeMin: 26,
|
||||||
|
heroFontSizeMax: 34,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROFILE_OVERRIDES: Record<string, Partial<EmojiBurstProfile>> = {
|
||||||
|
'😆': {
|
||||||
|
style: 'bounce',
|
||||||
|
particleCount: 28,
|
||||||
|
gravity: 520,
|
||||||
|
speedMin: 180,
|
||||||
|
speedMax: 360,
|
||||||
|
launchUpMin: 120,
|
||||||
|
launchUpMax: 220,
|
||||||
|
spinMin: -240,
|
||||||
|
spinMax: 240,
|
||||||
|
wobble: true,
|
||||||
|
},
|
||||||
|
'😂': {
|
||||||
|
style: 'bounce',
|
||||||
|
particleCount: 30,
|
||||||
|
gravity: 500,
|
||||||
|
speedMin: 160,
|
||||||
|
speedMax: 340,
|
||||||
|
launchUpMin: 110,
|
||||||
|
launchUpMax: 210,
|
||||||
|
spinMin: -220,
|
||||||
|
spinMax: 220,
|
||||||
|
wobble: true,
|
||||||
|
},
|
||||||
|
'🤣': {
|
||||||
|
style: 'bounce',
|
||||||
|
particleCount: 32,
|
||||||
|
gravity: 480,
|
||||||
|
speedMin: 200,
|
||||||
|
speedMax: 380,
|
||||||
|
launchUpMin: 130,
|
||||||
|
launchUpMax: 240,
|
||||||
|
wobble: true,
|
||||||
|
},
|
||||||
|
'🔥': {
|
||||||
|
style: 'rise',
|
||||||
|
particleCount: 24,
|
||||||
|
gravity: -120,
|
||||||
|
drag: 0.96,
|
||||||
|
speedMin: 80,
|
||||||
|
speedMax: 200,
|
||||||
|
launchUpMin: 60,
|
||||||
|
launchUpMax: 160,
|
||||||
|
spinMin: -90,
|
||||||
|
spinMax: 90,
|
||||||
|
fontSizeMin: 16,
|
||||||
|
fontSizeMax: 28,
|
||||||
|
twinkle: true,
|
||||||
|
wobble: true,
|
||||||
|
companions: ['✨'],
|
||||||
|
companionChance: 0.35,
|
||||||
|
},
|
||||||
|
'❤️': {
|
||||||
|
style: 'rise',
|
||||||
|
particleCount: 22,
|
||||||
|
gravity: -80,
|
||||||
|
drag: 0.97,
|
||||||
|
speedMin: 60,
|
||||||
|
speedMax: 150,
|
||||||
|
launchUpMin: 40,
|
||||||
|
launchUpMax: 110,
|
||||||
|
spinMin: -40,
|
||||||
|
spinMax: 40,
|
||||||
|
fontSizeMin: 14,
|
||||||
|
fontSizeMax: 24,
|
||||||
|
companions: ['💕', '💖'],
|
||||||
|
companionChance: 0.4,
|
||||||
|
},
|
||||||
|
'💕': {
|
||||||
|
style: 'rise',
|
||||||
|
particleCount: 20,
|
||||||
|
gravity: -70,
|
||||||
|
drag: 0.97,
|
||||||
|
speedMin: 50,
|
||||||
|
speedMax: 140,
|
||||||
|
launchUpMin: 35,
|
||||||
|
launchUpMax: 100,
|
||||||
|
spinMin: -35,
|
||||||
|
spinMax: 35,
|
||||||
|
},
|
||||||
|
'💖': {
|
||||||
|
style: 'rise',
|
||||||
|
particleCount: 20,
|
||||||
|
gravity: -90,
|
||||||
|
drag: 0.968,
|
||||||
|
speedMin: 55,
|
||||||
|
speedMax: 150,
|
||||||
|
launchUpMin: 45,
|
||||||
|
launchUpMax: 115,
|
||||||
|
twinkle: true,
|
||||||
|
},
|
||||||
|
'⭐': {
|
||||||
|
style: 'sparkle',
|
||||||
|
particleCount: 26,
|
||||||
|
gravity: 40,
|
||||||
|
drag: 0.94,
|
||||||
|
speedMin: 100,
|
||||||
|
speedMax: 260,
|
||||||
|
launchUpMin: 20,
|
||||||
|
launchUpMax: 80,
|
||||||
|
spinMin: -120,
|
||||||
|
spinMax: 120,
|
||||||
|
twinkle: true,
|
||||||
|
companions: ['✨'],
|
||||||
|
companionChance: 0.5,
|
||||||
|
},
|
||||||
|
'✨': {
|
||||||
|
style: 'sparkle',
|
||||||
|
particleCount: 30,
|
||||||
|
gravity: 30,
|
||||||
|
drag: 0.945,
|
||||||
|
speedMin: 90,
|
||||||
|
speedMax: 240,
|
||||||
|
launchUpMin: 15,
|
||||||
|
launchUpMax: 70,
|
||||||
|
spinMin: -200,
|
||||||
|
spinMax: 200,
|
||||||
|
twinkle: true,
|
||||||
|
},
|
||||||
|
'💀': {
|
||||||
|
style: 'scatter',
|
||||||
|
particleCount: 20,
|
||||||
|
gravity: 620,
|
||||||
|
drag: 0.92,
|
||||||
|
speedMin: 200,
|
||||||
|
speedMax: 420,
|
||||||
|
launchUpMin: 20,
|
||||||
|
launchUpMax: 90,
|
||||||
|
spinMin: -360,
|
||||||
|
spinMax: 360,
|
||||||
|
fontSizeMin: 16,
|
||||||
|
fontSizeMax: 28,
|
||||||
|
},
|
||||||
|
'🎉': {
|
||||||
|
style: 'shower',
|
||||||
|
particleCount: 40,
|
||||||
|
gravity: 280,
|
||||||
|
drag: 0.93,
|
||||||
|
speedMin: 200,
|
||||||
|
speedMax: 480,
|
||||||
|
launchUpMin: 80,
|
||||||
|
launchUpMax: 200,
|
||||||
|
companions: ['🎊', '✨', '🎈'],
|
||||||
|
companionChance: 0.45,
|
||||||
|
},
|
||||||
|
'🎊': {
|
||||||
|
style: 'shower',
|
||||||
|
particleCount: 38,
|
||||||
|
gravity: 260,
|
||||||
|
drag: 0.932,
|
||||||
|
speedMin: 190,
|
||||||
|
speedMax: 460,
|
||||||
|
launchUpMin: 70,
|
||||||
|
launchUpMax: 190,
|
||||||
|
companions: ['🎉', '✨'],
|
||||||
|
companionChance: 0.4,
|
||||||
|
},
|
||||||
|
'👏': {
|
||||||
|
style: 'punch',
|
||||||
|
particleCount: 18,
|
||||||
|
gravity: 320,
|
||||||
|
speedMin: 220,
|
||||||
|
speedMax: 400,
|
||||||
|
launchUpMin: 30,
|
||||||
|
launchUpMax: 100,
|
||||||
|
angleBias: -Math.PI / 2,
|
||||||
|
angleSpread: Math.PI * 0.85,
|
||||||
|
spinMin: -100,
|
||||||
|
spinMax: 100,
|
||||||
|
},
|
||||||
|
'👍': {
|
||||||
|
style: 'punch',
|
||||||
|
particleCount: 14,
|
||||||
|
gravity: 380,
|
||||||
|
speedMin: 260,
|
||||||
|
speedMax: 440,
|
||||||
|
launchUpMin: 100,
|
||||||
|
launchUpMax: 200,
|
||||||
|
angleBias: -Math.PI / 2,
|
||||||
|
angleSpread: Math.PI * 0.55,
|
||||||
|
spinMin: -60,
|
||||||
|
spinMax: 60,
|
||||||
|
},
|
||||||
|
'💯': {
|
||||||
|
style: 'punch',
|
||||||
|
particleCount: 16,
|
||||||
|
gravity: 300,
|
||||||
|
speedMin: 200,
|
||||||
|
speedMax: 380,
|
||||||
|
launchUpMin: 140,
|
||||||
|
launchUpMax: 240,
|
||||||
|
angleBias: -Math.PI / 2,
|
||||||
|
angleSpread: Math.PI * 0.45,
|
||||||
|
fontSizeMin: 16,
|
||||||
|
fontSizeMax: 30,
|
||||||
|
wobble: true,
|
||||||
|
},
|
||||||
|
'💦': {
|
||||||
|
style: 'splash',
|
||||||
|
particleCount: 32,
|
||||||
|
gravity: 540,
|
||||||
|
drag: 0.925,
|
||||||
|
speedMin: 280,
|
||||||
|
speedMax: 520,
|
||||||
|
launchUpMin: 160,
|
||||||
|
launchUpMax: 300,
|
||||||
|
spinMin: -140,
|
||||||
|
spinMax: 140,
|
||||||
|
angleBias: -Math.PI / 2,
|
||||||
|
angleSpread: Math.PI * 1.1,
|
||||||
|
},
|
||||||
|
'🌊': {
|
||||||
|
style: 'splash',
|
||||||
|
particleCount: 28,
|
||||||
|
gravity: 420,
|
||||||
|
drag: 0.93,
|
||||||
|
speedMin: 220,
|
||||||
|
speedMax: 460,
|
||||||
|
launchUpMin: 100,
|
||||||
|
launchUpMax: 220,
|
||||||
|
angleBias: -Math.PI / 2,
|
||||||
|
angleSpread: Math.PI,
|
||||||
|
companions: ['💧'],
|
||||||
|
companionChance: 0.3,
|
||||||
|
},
|
||||||
|
'⚡': {
|
||||||
|
style: 'scatter',
|
||||||
|
particleCount: 14,
|
||||||
|
gravity: 180,
|
||||||
|
drag: 0.88,
|
||||||
|
speedMin: 380,
|
||||||
|
speedMax: 680,
|
||||||
|
launchUpMin: 10,
|
||||||
|
launchUpMax: 60,
|
||||||
|
spinMin: -30,
|
||||||
|
spinMax: 30,
|
||||||
|
fontSizeMin: 18,
|
||||||
|
fontSizeMax: 32,
|
||||||
|
angleSpread: Math.PI * 1.2,
|
||||||
|
},
|
||||||
|
'🌀': {
|
||||||
|
style: 'spiral',
|
||||||
|
particleCount: 24,
|
||||||
|
gravity: 60,
|
||||||
|
drag: 0.96,
|
||||||
|
speedMin: 120,
|
||||||
|
speedMax: 260,
|
||||||
|
launchUpMin: 0,
|
||||||
|
launchUpMax: 40,
|
||||||
|
spinMin: -420,
|
||||||
|
spinMax: 420,
|
||||||
|
},
|
||||||
|
'🤯': {
|
||||||
|
style: 'explode',
|
||||||
|
particleCount: 34,
|
||||||
|
gravity: 360,
|
||||||
|
speedMin: 280,
|
||||||
|
speedMax: 580,
|
||||||
|
launchUpMin: 60,
|
||||||
|
launchUpMax: 160,
|
||||||
|
companions: ['💥', '✨', '⭐'],
|
||||||
|
companionChance: 0.5,
|
||||||
|
},
|
||||||
|
'😭': {
|
||||||
|
style: 'splash',
|
||||||
|
particleCount: 26,
|
||||||
|
gravity: 480,
|
||||||
|
drag: 0.94,
|
||||||
|
speedMin: 140,
|
||||||
|
speedMax: 300,
|
||||||
|
launchUpMin: 40,
|
||||||
|
launchUpMax: 120,
|
||||||
|
angleBias: Math.PI / 2,
|
||||||
|
angleSpread: Math.PI * 0.7,
|
||||||
|
companions: ['💧'],
|
||||||
|
companionChance: 0.55,
|
||||||
|
},
|
||||||
|
'🥳': {
|
||||||
|
style: 'shower',
|
||||||
|
particleCount: 36,
|
||||||
|
gravity: 250,
|
||||||
|
speedMin: 180,
|
||||||
|
speedMax: 440,
|
||||||
|
launchUpMin: 90,
|
||||||
|
launchUpMax: 210,
|
||||||
|
companions: ['🎉', '🎊', '🎈'],
|
||||||
|
companionChance: 0.4,
|
||||||
|
},
|
||||||
|
'🎆': {
|
||||||
|
style: 'firework',
|
||||||
|
fullscreen: true,
|
||||||
|
particleCount: 320,
|
||||||
|
gravity: 980,
|
||||||
|
drag: 1,
|
||||||
|
speedMin: 420,
|
||||||
|
speedMax: 980,
|
||||||
|
launchUpMin: 180,
|
||||||
|
launchUpMax: 420,
|
||||||
|
spinMin: -520,
|
||||||
|
spinMax: 520,
|
||||||
|
fontSizeMin: 16,
|
||||||
|
fontSizeMax: 30,
|
||||||
|
heroFontSizeMin: 40,
|
||||||
|
heroFontSizeMax: 52,
|
||||||
|
companions: ['🎇', '✨', '💥', '⭐', '🎉'],
|
||||||
|
companionChance: 0.55,
|
||||||
|
},
|
||||||
|
'🎇': {
|
||||||
|
style: 'firework',
|
||||||
|
fullscreen: true,
|
||||||
|
particleCount: 280,
|
||||||
|
gravity: 960,
|
||||||
|
drag: 1,
|
||||||
|
speedMin: 380,
|
||||||
|
speedMax: 920,
|
||||||
|
launchUpMin: 160,
|
||||||
|
launchUpMax: 400,
|
||||||
|
spinMin: -480,
|
||||||
|
spinMax: 480,
|
||||||
|
fontSizeMin: 14,
|
||||||
|
fontSizeMax: 28,
|
||||||
|
heroFontSizeMin: 36,
|
||||||
|
heroFontSizeMax: 48,
|
||||||
|
companions: ['🎆', '✨', '⭐', '💥'],
|
||||||
|
companionChance: 0.5,
|
||||||
|
twinkle: true,
|
||||||
|
},
|
||||||
|
// Full-viewport spew — downward cone, green-tinted droplets, piles on the floor.
|
||||||
|
'🤢': {
|
||||||
|
style: 'firework',
|
||||||
|
fullscreen: true,
|
||||||
|
particleCount: 420,
|
||||||
|
gravity: 1200,
|
||||||
|
drag: 1,
|
||||||
|
speedMin: 380,
|
||||||
|
speedMax: 980,
|
||||||
|
launchUpMin: 0,
|
||||||
|
launchUpMax: 40,
|
||||||
|
spinMin: -560,
|
||||||
|
spinMax: 560,
|
||||||
|
fontSizeMin: 18,
|
||||||
|
fontSizeMax: 34,
|
||||||
|
heroFontSizeMin: 44,
|
||||||
|
heroFontSizeMax: 58,
|
||||||
|
// Downward throw-up cone (π/2 = down in canvas space).
|
||||||
|
angleBias: Math.PI / 2,
|
||||||
|
angleSpread: Math.PI * 1.05,
|
||||||
|
companions: ['💦', '💧', '💚'],
|
||||||
|
companionChance: 1,
|
||||||
|
tintColor: 'rgba(72, 190, 48, 0.72)',
|
||||||
|
tintEmojis: ['💦', '💧'],
|
||||||
|
morphTo: '🤮',
|
||||||
|
morphAfterMs: 220,
|
||||||
|
wobble: true,
|
||||||
|
},
|
||||||
|
'🤮': {
|
||||||
|
style: 'firework',
|
||||||
|
fullscreen: true,
|
||||||
|
particleCount: 460,
|
||||||
|
gravity: 1250,
|
||||||
|
drag: 1,
|
||||||
|
speedMin: 420,
|
||||||
|
speedMax: 1050,
|
||||||
|
launchUpMin: 0,
|
||||||
|
launchUpMax: 30,
|
||||||
|
spinMin: -600,
|
||||||
|
spinMax: 600,
|
||||||
|
fontSizeMin: 18,
|
||||||
|
fontSizeMax: 36,
|
||||||
|
heroFontSizeMin: 46,
|
||||||
|
heroFontSizeMax: 60,
|
||||||
|
angleBias: Math.PI / 2,
|
||||||
|
angleSpread: Math.PI * 1.15,
|
||||||
|
companions: ['💦', '💧', '💚'],
|
||||||
|
companionChance: 1,
|
||||||
|
tintColor: 'rgba(72, 190, 48, 0.72)',
|
||||||
|
tintEmojis: ['💦', '💧'],
|
||||||
|
wobble: true,
|
||||||
|
},
|
||||||
|
'🐱': {
|
||||||
|
style: 'bounce',
|
||||||
|
particleCount: 20,
|
||||||
|
gravity: 440,
|
||||||
|
speedMin: 150,
|
||||||
|
speedMax: 320,
|
||||||
|
launchUpMin: 90,
|
||||||
|
launchUpMax: 180,
|
||||||
|
spinMin: -160,
|
||||||
|
spinMax: 160,
|
||||||
|
},
|
||||||
|
'🐶': {
|
||||||
|
style: 'bounce',
|
||||||
|
particleCount: 20,
|
||||||
|
gravity: 460,
|
||||||
|
speedMin: 160,
|
||||||
|
speedMax: 330,
|
||||||
|
launchUpMin: 85,
|
||||||
|
launchUpMax: 175,
|
||||||
|
wobble: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getEmojiBurstProfile(emoji: string): EmojiBurstProfile {
|
||||||
|
const override = PROFILE_OVERRIDES[emoji];
|
||||||
|
if (!override) return DEFAULT_PROFILE;
|
||||||
|
return { ...DEFAULT_PROFILE, ...override };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFullscreenBurstEmoji(emoji: string): boolean {
|
||||||
|
return getEmojiBurstProfile(emoji).fullscreen === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickParticleEmoji(profile: EmojiBurstProfile, primaryEmoji: string): string {
|
||||||
|
if (!profile.companions?.length || !profile.companionChance) {
|
||||||
|
return primaryEmoji;
|
||||||
|
}
|
||||||
|
if (Math.random() >= profile.companionChance) {
|
||||||
|
return primaryEmoji;
|
||||||
|
}
|
||||||
|
const companion = profile.companions[Math.floor(Math.random() * profile.companions.length)];
|
||||||
|
return companion ?? primaryEmoji;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sampleBurstAngle(profile: EmojiBurstProfile): number {
|
||||||
|
if (profile.angleSpread === undefined) {
|
||||||
|
return Math.random() * Math.PI * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bias = profile.angleBias ?? -Math.PI / 2;
|
||||||
|
const halfSpread = profile.angleSpread / 2;
|
||||||
|
return bias + (Math.random() - 0.5) * profile.angleSpread * (0.6 + Math.random() * 0.4);
|
||||||
|
}
|
||||||
65
src/app/features/room/emoji-confetti/findJumboMount.ts
Normal file
65
src/app/features/room/emoji-confetti/findJumboMount.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
export function findJumboEmojiElement(targetEventId: string): HTMLElement | null {
|
||||||
|
const message = document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
|
||||||
|
if (!message) return null;
|
||||||
|
|
||||||
|
const jumbo = message.querySelector('[data-jumbo-emoji]');
|
||||||
|
return jumbo instanceof HTMLElement ? jumbo : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JumboGlyphMetrics = {
|
||||||
|
/** Outer jumbo mount (message body) — hide/show this. */
|
||||||
|
mount: HTMLElement;
|
||||||
|
/** Visual glyph center in viewport coords. */
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
/** CSS px size matching the rendered emoji (usually computed font-size). */
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure the on-screen jumbo glyph. Prefer computed font-size so the canvas
|
||||||
|
* stand-in matches unicode emoji; fall back to the glyph box for images.
|
||||||
|
*/
|
||||||
|
export function measureJumboGlyph(targetEventId: string): JumboGlyphMetrics | null {
|
||||||
|
const mount = findJumboEmojiElement(targetEventId);
|
||||||
|
if (!mount) return null;
|
||||||
|
|
||||||
|
const glyph =
|
||||||
|
(mount.querySelector('[data-emoticon]') as HTMLElement | null) ||
|
||||||
|
(mount.querySelector('img') as HTMLElement | null) ||
|
||||||
|
mount;
|
||||||
|
|
||||||
|
const rect = glyph.getBoundingClientRect();
|
||||||
|
const fontSize =
|
||||||
|
parseFloat(getComputedStyle(glyph).fontSize) ||
|
||||||
|
parseFloat(getComputedStyle(mount).fontSize) ||
|
||||||
|
0;
|
||||||
|
|
||||||
|
const isImg = glyph instanceof HTMLImageElement || glyph.tagName === 'IMG';
|
||||||
|
const size = isImg
|
||||||
|
? Math.max(rect.width, rect.height)
|
||||||
|
: fontSize > 0
|
||||||
|
? fontSize
|
||||||
|
: Math.max(rect.width, rect.height);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mount,
|
||||||
|
x: rect.left + rect.width / 2,
|
||||||
|
y: rect.top + rect.height / 2,
|
||||||
|
size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setJumboEmojiHidden(targetEventId: string, hidden: boolean) {
|
||||||
|
const mount = findJumboEmojiElement(targetEventId);
|
||||||
|
if (!mount) return;
|
||||||
|
if (hidden) {
|
||||||
|
mount.style.visibility = 'hidden';
|
||||||
|
} else {
|
||||||
|
mount.style.removeProperty('visibility');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalBurstCanvasSize(maskRadius: number): number {
|
||||||
|
return Math.max(300, Math.round(maskRadius * 6.5));
|
||||||
|
}
|
||||||
515
src/app/features/room/emoji-confetti/fireworkParticleEngine.ts
Normal file
515
src/app/features/room/emoji-confetti/fireworkParticleEngine.ts
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
/**
|
||||||
|
* Old-school fullscreen firework fountain — no physics engine.
|
||||||
|
* Position/velocity arrays, gravity, floor bounce, settle into a pile.
|
||||||
|
* Same vibe as late-90s/MSN page effects, just with emoji.
|
||||||
|
*/
|
||||||
|
import { BurstPoint } from './burstOrigin';
|
||||||
|
import {
|
||||||
|
pickParticleEmoji,
|
||||||
|
sampleBurstAngle,
|
||||||
|
type EmojiBurstProfile,
|
||||||
|
} from './emojiParticleProfiles';
|
||||||
|
|
||||||
|
const EMOJI_CACHE_PX = 40;
|
||||||
|
const EMOJI_CACHE_SCALE = 2;
|
||||||
|
|
||||||
|
const SPAWN_WINDOW_MS = 2200;
|
||||||
|
const SETTLE_HOLD_MS = 7800;
|
||||||
|
const FADE_MS = 800;
|
||||||
|
export const FIREWORK_TOTAL_MS = SETTLE_HOLD_MS + FADE_MS;
|
||||||
|
|
||||||
|
const SPARKLE_IN_MS = 120;
|
||||||
|
const MAX_SPAWNS_PER_FRAME = 6;
|
||||||
|
const FLOOR_PAD = 8;
|
||||||
|
const BOUNCE = 0.38;
|
||||||
|
const DRAG = 0.992;
|
||||||
|
const SETTLE_SPEED = 55;
|
||||||
|
const PILE_CELL = 22;
|
||||||
|
|
||||||
|
type SpawnItem = {
|
||||||
|
atMs: number;
|
||||||
|
emoji: string;
|
||||||
|
fontSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FireworkParticle = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
spin: number;
|
||||||
|
angle: number;
|
||||||
|
emoji: string;
|
||||||
|
emojiCanvas: HTMLCanvasElement;
|
||||||
|
drawSize: number;
|
||||||
|
halfSize: number;
|
||||||
|
bornAt: number;
|
||||||
|
settled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HeroFace = {
|
||||||
|
emoji: string;
|
||||||
|
emojiCanvas: HTMLCanvasElement;
|
||||||
|
/** Target CSS pixel size of the glyph (matches jumbo font-size). */
|
||||||
|
drawSize: number;
|
||||||
|
halfSize: number;
|
||||||
|
/** canvasPx / fontPx — draw box is drawSize * canvasScale so the glyph isn't cropped. */
|
||||||
|
canvasScale: number;
|
||||||
|
morphed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FireworkSim = {
|
||||||
|
particles: FireworkParticle[];
|
||||||
|
profile: EmojiBurstProfile;
|
||||||
|
primaryEmoji: string;
|
||||||
|
morphAtMs: number | null;
|
||||||
|
/** When to drop the stand-in hero and reveal the real jumbo again. */
|
||||||
|
heroUntilMs: number | null;
|
||||||
|
hero: HeroFace | null;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
origin: BurstPoint;
|
||||||
|
startMs: number;
|
||||||
|
spawnPlan: SpawnItem[];
|
||||||
|
spawnCursor: number;
|
||||||
|
floorY: number;
|
||||||
|
/** Column stack heights for the settled pile (in cells). */
|
||||||
|
pileCols: Uint16Array;
|
||||||
|
pileCanvas: HTMLCanvasElement | null;
|
||||||
|
pileCtx: CanvasRenderingContext2D | null;
|
||||||
|
flyingCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emojiCanvasCache = new Map<string, HTMLCanvasElement>();
|
||||||
|
|
||||||
|
function lerp(min: number, max: number): number {
|
||||||
|
return min + Math.random() * (max - min);
|
||||||
|
}
|
||||||
|
|
||||||
|
const HERO_PAD_RATIO = 0.24;
|
||||||
|
|
||||||
|
type EmojiBitmap = {
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
/** Multiply CSS glyph size by this when drawImage'ing to keep padding uncropped. */
|
||||||
|
canvasScale: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getEmojiCanvas(emoji: string, tintColor?: string, cssPx?: number): HTMLCanvasElement {
|
||||||
|
return getEmojiBitmap(emoji, tintColor, cssPx, 0).canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmojiBitmap(
|
||||||
|
emoji: string,
|
||||||
|
tintColor: string | undefined,
|
||||||
|
cssPx: number | undefined,
|
||||||
|
padRatio: number
|
||||||
|
): EmojiBitmap {
|
||||||
|
const targetCss = Math.max(16, Math.round(cssPx ?? EMOJI_CACHE_PX));
|
||||||
|
const dpr = typeof window !== 'undefined' ? Math.min(window.devicePixelRatio || 1, 2) : 1;
|
||||||
|
const fontPx = Math.round(targetCss * dpr);
|
||||||
|
const pad = Math.ceil(fontPx * padRatio);
|
||||||
|
const size = fontPx + pad * 2;
|
||||||
|
const cacheKey = `${emoji}|px:${fontPx}|pad:${pad}|tint:${tintColor ?? ''}`;
|
||||||
|
const cached = emojiCanvasCache.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return { canvas: cached, canvasScale: size / fontPx };
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
ctx.font = `${fontPx}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
|
||||||
|
// Slight optical nudge — color emoji fonts sit high in the em box.
|
||||||
|
ctx.fillText(emoji, size / 2, size / 2 + fontPx * 0.06);
|
||||||
|
|
||||||
|
if (tintColor) {
|
||||||
|
ctx.globalCompositeOperation = 'source-atop';
|
||||||
|
ctx.fillStyle = tintColor;
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emojiCanvasCache.set(cacheKey, canvas);
|
||||||
|
return { canvas, canvasScale: size / Math.max(fontPx, 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHeroEmoji(hero: HeroFace, emoji: string) {
|
||||||
|
const bitmap = getEmojiBitmap(emoji, undefined, hero.drawSize, HERO_PAD_RATIO);
|
||||||
|
hero.emoji = emoji;
|
||||||
|
hero.emojiCanvas = bitmap.canvas;
|
||||||
|
hero.canvasScale = bitmap.canvasScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHeroSize(hero: HeroFace, cssPx: number) {
|
||||||
|
if (!(cssPx > 0)) return;
|
||||||
|
if (Math.abs(cssPx - hero.drawSize) < 0.5) return;
|
||||||
|
hero.drawSize = cssPx;
|
||||||
|
hero.halfSize = cssPx / 2;
|
||||||
|
const bitmap = getEmojiBitmap(hero.emoji, undefined, cssPx, HERO_PAD_RATIO);
|
||||||
|
hero.emojiCanvas = bitmap.canvas;
|
||||||
|
hero.canvasScale = bitmap.canvasScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
function particleTint(profile: EmojiBurstProfile, emoji: string): string | undefined {
|
||||||
|
if (!profile.tintColor || !profile.tintEmojis?.length) return undefined;
|
||||||
|
return profile.tintEmojis.includes(emoji) ? profile.tintColor : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spew particles for morph bursts — never the morphTo face (that stays on the hero). */
|
||||||
|
function pickSpewEmoji(profile: EmojiBurstProfile, primaryEmoji: string): string {
|
||||||
|
const morphTo = profile.morphTo;
|
||||||
|
const companions = (profile.companions ?? []).filter((e) => e !== morphTo);
|
||||||
|
if (morphTo && companions.length) {
|
||||||
|
return companions[Math.floor(Math.random() * companions.length)] ?? primaryEmoji;
|
||||||
|
}
|
||||||
|
const picked = pickParticleEmoji(profile, primaryEmoji);
|
||||||
|
return picked === morphTo ? primaryEmoji : picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSpawnPlan(
|
||||||
|
profile: EmojiBurstProfile,
|
||||||
|
primaryEmoji: string,
|
||||||
|
startMs: number
|
||||||
|
): SpawnItem[] {
|
||||||
|
// Anchored morphing hero is drawn separately — skip a flying hero twin.
|
||||||
|
const plan: SpawnItem[] = profile.morphTo
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
atMs: startMs,
|
||||||
|
emoji: primaryEmoji,
|
||||||
|
fontSize: lerp(profile.heroFontSizeMin, profile.heroFontSizeMax),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const count = profile.particleCount;
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
const slot = (i + 0.5) / count;
|
||||||
|
const eased = slot * slot;
|
||||||
|
plan.push({
|
||||||
|
atMs:
|
||||||
|
startMs +
|
||||||
|
80 +
|
||||||
|
eased * SPAWN_WINDOW_MS +
|
||||||
|
(Math.random() - 0.5) * 70,
|
||||||
|
emoji: pickSpewEmoji(profile, primaryEmoji),
|
||||||
|
fontSize: lerp(profile.fontSizeMin, profile.fontSizeMax),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.sort((a, b) => a.atMs - b.atMs);
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMorph(sim: FireworkSim, now: number) {
|
||||||
|
const to = sim.profile.morphTo;
|
||||||
|
if (!to || sim.morphAtMs === null || now < sim.morphAtMs) return;
|
||||||
|
if (sim.hero?.morphed) return;
|
||||||
|
|
||||||
|
// Only the stand-in face morphs — spray stays droplets / companions.
|
||||||
|
if (sim.hero) {
|
||||||
|
setHeroEmoji(sim.hero, to);
|
||||||
|
sim.hero.morphed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePile(sim: FireworkSim): CanvasRenderingContext2D {
|
||||||
|
if (sim.pileCanvas && sim.pileCtx) return sim.pileCtx;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = sim.width;
|
||||||
|
canvas.height = sim.height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('firework pile unsupported');
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
sim.pileCanvas = canvas;
|
||||||
|
sim.pileCtx = ctx;
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stampSettled(sim: FireworkSim, p: FireworkParticle) {
|
||||||
|
const ctx = ensurePile(sim);
|
||||||
|
const rad = (p.angle * Math.PI) / 180;
|
||||||
|
const cos = Math.cos(rad);
|
||||||
|
const sin = Math.sin(rad);
|
||||||
|
ctx.setTransform(cos, sin, -sin, cos, p.x, p.y);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.drawImage(p.emojiCanvas, -p.halfSize, -p.halfSize, p.drawSize, p.drawSize);
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnOne(sim: FireworkSim, item: SpawnItem, now: number) {
|
||||||
|
const { profile, origin } = sim;
|
||||||
|
const jitter = Math.max(4, (origin.maskRadius ?? 28) * 0.25);
|
||||||
|
const directed = profile.angleSpread !== undefined;
|
||||||
|
const angle = directed ? sampleBurstAngle(profile) : Math.random() * Math.PI * 2;
|
||||||
|
const speed = lerp(profile.speedMin, profile.speedMax);
|
||||||
|
const launchUp = lerp(profile.launchUpMin, profile.launchUpMax);
|
||||||
|
const drawSize = item.fontSize * EMOJI_CACHE_SCALE * 0.85;
|
||||||
|
const emoji = item.emoji;
|
||||||
|
|
||||||
|
// Directed cones (e.g. throw-up) push along the sampled angle.
|
||||||
|
// Undirected fireworks keep the classic upward launch kick.
|
||||||
|
let vx = Math.cos(angle) * speed + (Math.random() - 0.5) * 40;
|
||||||
|
let vy = Math.sin(angle) * speed;
|
||||||
|
if (directed) {
|
||||||
|
const bias = profile.angleBias ?? angle;
|
||||||
|
vx += Math.cos(bias) * launchUp * 0.25;
|
||||||
|
vy += Math.sin(bias) * launchUp;
|
||||||
|
} else {
|
||||||
|
vy -= launchUp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tint = particleTint(profile, emoji);
|
||||||
|
|
||||||
|
sim.particles.push({
|
||||||
|
x: origin.x + (Math.random() - 0.5) * jitter,
|
||||||
|
y: origin.y + (Math.random() - 0.5) * jitter,
|
||||||
|
vx,
|
||||||
|
vy,
|
||||||
|
spin: lerp(profile.spinMin, profile.spinMax) * 0.35,
|
||||||
|
angle: (Math.random() - 0.5) * 40,
|
||||||
|
emoji,
|
||||||
|
emojiCanvas: getEmojiCanvas(emoji, tint),
|
||||||
|
drawSize,
|
||||||
|
halfSize: drawSize / 2,
|
||||||
|
bornAt: now,
|
||||||
|
settled: false,
|
||||||
|
});
|
||||||
|
sim.flyingCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function settleParticle(sim: FireworkSim, p: FireworkParticle) {
|
||||||
|
const cols = sim.pileCols.length;
|
||||||
|
let col = Math.floor(p.x / PILE_CELL);
|
||||||
|
if (col < 0) col = 0;
|
||||||
|
if (col >= cols) col = cols - 1;
|
||||||
|
|
||||||
|
// Prefer the intended column; spill to neighbors if that stack is already tall.
|
||||||
|
let bestCol = col;
|
||||||
|
let bestH = sim.pileCols[col];
|
||||||
|
for (const delta of [0, -1, 1, -2, 2]) {
|
||||||
|
const c = col + delta;
|
||||||
|
if (c < 0 || c >= cols) continue;
|
||||||
|
if (sim.pileCols[c] < bestH) {
|
||||||
|
bestH = sim.pileCols[c];
|
||||||
|
bestCol = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = sim.pileCols[bestCol];
|
||||||
|
sim.pileCols[bestCol] = stack + 1;
|
||||||
|
p.x = bestCol * PILE_CELL + PILE_CELL * 0.5 + (Math.random() - 0.5) * 6;
|
||||||
|
p.y = sim.floorY - stack * (PILE_CELL * 0.72) - p.halfSize;
|
||||||
|
p.vx = 0;
|
||||||
|
p.vy = 0;
|
||||||
|
p.spin = 0;
|
||||||
|
p.settled = true;
|
||||||
|
sim.flyingCount = Math.max(0, sim.flyingCount - 1);
|
||||||
|
stampSettled(sim, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFireworkSim(
|
||||||
|
widthPx: number,
|
||||||
|
heightPx: number,
|
||||||
|
originPx: BurstPoint,
|
||||||
|
primaryEmoji: string,
|
||||||
|
profile: EmojiBurstProfile,
|
||||||
|
startMs = performance.now(),
|
||||||
|
/** Pixel size of the source jumbo emoji — hero face matches this when set. */
|
||||||
|
heroSizePx?: number
|
||||||
|
): FireworkSim {
|
||||||
|
const colCount = Math.max(8, Math.ceil(widthPx / PILE_CELL));
|
||||||
|
const fallbackHero =
|
||||||
|
lerp(profile.heroFontSizeMin, profile.heroFontSizeMax) * EMOJI_CACHE_SCALE * 0.95;
|
||||||
|
const heroSize = heroSizePx && heroSizePx > 0 ? heroSizePx : fallbackHero;
|
||||||
|
const hero: HeroFace | null = profile.morphTo
|
||||||
|
? (() => {
|
||||||
|
const bitmap = getEmojiBitmap(primaryEmoji, undefined, heroSize, HERO_PAD_RATIO);
|
||||||
|
return {
|
||||||
|
emoji: primaryEmoji,
|
||||||
|
emojiCanvas: bitmap.canvas,
|
||||||
|
drawSize: heroSize,
|
||||||
|
halfSize: heroSize / 2,
|
||||||
|
canvasScale: bitmap.canvasScale,
|
||||||
|
morphed: false,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const spawnPlan = buildSpawnPlan(profile, primaryEmoji, startMs);
|
||||||
|
// Keep the spewing stand-in up for the whole fountain — not just a short flash.
|
||||||
|
const lastSpawnAt = spawnPlan.length > 0 ? spawnPlan[spawnPlan.length - 1].atMs : startMs;
|
||||||
|
const heroUntilMs = profile.morphTo
|
||||||
|
? profile.heroDurationMs != null
|
||||||
|
? startMs + profile.heroDurationMs
|
||||||
|
: lastSpawnAt + 120
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
particles: [],
|
||||||
|
profile,
|
||||||
|
primaryEmoji,
|
||||||
|
morphAtMs: profile.morphTo ? startMs + (profile.morphAfterMs ?? 220) : null,
|
||||||
|
heroUntilMs,
|
||||||
|
hero,
|
||||||
|
width: widthPx,
|
||||||
|
height: heightPx,
|
||||||
|
origin: originPx,
|
||||||
|
startMs,
|
||||||
|
spawnPlan,
|
||||||
|
spawnCursor: 0,
|
||||||
|
floorY: heightPx - FLOOR_PAD,
|
||||||
|
pileCols: new Uint16Array(colCount),
|
||||||
|
pileCanvas: null,
|
||||||
|
pileCtx: null,
|
||||||
|
flyingCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the stand-in face glued to the live jumbo metrics. */
|
||||||
|
export function syncFireworkHero(
|
||||||
|
sim: FireworkSim,
|
||||||
|
metrics: { x: number; y: number; size: number } | null
|
||||||
|
) {
|
||||||
|
if (!sim.hero || !metrics) return;
|
||||||
|
sim.origin.x = metrics.x;
|
||||||
|
sim.origin.y = metrics.y;
|
||||||
|
setHeroSize(sim.hero, metrics.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns false when the burst should be removed. */
|
||||||
|
export function stepFireworkSim(sim: FireworkSim, now: number, dtSeconds: number): boolean {
|
||||||
|
if (now - sim.startMs > FIREWORK_TOTAL_MS) return false;
|
||||||
|
|
||||||
|
applyMorph(sim, now);
|
||||||
|
|
||||||
|
if (sim.hero && sim.heroUntilMs !== null && now >= sim.heroUntilMs) {
|
||||||
|
sim.hero = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dt = Math.min(dtSeconds, 0.05);
|
||||||
|
const g = sim.profile.gravity;
|
||||||
|
|
||||||
|
let spawned = 0;
|
||||||
|
while (
|
||||||
|
spawned < MAX_SPAWNS_PER_FRAME &&
|
||||||
|
sim.spawnCursor < sim.spawnPlan.length &&
|
||||||
|
sim.spawnPlan[sim.spawnCursor].atMs <= now
|
||||||
|
) {
|
||||||
|
spawnOne(sim, sim.spawnPlan[sim.spawnCursor], now);
|
||||||
|
sim.spawnCursor += 1;
|
||||||
|
spawned += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const floor = sim.floorY;
|
||||||
|
const left = 4;
|
||||||
|
const right = sim.width - 4;
|
||||||
|
|
||||||
|
for (let i = 0; i < sim.particles.length; i += 1) {
|
||||||
|
const p = sim.particles[i];
|
||||||
|
if (p.settled) continue;
|
||||||
|
|
||||||
|
p.vy += g * dt;
|
||||||
|
p.vx *= DRAG;
|
||||||
|
p.vy *= DRAG;
|
||||||
|
p.x += p.vx * dt;
|
||||||
|
p.y += p.vy * dt;
|
||||||
|
p.angle += p.spin * dt;
|
||||||
|
|
||||||
|
if (p.x < left) {
|
||||||
|
p.x = left;
|
||||||
|
p.vx = Math.abs(p.vx) * BOUNCE;
|
||||||
|
} else if (p.x > right) {
|
||||||
|
p.x = right;
|
||||||
|
p.vx = -Math.abs(p.vx) * BOUNCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.y >= floor - p.halfSize) {
|
||||||
|
p.y = floor - p.halfSize;
|
||||||
|
const speed = Math.hypot(p.vx, p.vy);
|
||||||
|
if (speed < SETTLE_SPEED || Math.abs(p.vy) < SETTLE_SPEED * 0.55) {
|
||||||
|
settleParticle(sim, p);
|
||||||
|
} else {
|
||||||
|
p.vy = -Math.abs(p.vy) * BOUNCE;
|
||||||
|
p.vx *= 0.85;
|
||||||
|
p.spin *= 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawFireworkSim(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
sim: FireworkSim,
|
||||||
|
now: number
|
||||||
|
) {
|
||||||
|
const elapsed = now - sim.startMs;
|
||||||
|
let fade = 1;
|
||||||
|
if (elapsed >= SETTLE_HOLD_MS) {
|
||||||
|
fade = 1 - Math.min(1, (elapsed - SETTLE_HOLD_MS) / FADE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
context.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
if (sim.pileCanvas) {
|
||||||
|
context.globalAlpha = fade;
|
||||||
|
context.drawImage(sim.pileCanvas, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anchored face at the spew origin — morphs 🤢 → 🤮 mid-throw, then drops away.
|
||||||
|
if (sim.hero && fade > 0.02) {
|
||||||
|
const hero = sim.hero;
|
||||||
|
const heaveAmp = Math.max(1.5, hero.drawSize * 0.02);
|
||||||
|
const heave = Math.sin(elapsed / 70) * (hero.morphed ? heaveAmp * 1.25 : heaveAmp);
|
||||||
|
// Scale includes bitmap padding so the glyph matches jumbo size without clipping.
|
||||||
|
const box = hero.drawSize * hero.canvasScale;
|
||||||
|
const half = box / 2;
|
||||||
|
context.globalAlpha = fade;
|
||||||
|
context.setTransform(1, 0, 0, 1, sim.origin.x, sim.origin.y + heave);
|
||||||
|
context.drawImage(hero.emojiCanvas, -half, -half, box, box);
|
||||||
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < sim.particles.length; i += 1) {
|
||||||
|
const p = sim.particles[i];
|
||||||
|
if (p.settled) continue;
|
||||||
|
|
||||||
|
const ageMs = now - p.bornAt;
|
||||||
|
if (ageMs < 0) continue;
|
||||||
|
|
||||||
|
let opacity = fade;
|
||||||
|
let scale = 1;
|
||||||
|
if (ageMs < SPARKLE_IN_MS) {
|
||||||
|
const t = ageMs / SPARKLE_IN_MS;
|
||||||
|
const appear = t * t * (3 - 2 * t);
|
||||||
|
scale = 0.5 + 0.65 * appear;
|
||||||
|
opacity *= appear;
|
||||||
|
}
|
||||||
|
if (opacity <= 0.02) continue;
|
||||||
|
|
||||||
|
const rad = (p.angle * Math.PI) / 180;
|
||||||
|
const size = p.drawSize * scale;
|
||||||
|
const half = size / 2;
|
||||||
|
const cos = Math.cos(rad);
|
||||||
|
const sin = Math.sin(rad);
|
||||||
|
|
||||||
|
context.globalAlpha = opacity;
|
||||||
|
context.setTransform(cos, sin, -sin, cos, p.x, p.y);
|
||||||
|
context.drawImage(p.emojiCanvas, -half, -half, size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.globalAlpha = 1;
|
||||||
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFireworkDpr(): number {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { KeyboardEvent, MouseEvent } from 'react';
|
||||||
|
|
||||||
|
export type JumboEmojiClickHandler = (body: string, event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => void;
|
||||||
|
|
||||||
|
export function getJumboEmojiInteractionProps({
|
||||||
|
body,
|
||||||
|
isJumboEmoji,
|
||||||
|
onJumboEmojiClick,
|
||||||
|
}: {
|
||||||
|
body: string;
|
||||||
|
isJumboEmoji: boolean;
|
||||||
|
onJumboEmojiClick?: JumboEmojiClickHandler;
|
||||||
|
}) {
|
||||||
|
if (!isJumboEmoji || !onJumboEmojiClick) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const activate = (event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => {
|
||||||
|
if ('key' in event) {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
event.stopPropagation();
|
||||||
|
onJumboEmojiClick(body, event);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: 'button' as const,
|
||||||
|
tabIndex: 0,
|
||||||
|
title: 'Throw emoji confetti',
|
||||||
|
onClick: activate,
|
||||||
|
onKeyDown: activate,
|
||||||
|
};
|
||||||
|
}
|
||||||
54
src/app/features/room/emoji-confetti/resolveClickedEmoji.ts
Normal file
54
src/app/features/room/emoji-confetti/resolveClickedEmoji.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { KeyboardEvent, MouseEvent } from 'react';
|
||||||
|
import { extractJumboEmojis } from './sendEmojiConfetti';
|
||||||
|
import { BurstPoint, getBurstPointFromElement } from './burstOrigin';
|
||||||
|
|
||||||
|
export function resolveClickedEmoji(
|
||||||
|
event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>,
|
||||||
|
fallbackBody: string
|
||||||
|
): { emoji: string; origin: BurstPoint } | null {
|
||||||
|
const currentTarget = event.currentTarget as HTMLElement;
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
const emoticonEl = target.closest('[data-emoticon]') ?? currentTarget.querySelector('[data-emoticon]');
|
||||||
|
if (emoticonEl instanceof HTMLElement) {
|
||||||
|
const emoji = emoticonEl.getAttribute('data-emoticon');
|
||||||
|
if (emoji) {
|
||||||
|
return {
|
||||||
|
emoji,
|
||||||
|
origin: getBurstPointFromElement(emoticonEl),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentTarget.closest('[data-jumbo-emoji]') && !currentTarget.hasAttribute('data-jumbo-emoji')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojis = extractJumboEmojis(fallbackBody);
|
||||||
|
if (emojis.length === 0) return null;
|
||||||
|
|
||||||
|
if ('clientX' in event) {
|
||||||
|
return {
|
||||||
|
emoji: emojis[0],
|
||||||
|
origin: {
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstEmoticon = currentTarget.querySelector('[data-emoticon]');
|
||||||
|
if (firstEmoticon instanceof HTMLElement) {
|
||||||
|
const emoji = firstEmoticon.getAttribute('data-emoticon');
|
||||||
|
if (emoji) {
|
||||||
|
return {
|
||||||
|
emoji,
|
||||||
|
origin: getBurstPointFromElement(firstEmoticon),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
emoji: emojis[0],
|
||||||
|
origin: getBurstPointFromElement(currentTarget),
|
||||||
|
};
|
||||||
|
}
|
||||||
42
src/app/features/room/emoji-confetti/sendEmojiConfetti.ts
Normal file
42
src/app/features/room/emoji-confetti/sendEmojiConfetti.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { EMOJI_REG_G, JUMBO_EMOJI_REG } from '../../../utils/regex';
|
||||||
|
import { trimReplyFromBody } from '../../../utils/room';
|
||||||
|
import { emojis } from '../../../plugins/emoji';
|
||||||
|
import { EMOJI_CONFETTI_EVENT_TYPE, EmojiConfettiContent } from './types';
|
||||||
|
|
||||||
|
const SHORTCODE_PATTERN = /:([^\s:]+):/g;
|
||||||
|
|
||||||
|
export function extractJumboEmojis(body: string): string[] {
|
||||||
|
const trimmedBody = trimReplyFromBody(body).trim();
|
||||||
|
if (!JUMBO_EMOJI_REG.test(trimmedBody)) return [];
|
||||||
|
|
||||||
|
const found: string[] = [];
|
||||||
|
|
||||||
|
for (const match of trimmedBody.matchAll(EMOJI_REG_G)) {
|
||||||
|
const emoji = match[1];
|
||||||
|
if (emoji) found.push(emoji);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of trimmedBody.matchAll(SHORTCODE_PATTERN)) {
|
||||||
|
const shortcode = match[1];
|
||||||
|
const resolved = emojis.find((emoji) => emoji.shortcode === shortcode);
|
||||||
|
if (resolved) found.push(resolved.unicode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return found.length > 0 ? found : ['🎉'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendEmojiConfettiEvent(
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
targetEventId: string,
|
||||||
|
emojis: string[]
|
||||||
|
) {
|
||||||
|
const content: EmojiConfettiContent = {
|
||||||
|
emojis,
|
||||||
|
msgtype: EMOJI_CONFETTI_EVENT_TYPE,
|
||||||
|
target_event_id: targetEventId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return mx.sendEvent(roomId, EMOJI_CONFETTI_EVENT_TYPE as never, content);
|
||||||
|
}
|
||||||
16
src/app/features/room/emoji-confetti/types.ts
Normal file
16
src/app/features/room/emoji-confetti/types.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { BurstPoint } from './burstOrigin';
|
||||||
|
|
||||||
|
export const EMOJI_CONFETTI_EVENT_TYPE = 'app.relay.emoji_confetti';
|
||||||
|
|
||||||
|
export type EmojiConfettiContent = {
|
||||||
|
emojis?: string[];
|
||||||
|
msgtype?: string;
|
||||||
|
target_event_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EmojiConfettiBurst = {
|
||||||
|
id: string;
|
||||||
|
targetEventId: string;
|
||||||
|
emojis: string[];
|
||||||
|
origin: BurstPoint;
|
||||||
|
};
|
||||||
137
src/app/features/room/emoji-confetti/useEmojiConfetti.ts
Normal file
137
src/app/features/room/emoji-confetti/useEmojiConfetti.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
|
||||||
|
import { BurstPoint, resolveBurstOrigin } from './burstOrigin';
|
||||||
|
import {
|
||||||
|
EMOJI_CONFETTI_EVENT_TYPE,
|
||||||
|
EmojiConfettiBurst,
|
||||||
|
EmojiConfettiContent,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
function scheduleBurstFromValues(
|
||||||
|
burstId: string,
|
||||||
|
emojis: string[],
|
||||||
|
targetEventId: string | undefined,
|
||||||
|
onBurst: (burst: EmojiConfettiBurst) => void,
|
||||||
|
attempt = 0,
|
||||||
|
explicitOrigin?: BurstPoint
|
||||||
|
) {
|
||||||
|
const targetElement =
|
||||||
|
targetEventId &&
|
||||||
|
document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
|
||||||
|
|
||||||
|
if (targetEventId && !targetElement && attempt < 8) {
|
||||||
|
window.setTimeout(
|
||||||
|
() =>
|
||||||
|
scheduleBurstFromValues(
|
||||||
|
burstId,
|
||||||
|
emojis,
|
||||||
|
targetEventId,
|
||||||
|
onBurst,
|
||||||
|
attempt + 1,
|
||||||
|
explicitOrigin
|
||||||
|
),
|
||||||
|
50
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onBurst({
|
||||||
|
id: burstId,
|
||||||
|
targetEventId: targetEventId ?? '',
|
||||||
|
emojis: emojis.length > 0 ? emojis : ['🎉'],
|
||||||
|
origin: resolveBurstOrigin(targetEventId, emojis, explicitOrigin),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleBurst(
|
||||||
|
event: MatrixEvent,
|
||||||
|
onBurst: (burst: EmojiConfettiBurst) => void,
|
||||||
|
attempt = 0
|
||||||
|
) {
|
||||||
|
const eventId = event.getId();
|
||||||
|
if (!eventId) return;
|
||||||
|
|
||||||
|
const { emojis, targetEventId } = parseEmojiConfettiContent(event.getContent());
|
||||||
|
scheduleBurstFromValues(eventId, emojis, targetEventId, onBurst, attempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEmojiConfettiContent(content: unknown): { emojis: string[]; targetEventId?: string } {
|
||||||
|
const confettiContent = content as EmojiConfettiContent;
|
||||||
|
const emojis = Array.isArray(confettiContent.emojis)
|
||||||
|
? confettiContent.emojis.filter((emoji): emoji is string => typeof emoji === 'string' && emoji.length > 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
emojis: emojis.length > 0 ? emojis : ['🎉'],
|
||||||
|
targetEventId:
|
||||||
|
typeof confettiContent.target_event_id === 'string'
|
||||||
|
? confettiContent.target_event_id
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEmojiConfetti(room: Room) {
|
||||||
|
const [bursts, setBursts] = useState<EmojiConfettiBurst[]>([]);
|
||||||
|
const seenEventIdsRef = useRef(new Set<string>());
|
||||||
|
const ownEventIdsRef = useRef(new Set<string>());
|
||||||
|
|
||||||
|
const addBurst = useCallback((burst: EmojiConfettiBurst) => {
|
||||||
|
setBursts((current) => [...current, burst]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const registerOwnEventId = useCallback((eventId: string) => {
|
||||||
|
ownEventIdsRef.current.add(eventId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const queueBurst = useCallback((event: MatrixEvent) => {
|
||||||
|
const eventId = event.getId();
|
||||||
|
if (!eventId || seenEventIdsRef.current.has(eventId)) return;
|
||||||
|
|
||||||
|
seenEventIdsRef.current.add(eventId);
|
||||||
|
|
||||||
|
if (ownEventIdsRef.current.has(eventId)) {
|
||||||
|
ownEventIdsRef.current.delete(eventId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleBurst(event, addBurst);
|
||||||
|
}, [addBurst]);
|
||||||
|
|
||||||
|
const triggerLocalBurst = useCallback(
|
||||||
|
(targetEventId: string, emojis: string[], origin?: BurstPoint) => {
|
||||||
|
const burstId = `local-${targetEventId}-${Date.now()}`;
|
||||||
|
scheduleBurstFromValues(burstId, emojis, targetEventId, addBurst, 0, origin);
|
||||||
|
},
|
||||||
|
[addBurst]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleTimeline: (
|
||||||
|
event: MatrixEvent,
|
||||||
|
eventRoom: Room | undefined,
|
||||||
|
toStartOfTimeline?: boolean,
|
||||||
|
removed?: boolean
|
||||||
|
) => void = (event, eventRoom, toStartOfTimeline, removed) => {
|
||||||
|
if (removed || toStartOfTimeline || eventRoom?.roomId !== room.roomId) return;
|
||||||
|
if (event.getType() !== EMOJI_CONFETTI_EVENT_TYPE) return;
|
||||||
|
|
||||||
|
queueBurst(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
room.on(RoomEvent.Timeline, handleTimeline);
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.Timeline, handleTimeline);
|
||||||
|
};
|
||||||
|
}, [queueBurst, room]);
|
||||||
|
|
||||||
|
const removeBurst = useCallback((burstId: string) => {
|
||||||
|
setBursts((current) => current.filter((burst) => burst.id !== burstId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bursts,
|
||||||
|
removeBurst,
|
||||||
|
triggerLocalBurst,
|
||||||
|
registerOwnEventId,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { KeyboardEvent, MouseEvent, useCallback } from 'react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { resolveClickedEmoji } from './resolveClickedEmoji';
|
||||||
|
import { sendEmojiConfettiEvent } from './sendEmojiConfetti';
|
||||||
|
import { useEmojiConfetti } from './useEmojiConfetti';
|
||||||
|
|
||||||
|
export function useJumboEmojiConfetti(room: Room) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const { bursts, removeBurst, triggerLocalBurst, registerOwnEventId } = useEmojiConfetti(room);
|
||||||
|
|
||||||
|
const handleJumboEmojiClick = useCallback(
|
||||||
|
(targetEventId: string, body: string, event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => {
|
||||||
|
const clicked = resolveClickedEmoji(event, body);
|
||||||
|
if (!clicked) return;
|
||||||
|
|
||||||
|
const emojis = [clicked.emoji];
|
||||||
|
|
||||||
|
triggerLocalBurst(targetEventId, emojis, clicked.origin);
|
||||||
|
sendEmojiConfettiEvent(mx, room.roomId, targetEventId, emojis)
|
||||||
|
.then((response) => {
|
||||||
|
const eventId = response?.event_id;
|
||||||
|
if (eventId) registerOwnEventId(eventId);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('[emoji-confetti] Failed to send confetti event:', error);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[mx, registerOwnEventId, room.roomId, triggerLocalBurst]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bursts,
|
||||||
|
removeBurst,
|
||||||
|
handleJumboEmojiClick,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -785,7 +785,7 @@ export const Message = as<'div', MessageProps>(
|
|||||||
const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
|
const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
|
||||||
|
|
||||||
// Get custom user color from avatar metadata (takes priority)
|
// Get custom user color from avatar metadata (takes priority)
|
||||||
const customUserColor = useOtherUserColor(senderId, senderAvatarMxc);
|
const customUserColor = useOtherUserColor(senderId, room);
|
||||||
|
|
||||||
const tagColor = memberPowerTag?.color
|
const tagColor = memberPowerTag?.color
|
||||||
? accessibleTagColors?.get(memberPowerTag.color)
|
? accessibleTagColors?.get(memberPowerTag.color)
|
||||||
|
|||||||
149
src/app/features/room/room-media-menu/RoomMediaMenu.css.ts
Normal file
149
src/app/features/room/room-media-menu/RoomMediaMenu.css.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const MediaDrawer = style({
|
||||||
|
width: toRem(399),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaDrawerSolo = style({
|
||||||
|
width: '100%',
|
||||||
|
flexGrow: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaDrawerHeader = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaDrawerContentBase = style({
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaDrawerContent = style({
|
||||||
|
padding: `${config.space.S100} ${config.space.S200} ${config.space.S200}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaMonthSection = style({
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaMonthHeader = style({
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 2,
|
||||||
|
margin: '5px 0',
|
||||||
|
padding: `0 ${config.space.S100}`,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
backgroundColor: color.Background.Container,
|
||||||
|
color: color.Background.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaDaySection = style({
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaDayHeader = style({
|
||||||
|
padding: `${config.space.S100} ${config.space.S100} ${config.space.S200}`,
|
||||||
|
color: color.Background.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaGrid = style({
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
||||||
|
gap: config.space.S200,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaTile = style({
|
||||||
|
position: 'relative',
|
||||||
|
aspectRatio: '1',
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaTileImg = style({
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
display: 'block',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaTileBadge = style({
|
||||||
|
position: 'absolute',
|
||||||
|
right: config.space.S100,
|
||||||
|
bottom: config.space.S100,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: toRem(24),
|
||||||
|
height: toRem(24),
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
||||||
|
color: '#fff',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaEmpty = style({
|
||||||
|
padding: config.space.S400,
|
||||||
|
textAlign: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaFooter = style({
|
||||||
|
padding: config.space.S200,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaUserFiltersScroll = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
flexGrow: 0,
|
||||||
|
overflowX: 'auto',
|
||||||
|
overflowY: 'hidden',
|
||||||
|
scrollbarWidth: 'thin',
|
||||||
|
scrollbarColor: `${color.SurfaceVariant.ContainerLine} ${color.SurfaceVariant.Container}`,
|
||||||
|
selectors: {
|
||||||
|
'&::-webkit-scrollbar': {
|
||||||
|
height: toRem(8),
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-track': {
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
backgroundColor: color.SurfaceVariant.ContainerLine,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.Container}`,
|
||||||
|
backgroundClip: 'padding-box',
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-thumb:hover': {
|
||||||
|
backgroundColor: color.Surface.ContainerLine,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaUserFilters = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'nowrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S500,
|
||||||
|
padding: `${config.space.S200} ${config.space.S200}`,
|
||||||
|
width: 'max-content',
|
||||||
|
minWidth: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaUserChip = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaUserChipLabel = style({
|
||||||
|
maxWidth: toRem(72),
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
});
|
||||||
524
src/app/features/room/room-media-menu/RoomMediaMenu.tsx
Normal file
524
src/app/features/room/room-media-menu/RoomMediaMenu.tsx
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Header,
|
||||||
|
IconButton,
|
||||||
|
Scroll,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
as,
|
||||||
|
config,
|
||||||
|
Chip,
|
||||||
|
Avatar,
|
||||||
|
} from 'folds';
|
||||||
|
import { MsgType, Room } from 'matrix-js-sdk';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { Icon, Icons } from '../../../components/icons';
|
||||||
|
import * as css from './RoomMediaMenu.css';
|
||||||
|
import { RoomMediaItem, useRoomMediaEvents } from '../../../hooks/useRoomMediaEvents';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
|
import {
|
||||||
|
decryptFile,
|
||||||
|
downloadEncryptedMedia,
|
||||||
|
getMxIdLocalPart,
|
||||||
|
isAuthenticatedMediaUrl,
|
||||||
|
mxcUrlToHttp,
|
||||||
|
} from '../../../utils/matrix';
|
||||||
|
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||||
|
import { Image } from '../../../components/media';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||||
|
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
|
||||||
|
import { VideoFrameThumbnail } from '../../../components/message/content/VideoFrameThumbnail';
|
||||||
|
import { getMemberAvatarMxc, getMemberDisplayName } from '../../../utils/room';
|
||||||
|
import { UserAvatar } from '../../../components/user-avatar';
|
||||||
|
import {
|
||||||
|
getMediaDrawerScrollTop,
|
||||||
|
isMediaDrawerAtom,
|
||||||
|
setMediaDrawerScrollTop,
|
||||||
|
} from '../../../state/mediaDrawer';
|
||||||
|
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
||||||
|
|
||||||
|
const fetchAuthenticatedMedia = async (
|
||||||
|
url: string,
|
||||||
|
accessToken: string | null
|
||||||
|
): Promise<string> => {
|
||||||
|
try {
|
||||||
|
let response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok && response.status === 401 && accessToken) {
|
||||||
|
response = await fetch(url, { method: 'GET' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) return url;
|
||||||
|
const blob = await response.blob();
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaTileThumbProps = {
|
||||||
|
item: RoomMediaItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
function MediaTileImageThumb({ item }: { item: RoomMediaItem & { thumbMxc: string } }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
||||||
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
const accessToken = getCurrentAccessToken();
|
||||||
|
|
||||||
|
if (item.thumbEncInfo) {
|
||||||
|
const mediaUrl = mxcUrlToHttp(mx, item.thumbMxc, useAuthentication) ?? item.thumbMxc;
|
||||||
|
const fileContent = await downloadEncryptedMedia(
|
||||||
|
mediaUrl,
|
||||||
|
(encBuf) =>
|
||||||
|
decryptFile(encBuf, item.thumbMimeType ?? FALLBACK_MIMETYPE, item.thumbEncInfo!),
|
||||||
|
accessToken
|
||||||
|
);
|
||||||
|
return URL.createObjectURL(fileContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaUrl =
|
||||||
|
mxcUrlToHttp(mx, item.thumbMxc, useAuthentication, 240, 240, 'crop') ?? item.thumbMxc;
|
||||||
|
|
||||||
|
if (useAuthentication && isAuthenticatedMediaUrl(mediaUrl)) {
|
||||||
|
return fetchAuthenticatedMedia(mediaUrl, accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaUrl;
|
||||||
|
}, [mx, item.thumbMxc, item.thumbEncInfo, item.thumbMimeType, useAuthentication])
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSrc();
|
||||||
|
}, [loadSrc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (srcState.status !== AsyncStatus.Success) return undefined;
|
||||||
|
const src = srcState.data;
|
||||||
|
if (!src.startsWith('blob:')) return undefined;
|
||||||
|
return () => URL.revokeObjectURL(src);
|
||||||
|
}, [srcState]);
|
||||||
|
|
||||||
|
if (srcState.status === AsyncStatus.Success) {
|
||||||
|
return <Image className={css.MediaTileImg} src={srcState.data} alt={item.body} loading="lazy" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box alignItems="Center" justifyContent="Center" style={{ width: '100%', height: '100%' }}>
|
||||||
|
{srcState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner size="200" />
|
||||||
|
) : (
|
||||||
|
<Icon src={Icons.Photo} size="400" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaTileFullImageThumb({ item }: { item: RoomMediaItem }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
||||||
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
const accessToken = getCurrentAccessToken();
|
||||||
|
const mediaUrl = mxcUrlToHttp(mx, item.fullMxc, useAuthentication) ?? item.fullMxc;
|
||||||
|
|
||||||
|
if (item.fullEncInfo) {
|
||||||
|
const fileContent = await downloadEncryptedMedia(
|
||||||
|
mediaUrl,
|
||||||
|
(encBuf) =>
|
||||||
|
decryptFile(encBuf, item.fullMimeType ?? FALLBACK_MIMETYPE, item.fullEncInfo!),
|
||||||
|
accessToken
|
||||||
|
);
|
||||||
|
return URL.createObjectURL(fileContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useAuthentication && isAuthenticatedMediaUrl(mediaUrl)) {
|
||||||
|
return fetchAuthenticatedMedia(mediaUrl, accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaUrl;
|
||||||
|
}, [mx, item.fullMxc, item.fullEncInfo, item.fullMimeType, useAuthentication])
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSrc();
|
||||||
|
}, [loadSrc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (srcState.status !== AsyncStatus.Success) return undefined;
|
||||||
|
const src = srcState.data;
|
||||||
|
if (!src.startsWith('blob:')) return undefined;
|
||||||
|
return () => URL.revokeObjectURL(src);
|
||||||
|
}, [srcState]);
|
||||||
|
|
||||||
|
if (srcState.status === AsyncStatus.Success) {
|
||||||
|
return <Image className={css.MediaTileImg} src={srcState.data} alt={item.body} loading="lazy" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box alignItems="Center" justifyContent="Center" style={{ width: '100%', height: '100%' }}>
|
||||||
|
{srcState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner size="200" />
|
||||||
|
) : (
|
||||||
|
<Icon src={Icons.Photo} size="400" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaTileThumb({ item }: MediaTileThumbProps) {
|
||||||
|
if (item.thumbMxc) {
|
||||||
|
return <MediaTileImageThumb item={{ ...item, thumbMxc: item.thumbMxc }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.msgtype === MsgType.Video) {
|
||||||
|
return (
|
||||||
|
<Box style={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||||
|
<Box
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
style={{ position: 'absolute', inset: 0 }}
|
||||||
|
>
|
||||||
|
<Spinner size="200" />
|
||||||
|
</Box>
|
||||||
|
<VideoFrameThumbnail
|
||||||
|
mimeType={item.fullMimeType ?? 'video/mp4'}
|
||||||
|
url={item.fullMxc}
|
||||||
|
encInfo={item.fullEncInfo}
|
||||||
|
renderImage={(src) => (
|
||||||
|
<Image className={css.MediaTileImg} src={src} alt={item.body} loading="lazy" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MediaTileFullImageThumb item={item} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaDayGroup = {
|
||||||
|
dayKey: string;
|
||||||
|
dayLabel: string;
|
||||||
|
items: RoomMediaItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaMonthGroup = {
|
||||||
|
monthKey: string;
|
||||||
|
monthLabel: string;
|
||||||
|
days: MediaDayGroup[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dayFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
const sameDayFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDayLabel = (ts: number): string => {
|
||||||
|
const date = new Date(ts);
|
||||||
|
const today = new Date();
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setDate(today.getDate() - 1);
|
||||||
|
|
||||||
|
if (sameDayFormatter.format(date) === sameDayFormatter.format(today)) return 'Today';
|
||||||
|
if (sameDayFormatter.format(date) === sameDayFormatter.format(yesterday)) return 'Yesterday';
|
||||||
|
return dayFormatter.format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupMediaByMonthAndDay = (items: RoomMediaItem[]): MediaMonthGroup[] => {
|
||||||
|
const months: MediaMonthGroup[] = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const date = new Date(item.ts);
|
||||||
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
const dayKey = `${monthKey}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
let month = months[months.length - 1];
|
||||||
|
if (!month || month.monthKey !== monthKey) {
|
||||||
|
month = {
|
||||||
|
monthKey,
|
||||||
|
monthLabel: monthFormatter.format(date),
|
||||||
|
days: [],
|
||||||
|
};
|
||||||
|
months.push(month);
|
||||||
|
}
|
||||||
|
|
||||||
|
let day = month.days[month.days.length - 1];
|
||||||
|
if (!day || day.dayKey !== dayKey) {
|
||||||
|
day = {
|
||||||
|
dayKey,
|
||||||
|
dayLabel: formatDayLabel(item.ts),
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
month.days.push(day);
|
||||||
|
}
|
||||||
|
|
||||||
|
day.items.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return months;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaSenderChip = {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
avatarMxc?: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MediaDrawerProps = {
|
||||||
|
room: Room;
|
||||||
|
/** Full-page takeover when the layout is too narrow for a side rail. */
|
||||||
|
solo?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MediaDrawer = as<'div', MediaDrawerProps>(({ room, solo = false, className, ...props }, ref) => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const { navigateRoom } = useRoomNavigate();
|
||||||
|
const [, setMediaDrawer] = useAtom(isMediaDrawerAtom);
|
||||||
|
const { items, loading, hasMore, loadMore } = useRoomMediaEvents(room);
|
||||||
|
const [excludedSenders, setExcludedSenders] = useState<Set<string>>(() => new Set());
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const restoredScrollRef = useRef(false);
|
||||||
|
|
||||||
|
const senders = useMemo<MediaSenderChip[]>(() => {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const item of items) {
|
||||||
|
counts.set(item.sender, (counts.get(item.sender) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(counts.entries())
|
||||||
|
.map(([userId, count]) => ({
|
||||||
|
userId,
|
||||||
|
count,
|
||||||
|
name: getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId,
|
||||||
|
avatarMxc: getMemberAvatarMxc(room, userId),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
||||||
|
}, [items, room]);
|
||||||
|
|
||||||
|
const filteredItems = useMemo(
|
||||||
|
() => items.filter((item) => !excludedSenders.has(item.sender)),
|
||||||
|
[items, excludedSenders]
|
||||||
|
);
|
||||||
|
const sections = useMemo(() => groupMediaByMonthAndDay(filteredItems), [filteredItems]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
restoredScrollRef.current = false;
|
||||||
|
}, [room.roomId]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (restoredScrollRef.current || items.length === 0) return;
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const saved = getMediaDrawerScrollTop(room.roomId);
|
||||||
|
if (typeof saved === 'number') {
|
||||||
|
el.scrollTop = saved;
|
||||||
|
}
|
||||||
|
restoredScrollRef.current = true;
|
||||||
|
}, [room.roomId, items.length]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setMediaDrawer(false);
|
||||||
|
}, [setMediaDrawer]);
|
||||||
|
|
||||||
|
const handleSenderClick = useCallback(
|
||||||
|
(evt: React.MouseEvent, userId: string) => {
|
||||||
|
if (evt.ctrlKey || evt.metaKey) {
|
||||||
|
evt.preventDefault();
|
||||||
|
const allIds = senders.map((sender) => sender.userId);
|
||||||
|
setExcludedSenders((prev) => {
|
||||||
|
const enabledIds = allIds.filter((id) => !prev.has(id));
|
||||||
|
const alreadySolo = enabledIds.length === 1 && enabledIds[0] === userId;
|
||||||
|
if (alreadySolo) return new Set();
|
||||||
|
return new Set(allIds.filter((id) => id !== userId));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExcludedSenders((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(userId)) next.delete(userId);
|
||||||
|
else next.add(userId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[senders]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpen = useCallback(
|
||||||
|
(eventId: string) => {
|
||||||
|
navigateRoom(room.roomId, eventId);
|
||||||
|
if (solo) setMediaDrawer(false);
|
||||||
|
},
|
||||||
|
[navigateRoom, room.roomId, solo, setMediaDrawer]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleScroll = useCallback(
|
||||||
|
(evt: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const el = evt.currentTarget;
|
||||||
|
setMediaDrawerScrollTop(room.roomId, el.scrollTop);
|
||||||
|
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80;
|
||||||
|
if (nearBottom && hasMore && !loading) {
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hasMore, loading, loadMore, room.roomId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
grow={solo ? 'Yes' : undefined}
|
||||||
|
direction="Column"
|
||||||
|
className={classNames(
|
||||||
|
solo ? css.MediaDrawerSolo : css.MediaDrawer,
|
||||||
|
ContainerColor({ variant: 'Background' }),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Header className={css.MediaDrawerHeader} size="600" variant="Background">
|
||||||
|
<Box grow="Yes" alignItems="Center">
|
||||||
|
<Text size="H5" truncate>
|
||||||
|
Shared Media
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
|
||||||
|
<Icon src={Icons.Cross} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
{senders.length > 0 && (
|
||||||
|
<div className={css.MediaUserFiltersScroll}>
|
||||||
|
<div className={css.MediaUserFilters}>
|
||||||
|
{senders.map((sender) => {
|
||||||
|
const enabled = !excludedSenders.has(sender.userId);
|
||||||
|
const avatarUrl = sender.avatarMxc
|
||||||
|
? mxcUrlToHttp(mx, sender.avatarMxc, useAuthentication, 48, 48, 'crop') ??
|
||||||
|
undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={sender.userId}
|
||||||
|
className={css.MediaUserChip}
|
||||||
|
variant={enabled ? 'Primary' : 'SurfaceVariant'}
|
||||||
|
outlined
|
||||||
|
radii="Pill"
|
||||||
|
aria-pressed={enabled}
|
||||||
|
onClick={(evt) => handleSenderClick(evt, sender.userId)}
|
||||||
|
before={
|
||||||
|
<Avatar size="200">
|
||||||
|
<UserAvatar
|
||||||
|
userId={sender.userId}
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={sender.name}
|
||||||
|
renderFallback={() => (
|
||||||
|
<Text as="span" size="L400">
|
||||||
|
{sender.name.slice(0, 1).toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text className={css.MediaUserChipLabel} size="T200" truncate>
|
||||||
|
{sender.name}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box grow="Yes" direction="Column" className={css.MediaDrawerContentBase}>
|
||||||
|
<Scroll
|
||||||
|
ref={scrollRef}
|
||||||
|
visibility="Hover"
|
||||||
|
hideTrack
|
||||||
|
variant="Background"
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
|
<div className={css.MediaDrawerContent}>
|
||||||
|
{items.length === 0 && !loading && (
|
||||||
|
<Text className={css.MediaEmpty} size="T300" priority="300">
|
||||||
|
No shared photos or videos yet.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length > 0 && filteredItems.length === 0 && (
|
||||||
|
<Text className={css.MediaEmpty} size="T300" priority="300">
|
||||||
|
No media from the selected people.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sections.map((month) => (
|
||||||
|
<section key={month.monthKey} className={css.MediaMonthSection}>
|
||||||
|
<Text className={css.MediaMonthHeader} size="H6" as="h3">
|
||||||
|
{month.monthLabel}
|
||||||
|
</Text>
|
||||||
|
{month.days.map((day) => (
|
||||||
|
<div key={day.dayKey} className={css.MediaDaySection}>
|
||||||
|
<Text className={css.MediaDayHeader} size="T200" priority="300" as="h4">
|
||||||
|
{day.dayLabel}
|
||||||
|
</Text>
|
||||||
|
<div className={css.MediaGrid}>
|
||||||
|
{day.items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.eventId}
|
||||||
|
type="button"
|
||||||
|
className={css.MediaTile}
|
||||||
|
onClick={() => handleOpen(item.eventId)}
|
||||||
|
title={item.body}
|
||||||
|
aria-label={item.body}
|
||||||
|
>
|
||||||
|
<MediaTileThumb item={item} />
|
||||||
|
{item.msgtype === MsgType.Video && (
|
||||||
|
<span className={css.MediaTileBadge}>
|
||||||
|
<Icon src={Icons.Play} size="100" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{(loading || hasMore) && (
|
||||||
|
<div className={css.MediaFooter} style={{ minHeight: config.space.S700 }}>
|
||||||
|
{loading && <Spinner size="400" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
1
src/app/features/room/room-media-menu/index.ts
Normal file
1
src/app/features/room/room-media-menu/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './RoomMediaMenu';
|
||||||
@@ -116,7 +116,7 @@ function PinnedMessage({
|
|||||||
|
|
||||||
const sender = pinnedEvent?.getSender();
|
const sender = pinnedEvent?.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 handleOpenClick: MouseEventHandler = (evt) => {
|
const handleOpenClick: MouseEventHandler = (evt) => {
|
||||||
evt.stopPropagation();
|
evt.stopPropagation();
|
||||||
|
|||||||
@@ -145,11 +145,23 @@ export function Search({ requestClose }: SearchProps) {
|
|||||||
|
|
||||||
const getTargetStr: SearchItemStrGetter<string> = useCallback(
|
const getTargetStr: SearchItemStrGetter<string> = useCallback(
|
||||||
(roomId: string) => {
|
(roomId: string) => {
|
||||||
const roomName = getRoom(roomId)?.name ?? roomId;
|
const room = getRoom(roomId);
|
||||||
|
const roomName = room?.name ?? roomId;
|
||||||
if (mDirects.has(roomId)) {
|
if (mDirects.has(roomId)) {
|
||||||
const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId());
|
const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId());
|
||||||
const targetUsername = targetUserId && getMxIdLocalPart(targetUserId);
|
if (!targetUserId) return roomName;
|
||||||
if (targetUsername) return [roomName, targetUsername];
|
const targetUsername = getMxIdLocalPart(targetUserId);
|
||||||
|
const targetServer = getMxIdServer(targetUserId);
|
||||||
|
const member = room?.getMember(targetUserId);
|
||||||
|
const displayName = member?.name || member?.rawDisplayName;
|
||||||
|
return [
|
||||||
|
roomName,
|
||||||
|
displayName,
|
||||||
|
targetUsername,
|
||||||
|
targetUserId,
|
||||||
|
targetServer ? `@${targetUsername}:${targetServer}` : undefined,
|
||||||
|
targetServer,
|
||||||
|
].filter((s): s is string => typeof s === 'string' && s.length > 0);
|
||||||
}
|
}
|
||||||
return roomName;
|
return roomName;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { getVersion } from '@tauri-apps/api/app';
|
import { getAppVersion } from '../../../utils/appVersion';
|
||||||
|
import { useOpenReleaseNotesDialog } from '../../../components/updates-dialog/UpdatesDialogHost';
|
||||||
|
import { getCurrentUpdatePreview } from '../../../data/updateNotes';
|
||||||
import { Box, Text, IconButton, Scroll, Button, config, toRem } from 'folds';
|
import { Box, Text, IconButton, Scroll, Button, config, toRem } from 'folds';
|
||||||
import { Icon, Icons } from '../../../components/icons';
|
import { Icon, Icons } from '../../../components/icons';
|
||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
@@ -15,6 +17,7 @@ type AboutProps = {
|
|||||||
};
|
};
|
||||||
export function About({ requestClose }: AboutProps) {
|
export function About({ requestClose }: AboutProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const openReleaseNotes = useOpenReleaseNotesDialog();
|
||||||
const [version, setVersion] = useState<string>('');
|
const [version, setVersion] = useState<string>('');
|
||||||
const [protocolStatus, setProtocolStatus] = useState<string>('Checking desktop protocol integration...');
|
const [protocolStatus, setProtocolStatus] = useState<string>('Checking desktop protocol integration...');
|
||||||
const [protocolBusy, setProtocolBusy] = useState<boolean>(false);
|
const [protocolBusy, setProtocolBusy] = useState<boolean>(false);
|
||||||
@@ -93,10 +96,13 @@ export function About({ requestClose }: AboutProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getVersion().then(setVersion).catch(() => setVersion('unknown'));
|
getAppVersion().then(setVersion).catch(() => setVersion('unknown'));
|
||||||
refreshProtocolStatus();
|
refreshProtocolStatus();
|
||||||
|
getCurrentUpdatePreview().then(setUpdatePreview).catch(() => setUpdatePreview(null));
|
||||||
}, [refreshProtocolStatus]);
|
}, [refreshProtocolStatus]);
|
||||||
|
|
||||||
|
const [updatePreview, setUpdatePreview] = useState<{ title: string; description: string } | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader outlined={false}>
|
<PageHeader outlined={false}>
|
||||||
@@ -151,6 +157,36 @@ export function About({ requestClose }: AboutProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">What's new</Text>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="300"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title={updatePreview?.title ?? 'Release notes'}
|
||||||
|
description={
|
||||||
|
updatePreview
|
||||||
|
? `Paarrot ${version} — ${updatePreview.description}`
|
||||||
|
: 'View highlights from the latest Paarrot update.'
|
||||||
|
}
|
||||||
|
after={
|
||||||
|
<Button
|
||||||
|
onClick={openReleaseNotes}
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
outlined
|
||||||
|
>
|
||||||
|
<Text size="B300">View updates</Text>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Options</Text>
|
<Text size="L400">Options</Text>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import React, {
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Box, Text, IconButton, Input, Avatar, Button, Overlay, OverlayBackdrop, OverlayCenter, Modal, Dialog, Header, config, Spinner, color, toRem } from 'folds';
|
import { Box, Text, IconButton, Input, Avatar, Button, Overlay, OverlayBackdrop, OverlayCenter, Modal, Dialog, Header, config, Spinner, color, toRem } from 'folds';
|
||||||
import { Icon, Icons } from '../../../components/icons';
|
import { Icon, Icons } from '../../../components/icons';
|
||||||
import { HexColorPicker, RgbaColorPicker, RgbaColor } from 'react-colorful';
|
import { HexColorPicker } from 'react-colorful';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
||||||
@@ -19,7 +19,7 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
|||||||
import { UserAvatar } from '../../../components/user-avatar';
|
import { UserAvatar } from '../../../components/user-avatar';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { useAuthenticatedMediaUrl } from '../../../hooks/useAuthenticatedMediaUrl';
|
import { useAuthenticatedMediaUrl } from '../../../hooks/useAuthenticatedMediaUrl';
|
||||||
import { nameInitials, getContrastingTextColor, stripAlphaFromColor, getTextShadowColor } from '../../../utils/common';
|
import { getTextShadowColor } from '../../../utils/common';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useFilePicker } from '../../../hooks/useFilePicker';
|
import { useFilePicker } from '../../../hooks/useFilePicker';
|
||||||
import { useObjectURL } from '../../../hooks/useObjectURL';
|
import { useObjectURL } from '../../../hooks/useObjectURL';
|
||||||
@@ -29,28 +29,22 @@ import { ModalWide } from '../../../styles/Modal.css';
|
|||||||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
||||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||||
import { useCapabilities } from '../../../hooks/useCapabilities';
|
import { useCapabilities } from '../../../hooks/useCapabilities';
|
||||||
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
|
import { useUserColorPreference } from '../../../hooks/useUserColor';
|
||||||
import { AngleSelector } from '../../../components/AngleSelector';
|
import { useUserBanner } from '../../../hooks/useUserBanner';
|
||||||
import { useUserColor, useOtherUserColor } from '../../../hooks/useUserColor';
|
|
||||||
import { useUserBanner, useOtherUserBanner } from '../../../hooks/useUserBanner';
|
|
||||||
import { useUserProfileStyle } from '../../../hooks/useUserProfileStyle';
|
|
||||||
import { useUserPresence } from '../../../hooks/useUserPresence';
|
import { useUserPresence } from '../../../hooks/useUserPresence';
|
||||||
|
import { useTheme, ThemeKind } from '../../../hooks/useTheme';
|
||||||
import { AvatarPresence, PresenceBadge } from '../../../components/presence';
|
import { AvatarPresence, PresenceBadge } from '../../../components/presence';
|
||||||
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
|
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
|
||||||
import colorMXID, { getColorMXIDValue } from '../../../../util/colorMXID';
|
import colorMXID, { getColorMXIDValue } from '../../../../util/colorMXID';
|
||||||
import {
|
|
||||||
extractMetadataFromImage,
|
|
||||||
embedMetadataInImage,
|
|
||||||
detectImageFormat,
|
|
||||||
getMimeType,
|
|
||||||
getExtension,
|
|
||||||
ImageMetadata,
|
|
||||||
} from '../../../utils/imageMetadata';
|
|
||||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||||
|
import {
|
||||||
|
ColorPreference,
|
||||||
|
hasColorPreference,
|
||||||
|
resolveColorForTheme,
|
||||||
|
} from '../../../utils/profileFields';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Banner upload component for user's profile banner
|
* Banner upload component for user's profile banner (MSC4427 via MSC4133).
|
||||||
* Stored in avatar image metadata, visible to other Paarrot users
|
|
||||||
*/
|
*/
|
||||||
function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTMLDivElement>; nameRef: React.RefObject<HTMLDivElement> }) {
|
function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTMLDivElement>; nameRef: React.RefObject<HTMLDivElement> }) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
@@ -96,26 +90,25 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
: undefined;
|
: undefined;
|
||||||
const authenticatedCoverUrl = useAuthenticatedMediaUrl(avatarCoverUrl, useAuthentication);
|
const authenticatedCoverUrl = useAuthenticatedMediaUrl(avatarCoverUrl, useAuthentication);
|
||||||
|
|
||||||
const [userColor, setUserColor] = useUserColor();
|
const theme = useTheme();
|
||||||
const [localColor, setLocalColor] = useState(userColor ?? '#3b82f6');
|
const [colorPreference, setColorPreference, colorLoading] = useUserColorPreference();
|
||||||
|
const [localOnDark, setLocalOnDark] = useState('#ffd9f5');
|
||||||
|
const [localOnLight, setLocalOnLight] = useState('#440000');
|
||||||
const [savingColor, setSavingColor] = useState(false);
|
const [savingColor, setSavingColor] = useState(false);
|
||||||
const [colorError, setColorError] = useState<string>();
|
const [colorError, setColorError] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userColor) {
|
if (colorPreference?.on_dark) setLocalOnDark(colorPreference.on_dark);
|
||||||
setLocalColor(userColor);
|
if (colorPreference?.on_light) setLocalOnLight(colorPreference.on_light);
|
||||||
}
|
}, [colorPreference]);
|
||||||
}, [userColor]);
|
|
||||||
|
|
||||||
const handleColorSave = async () => {
|
const handleColorSave = async () => {
|
||||||
setSavingColor(true);
|
setSavingColor(true);
|
||||||
setColorError(undefined);
|
setColorError(undefined);
|
||||||
try {
|
try {
|
||||||
await setUserColor(localColor);
|
await setColorPreference({ on_dark: localOnDark, on_light: localOnLight });
|
||||||
// Sync user object to propagate changes throughout app
|
|
||||||
await syncUserAvatar();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setColorError('Failed to save color');
|
setColorError('Failed to save colors. Your server may not support profile fields (MSC4133).');
|
||||||
}
|
}
|
||||||
setSavingColor(false);
|
setSavingColor(false);
|
||||||
};
|
};
|
||||||
@@ -124,141 +117,15 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
setSavingColor(true);
|
setSavingColor(true);
|
||||||
setColorError(undefined);
|
setColorError(undefined);
|
||||||
try {
|
try {
|
||||||
await setUserColor(undefined);
|
await setColorPreference(undefined);
|
||||||
|
setLocalOnDark('#ffd9f5');
|
||||||
|
setLocalOnLight('#440000');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setColorError('Failed to remove color');
|
setColorError('Failed to remove colors');
|
||||||
}
|
}
|
||||||
setSavingColor(false);
|
setSavingColor(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Profile style settings (border color and gradient)
|
|
||||||
const [profileStyle, setProfileStyle, styleLoading] = useUserProfileStyle();
|
|
||||||
// Initialize with transparent alpha so saved values show in preview until user edits
|
|
||||||
const [localBorderColor, setLocalBorderColor] = useState<RgbaColor>({ r: 59, g: 130, b: 246, a: 0 });
|
|
||||||
const [localGradientStart, setLocalGradientStart] = useState<RgbaColor>({ r: 0, g: 0, b: 0, a: 0 });
|
|
||||||
const [localGradientStop, setLocalGradientStop] = useState<RgbaColor>({ r: 0, g: 0, b: 0, a: 0 });
|
|
||||||
const [localGradientDirection, setLocalGradientDirection] = useState(180); // degrees (180 = top to bottom)
|
|
||||||
const [savingStyle, setSavingStyle] = useState(false);
|
|
||||||
const [styleError, setStyleError] = useState<string>();
|
|
||||||
// Track if user has started editing (to show local values in preview)
|
|
||||||
const [editingBorder, setEditingBorder] = useState(false);
|
|
||||||
const [editingGradient, setEditingGradient] = useState(false);
|
|
||||||
|
|
||||||
// Helper to convert RGBA to hex with alpha (#RRGGBBAA)
|
|
||||||
const rgbaToHex = (rgba: RgbaColor): string => {
|
|
||||||
const r = rgba.r.toString(16).padStart(2, '0');
|
|
||||||
const g = rgba.g.toString(16).padStart(2, '0');
|
|
||||||
const b = rgba.b.toString(16).padStart(2, '0');
|
|
||||||
const a = Math.round(rgba.a * 255).toString(16).padStart(2, '0');
|
|
||||||
return `#${r}${g}${b}${a}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to convert hex with alpha to RGBA
|
|
||||||
const hexToRgba = (hex: string): RgbaColor => {
|
|
||||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i.exec(hex);
|
|
||||||
if (result) {
|
|
||||||
return {
|
|
||||||
r: parseInt(result[1], 16),
|
|
||||||
g: parseInt(result[2], 16),
|
|
||||||
b: parseInt(result[3], 16),
|
|
||||||
a: result[4] ? parseInt(result[4], 16) / 255 : 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { r: 59, g: 130, b: 246, a: 1 };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to parse degrees from direction string (e.g., "180deg" -> 180)
|
|
||||||
const parseDirectionDegrees = (direction: string): number => {
|
|
||||||
const degMatch = direction.match(/^(\d+)deg$/i);
|
|
||||||
if (degMatch) return parseInt(degMatch[1], 10);
|
|
||||||
// Fallback for legacy "to X" format
|
|
||||||
const keywordMap: Record<string, number> = {
|
|
||||||
'to top': 0,
|
|
||||||
'to top right': 45,
|
|
||||||
'to right': 90,
|
|
||||||
'to bottom right': 135,
|
|
||||||
'to bottom': 180,
|
|
||||||
'to bottom left': 225,
|
|
||||||
'to left': 270,
|
|
||||||
'to top left': 315,
|
|
||||||
};
|
|
||||||
return keywordMap[direction.toLowerCase()] ?? 180;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sync local style state with loaded profile style
|
|
||||||
useEffect(() => {
|
|
||||||
if (profileStyle.avatarBorderColor) {
|
|
||||||
setLocalBorderColor(hexToRgba(profileStyle.avatarBorderColor));
|
|
||||||
}
|
|
||||||
if (profileStyle.gradient) {
|
|
||||||
setLocalGradientStart(hexToRgba(profileStyle.gradient.startColor));
|
|
||||||
setLocalGradientStop(hexToRgba(profileStyle.gradient.stopColor));
|
|
||||||
setLocalGradientDirection(parseDirectionDegrees(profileStyle.gradient.direction));
|
|
||||||
}
|
|
||||||
}, [profileStyle]);
|
|
||||||
|
|
||||||
const handleBorderColorSave = async () => {
|
|
||||||
setSavingStyle(true);
|
|
||||||
setStyleError(undefined);
|
|
||||||
try {
|
|
||||||
await setProfileStyle({ avatarBorderColor: rgbaToHex(localBorderColor) });
|
|
||||||
await syncUserAvatar();
|
|
||||||
setEditingBorder(false);
|
|
||||||
} catch (e) {
|
|
||||||
setStyleError('Failed to save border color');
|
|
||||||
}
|
|
||||||
setSavingStyle(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBorderColorRemove = async () => {
|
|
||||||
setSavingStyle(true);
|
|
||||||
setStyleError(undefined);
|
|
||||||
try {
|
|
||||||
await setProfileStyle({ avatarBorderColor: undefined });
|
|
||||||
await syncUserAvatar();
|
|
||||||
setEditingBorder(false);
|
|
||||||
setLocalBorderColor({ r: 59, g: 130, b: 246, a: 0 });
|
|
||||||
} catch (e) {
|
|
||||||
setStyleError('Failed to remove border color');
|
|
||||||
}
|
|
||||||
setSavingStyle(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGradientSave = async () => {
|
|
||||||
setSavingStyle(true);
|
|
||||||
setStyleError(undefined);
|
|
||||||
try {
|
|
||||||
await setProfileStyle({
|
|
||||||
gradient: {
|
|
||||||
direction: `${localGradientDirection}deg`,
|
|
||||||
startColor: rgbaToHex(localGradientStart),
|
|
||||||
stopColor: rgbaToHex(localGradientStop),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await syncUserAvatar();
|
|
||||||
setEditingGradient(false);
|
|
||||||
} catch (e) {
|
|
||||||
setStyleError('Failed to save gradient');
|
|
||||||
}
|
|
||||||
setSavingStyle(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGradientRemove = async () => {
|
|
||||||
setSavingStyle(true);
|
|
||||||
setStyleError(undefined);
|
|
||||||
try {
|
|
||||||
await setProfileStyle({ gradient: undefined });
|
|
||||||
await syncUserAvatar();
|
|
||||||
setEditingGradient(false);
|
|
||||||
setLocalGradientStart({ r: 0, g: 0, b: 0, a: 0 });
|
|
||||||
setLocalGradientStop({ r: 0, g: 0, b: 0, a: 0 });
|
|
||||||
setLocalGradientDirection(180);
|
|
||||||
} catch (e) {
|
|
||||||
setStyleError('Failed to remove gradient');
|
|
||||||
}
|
|
||||||
setSavingStyle(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [isEditingName, setIsEditingName] = useState(false);
|
const [isEditingName, setIsEditingName] = useState(false);
|
||||||
const [editedName, setEditedName] = useState(profile.displayName || '');
|
const [editedName, setEditedName] = useState(profile.displayName || '');
|
||||||
const [savingName, setSavingName] = useState(false);
|
const [savingName, setSavingName] = useState(false);
|
||||||
@@ -473,51 +340,14 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
try {
|
try {
|
||||||
// Fetch the raw uploaded image so we can embed existing banner/color metadata
|
await mx.setAvatarUrl(upload.mxc);
|
||||||
const httpUrl = mxcUrlToHttp(mx, upload.mxc, useAuthentication);
|
|
||||||
if (!httpUrl) throw new Error('Could not resolve uploaded avatar URL');
|
|
||||||
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
let response = await fetch(httpUrl, {
|
|
||||||
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// If we got a 401 and we tried with auth, fallback to unauthenticated request
|
|
||||||
if (!response.ok && response.status === 401 && accessToken && useAuthentication) {
|
|
||||||
console.warn('[Profile] Auth failed (401), attempting unauthenticated fallback for avatar fetch');
|
|
||||||
response = await fetch(httpUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch uploaded avatar');
|
|
||||||
const avatarData = await response.arrayBuffer();
|
|
||||||
|
|
||||||
const format = detectImageFormat(avatarData);
|
|
||||||
if (format === 'unknown') throw new Error('Unsupported avatar image format');
|
|
||||||
|
|
||||||
// Re-embed the existing banner URL and profile color into the new avatar
|
|
||||||
const metadata: ImageMetadata = {
|
|
||||||
banner: userBanner,
|
|
||||||
color: userColor,
|
|
||||||
};
|
|
||||||
const modifiedData = embedMetadataInImage(avatarData, metadata);
|
|
||||||
if (!modifiedData) throw new Error('Failed to embed metadata in avatar');
|
|
||||||
|
|
||||||
const mimeType = getMimeType(format);
|
|
||||||
const extension = getExtension(format);
|
|
||||||
const blob = new Blob([modifiedData], { type: mimeType });
|
|
||||||
const uploadResponse = await mx.uploadContent(blob, { name: `avatar.${extension}`, type: mimeType });
|
|
||||||
|
|
||||||
await mx.setAvatarUrl(uploadResponse.content_uri);
|
|
||||||
|
|
||||||
const userId = mx.getUserId();
|
const userId = mx.getUserId();
|
||||||
if (userId) {
|
if (userId) {
|
||||||
const user = mx.getUser(userId);
|
const user = mx.getUser(userId);
|
||||||
if (user && user.avatarUrl !== uploadResponse.content_uri) {
|
if (user && user.avatarUrl !== upload.mxc) {
|
||||||
user.setAvatarUrl(uploadResponse.content_uri);
|
user.setAvatarUrl(upload.mxc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setAvatarFile(undefined);
|
setAvatarFile(undefined);
|
||||||
await syncUserAvatar();
|
await syncUserAvatar();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -525,7 +355,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
}
|
}
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
},
|
},
|
||||||
[mx, useAuthentication, userBanner, userColor, syncUserAvatar]
|
[mx, syncUserAvatar]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRemoveBanner = async () => {
|
const handleRemoveBanner = async () => {
|
||||||
@@ -541,24 +371,11 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build gradient CSS for preview (shows local editing values when user is editing,
|
const previewPreference: ColorPreference = { on_dark: localOnDark, on_light: localOnLight };
|
||||||
// otherwise falls back to saved profile style)
|
const previewProfileColor =
|
||||||
const previewGradient = editingGradient
|
resolveColorForTheme(previewPreference, theme.kind) || getColorMXIDValue(userId, theme.kind === ThemeKind.Dark);
|
||||||
? `linear-gradient(${localGradientDirection}deg, ${rgbaToHex(localGradientStart)}, ${rgbaToHex(localGradientStop)})`
|
|
||||||
: profileStyle.gradient
|
|
||||||
? `linear-gradient(${profileStyle.gradient.direction}, ${profileStyle.gradient.startColor}, ${profileStyle.gradient.stopColor})`
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Build border color for preview (shows local editing value when user is editing,
|
|
||||||
// otherwise falls back to saved profile style)
|
|
||||||
const previewBorderColor = editingBorder
|
|
||||||
? rgbaToHex(localBorderColor)
|
|
||||||
: profileStyle.avatarBorderColor;
|
|
||||||
|
|
||||||
const previewPillBgColor = previewBorderColor ? stripAlphaFromColor(previewBorderColor) : undefined;
|
|
||||||
const previewProfileColor = userColor || getColorMXIDValue(userId);
|
|
||||||
|
|
||||||
const previewTextShadow = `0 1px 4px ${getTextShadowColor(previewProfileColor)}`;
|
const previewTextShadow = `0 1px 4px ${getTextShadowColor(previewProfileColor)}`;
|
||||||
|
const hasSavedColors = hasColorPreference(colorPreference);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="300">
|
<Box direction="Column" gap="300">
|
||||||
@@ -594,7 +411,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
backgroundColor: bannerBlobUrl ? 'transparent' : (userColor || colorMXID(userId)),
|
backgroundColor: bannerBlobUrl ? 'transparent' : colorMXID(userId),
|
||||||
filter: bannerBlobUrl || authenticatedCoverUrl ? 'none' : 'brightness(50%)',
|
filter: bannerBlobUrl || authenticatedCoverUrl ? 'none' : 'brightness(50%)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -707,7 +524,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
as="button"
|
as="button"
|
||||||
onClick={handleAvatarClick}
|
onClick={handleAvatarClick}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: previewBorderColor || color.Surface.Container,
|
backgroundColor: color.Surface.Container,
|
||||||
border: 'none',
|
border: 'none',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@@ -715,7 +532,6 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AvatarPresence
|
<AvatarPresence
|
||||||
badgeBackgroundColor={previewBorderColor}
|
|
||||||
badge={
|
badge={
|
||||||
presence && (
|
presence && (
|
||||||
<PresenceBadge presence={presence.presence} status={presence.status} />
|
<PresenceBadge presence={presence.presence} status={presence.status} />
|
||||||
@@ -727,9 +543,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
style={{
|
style={{
|
||||||
width: toRem(72),
|
width: toRem(72),
|
||||||
height: toRem(72),
|
height: toRem(72),
|
||||||
outline: previewBorderColor
|
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
|
||||||
? `${toRem(4)} solid ${previewBorderColor}`
|
|
||||||
: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
@@ -799,73 +613,20 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Profile Info Section - matches UserRoomProfile gradient section */}
|
{/* Profile Info Section */}
|
||||||
<Box
|
<Box
|
||||||
direction="Column"
|
direction="Column"
|
||||||
gap="400"
|
gap="200"
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
style={{
|
style={{
|
||||||
padding: config.space.S400,
|
padding: config.space.S400,
|
||||||
paddingTop: `calc(${config.space.S400} + ${toRem(36)})`,
|
paddingTop: `calc(${config.space.S400} + ${toRem(36)})`,
|
||||||
marginTop: toRem(-36),
|
marginTop: toRem(-36),
|
||||||
background: previewGradient,
|
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Display Name with Color Picker */}
|
{/* Display Name */}
|
||||||
<Box alignItems="Center" gap="200" justifyContent="Center">
|
<Box style={{ width: '100%' }}>
|
||||||
<HexColorPickerPopOut
|
|
||||||
picker={
|
|
||||||
<Box direction="Column" gap="200">
|
|
||||||
<HexColorPicker color={localColor} onChange={setLocalColor} />
|
|
||||||
<Box gap="100" alignItems="Center">
|
|
||||||
<Input
|
|
||||||
size="300"
|
|
||||||
variant="Secondary"
|
|
||||||
style={{ width: toRem(100) }}
|
|
||||||
value={localColor}
|
|
||||||
onChange={(e) => {
|
|
||||||
setLocalColor(e.target.value);
|
|
||||||
setColorError(undefined);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="300"
|
|
||||||
variant="Primary"
|
|
||||||
fill="Solid"
|
|
||||||
radii="300"
|
|
||||||
onClick={handleColorSave}
|
|
||||||
disabled={savingColor}
|
|
||||||
>
|
|
||||||
<Text size="B300">{savingColor ? 'Saving...' : 'Save'}</Text>
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
{colorError && (
|
|
||||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
|
||||||
{colorError}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
onRemove={userColor ? handleColorRemove : undefined}
|
|
||||||
>
|
|
||||||
{(onOpen) => (
|
|
||||||
<Box
|
|
||||||
as="button"
|
|
||||||
onClick={onOpen}
|
|
||||||
disabled={savingColor}
|
|
||||||
style={{
|
|
||||||
width: toRem(24),
|
|
||||||
height: toRem(24),
|
|
||||||
borderRadius: toRem(6),
|
|
||||||
backgroundColor: userColor ?? localColor,
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</HexColorPickerPopOut>
|
|
||||||
{isEditingName ? (
|
{isEditingName ? (
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -877,6 +638,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
size="400"
|
size="400"
|
||||||
disabled={savingName}
|
disabled={savingName}
|
||||||
style={{
|
style={{
|
||||||
|
width: '100%',
|
||||||
fontSize: 'var(--token.font-size.H400)',
|
fontSize: 'var(--token.font-size.H400)',
|
||||||
fontWeight: 'var(--token.font-weight.H400)',
|
fontWeight: 'var(--token.font-weight.H400)',
|
||||||
padding: `${toRem(4)} ${toRem(8)}`,
|
padding: `${toRem(4)} ${toRem(8)}`,
|
||||||
@@ -893,19 +655,28 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
onMouseEnter={() => setHoveredArea('name')}
|
onMouseEnter={() => setHoveredArea('name')}
|
||||||
onMouseLeave={() => setHoveredArea(null)}
|
onMouseLeave={() => setHoveredArea(null)}
|
||||||
style={{
|
style={{
|
||||||
|
width: '100%',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
background: 'none',
|
background: 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
padding: `${toRem(4)} ${toRem(8)}`,
|
padding: `${toRem(4)} ${toRem(8)}`,
|
||||||
margin: `${toRem(-4)} ${toRem(-8)}`,
|
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
lineHeight: 1.2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
size="H4"
|
size="H4"
|
||||||
className={classNames(BreakWord, LineClamp3)}
|
className={classNames(BreakWord, LineClamp3)}
|
||||||
title={profile.displayName || getMxIdLocalPart(userId)}
|
title={profile.displayName || getMxIdLocalPart(userId)}
|
||||||
style={{ color: previewProfileColor, textShadow: previewTextShadow }}
|
style={{
|
||||||
|
color: previewProfileColor,
|
||||||
|
textShadow: previewTextShadow,
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: '100%',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{profile.displayName || getMxIdLocalPart(userId) || userId}
|
{profile.displayName || getMxIdLocalPart(userId) || userId}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -913,8 +684,9 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: '50%',
|
||||||
right: toRem(-32),
|
right: 0,
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
backgroundColor: color.Surface.Container,
|
backgroundColor: color.Surface.Container,
|
||||||
borderRadius: toRem(20),
|
borderRadius: toRem(20),
|
||||||
padding: toRem(6),
|
padding: toRem(6),
|
||||||
@@ -947,8 +719,8 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
onMouseEnter={() => setHoveredArea('status')}
|
onMouseEnter={() => setHoveredArea('status')}
|
||||||
onMouseLeave={() => setHoveredArea(null)}
|
onMouseLeave={() => setHoveredArea(null)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: previewPillBgColor || color.Surface.Container,
|
backgroundColor: color.Surface.Container,
|
||||||
border: previewPillBgColor ? '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),
|
||||||
@@ -999,14 +771,13 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
className={BreakWord}
|
className={BreakWord}
|
||||||
style={{
|
style={{
|
||||||
fontStyle: presence?.status ? 'normal' : 'italic',
|
fontStyle: presence?.status ? 'normal' : 'italic',
|
||||||
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{presence?.status || 'Click to set a custom status...'}</Text>
|
{presence?.status || 'Click to set a custom status...'}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{presence?.status && hoveredArea === 'status' && (
|
{presence?.status && hoveredArea === 'status' && (
|
||||||
<>
|
<>
|
||||||
<Icon size="50" src={Icons.Pencil} style={{ color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined }} />
|
<Icon size="50" src={Icons.Pencil} />
|
||||||
<Box
|
<Box
|
||||||
as="button"
|
as="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -1037,63 +808,42 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
|
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: previewPillBgColor || color.Surface.Container,
|
backgroundColor: color.Surface.Container,
|
||||||
border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
padding: `${toRem(4)} ${toRem(8)}`,
|
padding: `${toRem(4)} ${toRem(8)}`,
|
||||||
borderRadius: toRem(16),
|
borderRadius: toRem(16),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text size="B300">ruv.wtf</Text>
|
||||||
size="B300"
|
|
||||||
style={{
|
|
||||||
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
ruv.wtf
|
|
||||||
</Text>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: previewPillBgColor || color.Surface.Container,
|
backgroundColor: color.Surface.Container,
|
||||||
border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
padding: `${toRem(4)} ${toRem(8)}`,
|
padding: `${toRem(4)} ${toRem(8)}`,
|
||||||
borderRadius: toRem(16),
|
borderRadius: toRem(16),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text size="B300">Share</Text>
|
||||||
size="B300"
|
|
||||||
style={{
|
|
||||||
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Share
|
|
||||||
</Text>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: previewPillBgColor || color.Surface.Container,
|
backgroundColor: color.Surface.Container,
|
||||||
border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
padding: `${toRem(4)} ${toRem(8)}`,
|
padding: `${toRem(4)} ${toRem(8)}`,
|
||||||
borderRadius: toRem(16),
|
borderRadius: toRem(16),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text size="B300">Admin</Text>
|
||||||
size="B300"
|
|
||||||
style={{
|
|
||||||
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Admin
|
|
||||||
</Text>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Profile Style Settings */}
|
{/* MSC4522 username colors */}
|
||||||
<Box
|
<Box
|
||||||
direction="Column"
|
direction="Column"
|
||||||
gap="200"
|
gap="300"
|
||||||
style={{
|
style={{
|
||||||
padding: config.space.S300,
|
padding: config.space.S300,
|
||||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
@@ -1101,111 +851,91 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="H6">Profile Style</Text>
|
<Text size="H6">Username colors</Text>
|
||||||
|
<Text size="T200" style={{ opacity: 0.8 }}>
|
||||||
{/* Avatar Border Color */}
|
Set how your name appears on dark and light themes (MSC4522). Other clients that support this
|
||||||
<Box direction="Column" gap="100">
|
spec will see your chosen colors.
|
||||||
<Text size="T300">Avatar Border Color</Text>
|
</Text>
|
||||||
<Box gap="200" alignItems="Center" wrap="Wrap">
|
|
||||||
<RgbaColorPicker color={localBorderColor} onChange={(c) => { setLocalBorderColor(c); setEditingBorder(true); }} />
|
<Box gap="400" wrap="Wrap">
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="200">
|
||||||
<Text size="T200" style={{ opacity: 0.7 }}>
|
<Text size="T300">On dark themes</Text>
|
||||||
Preview: {rgbaToHex(localBorderColor)}
|
<Text size="T200" style={{ opacity: 0.7 }}>Bright colors work best</Text>
|
||||||
</Text>
|
<HexColorPicker color={localOnDark} onChange={(c) => { setLocalOnDark(c); setColorError(undefined); }} />
|
||||||
<Box
|
<Input
|
||||||
style={{
|
size="300"
|
||||||
width: toRem(48),
|
variant="Secondary"
|
||||||
height: toRem(48),
|
style={{ width: toRem(120) }}
|
||||||
borderRadius: '50%',
|
value={localOnDark}
|
||||||
border: `${toRem(4)} solid ${rgbaToHex(localBorderColor)}`,
|
onChange={(e) => {
|
||||||
backgroundColor: color.Surface.Container,
|
setLocalOnDark(e.target.value);
|
||||||
}}
|
setColorError(undefined);
|
||||||
/>
|
}}
|
||||||
<Box gap="100">
|
/>
|
||||||
<Button
|
<Box
|
||||||
size="300"
|
style={{
|
||||||
variant="Primary"
|
width: toRem(48),
|
||||||
fill="Solid"
|
height: toRem(48),
|
||||||
radii="300"
|
borderRadius: toRem(8),
|
||||||
onClick={handleBorderColorSave}
|
backgroundColor: localOnDark,
|
||||||
disabled={savingStyle}
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
>
|
}}
|
||||||
<Text size="B300">Save</Text>
|
/>
|
||||||
</Button>
|
</Box>
|
||||||
{profileStyle.avatarBorderColor && (
|
|
||||||
<Button
|
<Box direction="Column" gap="200">
|
||||||
size="300"
|
<Text size="T300">On light themes</Text>
|
||||||
variant="Critical"
|
<Text size="T200" style={{ opacity: 0.7 }}>Darker colors work best</Text>
|
||||||
fill="Soft"
|
<HexColorPicker color={localOnLight} onChange={(c) => { setLocalOnLight(c); setColorError(undefined); }} />
|
||||||
radii="300"
|
<Input
|
||||||
onClick={handleBorderColorRemove}
|
size="300"
|
||||||
disabled={savingStyle}
|
variant="Secondary"
|
||||||
>
|
style={{ width: toRem(120) }}
|
||||||
<Text size="B300">Remove</Text>
|
value={localOnLight}
|
||||||
</Button>
|
onChange={(e) => {
|
||||||
)}
|
setLocalOnLight(e.target.value);
|
||||||
</Box>
|
setColorError(undefined);
|
||||||
</Box>
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: toRem(48),
|
||||||
|
height: toRem(48),
|
||||||
|
borderRadius: toRem(8),
|
||||||
|
backgroundColor: localOnLight,
|
||||||
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Profile Gradient */}
|
<Box gap="200" alignItems="Center" wrap="Wrap">
|
||||||
<Box direction="Column" gap="100">
|
<Button
|
||||||
<Text size="T300">Profile Card Gradient</Text>
|
size="300"
|
||||||
<Box gap="200" alignItems="Start" wrap="Wrap">
|
variant="Primary"
|
||||||
<Box direction="Column" gap="100">
|
fill="Solid"
|
||||||
<Text size="T200">Start Color</Text>
|
radii="300"
|
||||||
<RgbaColorPicker color={localGradientStart} onChange={(c) => { setLocalGradientStart(c); setEditingGradient(true); }} />
|
onClick={handleColorSave}
|
||||||
</Box>
|
disabled={savingColor || colorLoading}
|
||||||
<Box direction="Column" gap="100">
|
>
|
||||||
<Text size="T200">End Color</Text>
|
<Text size="B300">{savingColor ? 'Saving...' : 'Save colors'}</Text>
|
||||||
<RgbaColorPicker color={localGradientStop} onChange={(c) => { setLocalGradientStop(c); setEditingGradient(true); }} />
|
</Button>
|
||||||
</Box>
|
{hasSavedColors && (
|
||||||
<Box direction="Column" gap="100">
|
<Button
|
||||||
<Text size="T200">Direction</Text>
|
size="300"
|
||||||
<AngleSelector
|
variant="Critical"
|
||||||
value={localGradientDirection}
|
fill="Soft"
|
||||||
onChange={(deg) => { setLocalGradientDirection(deg); setEditingGradient(true); }}
|
radii="300"
|
||||||
/>
|
onClick={handleColorRemove}
|
||||||
<Box
|
disabled={savingColor}
|
||||||
style={{
|
>
|
||||||
width: toRem(100),
|
<Text size="B300">Remove colors</Text>
|
||||||
height: toRem(60),
|
</Button>
|
||||||
borderRadius: toRem(8),
|
)}
|
||||||
background: `linear-gradient(${localGradientDirection}deg, ${rgbaToHex(localGradientStart)}, ${rgbaToHex(localGradientStop)})`,
|
|
||||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box gap="100">
|
|
||||||
<Button
|
|
||||||
size="300"
|
|
||||||
variant="Primary"
|
|
||||||
fill="Solid"
|
|
||||||
radii="300"
|
|
||||||
onClick={handleGradientSave}
|
|
||||||
disabled={savingStyle}
|
|
||||||
>
|
|
||||||
<Text size="B300">Save</Text>
|
|
||||||
</Button>
|
|
||||||
{profileStyle.gradient && (
|
|
||||||
<Button
|
|
||||||
size="300"
|
|
||||||
variant="Critical"
|
|
||||||
fill="Soft"
|
|
||||||
radii="300"
|
|
||||||
onClick={handleGradientRemove}
|
|
||||||
disabled={savingStyle}
|
|
||||||
>
|
|
||||||
<Text size="B300">Remove</Text>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{styleError && (
|
{colorError && (
|
||||||
<Text size="T200" style={{ color: color.Critical.Main }}>{styleError}</Text>
|
<Text size="T200" style={{ color: color.Critical.Main }}>{colorError}</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import React, {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { as, Box, Button, Chip, color, config, Header, IconButton, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Switch, Text, toRem } from 'folds';
|
import { as, Box, Button, Chip, color, config, Header, IconButton, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Switch, Text, toRem } from 'folds';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
import { Icon, Icons } from '../../../components/icons';
|
import { Icon, Icons } from '../../../components/icons';
|
||||||
import { HexColorPicker } from 'react-colorful';
|
import { HexColorPicker } from 'react-colorful';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
LightTheme,
|
LightTheme,
|
||||||
Theme,
|
Theme,
|
||||||
ThemeKind,
|
ThemeKind,
|
||||||
|
themePreviewIdAtom,
|
||||||
useSystemThemeKind,
|
useSystemThemeKind,
|
||||||
useThemeNames,
|
useThemeNames,
|
||||||
useThemes,
|
useThemes,
|
||||||
@@ -41,28 +43,49 @@ type ThemeSelectorProps = {
|
|||||||
onSelect: (theme: Theme) => void;
|
onSelect: (theme: Theme) => void;
|
||||||
};
|
};
|
||||||
const ThemeSelector = as<'div', ThemeSelectorProps>(
|
const ThemeSelector = as<'div', ThemeSelectorProps>(
|
||||||
({ themeNames, themes, selected, onSelect, ...props }, ref) => (
|
({ themeNames, themes, selected, onSelect, ...props }, ref) => {
|
||||||
<Menu {...props} ref={ref}>
|
const setPreviewId = useSetAtom(themePreviewIdAtom);
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
|
||||||
{themes.map((theme) => (
|
useEffect(
|
||||||
<MenuItem
|
() => () => {
|
||||||
key={theme.id}
|
setPreviewId(undefined);
|
||||||
size="300"
|
},
|
||||||
variant={theme.id === selected.id ? 'Primary' : 'Surface'}
|
[setPreviewId]
|
||||||
radii="300"
|
);
|
||||||
onClick={() => onSelect(theme)}
|
|
||||||
>
|
const clearPreview = () => setPreviewId(undefined);
|
||||||
<Text size="T300">{themeNames[theme.id] ?? theme.id}</Text>
|
const previewTheme = (theme: Theme) => setPreviewId(theme.id);
|
||||||
</MenuItem>
|
|
||||||
))}
|
return (
|
||||||
</Box>
|
<Menu
|
||||||
</Menu>
|
{...props}
|
||||||
)
|
ref={ref}
|
||||||
|
onMouseLeave={clearPreview}
|
||||||
|
>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{themes.map((theme) => (
|
||||||
|
<MenuItem
|
||||||
|
key={theme.id}
|
||||||
|
size="300"
|
||||||
|
variant={theme.id === selected.id ? 'Primary' : 'Surface'}
|
||||||
|
radii="300"
|
||||||
|
onMouseEnter={() => previewTheme(theme)}
|
||||||
|
onFocus={() => previewTheme(theme)}
|
||||||
|
onClick={() => onSelect(theme)}
|
||||||
|
>
|
||||||
|
<Text size="T300">{themeNames[theme.id] ?? theme.id}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
function SelectTheme({ disabled }: { disabled?: boolean }) {
|
function SelectTheme({ disabled }: { disabled?: boolean }) {
|
||||||
const themes = useThemes();
|
const themes = useThemes();
|
||||||
const themeNames = useThemeNames();
|
const themeNames = useThemeNames();
|
||||||
|
const setPreviewId = useSetAtom(themePreviewIdAtom);
|
||||||
const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
|
const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
|
||||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
|
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
|
||||||
@@ -72,10 +95,16 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleThemeSelect = (theme: Theme) => {
|
const handleThemeSelect = (theme: Theme) => {
|
||||||
|
setPreviewId(undefined);
|
||||||
setThemeId(theme.id);
|
setThemeId(theme.id);
|
||||||
setMenuCords(undefined);
|
setMenuCords(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeMenu = () => {
|
||||||
|
setPreviewId(undefined);
|
||||||
|
setMenuCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -99,7 +128,7 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
|
|||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
onDeactivate: () => setMenuCords(undefined),
|
onDeactivate: closeMenu,
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
isKeyForward: (evt: KeyboardEvent) =>
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
@@ -125,6 +154,7 @@ function SystemThemePreferences() {
|
|||||||
const themeKind = useSystemThemeKind();
|
const themeKind = useSystemThemeKind();
|
||||||
const themeNames = useThemeNames();
|
const themeNames = useThemeNames();
|
||||||
const themes = useThemes();
|
const themes = useThemes();
|
||||||
|
const setPreviewId = useSetAtom(themePreviewIdAtom);
|
||||||
const [lightThemeId, setLightThemeId] = useSetting(settingsAtom, 'lightThemeId');
|
const [lightThemeId, setLightThemeId] = useSetting(settingsAtom, 'lightThemeId');
|
||||||
const [darkThemeId, setDarkThemeId] = useSetting(settingsAtom, 'darkThemeId');
|
const [darkThemeId, setDarkThemeId] = useSetting(settingsAtom, 'darkThemeId');
|
||||||
|
|
||||||
@@ -145,15 +175,27 @@ function SystemThemePreferences() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleLightThemeSelect = (theme: Theme) => {
|
const handleLightThemeSelect = (theme: Theme) => {
|
||||||
|
setPreviewId(undefined);
|
||||||
setLightThemeId(theme.id);
|
setLightThemeId(theme.id);
|
||||||
setLTCords(undefined);
|
setLTCords(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDarkThemeSelect = (theme: Theme) => {
|
const handleDarkThemeSelect = (theme: Theme) => {
|
||||||
|
setPreviewId(undefined);
|
||||||
setDarkThemeId(theme.id);
|
setDarkThemeId(theme.id);
|
||||||
setDTCords(undefined);
|
setDTCords(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeLightMenu = () => {
|
||||||
|
setPreviewId(undefined);
|
||||||
|
setLTCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDarkMenu = () => {
|
||||||
|
setPreviewId(undefined);
|
||||||
|
setDTCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box wrap="Wrap" gap="400">
|
<Box wrap="Wrap" gap="400">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
@@ -179,7 +221,7 @@ function SystemThemePreferences() {
|
|||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
onDeactivate: () => setLTCords(undefined),
|
onDeactivate: closeLightMenu,
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
isKeyForward: (evt: KeyboardEvent) =>
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
@@ -220,7 +262,7 @@ function SystemThemePreferences() {
|
|||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
onDeactivate: () => setDTCords(undefined),
|
onDeactivate: closeDarkMenu,
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
isKeyForward: (evt: KeyboardEvent) =>
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
|
|||||||
@@ -170,22 +170,34 @@ type PushStatus = {
|
|||||||
registered: boolean;
|
registered: boolean;
|
||||||
distributor: string;
|
distributor: string;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
|
distributors: string[];
|
||||||
|
lastFailure: string;
|
||||||
|
autoStartBlocked: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Android-only section showing UnifiedPush registration status and controls. */
|
/** Android-only section showing UnifiedPush registration status and controls. */
|
||||||
function AndroidPushNotifications() {
|
function AndroidPushNotifications() {
|
||||||
const [status, setStatus] = useState<PushStatus | undefined>(undefined);
|
const [status, setStatus] = useState<PushStatus | undefined>(undefined);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [lastError, setLastError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const s = await getBackgroundSyncStatus();
|
const s = await getBackgroundSyncStatus();
|
||||||
|
const distributors = Array.isArray(s?.distributors)
|
||||||
|
? s.distributors
|
||||||
|
: typeof s?.distributors === 'string' && s.distributors && s.distributors !== '[]'
|
||||||
|
? [s.distributors]
|
||||||
|
: [];
|
||||||
setStatus(
|
setStatus(
|
||||||
s
|
s
|
||||||
? {
|
? {
|
||||||
registered: s.registered,
|
registered: s.registered,
|
||||||
distributor: s.distributor || '',
|
distributor: s.distributor || '',
|
||||||
endpoint: s.endpoint || '',
|
endpoint: s.endpoint || '',
|
||||||
|
distributors,
|
||||||
|
lastFailure: s.lastFailure || '',
|
||||||
|
autoStartBlocked: Boolean(s.autoStartBlocked),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
);
|
);
|
||||||
@@ -198,8 +210,14 @@ function AndroidPushNotifications() {
|
|||||||
|
|
||||||
const [resetState, reset] = useAsyncCallback(
|
const [resetState, reset] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
await requestResetPushRegistration();
|
setLastError(undefined);
|
||||||
|
const result = await requestResetPushRegistration();
|
||||||
await refresh();
|
await refresh();
|
||||||
|
if (!result.success) {
|
||||||
|
setLastError(
|
||||||
|
'Selected distributor but did not get a push endpoint. In ntfy: enable UnifiedPush, allow unrestricted battery, then try Reset again.'
|
||||||
|
);
|
||||||
|
}
|
||||||
}, [refresh])
|
}, [refresh])
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -214,6 +232,50 @@ function AndroidPushNotifications() {
|
|||||||
resetState.status === AsyncStatus.Loading ||
|
resetState.status === AsyncStatus.Loading ||
|
||||||
pingState.status === AsyncStatus.Loading;
|
pingState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const statusDescription = (() => {
|
||||||
|
if (loading) return 'Loading status…';
|
||||||
|
if (status === undefined) {
|
||||||
|
return (
|
||||||
|
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||||
|
Failed to read push status.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status.registered) {
|
||||||
|
return (
|
||||||
|
<Text as="span" size="T200">
|
||||||
|
{`Distributor: ${status.distributor || 'unknown'}`}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status.lastFailure === 'AUTO_START_BLOCKED' || status.autoStartBlocked) {
|
||||||
|
return (
|
||||||
|
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||||
|
Phone is blocking auto-start for Paarrot. Open App Boot / Auto-start settings, allow Paarrot (and ntfy), then tap Reset.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status.lastFailure) {
|
||||||
|
return (
|
||||||
|
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||||
|
{`Registration failed (${status.lastFailure}). Tap Reset and pick ntfy again.`}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status.distributors.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||||
|
No UnifiedPush distributor found. Install ntfy from Play Store/F-Droid and enable UnifiedPush in ntfy settings.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
|
||||||
|
{`Found ${status.distributors.join(', ')} but not registered yet. Tap Reset and pick ntfy.`}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Android Push (UnifiedPush)</Text>
|
<Text size="L400">Android Push (UnifiedPush)</Text>
|
||||||
@@ -226,29 +288,20 @@ function AndroidPushNotifications() {
|
|||||||
<SettingTile
|
<SettingTile
|
||||||
title="Background Notifications"
|
title="Background Notifications"
|
||||||
description={
|
description={
|
||||||
loading ? (
|
<>
|
||||||
'Loading status…'
|
{statusDescription}
|
||||||
) : status === undefined ? (
|
{lastError ? (
|
||||||
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
<Text as="span" style={{ color: color.Critical.Main, display: 'block' }} size="T200">
|
||||||
Failed to read push status.
|
{lastError}
|
||||||
</Text>
|
|
||||||
) : status.registered ? (
|
|
||||||
<>
|
|
||||||
<Text as="span" size="T200">
|
|
||||||
{`Distributor: ${status.distributor || 'unknown'}`}
|
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
) : null}
|
||||||
) : (
|
</>
|
||||||
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
|
|
||||||
Not registered. Tap "Reset" to choose a distributor app.
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
after={loading ? <Spinner variant="Secondary" /> : undefined}
|
after={loading ? <Spinner variant="Secondary" /> : undefined}
|
||||||
/>
|
/>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Change Distributor"
|
title="Change Distributor"
|
||||||
description="Re-open the UnifiedPush distributor selection dialog."
|
description="Shows a list of installed UnifiedPush apps (ntfy, etc.) and registers the one you pick."
|
||||||
after={
|
after={
|
||||||
<Button
|
<Button
|
||||||
size="300"
|
size="300"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMatch } from 'react-router-dom';
|
import { useMatch } from 'react-router-dom';
|
||||||
import { getDirectCreatePath, getDirectPath } from '../../pages/pathUtils';
|
import { getDirectCreatePath, getDirectPath, getDirectSearchPath } from '../../pages/pathUtils';
|
||||||
|
|
||||||
export const useDirectSelected = (): boolean => {
|
export const useDirectSelected = (): boolean => {
|
||||||
const directMatch = useMatch({
|
const directMatch = useMatch({
|
||||||
@@ -20,3 +20,13 @@ export const useDirectCreateSelected = (): boolean => {
|
|||||||
|
|
||||||
return !!match;
|
return !!match;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useDirectSearchSelected = (): boolean => {
|
||||||
|
const match = useMatch({
|
||||||
|
path: getDirectSearchPath(),
|
||||||
|
caseSensitive: true,
|
||||||
|
end: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return !!match;
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,21 +4,30 @@ import { readClipboardImage, isTauri, isLinux } from '../utils/tauri';
|
|||||||
|
|
||||||
export const useFilePasteHandler = (onPaste: (file: File[]) => void): ClipboardEventHandler =>
|
export const useFilePasteHandler = (onPaste: (file: File[]) => void): ClipboardEventHandler =>
|
||||||
useCallback(
|
useCallback(
|
||||||
async (evt) => {
|
(evt) => {
|
||||||
const files = getDataTransferFiles(evt.clipboardData);
|
const files = getDataTransferFiles(evt.clipboardData);
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
|
evt.preventDefault();
|
||||||
onPaste(files);
|
onPaste(files);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// On Linux with Tauri, browser clipboard API doesn't work for images
|
// Address-bar / plain-text copies still win. On Linux the native clipboard
|
||||||
// Use our custom Tauri command with arboard/Wayland support
|
// often still has a previous image (or a junk bitmap) alongside text/plain;
|
||||||
if (isTauri() && isLinux()) {
|
// don't treat those as an image paste.
|
||||||
const clipboardImage = await readClipboardImage();
|
const text = evt.clipboardData?.getData('text/plain')?.trim() ?? '';
|
||||||
if (clipboardImage) {
|
if (text.length > 0) {
|
||||||
onPaste([clipboardImage]);
|
return;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Browser clipboardData.files is empty for raw image copies on Linux Tauri/Electron.
|
||||||
|
if (!(isTauri() && isLinux())) return;
|
||||||
|
|
||||||
|
// preventDefault before the async read, or the editor also inserts text.
|
||||||
|
evt.preventDefault();
|
||||||
|
void readClipboardImage().then((clipboardImage) => {
|
||||||
|
if (clipboardImage) onPaste([clipboardImage]);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[onPaste]
|
[onPaste]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,30 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useClientConfig } from './useClientConfig';
|
import { useClientConfig } from './useClientConfig';
|
||||||
import { trimLeadingSlash, trimSlash, trimTrailingSlash } from '../utils/common';
|
import { trimLeadingSlash, trimSlash, trimTrailingSlash } from '../utils/common';
|
||||||
|
import { isElectron } from '../utils/tauri';
|
||||||
|
|
||||||
|
/** Authority shown on Matrix SSO consent ("Continue to paarrot://desktop"). */
|
||||||
|
export const ELECTRON_PROTOCOL_HOST = 'desktop';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an absolute URL for the given in-app path.
|
||||||
|
*
|
||||||
|
* In Electron, SSO (and other browser handoffs) must return via paarrot://desktop/...
|
||||||
|
* — a named host so consent UI identifies the desktop app, and a real pathname
|
||||||
|
* (not a hash) because browsers strip fragments when launching custom protocols.
|
||||||
|
*/
|
||||||
export const usePathWithOrigin = (path: string): string => {
|
export const usePathWithOrigin = (path: string): string => {
|
||||||
const { hashRouter } = useClientConfig();
|
const { hashRouter } = useClientConfig();
|
||||||
const { origin } = window.location;
|
const { origin } = window.location;
|
||||||
|
|
||||||
const pathWithOrigin = useMemo(() => {
|
const pathWithOrigin = useMemo(() => {
|
||||||
|
if (isElectron()) {
|
||||||
|
const basename = trimSlash(hashRouter?.basename ?? '');
|
||||||
|
const routeParts = [basename, trimLeadingSlash(path)].filter(Boolean);
|
||||||
|
const route = `/${routeParts.join('/')}`.replace(/\/{2,}/g, '/');
|
||||||
|
return `paarrot://${ELECTRON_PROTOCOL_HOST}${route}`;
|
||||||
|
}
|
||||||
|
|
||||||
let url: string = trimSlash(origin);
|
let url: string = trimSlash(origin);
|
||||||
|
|
||||||
url += `/${trimSlash(import.meta.env.BASE_URL ?? '')}`;
|
url += `/${trimSlash(import.meta.env.BASE_URL ?? '')}`;
|
||||||
|
|||||||
256
src/app/hooks/useRoomMediaEvents.ts
Normal file
256
src/app/hooks/useRoomMediaEvents.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import to from 'await-to-js';
|
||||||
|
import {
|
||||||
|
Direction,
|
||||||
|
IRoomEvent,
|
||||||
|
MatrixEvent,
|
||||||
|
MsgType,
|
||||||
|
RelationType,
|
||||||
|
Room,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
|
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
||||||
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { IEncryptedFile } from '../../types/matrix/common';
|
||||||
|
|
||||||
|
export type RoomMediaItem = {
|
||||||
|
eventId: string;
|
||||||
|
msgtype: typeof MsgType.Image | typeof MsgType.Video;
|
||||||
|
/** MXC used for an image thumbnail when available. */
|
||||||
|
thumbMxc?: string;
|
||||||
|
thumbMimeType?: string;
|
||||||
|
thumbEncInfo?: EncryptedAttachmentInfo;
|
||||||
|
/** Full media MXC — used to extract a video frame when no image thumb exists. */
|
||||||
|
fullMxc: string;
|
||||||
|
fullMimeType?: string;
|
||||||
|
fullEncInfo?: EncryptedAttachmentInfo;
|
||||||
|
body: string;
|
||||||
|
ts: number;
|
||||||
|
sender: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
const TARGET_BATCH = 24;
|
||||||
|
const MAX_PAGES_PER_LOAD = 8;
|
||||||
|
|
||||||
|
type RoomMediaCache = {
|
||||||
|
items: RoomMediaItem[];
|
||||||
|
hasMore: boolean;
|
||||||
|
nextToken: string | null;
|
||||||
|
seenIds: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Survives MediaDrawer remounts within the same room/session. */
|
||||||
|
const mediaCache = new Map<string, RoomMediaCache>();
|
||||||
|
|
||||||
|
const getOrCreateCache = (roomId: string): RoomMediaCache => {
|
||||||
|
const existing = mediaCache.get(roomId);
|
||||||
|
if (existing) return existing;
|
||||||
|
const created: RoomMediaCache = {
|
||||||
|
items: [],
|
||||||
|
hasMore: true,
|
||||||
|
nextToken: null,
|
||||||
|
seenIds: new Set(),
|
||||||
|
};
|
||||||
|
mediaCache.set(roomId, created);
|
||||||
|
return created;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toMediaItem = (mEvent: MatrixEvent): RoomMediaItem | undefined => {
|
||||||
|
if (mEvent.isRedacted()) return undefined;
|
||||||
|
|
||||||
|
const relation = mEvent.getRelation();
|
||||||
|
if (relation?.rel_type === RelationType.Replace) return undefined;
|
||||||
|
|
||||||
|
const content = mEvent.getContent();
|
||||||
|
const msgtype = content.msgtype;
|
||||||
|
if (msgtype !== MsgType.Image && msgtype !== MsgType.Video) return undefined;
|
||||||
|
|
||||||
|
const file = content.file as IEncryptedFile | undefined;
|
||||||
|
const info = content.info as
|
||||||
|
| {
|
||||||
|
mimetype?: string;
|
||||||
|
thumbnail_url?: string;
|
||||||
|
thumbnail_info?: { mimetype?: string };
|
||||||
|
thumbnail_file?: IEncryptedFile;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
const fullMxc =
|
||||||
|
(typeof content.url === 'string' && content.url) ||
|
||||||
|
(typeof file?.url === 'string' && file.url) ||
|
||||||
|
undefined;
|
||||||
|
if (!fullMxc) return undefined;
|
||||||
|
|
||||||
|
const thumbFile = info?.thumbnail_file;
|
||||||
|
const thumbMxc =
|
||||||
|
(typeof info?.thumbnail_url === 'string' && info.thumbnail_url) ||
|
||||||
|
(typeof thumbFile?.url === 'string' && thumbFile.url) ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
const eventId = mEvent.getId();
|
||||||
|
const sender = mEvent.getSender();
|
||||||
|
if (!eventId || !sender) return undefined;
|
||||||
|
|
||||||
|
const usingThumbEnc = !!thumbMxc && thumbMxc === thumbFile?.url;
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventId,
|
||||||
|
msgtype,
|
||||||
|
thumbMxc,
|
||||||
|
thumbMimeType: usingThumbEnc
|
||||||
|
? info?.thumbnail_info?.mimetype
|
||||||
|
: thumbMxc
|
||||||
|
? info?.thumbnail_info?.mimetype
|
||||||
|
: undefined,
|
||||||
|
thumbEncInfo: usingThumbEnc ? thumbFile : undefined,
|
||||||
|
fullMxc,
|
||||||
|
fullMimeType: info?.mimetype,
|
||||||
|
fullEncInfo: file,
|
||||||
|
body: typeof content.body === 'string' ? content.body : msgtype,
|
||||||
|
ts: mEvent.getTs(),
|
||||||
|
sender,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginates room history for image/video messages without mutating the live timeline.
|
||||||
|
*/
|
||||||
|
export const useRoomMediaEvents = (room: Room) => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const roomId = room.roomId;
|
||||||
|
const cacheRef = useRef(getOrCreateCache(roomId));
|
||||||
|
if (cacheRef.current !== mediaCache.get(roomId)) {
|
||||||
|
cacheRef.current = getOrCreateCache(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [items, setItems] = useState<RoomMediaItem[]>(() => cacheRef.current.items);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(() => cacheRef.current.hasMore);
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
|
||||||
|
const decryptEvent = useCallback(
|
||||||
|
async (raw: IRoomEvent | MatrixEvent) => {
|
||||||
|
const mEvent = raw instanceof MatrixEvent ? raw : new MatrixEvent(raw);
|
||||||
|
if (mEvent.isEncrypted() && mx.getCrypto()) {
|
||||||
|
await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
|
||||||
|
}
|
||||||
|
return mEvent;
|
||||||
|
},
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchPages = useCallback(async () => {
|
||||||
|
const cache = cacheRef.current;
|
||||||
|
const collected: RoomMediaItem[] = [];
|
||||||
|
|
||||||
|
for (let page = 0; page < MAX_PAGES_PER_LOAD; page += 1) {
|
||||||
|
const response = await mx.createMessagesRequest(
|
||||||
|
roomId,
|
||||||
|
cache.nextToken,
|
||||||
|
PAGE_SIZE,
|
||||||
|
Direction.Backward
|
||||||
|
);
|
||||||
|
|
||||||
|
cache.nextToken = response.end ?? null;
|
||||||
|
if (!response.end) {
|
||||||
|
cache.hasMore = false;
|
||||||
|
setHasMore(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const raw of response.chunk ?? []) {
|
||||||
|
const mEvent = await decryptEvent(raw);
|
||||||
|
const item = toMediaItem(mEvent);
|
||||||
|
if (!item || cache.seenIds.has(item.eventId)) continue;
|
||||||
|
cache.seenIds.add(item.eventId);
|
||||||
|
collected.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collected.length >= TARGET_BATCH || !response.end) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return collected;
|
||||||
|
}, [mx, roomId, decryptEvent]);
|
||||||
|
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
const cache = cacheRef.current;
|
||||||
|
if (loadingRef.current || !cache.hasMore) return;
|
||||||
|
loadingRef.current = true;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const collected = await fetchPages();
|
||||||
|
if (collected.length > 0) {
|
||||||
|
cache.items = [...cache.items, ...collected];
|
||||||
|
setItems(cache.items);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingRef.current = false;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [fetchPages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cache = getOrCreateCache(roomId);
|
||||||
|
cacheRef.current = cache;
|
||||||
|
|
||||||
|
// Already loaded for this room — restore without re-fetching.
|
||||||
|
if (cache.items.length > 0 || cache.nextToken !== null || !cache.hasMore) {
|
||||||
|
setItems(cache.items);
|
||||||
|
setHasMore(cache.hasMore);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const bootstrap = async () => {
|
||||||
|
cache.seenIds = new Set();
|
||||||
|
cache.nextToken = null;
|
||||||
|
cache.hasMore = true;
|
||||||
|
cache.items = [];
|
||||||
|
setItems([]);
|
||||||
|
setHasMore(true);
|
||||||
|
setLoading(true);
|
||||||
|
loadingRef.current = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localFound: RoomMediaItem[] = [];
|
||||||
|
const localEvents = room.getLiveTimeline().getEvents();
|
||||||
|
for (let i = localEvents.length - 1; i >= 0; i -= 1) {
|
||||||
|
const mEvent = await decryptEvent(localEvents[i]);
|
||||||
|
const item = toMediaItem(mEvent);
|
||||||
|
if (!item || cache.seenIds.has(item.eventId)) continue;
|
||||||
|
cache.seenIds.add(item.eventId);
|
||||||
|
localFound.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
if (localFound.length > 0) {
|
||||||
|
cache.items = localFound;
|
||||||
|
setItems(localFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
const collected = await fetchPages();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (collected.length > 0) {
|
||||||
|
cache.items = [...cache.items, ...collected];
|
||||||
|
setItems(cache.items);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
loadingRef.current = false;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// Only re-bootstrap when switching rooms — not when callback identities change.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [roomId]);
|
||||||
|
|
||||||
|
return { items, loading, hasMore, loadMore };
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { NavigateOptions, useNavigate } from 'react-router-dom';
|
import { NavigateOptions, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { getCanonicalAliasOrRoomId } from '../utils/matrix';
|
import { getCanonicalAliasOrRoomId } from '../utils/matrix';
|
||||||
import {
|
import {
|
||||||
@@ -7,21 +7,25 @@ import {
|
|||||||
getHomeRoomPath,
|
getHomeRoomPath,
|
||||||
getSpacePath,
|
getSpacePath,
|
||||||
getSpaceRoomPath,
|
getSpaceRoomPath,
|
||||||
|
withRoomEventId,
|
||||||
} from '../pages/pathUtils';
|
} from '../pages/pathUtils';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { getOrphanParents, guessPerfectParent, isSpace } from '../utils/room';
|
import { getOrphanParents, guessPerfectParent, isSpace } from '../utils/room';
|
||||||
import { roomToParentsAtom } from '../state/room/roomToParents';
|
import { roomToParentsAtom } from '../state/room/roomToParents';
|
||||||
import { mDirectAtom } from '../state/mDirectList';
|
import { mDirectAtom } from '../state/mDirectList';
|
||||||
|
import { useSelectedRoom } from './router/useSelectedRoom';
|
||||||
import { useSelectedSpace } from './router/useSelectedSpace';
|
import { useSelectedSpace } from './router/useSelectedSpace';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
|
|
||||||
export const useRoomNavigate = () => {
|
export const useRoomNavigate = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
const spaceSelectedId = useSelectedSpace();
|
const spaceSelectedId = useSelectedSpace();
|
||||||
|
const selectedRoomId = useSelectedRoom();
|
||||||
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
|
|
||||||
const navigateSpace = useCallback(
|
const navigateSpace = useCallback(
|
||||||
@@ -34,6 +38,14 @@ export const useRoomNavigate = () => {
|
|||||||
|
|
||||||
const navigateRoom = useCallback(
|
const navigateRoom = useCallback(
|
||||||
(roomId: string, eventId?: string, opts?: NavigateOptions) => {
|
(roomId: string, eventId?: string, opts?: NavigateOptions) => {
|
||||||
|
// Already viewing this room — keep the current home/direct/space URL and only
|
||||||
|
// change the event id. Otherwise guessPerfectParent can yank the sidebar to
|
||||||
|
// wherever the room "belongs".
|
||||||
|
if (selectedRoomId === roomId) {
|
||||||
|
navigate(withRoomEventId(location.pathname, eventId), opts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
|
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
|
||||||
const openSpaceTimeline = developerTools && spaceSelectedId === roomId;
|
const openSpaceTimeline = developerTools && spaceSelectedId === roomId;
|
||||||
@@ -44,6 +56,19 @@ export const useRoomNavigate = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId);
|
const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId);
|
||||||
|
|
||||||
|
// Prefer Direct for m.direct rooms unless we are already browsing a parent space
|
||||||
|
// of this room (keep space chrome). Opening via space while roomToParents is still
|
||||||
|
// catching up used to hit JoinBeforeNavigate even though the DM was listed.
|
||||||
|
if (mDirects.has(roomId)) {
|
||||||
|
const stayInSelectedSpace =
|
||||||
|
Boolean(spaceSelectedId) && orphanParents.includes(spaceSelectedId!);
|
||||||
|
if (!stayInSelectedSpace) {
|
||||||
|
navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (orphanParents.length > 0) {
|
if (orphanParents.length > 0) {
|
||||||
let parentSpace: string;
|
let parentSpace: string;
|
||||||
if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) {
|
if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) {
|
||||||
@@ -61,14 +86,18 @@ export const useRoomNavigate = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mDirects.has(roomId)) {
|
|
||||||
navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
|
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
|
||||||
},
|
},
|
||||||
[mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools]
|
[
|
||||||
|
mx,
|
||||||
|
navigate,
|
||||||
|
location.pathname,
|
||||||
|
selectedRoomId,
|
||||||
|
spaceSelectedId,
|
||||||
|
roomToParents,
|
||||||
|
mDirects,
|
||||||
|
developerTools,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
60
src/app/hooks/useRoomUploadFiles.ts
Normal file
60
src/app/hooks/useRoomUploadFiles.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import {
|
||||||
|
roomIdToUploadItemsAtomFamily,
|
||||||
|
TUploadItem,
|
||||||
|
} from '../state/room/roomInputDrafts';
|
||||||
|
import { encryptFile } from '../utils/matrix';
|
||||||
|
import { safeFile } from '../utils/mimeTypes';
|
||||||
|
import { fulfilledPromiseSettledResult } from '../utils/common';
|
||||||
|
import { useFilePicker } from './useFilePicker';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueues files into the room composer upload board (shared by RoomInput and header media).
|
||||||
|
*/
|
||||||
|
export const useRoomUploadFiles = (room: Room) => {
|
||||||
|
const setSelectedFiles = useSetAtom(roomIdToUploadItemsAtomFamily(room.roomId));
|
||||||
|
|
||||||
|
const handleFiles = useCallback(
|
||||||
|
async (files: File[]) => {
|
||||||
|
const safeFiles = files.map(safeFile);
|
||||||
|
const fileItems: TUploadItem[] = [];
|
||||||
|
|
||||||
|
if (room.hasEncryptionStateEvent()) {
|
||||||
|
const encryptFiles = fulfilledPromiseSettledResult(
|
||||||
|
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
|
||||||
|
);
|
||||||
|
encryptFiles.forEach((ef) =>
|
||||||
|
fileItems.push({
|
||||||
|
...ef,
|
||||||
|
metadata: {
|
||||||
|
markedAsSpoiler: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
safeFiles.forEach((f) =>
|
||||||
|
fileItems.push({
|
||||||
|
file: f,
|
||||||
|
originalFile: f,
|
||||||
|
encInfo: undefined,
|
||||||
|
metadata: {
|
||||||
|
markedAsSpoiler: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFiles({
|
||||||
|
type: 'PUT',
|
||||||
|
item: fileItems,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setSelectedFiles, room]
|
||||||
|
);
|
||||||
|
|
||||||
|
const pickFiles = useFilePicker(handleFiles, true);
|
||||||
|
|
||||||
|
return { handleFiles, pickFiles };
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { lightTheme } from 'folds';
|
import { lightTheme } from 'folds';
|
||||||
|
import { atom, useAtomValue } from 'jotai';
|
||||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
|
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
|
||||||
import { butterTheme, catppuccinMochaTheme, darkTheme, discordTheme, discordDarkerTheme, mochaTheme, silverTheme, stationeryDarkTheme, stationeryTheme, twilightTheme } from '../../colors.css';
|
import { butterTheme, catppuccinMochaTheme, darkTheme, discordTheme, discordDarkerTheme, mochaTheme, silverTheme, stationeryDarkTheme, stationeryTheme, twilightTheme } from '../../colors.css';
|
||||||
@@ -6,6 +7,9 @@ import { settingsAtom } from '../state/settings';
|
|||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { pluginRegistry } from '../features/settings/plugins/PluginAPI';
|
import { pluginRegistry } from '../features/settings/plugins/PluginAPI';
|
||||||
|
|
||||||
|
/** Ephemeral theme id while hovering theme options in settings (not persisted). */
|
||||||
|
export const themePreviewIdAtom = atom<string | undefined>(undefined);
|
||||||
|
|
||||||
export enum ThemeKind {
|
export enum ThemeKind {
|
||||||
Light = 'light',
|
Light = 'light',
|
||||||
Dark = 'dark',
|
Dark = 'dark',
|
||||||
@@ -190,6 +194,16 @@ export const useActiveTheme = (): Theme => {
|
|||||||
return selectedTheme;
|
return selectedTheme;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Active theme, or hover-preview theme when browsing the theme menu. */
|
||||||
|
export const useDisplayTheme = (): Theme => {
|
||||||
|
const activeTheme = useActiveTheme();
|
||||||
|
const themes = useThemes();
|
||||||
|
const previewId = useAtomValue(themePreviewIdAtom);
|
||||||
|
|
||||||
|
if (!previewId) return activeTheme;
|
||||||
|
return themes.find((theme) => theme.id === previewId) ?? activeTheme;
|
||||||
|
};
|
||||||
|
|
||||||
const ThemeContext = createContext<Theme | null>(null);
|
const ThemeContext = createContext<Theme | null>(null);
|
||||||
export const ThemeContextProvider = ThemeContext.Provider;
|
export const ThemeContextProvider = ThemeContext.Provider;
|
||||||
|
|
||||||
|
|||||||
@@ -1,54 +1,17 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { MatrixClient, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
|
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { useMediaAuthentication } from './useMediaAuthentication';
|
import { useMediaAuthentication } from './useMediaAuthentication';
|
||||||
import { mxcUrlToHttp } from '../utils/matrix';
|
import { mxcUrlToHttp } from '../utils/matrix';
|
||||||
import { getCurrentAccessToken } from '../utils/auth';
|
import { getCurrentAccessToken } from '../utils/auth';
|
||||||
import {
|
import {
|
||||||
fetchAndExtractMetadata,
|
deleteBannerUrl,
|
||||||
extractMetadataFromImage,
|
loadBannerUrl,
|
||||||
embedMetadataInImage,
|
registerBannerCacheClear,
|
||||||
detectImageFormat,
|
saveBannerUrl,
|
||||||
getMimeType,
|
} from '../utils/profileFields';
|
||||||
getExtension,
|
|
||||||
ImageMetadata,
|
|
||||||
} from '../utils/imageMetadata';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the user's current avatar as raw image data
|
* Hook to manage the user's profile banner (MSC4427 via MSC4133).
|
||||||
*/
|
|
||||||
async function fetchAvatarData(
|
|
||||||
mx: MatrixClient,
|
|
||||||
avatarMxc: string,
|
|
||||||
useAuthentication: boolean
|
|
||||||
): Promise<ArrayBuffer | null> {
|
|
||||||
const url = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
|
||||||
if (!url) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
let response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// If we got a 401 and we tried with auth, fallback to unauthenticated request
|
|
||||||
if (!response.ok && response.status === 401 && accessToken && useAuthentication) {
|
|
||||||
response = await fetch(url, { method: 'GET' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) return null;
|
|
||||||
return await response.arrayBuffer();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to manage the user's chosen profile banner, stored in avatar image metadata
|
|
||||||
* The banner URL is embedded in avatar metadata and syncs via the avatar
|
|
||||||
* @returns The current banner URL, a setter function, and loading state
|
|
||||||
*/
|
*/
|
||||||
export function useUserBanner(): [
|
export function useUserBanner(): [
|
||||||
string | undefined,
|
string | undefined,
|
||||||
@@ -56,268 +19,124 @@ export function useUserBanner(): [
|
|||||||
boolean
|
boolean
|
||||||
] {
|
] {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
|
||||||
const [banner, setBanner] = useState<string | undefined>();
|
const [banner, setBanner] = useState<string | undefined>();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Extract banner from current avatar on mount and when profile changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadBanner = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const userId = mx.getUserId();
|
|
||||||
if (!userId) {
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = await mx.getProfileInfo(userId);
|
|
||||||
const avatarUrl = profile.avatar_url;
|
|
||||||
console.log('[useUserBanner.loadBanner] Loading banner from avatar:', avatarUrl);
|
|
||||||
|
|
||||||
if (!avatarUrl) {
|
|
||||||
setBanner(undefined);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication);
|
|
||||||
if (!httpUrl) {
|
|
||||||
setBanner(undefined);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
|
||||||
console.log('[useUserBanner.loadBanner] Extracted banner:', metadata.banner);
|
|
||||||
setBanner(metadata.banner);
|
|
||||||
} catch {
|
|
||||||
setBanner(undefined);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadBanner();
|
|
||||||
|
|
||||||
// Listen for avatar changes and reload banner
|
|
||||||
const userId = mx.getUserId();
|
|
||||||
if (!userId) return undefined;
|
|
||||||
|
|
||||||
const user = mx.getUser(userId);
|
|
||||||
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = (newAvatarUrl) => {
|
|
||||||
console.log('[useUserBanner] Avatar changed event fired, new URL:', newAvatarUrl);
|
|
||||||
loadBanner();
|
|
||||||
};
|
|
||||||
|
|
||||||
user?.on(UserEvent.AvatarUrl, onAvatarChange);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
|
|
||||||
};
|
|
||||||
}, [mx, useAuthentication]);
|
|
||||||
|
|
||||||
const updateBanner = useCallback(async (newBanner: string | undefined) => {
|
|
||||||
const userId = mx.getUserId();
|
const userId = mx.getUserId();
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new Error('No user ID');
|
setLoading(false);
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = await mx.getProfileInfo(userId);
|
let cancelled = false;
|
||||||
const avatarUrl = profile.avatar_url;
|
|
||||||
|
|
||||||
if (!avatarUrl) {
|
const load = async () => {
|
||||||
throw new Error('No avatar set. Please upload an avatar first.');
|
setLoading(true);
|
||||||
}
|
try {
|
||||||
|
const bannerMxc = await loadBannerUrl(mx, userId);
|
||||||
// Fetch current avatar
|
if (!cancelled) setBanner(bannerMxc);
|
||||||
const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication);
|
} catch {
|
||||||
if (!avatarData) {
|
if (!cancelled) setBanner(undefined);
|
||||||
throw new Error('Failed to fetch current avatar');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect image format
|
|
||||||
const format = detectImageFormat(avatarData);
|
|
||||||
|
|
||||||
if (format === 'unknown') {
|
|
||||||
throw new Error('Unsupported avatar image format');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get existing metadata to preserve
|
|
||||||
const existingMetadata = extractMetadataFromImage(avatarData);
|
|
||||||
console.log('[updateBanner] Existing metadata:', existingMetadata);
|
|
||||||
|
|
||||||
// Modify image metadata with new banner, preserving color
|
|
||||||
const newMetadata: ImageMetadata = {
|
|
||||||
color: existingMetadata.color,
|
|
||||||
banner: newBanner,
|
|
||||||
avatarBorderColor: existingMetadata.avatarBorderColor,
|
|
||||||
gradient: existingMetadata.gradient,
|
|
||||||
};
|
|
||||||
console.log('[updateBanner] New metadata to embed:', newMetadata);
|
|
||||||
|
|
||||||
const newAvatarData = embedMetadataInImage(avatarData, newMetadata);
|
|
||||||
|
|
||||||
if (!newAvatarData) {
|
|
||||||
throw new Error('Failed to embed banner in avatar metadata');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the banner was embedded correctly before uploading
|
|
||||||
const verifyMetadata = extractMetadataFromImage(newAvatarData);
|
|
||||||
console.log('[updateBanner] Verification - extracted metadata from new avatar:', verifyMetadata);
|
|
||||||
|
|
||||||
if (newBanner && verifyMetadata.banner !== newBanner) {
|
|
||||||
console.error('[updateBanner] Banner verification FAILED!', 'Expected:', newBanner, 'Got:', verifyMetadata.banner);
|
|
||||||
throw new Error('Banner verification failed');
|
|
||||||
}
|
|
||||||
console.log('[updateBanner] Banner verification passed');
|
|
||||||
|
|
||||||
// Upload modified avatar with correct MIME type
|
|
||||||
const mimeType = getMimeType(format);
|
|
||||||
const extension = getExtension(format);
|
|
||||||
const blob = new Blob([newAvatarData], { type: mimeType });
|
|
||||||
|
|
||||||
const uploadResponse = await mx.uploadContent(blob, {
|
|
||||||
name: `avatar.${extension}`,
|
|
||||||
type: mimeType,
|
|
||||||
});
|
|
||||||
console.log('[updateBanner] Avatar uploaded successfully, MXC:', uploadResponse.content_uri);
|
|
||||||
|
|
||||||
// Update profile with new avatar
|
|
||||||
try {
|
|
||||||
console.log('[updateBanner] Setting avatar URL to:', uploadResponse.content_uri);
|
|
||||||
await Promise.race([
|
|
||||||
mx.setAvatarUrl(uploadResponse.content_uri),
|
|
||||||
new Promise((_, reject) => {
|
|
||||||
setTimeout(() => reject(new Error('setAvatarUrl timeout')), 30000);
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
console.log('[updateBanner] setAvatarUrl completed');
|
|
||||||
|
|
||||||
// Manually sync user object to ensure event listeners are triggered
|
|
||||||
const user = mx.getUser(userId);
|
|
||||||
if (user && user.avatarUrl !== uploadResponse.content_uri) {
|
|
||||||
console.log('[updateBanner] Manually syncing user avatar URL');
|
|
||||||
user.setAvatarUrl(uploadResponse.content_uri);
|
|
||||||
}
|
}
|
||||||
console.log('[updateBanner] Banner update complete');
|
if (!cancelled) setLoading(false);
|
||||||
} catch (error) {
|
};
|
||||||
throw new Error(`Failed to update avatar URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: Don't set banner here - let the avatar change event handle it
|
load();
|
||||||
// setBanner(newBanner);
|
return () => {
|
||||||
}, [mx, useAuthentication]);
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
|
const updateBanner = useCallback(
|
||||||
|
async (newBanner: string | undefined) => {
|
||||||
|
if (!newBanner) {
|
||||||
|
await deleteBannerUrl(mx);
|
||||||
|
setBanner(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveBannerUrl(mx, newBanner);
|
||||||
|
setBanner(newBanner);
|
||||||
|
},
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
|
||||||
return [banner, updateBanner, loading];
|
return [banner, updateBanner, loading];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache for user banners to avoid refetching for each message
|
|
||||||
const userBannerCache = new Map<string, { bannerMxc: string | undefined; timestamp: number }>();
|
const userBannerCache = new Map<string, { bannerMxc: string | undefined; timestamp: number }>();
|
||||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
registerBannerCacheClear((userId: string) => {
|
||||||
|
userBannerCache.delete(userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchBannerBlobUrl(
|
||||||
|
mx: ReturnType<typeof useMatrixClient>,
|
||||||
|
bannerMxc: string,
|
||||||
|
useAuthentication: boolean
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const bannerHttpUrl = mxcUrlToHttp(mx, bannerMxc, useAuthentication);
|
||||||
|
if (!bannerHttpUrl) return undefined;
|
||||||
|
|
||||||
|
const accessToken = getCurrentAccessToken();
|
||||||
|
const headers: HeadersInit = {};
|
||||||
|
if (useAuthentication && accessToken) {
|
||||||
|
headers.Authorization = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(bannerHttpUrl, { headers });
|
||||||
|
if (!response.ok) return undefined;
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to get another user's chosen profile banner from their avatar metadata
|
* Hook to get another user's profile banner as a blob URL.
|
||||||
* @param userId - The user ID to get banner for
|
|
||||||
* @param avatarMxc - The user's avatar MXC URL
|
|
||||||
* @returns The user's chosen banner as a blob URL or undefined
|
|
||||||
*/
|
*/
|
||||||
export function useOtherUserBanner(userId: string, avatarMxc: string | undefined): string | undefined {
|
export function useOtherUserBanner(userId: string): string | undefined {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const [bannerBlobUrl, setBannerBlobUrl] = useState<string | undefined>();
|
const [bannerBlobUrl, setBannerBlobUrl] = useState<string | undefined>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let blobUrl: string | undefined;
|
if (!userId) {
|
||||||
|
|
||||||
if (!avatarMxc) {
|
|
||||||
console.log('[useOtherUserBanner] No avatarMxc provided for', userId);
|
|
||||||
setBannerBlobUrl(undefined);
|
setBannerBlobUrl(undefined);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadBanner = async () => {
|
let blobUrl: string | undefined;
|
||||||
console.log('[useOtherUserBanner] Loading banner for', userId, 'avatarMxc:', avatarMxc);
|
let cancelled = false;
|
||||||
|
|
||||||
// Check cache first
|
const load = async () => {
|
||||||
const cacheKey = `${userId}:${avatarMxc}`;
|
const cached = userBannerCache.get(userId);
|
||||||
const cached = userBannerCache.get(cacheKey);
|
|
||||||
|
|
||||||
let bannerMxc: string | undefined;
|
let bannerMxc: string | undefined;
|
||||||
|
|
||||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||||
// Use cached banner MXC
|
|
||||||
console.log('[useOtherUserBanner] Using cached bannerMxc:', cached.bannerMxc);
|
|
||||||
bannerMxc = cached.bannerMxc;
|
bannerMxc = cached.bannerMxc;
|
||||||
} else {
|
} else {
|
||||||
// Fetch banner MXC from avatar metadata
|
bannerMxc = await loadBannerUrl(mx, userId);
|
||||||
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
userBannerCache.set(userId, { bannerMxc, timestamp: Date.now() });
|
||||||
console.log('[useOtherUserBanner] Fetching metadata from:', httpUrl);
|
|
||||||
|
|
||||||
if (!httpUrl) {
|
|
||||||
console.log('[useOtherUserBanner] Failed to get HTTP URL');
|
|
||||||
setBannerBlobUrl(undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
|
||||||
|
|
||||||
console.log('[useOtherUserBanner] Extracted metadata:', metadata);
|
|
||||||
|
|
||||||
bannerMxc = metadata.banner;
|
|
||||||
|
|
||||||
// Cache the banner MXC (not the blob URL)
|
|
||||||
userBannerCache.set(cacheKey, { bannerMxc, timestamp: Date.now() });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!bannerMxc) {
|
if (!bannerMxc) {
|
||||||
console.log('[useOtherUserBanner] No banner MXC found');
|
if (!cancelled) setBannerBlobUrl(undefined);
|
||||||
setBannerBlobUrl(undefined);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[useOtherUserBanner] Fetching banner image from MXC:', bannerMxc);
|
blobUrl = await fetchBannerBlobUrl(mx, bannerMxc, useAuthentication);
|
||||||
|
if (!cancelled) setBannerBlobUrl(blobUrl);
|
||||||
// Fetch the banner image data with authentication
|
|
||||||
const bannerHttpUrl = mxcUrlToHttp(mx, bannerMxc, useAuthentication);
|
|
||||||
if (!bannerHttpUrl) {
|
|
||||||
console.log('[useOtherUserBanner] Failed to convert banner MXC to HTTP URL');
|
|
||||||
setBannerBlobUrl(undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[useOtherUserBanner] Banner HTTP URL:', bannerHttpUrl);
|
|
||||||
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
const headers: HeadersInit = {};
|
|
||||||
if (useAuthentication && accessToken) {
|
|
||||||
headers.Authorization = `Bearer ${accessToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(bannerHttpUrl, { headers });
|
|
||||||
if (!response.ok) {
|
|
||||||
setBannerBlobUrl(undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
blobUrl = URL.createObjectURL(blob);
|
|
||||||
setBannerBlobUrl(blobUrl);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
loadBanner();
|
load();
|
||||||
|
|
||||||
// Cleanup blob URL when component unmounts or deps change
|
|
||||||
return () => {
|
return () => {
|
||||||
if (blobUrl) {
|
cancelled = true;
|
||||||
URL.revokeObjectURL(blobUrl);
|
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [mx, useAuthentication, avatarMxc, userId]);
|
}, [mx, useAuthentication, userId]);
|
||||||
|
|
||||||
return bannerBlobUrl;
|
return bannerBlobUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,226 +1,82 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { MatrixClient, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { useMediaAuthentication } from './useMediaAuthentication';
|
import { ThemeKind, useTheme } from './useTheme';
|
||||||
import { mxcUrlToHttp } from '../utils/matrix';
|
|
||||||
import { getCurrentAccessToken } from '../utils/auth';
|
|
||||||
import {
|
import {
|
||||||
embedColorInImage,
|
ColorPreference,
|
||||||
extractColorFromImage,
|
deleteColorPreference,
|
||||||
removeColorFromImage,
|
extractMemberColorPreference,
|
||||||
fetchAndExtractColor,
|
hasColorPreference,
|
||||||
detectImageFormat,
|
loadColorPreference,
|
||||||
getMimeType,
|
registerPreferenceCacheClear,
|
||||||
getExtension,
|
resolveColorForTheme,
|
||||||
} from '../utils/imageMetadata';
|
saveColorPreference,
|
||||||
|
} from '../utils/profileFields';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the user's current avatar as raw image data
|
* Hook to manage the user's MSC4522 color preference on their global profile.
|
||||||
*/
|
*/
|
||||||
async function fetchAvatarData(
|
export function useUserColorPreference(): [
|
||||||
mx: MatrixClient,
|
ColorPreference | undefined,
|
||||||
avatarMxc: string,
|
(preference: ColorPreference | undefined) => Promise<void>,
|
||||||
useAuthentication: boolean
|
|
||||||
): Promise<ArrayBuffer | null> {
|
|
||||||
const url = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
|
||||||
if (!url) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
let response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// If we got a 401 and we tried with auth, fallback to unauthenticated request
|
|
||||||
if (!response.ok && response.status === 401 && accessToken && useAuthentication) {
|
|
||||||
console.warn('[fetchAvatarData] Auth failed (401), attempting unauthenticated fallback');
|
|
||||||
response = await fetch(url, { method: 'GET' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) return null;
|
|
||||||
return await response.arrayBuffer();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to manage the user's chosen profile color, stored in avatar image metadata
|
|
||||||
* The color is embedded in PNG tEXt chunk or WebP XMP chunk and syncs via the avatar
|
|
||||||
* @returns The current color, a setter function, and loading state
|
|
||||||
*/
|
|
||||||
export function useUserColor(): [
|
|
||||||
string | undefined,
|
|
||||||
(color: string | undefined) => Promise<void>,
|
|
||||||
boolean
|
boolean
|
||||||
] {
|
] {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const [preference, setPreference] = useState<ColorPreference | undefined>();
|
||||||
const [color, setColor] = useState<string | undefined>();
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Extract color from current avatar on mount and when profile changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadColor = async () => {
|
const userId = mx.getUserId();
|
||||||
|
if (!userId) {
|
||||||
|
setLoading(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const userId = mx.getUserId();
|
const loaded = await loadColorPreference(mx, userId);
|
||||||
if (!userId) {
|
if (!cancelled) setPreference(loaded);
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = await mx.getProfileInfo(userId);
|
|
||||||
const avatarUrl = profile.avatar_url;
|
|
||||||
|
|
||||||
if (!avatarUrl) {
|
|
||||||
setColor(undefined);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication);
|
|
||||||
if (!httpUrl) {
|
|
||||||
setColor(undefined);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null);
|
|
||||||
setColor(extractedColor);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load user color from avatar:', e);
|
console.error('Failed to load color preference:', e);
|
||||||
setColor(undefined);
|
if (!cancelled) setPreference(undefined);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
loadColor();
|
load();
|
||||||
|
|
||||||
// Listen for avatar changes and reload color
|
|
||||||
const userId = mx.getUserId();
|
|
||||||
if (!userId) return undefined;
|
|
||||||
|
|
||||||
const user = mx.getUser(userId);
|
|
||||||
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = () => {
|
|
||||||
loadColor();
|
|
||||||
};
|
|
||||||
|
|
||||||
user?.on(UserEvent.AvatarUrl, onAvatarChange);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [mx, useAuthentication]);
|
}, [mx]);
|
||||||
|
|
||||||
const updateColor = useCallback(async (newColor: string | undefined) => {
|
const updatePreference = useCallback(
|
||||||
const userId = mx.getUserId();
|
async (newPreference: ColorPreference | undefined) => {
|
||||||
if (!userId) return;
|
if (!hasColorPreference(newPreference)) {
|
||||||
|
await deleteColorPreference(mx);
|
||||||
try {
|
setPreference(undefined);
|
||||||
const profile = await mx.getProfileInfo(userId);
|
return;
|
||||||
const avatarUrl = profile.avatar_url;
|
|
||||||
|
|
||||||
if (!avatarUrl) {
|
|
||||||
// No avatar to embed color in
|
|
||||||
console.warn('Cannot set color: no avatar image set');
|
|
||||||
throw new Error('No avatar set');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch current avatar
|
await saveColorPreference(mx, newPreference!);
|
||||||
const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication);
|
setPreference(newPreference);
|
||||||
if (!avatarData) {
|
},
|
||||||
console.error('Failed to fetch current avatar');
|
[mx]
|
||||||
throw new Error('Failed to fetch avatar');
|
);
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Original avatar size:', avatarData.byteLength, 'bytes');
|
return [preference, updatePreference, loading];
|
||||||
|
|
||||||
// Detect image format
|
|
||||||
const format = detectImageFormat(avatarData);
|
|
||||||
console.log('Detected image format:', format);
|
|
||||||
|
|
||||||
if (format === 'unknown') {
|
|
||||||
console.error('Unsupported avatar image format - must be PNG, WebP, JPEG, or GIF');
|
|
||||||
throw new Error('Unsupported image format');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modify image metadata
|
|
||||||
let newAvatarData: Uint8Array | null;
|
|
||||||
if (newColor) {
|
|
||||||
newAvatarData = embedColorInImage(avatarData, newColor);
|
|
||||||
} else {
|
|
||||||
newAvatarData = removeColorFromImage(avatarData);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!newAvatarData) {
|
|
||||||
console.error('Failed to modify avatar image metadata');
|
|
||||||
throw new Error('Failed to modify image metadata');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Modified avatar size:', newAvatarData.byteLength, 'bytes');
|
|
||||||
|
|
||||||
// Verify the color was embedded correctly before uploading
|
|
||||||
const verifyColor = extractColorFromImage(newAvatarData);
|
|
||||||
console.log('Verification - embedded color:', verifyColor);
|
|
||||||
|
|
||||||
if (newColor && verifyColor !== newColor) {
|
|
||||||
console.error('Color verification failed! Expected:', newColor, 'Got:', verifyColor);
|
|
||||||
throw new Error('Color embedding verification failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload modified avatar with correct MIME type
|
|
||||||
const mimeType = getMimeType(format);
|
|
||||||
const extension = getExtension(format);
|
|
||||||
const blob = new Blob([newAvatarData], { type: mimeType });
|
|
||||||
const uploadResponse = await mx.uploadContent(blob, {
|
|
||||||
name: `avatar.${extension}`,
|
|
||||||
type: mimeType,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Uploaded new avatar:', uploadResponse.content_uri);
|
|
||||||
|
|
||||||
// Update profile with new avatar
|
|
||||||
await mx.setAvatarUrl(uploadResponse.content_uri);
|
|
||||||
|
|
||||||
// Manually sync user object to ensure event listeners are triggered
|
|
||||||
const user = mx.getUser(userId);
|
|
||||||
if (user && user.avatarUrl !== uploadResponse.content_uri) {
|
|
||||||
user.setAvatarUrl(uploadResponse.content_uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
setColor(newColor);
|
|
||||||
console.log('User color updated in avatar:', newColor);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to update user color in avatar:', e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}, [mx, useAuthentication]);
|
|
||||||
|
|
||||||
return [color, updateColor, loading];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* LRU Cache implementation with max size limit
|
|
||||||
*/
|
|
||||||
class LRUCache<K, V> {
|
class LRUCache<K, V> {
|
||||||
private cache: Map<K, V>;
|
private cache = new Map<K, V>();
|
||||||
|
|
||||||
private maxSize: number;
|
constructor(private maxSize: number) {}
|
||||||
|
|
||||||
constructor(maxSize: number) {
|
|
||||||
this.cache = new Map();
|
|
||||||
this.maxSize = maxSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
get(key: K): V | undefined {
|
get(key: K): V | undefined {
|
||||||
const value = this.cache.get(key);
|
const value = this.cache.get(key);
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
// Move to end (most recently used)
|
|
||||||
this.cache.delete(key);
|
this.cache.delete(key);
|
||||||
this.cache.set(key, value);
|
this.cache.set(key, value);
|
||||||
}
|
}
|
||||||
@@ -228,88 +84,106 @@ class LRUCache<K, V> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set(key: K, value: V): void {
|
set(key: K, value: V): void {
|
||||||
// If key exists, delete it first to re-add at end
|
|
||||||
if (this.cache.has(key)) {
|
if (this.cache.has(key)) {
|
||||||
this.cache.delete(key);
|
this.cache.delete(key);
|
||||||
}
|
} else if (this.cache.size >= this.maxSize) {
|
||||||
// If at max size, remove oldest (first) entry
|
|
||||||
else if (this.cache.size >= this.maxSize) {
|
|
||||||
const firstKey = this.cache.keys().next().value;
|
const firstKey = this.cache.keys().next().value;
|
||||||
if (firstKey !== undefined) {
|
if (firstKey !== undefined) this.cache.delete(firstKey);
|
||||||
this.cache.delete(firstKey);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this.cache.set(key, value);
|
this.cache.set(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
delete(key: K): void {
|
||||||
this.cache.clear();
|
this.cache.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LRU cache for user colors to avoid refetching for each message
|
const preferenceCache = new LRUCache<string, { preference: ColorPreference | undefined; timestamp: number }>(200);
|
||||||
const userColorCache = new LRUCache<string, { color: string | undefined; timestamp: number }>(100);
|
const CACHE_TTL_MS = 30 * 60 * 1000;
|
||||||
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
||||||
|
registerPreferenceCacheClear((userId: string) => {
|
||||||
|
preferenceCache.delete(userId);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to get another user's chosen profile color from their avatar metadata
|
* Hook to get another user's MSC4522 color preference from their global profile.
|
||||||
* @param userId - The user ID to get color for
|
|
||||||
* @param avatarMxc - The user's avatar MXC URL
|
|
||||||
* @returns The user's chosen color or undefined
|
|
||||||
*/
|
*/
|
||||||
export function useOtherUserColor(userId: string, avatarMxc: string | undefined): string | undefined {
|
export function useOtherUserColorPreference(userId: string): ColorPreference | undefined {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const cachedEntry = preferenceCache.get(userId);
|
||||||
|
const cachedPreference =
|
||||||
// Check cache synchronously BEFORE initializing state to avoid flash
|
cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL_MS ? cachedEntry.preference : undefined;
|
||||||
const cacheKey = avatarMxc ? `${userId}:${avatarMxc}` : '';
|
|
||||||
const cachedEntry = cacheKey ? userColorCache.get(cacheKey) : undefined;
|
const [preference, setPreference] = useState<ColorPreference | undefined>(cachedPreference);
|
||||||
const cachedColor = cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL_MS
|
|
||||||
? cachedEntry.color
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const [color, setColor] = useState<string | undefined>(cachedColor);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!avatarMxc) {
|
if (!userId) {
|
||||||
console.log('[useOtherUserColor] No avatarMxc provided for', userId);
|
setPreference(undefined);
|
||||||
setColor(undefined);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check cache first
|
const cached = preferenceCache.get(userId);
|
||||||
const cacheKey = `${userId}:${avatarMxc}`;
|
|
||||||
const cached = userColorCache.get(cacheKey);
|
|
||||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||||
console.log('[useOtherUserColor] Using cached color for', userId, ':', cached.color);
|
setPreference(cached.preference);
|
||||||
setColor(cached.color);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadColor = async () => {
|
let cancelled = false;
|
||||||
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
|
||||||
console.log('[useOtherUserColor] Loading color for', userId, 'from', httpUrl);
|
|
||||||
if (!httpUrl) {
|
|
||||||
console.log('[useOtherUserColor] Failed to get HTTP URL for', avatarMxc);
|
|
||||||
setColor(undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
const load = async () => {
|
||||||
const accessToken = getCurrentAccessToken();
|
try {
|
||||||
const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null);
|
const loaded = await loadColorPreference(mx, userId);
|
||||||
|
preferenceCache.set(userId, { preference: loaded, timestamp: Date.now() });
|
||||||
console.log('[useOtherUserColor] Extracted color for', userId, ':', extractedColor);
|
if (!cancelled) setPreference(loaded);
|
||||||
|
} catch {
|
||||||
// Cache the result
|
if (!cancelled) setPreference(undefined);
|
||||||
userColorCache.set(cacheKey, { color: extractedColor, timestamp: Date.now() });
|
}
|
||||||
|
|
||||||
setColor(extractedColor);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
loadColor();
|
load();
|
||||||
}, [mx, useAuthentication, avatarMxc, userId]);
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [mx, userId]);
|
||||||
|
|
||||||
return color;
|
return preference;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MSC4522 per-room member color preference (overrides global profile when set).
|
||||||
|
*/
|
||||||
|
export function useMemberColorPreference(userId: string, room?: Room): ColorPreference | undefined {
|
||||||
|
const [preference, setPreference] = useState<ColorPreference | undefined>(() =>
|
||||||
|
extractMemberColorPreference(room, userId)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPreference(extractMemberColorPreference(room, userId));
|
||||||
|
}, [room, userId]);
|
||||||
|
|
||||||
|
return preference;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a user's username color for the active theme (MSC4522).
|
||||||
|
* Per-room member colors take precedence over global profile colors.
|
||||||
|
*/
|
||||||
|
export function useOtherUserColor(userId: string, room?: Room): string | undefined {
|
||||||
|
const theme = useTheme();
|
||||||
|
const profilePreference = useOtherUserColorPreference(userId);
|
||||||
|
const memberPreference = useMemberColorPreference(userId, room);
|
||||||
|
const preference = memberPreference ?? profilePreference;
|
||||||
|
return resolveColorForTheme(preference, theme.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the current user's username color for the active theme.
|
||||||
|
*/
|
||||||
|
export function useUsernameColor(userId: string, room?: Room, themeKind?: ThemeKind): string | undefined {
|
||||||
|
const theme = useTheme();
|
||||||
|
const kind = themeKind ?? theme.kind;
|
||||||
|
const profilePreference = useOtherUserColorPreference(userId);
|
||||||
|
const memberPreference = useMemberColorPreference(userId, room);
|
||||||
|
const preference = memberPreference ?? profilePreference;
|
||||||
|
return resolveColorForTheme(preference, kind);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,264 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
|
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
|
||||||
import { useMediaAuthentication } from './useMediaAuthentication';
|
|
||||||
import { mxcUrlToHttp } from '../utils/matrix';import { getCurrentAccessToken } from '../utils/auth';import {
|
|
||||||
fetchAndExtractMetadata,
|
|
||||||
extractMetadataFromImage,
|
|
||||||
embedMetadataInImage,
|
|
||||||
detectImageFormat,
|
|
||||||
getMimeType,
|
|
||||||
getExtension,
|
|
||||||
ImageMetadata,
|
|
||||||
ProfileGradient,
|
|
||||||
} from '../utils/imageMetadata';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the user's current avatar as raw image data
|
|
||||||
*/
|
|
||||||
async function fetchAvatarData(
|
|
||||||
mx: ReturnType<typeof useMatrixClient>,
|
|
||||||
avatarMxc: string,
|
|
||||||
useAuthentication: boolean
|
|
||||||
): Promise<ArrayBuffer | null> {
|
|
||||||
const url = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
|
||||||
if (!url) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
let response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// If we got a 401 and we tried with auth, fallback to unauthenticated request
|
|
||||||
if (!response.ok && response.status === 401 && accessToken && useAuthentication) {
|
|
||||||
response = await fetch(url, { method: 'GET' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) return null;
|
|
||||||
return await response.arrayBuffer();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProfileStyleData = {
|
|
||||||
avatarBorderColor?: string;
|
|
||||||
gradient?: ProfileGradient;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to manage the user's profile style (avatar border color and gradient)
|
|
||||||
* Stored in avatar image metadata
|
|
||||||
* @returns Current style data, a setter function, and loading state
|
|
||||||
*/
|
|
||||||
export function useUserProfileStyle(): [
|
|
||||||
ProfileStyleData,
|
|
||||||
(style: Partial<ProfileStyleData>) => Promise<void>,
|
|
||||||
boolean
|
|
||||||
] {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const useAuthentication = useMediaAuthentication();
|
|
||||||
const [style, setStyle] = useState<ProfileStyleData>({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
// Extract style from current avatar on mount and when profile changes
|
|
||||||
useEffect(() => {
|
|
||||||
const loadStyle = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const userId = mx.getUserId();
|
|
||||||
if (!userId) {
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = await mx.getProfileInfo(userId);
|
|
||||||
const avatarUrl = profile.avatar_url;
|
|
||||||
|
|
||||||
if (!avatarUrl) {
|
|
||||||
setStyle({});
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication);
|
|
||||||
if (!httpUrl) {
|
|
||||||
setStyle({});
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
|
||||||
setStyle({
|
|
||||||
avatarBorderColor: metadata.avatarBorderColor,
|
|
||||||
gradient: metadata.gradient,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
setStyle({});
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadStyle();
|
|
||||||
|
|
||||||
// Listen for avatar changes and reload style
|
|
||||||
const userId = mx.getUserId();
|
|
||||||
if (!userId) return undefined;
|
|
||||||
|
|
||||||
const user = mx.getUser(userId);
|
|
||||||
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = () => {
|
|
||||||
loadStyle();
|
|
||||||
};
|
|
||||||
|
|
||||||
user?.on(UserEvent.AvatarUrl, onAvatarChange);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
|
|
||||||
};
|
|
||||||
}, [mx, useAuthentication]);
|
|
||||||
|
|
||||||
const updateStyle = useCallback(
|
|
||||||
async (newStyle: Partial<ProfileStyleData>) => {
|
|
||||||
const userId = mx.getUserId();
|
|
||||||
if (!userId) {
|
|
||||||
throw new Error('No user ID');
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = await mx.getProfileInfo(userId);
|
|
||||||
const avatarUrl = profile.avatar_url;
|
|
||||||
|
|
||||||
if (!avatarUrl) {
|
|
||||||
throw new Error('No avatar set. Please upload an avatar first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch current avatar
|
|
||||||
const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication);
|
|
||||||
if (!avatarData) {
|
|
||||||
throw new Error('Failed to fetch current avatar');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect image format
|
|
||||||
const format = detectImageFormat(avatarData);
|
|
||||||
if (format === 'unknown') {
|
|
||||||
throw new Error('Unsupported avatar image format');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get existing metadata to preserve
|
|
||||||
const existingMetadata = extractMetadataFromImage(avatarData);
|
|
||||||
|
|
||||||
// Merge with new style
|
|
||||||
const newMetadata: ImageMetadata = {
|
|
||||||
...existingMetadata,
|
|
||||||
avatarBorderColor: newStyle.avatarBorderColor !== undefined ? newStyle.avatarBorderColor : existingMetadata.avatarBorderColor,
|
|
||||||
gradient: newStyle.gradient !== undefined ? newStyle.gradient : existingMetadata.gradient,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Embed updated metadata
|
|
||||||
const newAvatarData = embedMetadataInImage(avatarData, newMetadata);
|
|
||||||
if (!newAvatarData) {
|
|
||||||
throw new Error('Failed to embed style in avatar metadata');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload modified avatar with correct MIME type
|
|
||||||
const mimeType = getMimeType(format);
|
|
||||||
const extension = getExtension(format);
|
|
||||||
const blob = new Blob([newAvatarData], { type: mimeType });
|
|
||||||
|
|
||||||
const uploadResponse = await mx.uploadContent(blob, {
|
|
||||||
name: `avatar.${extension}`,
|
|
||||||
type: mimeType,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update profile with new avatar
|
|
||||||
await mx.setAvatarUrl(uploadResponse.content_uri);
|
|
||||||
|
|
||||||
// Manually sync user object
|
|
||||||
const user = mx.getUser(userId);
|
|
||||||
if (user && user.avatarUrl !== uploadResponse.content_uri) {
|
|
||||||
user.setAvatarUrl(uploadResponse.content_uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
setStyle((prev) => ({
|
|
||||||
...prev,
|
|
||||||
...newStyle,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
[mx, useAuthentication]
|
|
||||||
);
|
|
||||||
|
|
||||||
return [style, updateStyle, loading];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache for other users' profile styles
|
|
||||||
const userStyleCache = new Map<string, { style: ProfileStyleData; timestamp: number }>();
|
|
||||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to get another user's profile style from their avatar metadata
|
|
||||||
* @param userId - The user ID to get style for
|
|
||||||
* @param avatarMxc - The user's avatar MXC URL
|
|
||||||
* @returns The user's profile style data
|
|
||||||
*/
|
|
||||||
export function useOtherUserProfileStyle(userId: string, avatarMxc: string | undefined): ProfileStyleData {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const useAuthentication = useMediaAuthentication();
|
|
||||||
const [style, setStyle] = useState<ProfileStyleData>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!avatarMxc) {
|
|
||||||
console.log('[useOtherUserProfileStyle] No avatarMxc provided for', userId);
|
|
||||||
setStyle({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadStyle = async () => {
|
|
||||||
console.log('[useOtherUserProfileStyle] Loading style for', userId, 'avatarMxc:', avatarMxc);
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
const cacheKey = `${userId}:${avatarMxc}`;
|
|
||||||
const cached = userStyleCache.get(cacheKey);
|
|
||||||
|
|
||||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
|
||||||
console.log('[useOtherUserProfileStyle] Using cached style:', cached.style);
|
|
||||||
setStyle(cached.style);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
|
||||||
console.log('[useOtherUserProfileStyle] Fetching metadata from:', httpUrl);
|
|
||||||
|
|
||||||
if (!httpUrl) {
|
|
||||||
console.log('[useOtherUserProfileStyle] Failed to get HTTP URL');
|
|
||||||
setStyle({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
|
||||||
|
|
||||||
console.log('[useOtherUserProfileStyle] Extracted metadata:', metadata);
|
|
||||||
|
|
||||||
const styleData: ProfileStyleData = {
|
|
||||||
avatarBorderColor: metadata.avatarBorderColor,
|
|
||||||
gradient: metadata.gradient,
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('[useOtherUserProfileStyle] Final styleData:', styleData);
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
userStyleCache.set(cacheKey, { style: styleData, timestamp: Date.now() });
|
|
||||||
setStyle(styleData);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadStyle();
|
|
||||||
}, [mx, useAuthentication, avatarMxc, userId]);
|
|
||||||
|
|
||||||
return style;
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,14 @@ import { MatrixClient } from 'matrix-js-sdk';
|
|||||||
import { getHomeRoomPath, getDirectRoomPath, getSpaceRoomPath } from './pages/pathUtils';
|
import { getHomeRoomPath, getDirectRoomPath, getSpaceRoomPath } from './pages/pathUtils';
|
||||||
import { getCanonicalAliasOrRoomId, isRoomId } from './utils/matrix';
|
import { getCanonicalAliasOrRoomId, isRoomId } from './utils/matrix';
|
||||||
import { getCallService } from './features/call/useCall';
|
import { getCallService } from './features/call/useCall';
|
||||||
import { RoomType, StateEvent } from '../types/matrix/room';
|
import {
|
||||||
|
getNotificationType,
|
||||||
|
getUnreadInfo,
|
||||||
|
reactionOrEditEvent,
|
||||||
|
roomHaveNotification,
|
||||||
|
roomHaveUnread,
|
||||||
|
} from './utils/room';
|
||||||
|
import { MessageEvent, NotificationType, RoomType, StateEvent } from '../types/matrix/room';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global reference to the router navigate function
|
* Global reference to the router navigate function
|
||||||
@@ -120,6 +127,30 @@ export function initPaarrotAPI(matrixClient: MatrixClient) {
|
|||||||
result = await getCurrentRoom(matrixClient);
|
result = await getCurrentRoom(matrixClient);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'get-messages-groups':
|
||||||
|
result = await getMessagesByScope(matrixClient, 'groups', action.params.limit);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'get-messages-dms':
|
||||||
|
result = await getMessagesByScope(matrixClient, 'dms', action.params.limit);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'get-messages-combined':
|
||||||
|
result = await getMessagesByScope(matrixClient, 'combined', action.params.limit);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'get-unreads-groups':
|
||||||
|
result = await getUnreadsByScope(matrixClient, 'groups');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'get-unreads-dms':
|
||||||
|
result = await getUnreadsByScope(matrixClient, 'dms');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'get-unreads-combined':
|
||||||
|
result = await getUnreadsByScope(matrixClient, 'combined');
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown action: ${action.action}`);
|
throw new Error(`Unknown action: ${action.action}`);
|
||||||
}
|
}
|
||||||
@@ -410,6 +441,207 @@ async function getCurrentRoom(matrixClient: any) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MESSAGE_LIMIT = 10;
|
||||||
|
const MAX_MESSAGE_LIMIT = 100;
|
||||||
|
|
||||||
|
type MessageScope = 'groups' | 'dms' | 'combined';
|
||||||
|
|
||||||
|
type ApiMessage = {
|
||||||
|
eventId: string;
|
||||||
|
roomId: string;
|
||||||
|
roomName: string;
|
||||||
|
isDirect: boolean;
|
||||||
|
sender: string;
|
||||||
|
senderName: string;
|
||||||
|
timestamp: number;
|
||||||
|
msgtype: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isDirectRoom(room: any): boolean {
|
||||||
|
return room?.guessDMUserId?.() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMessageLimit(limit?: number | string): number {
|
||||||
|
const parsedLimit = Number(limit);
|
||||||
|
return Math.min(
|
||||||
|
Math.max(Number.isFinite(parsedLimit) ? Math.floor(parsedLimit) : DEFAULT_MESSAGE_LIMIT, 1),
|
||||||
|
MAX_MESSAGE_LIMIT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect recent room messages from the local live timeline (newest last).
|
||||||
|
* Skips reactions, edits, and redacted events.
|
||||||
|
*/
|
||||||
|
function collectRecentMessages(room: any, limit: number): ApiMessage[] {
|
||||||
|
const liveEvents = room.getLiveTimeline?.()?.getEvents?.() ?? [];
|
||||||
|
const isDirect = isDirectRoom(room);
|
||||||
|
const roomId = room.roomId;
|
||||||
|
const roomName = room.name || 'Unnamed Room';
|
||||||
|
const messages: ApiMessage[] = [];
|
||||||
|
|
||||||
|
for (let i = liveEvents.length - 1; i >= 0 && messages.length < limit; i -= 1) {
|
||||||
|
const evt = liveEvents[i];
|
||||||
|
if (!evt) continue;
|
||||||
|
if (typeof evt.isRedacted === 'function' && evt.isRedacted()) continue;
|
||||||
|
if (reactionOrEditEvent(evt)) continue;
|
||||||
|
|
||||||
|
const type = evt.getType?.();
|
||||||
|
if (
|
||||||
|
type !== MessageEvent.RoomMessage &&
|
||||||
|
type !== MessageEvent.RoomMessageEncrypted &&
|
||||||
|
type !== MessageEvent.Sticker
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = evt.getContent?.() ?? {};
|
||||||
|
const sender = evt.getSender?.() ?? '';
|
||||||
|
const member = sender ? room.getMember?.(sender) : null;
|
||||||
|
|
||||||
|
if (type === MessageEvent.RoomMessageEncrypted && !content.msgtype) {
|
||||||
|
messages.push({
|
||||||
|
eventId: evt.getId?.() ?? '',
|
||||||
|
roomId,
|
||||||
|
roomName,
|
||||||
|
isDirect,
|
||||||
|
sender,
|
||||||
|
senderName: member?.name ?? sender,
|
||||||
|
timestamp: evt.getTs?.() ?? 0,
|
||||||
|
msgtype: 'm.encrypted',
|
||||||
|
body: '[encrypted]',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
eventId: evt.getId?.() ?? '',
|
||||||
|
roomId,
|
||||||
|
roomName,
|
||||||
|
isDirect,
|
||||||
|
sender,
|
||||||
|
senderName: member?.name ?? sender,
|
||||||
|
timestamp: evt.getTs?.() ?? 0,
|
||||||
|
msgtype: content.msgtype || (type === MessageEvent.Sticker ? 'm.sticker' : 'm.text'),
|
||||||
|
body: content.body || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoomsForScope(matrixClient: any, scope: MessageScope) {
|
||||||
|
const rooms = (matrixClient?.getRooms?.() || []).filter(
|
||||||
|
(room: any) => !isSpaceLikeRoom(room) && room.getMyMembership?.() === 'join'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (scope === 'groups') {
|
||||||
|
return rooms.filter((room: any) => !isDirectRoom(room));
|
||||||
|
}
|
||||||
|
if (scope === 'dms') {
|
||||||
|
return rooms.filter((room: any) => isDirectRoom(room));
|
||||||
|
}
|
||||||
|
return rooms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent messages across groups, DMs, or both (combined).
|
||||||
|
*/
|
||||||
|
async function getMessagesByScope(
|
||||||
|
matrixClient: any,
|
||||||
|
scope: MessageScope,
|
||||||
|
limit?: number | string
|
||||||
|
) {
|
||||||
|
const limitNum = parseMessageLimit(limit);
|
||||||
|
const rooms = getRoomsForScope(matrixClient, scope);
|
||||||
|
|
||||||
|
const candidates: ApiMessage[] = [];
|
||||||
|
for (const room of rooms) {
|
||||||
|
candidates.push(...collectRecentMessages(room, limitNum));
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
const messages = candidates.slice(-limitNum);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scope,
|
||||||
|
limit: limitNum,
|
||||||
|
count: messages.length,
|
||||||
|
messages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List unread conversations across groups, DMs, or both (combined).
|
||||||
|
* Uses the same unread rules as the app UI (notification counts + read receipts, muted excluded).
|
||||||
|
*/
|
||||||
|
async function getUnreadsByScope(matrixClient: any, scope: MessageScope) {
|
||||||
|
const rooms = getRoomsForScope(matrixClient, scope);
|
||||||
|
const unreads: Array<{
|
||||||
|
roomId: string;
|
||||||
|
name: string;
|
||||||
|
isDirect: boolean;
|
||||||
|
avatar: string | null;
|
||||||
|
total: number;
|
||||||
|
highlight: number;
|
||||||
|
latest?: {
|
||||||
|
eventId: string;
|
||||||
|
sender: string;
|
||||||
|
senderName: string;
|
||||||
|
timestamp: number;
|
||||||
|
msgtype: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const room of rooms) {
|
||||||
|
if (getNotificationType(matrixClient, room.roomId) === NotificationType.Mute) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!roomHaveNotification(room) && !roomHaveUnread(matrixClient, room)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unreadInfo = getUnreadInfo(room);
|
||||||
|
const latestMessages = collectRecentMessages(room, 1);
|
||||||
|
const latest = latestMessages[0];
|
||||||
|
|
||||||
|
unreads.push({
|
||||||
|
roomId: room.roomId,
|
||||||
|
name: room.name || 'Unnamed Room',
|
||||||
|
isDirect: isDirectRoom(room),
|
||||||
|
avatar: room.getMxcAvatarUrl?.() || null,
|
||||||
|
total: unreadInfo.total,
|
||||||
|
highlight: unreadInfo.highlight,
|
||||||
|
latest: latest
|
||||||
|
? {
|
||||||
|
eventId: latest.eventId,
|
||||||
|
sender: latest.sender,
|
||||||
|
senderName: latest.senderName,
|
||||||
|
timestamp: latest.timestamp,
|
||||||
|
msgtype: latest.msgtype,
|
||||||
|
body: latest.body,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
unreads.sort((a, b) => {
|
||||||
|
const aTs = a.latest?.timestamp ?? 0;
|
||||||
|
const bTs = b.latest?.timestamp ?? 0;
|
||||||
|
if (bTs !== aTs) return bTs - aTs;
|
||||||
|
if (b.highlight !== a.highlight) return b.highlight - a.highlight;
|
||||||
|
return b.total - a.total;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
scope,
|
||||||
|
count: unreads.length,
|
||||||
|
unreads,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the currently active room ID
|
* Get the currently active room ID
|
||||||
* Extracts room ID from the current URL pathname
|
* Extracts room ID from the current URL pathname
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
} from './pathUtils';
|
} from './pathUtils';
|
||||||
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
|
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
|
||||||
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
|
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
|
||||||
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
|
import { Direct, DirectCreate, DirectRouteRoomProvider, DirectSearch } from './client/direct';
|
||||||
import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch, SpaceIndexRedirect } from './client/space';
|
import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch, SpaceIndexRedirect } from './client/space';
|
||||||
import { ForumAwareSpaceLayout } from '../features/forum/ForumAwareSpaceLayout';
|
import { ForumAwareSpaceLayout } from '../features/forum/ForumAwareSpaceLayout';
|
||||||
import { ForumFeedPage } from '../features/forum/ForumFeedPage';
|
import { ForumFeedPage } from '../features/forum/ForumFeedPage';
|
||||||
@@ -223,6 +223,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||||||
>
|
>
|
||||||
{mobile ? null : <Route index element={<WelcomePage />} />}
|
{mobile ? null : <Route index element={<WelcomePage />} />}
|
||||||
<Route path={_CREATE_PATH} element={<DirectCreate />} />
|
<Route path={_CREATE_PATH} element={<DirectCreate />} />
|
||||||
|
<Route path={_SEARCH_PATH} element={<DirectSearch />} />
|
||||||
<Route path={_NOTIFICATIONS_PATH} element={<Notifications />} />
|
<Route path={_NOTIFICATIONS_PATH} element={<Notifications />} />
|
||||||
<Route path={_INVITES_PATH} element={<Invites />} />
|
<Route path={_INVITES_PATH} element={<Invites />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
LightTheme,
|
LightTheme,
|
||||||
ThemeContextProvider,
|
ThemeContextProvider,
|
||||||
ThemeKind,
|
ThemeKind,
|
||||||
useActiveTheme,
|
useDisplayTheme,
|
||||||
useSystemThemeKind,
|
useSystemThemeKind,
|
||||||
} from '../hooks/useTheme';
|
} from '../hooks/useTheme';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
@@ -29,21 +29,21 @@ export function UnAuthRouteThemeManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
|
export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
|
||||||
const activeTheme = useActiveTheme();
|
const displayTheme = useDisplayTheme();
|
||||||
const [monochromeMode] = useSetting(settingsAtom, 'monochromeMode');
|
const [monochromeMode] = useSetting(settingsAtom, 'monochromeMode');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.className = '';
|
document.body.className = '';
|
||||||
document.body.classList.add(configClass, varsClass);
|
document.body.classList.add(configClass, varsClass);
|
||||||
|
|
||||||
document.body.classList.add(...activeTheme.classNames);
|
document.body.classList.add(...displayTheme.classNames);
|
||||||
|
|
||||||
if (monochromeMode) {
|
if (monochromeMode) {
|
||||||
document.body.style.filter = 'grayscale(1)';
|
document.body.style.filter = 'grayscale(1)';
|
||||||
} else {
|
} else {
|
||||||
document.body.style.filter = '';
|
document.body.style.filter = '';
|
||||||
}
|
}
|
||||||
}, [activeTheme, monochromeMode]);
|
}, [displayTheme, monochromeMode]);
|
||||||
|
|
||||||
return <ThemeContextProvider value={activeTheme}>{children}</ThemeContextProvider>;
|
return <ThemeContextProvider value={displayTheme}>{children}</ThemeContextProvider>;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user