Compare commits
37 Commits
9c7f71896c
...
b6f3f5c0aa
| Author | SHA1 | Date | |
|---|---|---|---|
| b6f3f5c0aa | |||
| 25be638c5e | |||
| a20c871726 | |||
| e65a516350 | |||
| 9887903f49 | |||
| 898d217451 | |||
| e08b4ec22e | |||
| 13523fea2b | |||
| 8a68a1e30a | |||
| 32bf2cbed5 | |||
| 17706ae019 | |||
| 82b22fa739 | |||
| 0da4f0d6cb | |||
| ea7642f0bb | |||
| 0fb3da20b9 | |||
| e460dbd34e | |||
| 0de2fd942f | |||
| 2603ca8e5c | |||
| 99a294791e | |||
| f62200ecd1 | |||
| b52926f7d8 | |||
| d338e1c35e | |||
| 9589680a81 | |||
| 27f1357fc0 | |||
| 9509a9705e | |||
| 61d41900cc | |||
| e7777f42d8 | |||
| ae70eae0fc | |||
| c286501be8 | |||
| 154f4dfdb0 | |||
| c7067021ba | |||
| 687f139719 | |||
| b711646b58 | |||
| bd6d753f47 | |||
| 3de3058ca9 | |||
| 02d96a9758 | |||
| 5374c10c61 |
53
.gitea/workflows/trigger-mobile.yml
Normal file
53
.gitea/workflows/trigger-mobile.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Trigger cinny-mobile
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !contains(github.event.head_commit.message || '', '[skip mobile]') }}
|
||||
steps:
|
||||
- name: Dispatch update-submodule on cinny-mobile
|
||||
env:
|
||||
# Prefer a PAT with write:repository on cinny-mobile (secret: ACTIONS_TOKEN).
|
||||
# Default GITHUB_TOKEN is usually scoped to this repo only.
|
||||
ACTIONS_TOKEN: ${{ secrets.ACTIONS_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API_BASE: ${{ github.server_url }}/api/v1
|
||||
CINNY_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
TOKEN="${ACTIONS_TOKEN:-${GITHUB_TOKEN:-}}"
|
||||
if [ -z "${TOKEN}" ]; then
|
||||
echo "No ACTIONS_TOKEN/GITHUB_TOKEN available to dispatch cinny-mobile"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BODY=$(jq -n \
|
||||
--arg ref "master" \
|
||||
--arg sha "${CINNY_SHA}" \
|
||||
'{ref: $ref, inputs: {cinny_sha: $sha}}')
|
||||
|
||||
echo "Dispatching litruv/cinny-mobile update-submodule.yml for ${CINNY_SHA}"
|
||||
HTTP_CODE=$(curl -sS -o /tmp/dispatch-body.txt -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "${BODY}" \
|
||||
"${API_BASE}/repos/litruv/cinny-mobile/actions/workflows/update-submodule.yml/dispatches?return_run_details=true")
|
||||
|
||||
echo "HTTP ${HTTP_CODE}"
|
||||
cat /tmp/dispatch-body.txt || true
|
||||
echo
|
||||
|
||||
case "${HTTP_CODE}" in
|
||||
200|201|204) echo "Dispatch accepted" ;;
|
||||
*)
|
||||
echo "Dispatch failed"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,4 +4,5 @@ node_modules
|
||||
devAssets
|
||||
|
||||
.DS_Store
|
||||
.idea
|
||||
.idea
|
||||
.cache
|
||||
|
||||
3
.npmrc
3
.npmrc
@@ -1,2 +1,3 @@
|
||||
legacy-peer-deps=true
|
||||
save-exact=true
|
||||
save-exact=true
|
||||
allow-git=all
|
||||
|
||||
@@ -55,6 +55,13 @@ curl -X POST http://127.0.0.1:33384/message/current \
|
||||
| `/channels` | GET | Get list of rooms |
|
||||
| `/channel` | POST | Switch to a 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
|
||||
|
||||
|
||||
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
|
||||
|
||||
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) |
|
||||
| Lobby (space card view) | [lobby-forums](./handoff/features/lobby-forums/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
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ tauri.ts / electron main
|
||||
- Multiple runtimes: `isTauri`, `isElectron`, `isCapacitorNative` branch logic
|
||||
- Pusher registration races on fast login/logout
|
||||
- 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
|
||||
|
||||
@@ -91,3 +92,4 @@ tauri.ts / electron main
|
||||
|
||||
- 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/`
|
||||
- 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-current` | `{ message }` | send to active room |
|
||||
| `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
|
||||
|
||||
|
||||
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"
|
||||
}
|
||||
1515
package-lock.json
generated
1515
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -8,13 +8,14 @@
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite --open false",
|
||||
"build": "vite build",
|
||||
"start": "vite",
|
||||
"build": "node scripts/generate-update-manifest.mjs && vite build",
|
||||
"lint": "yarn check:eslint && yarn check:prettier",
|
||||
"check:eslint": "eslint src/*",
|
||||
"check:prettier": "prettier --check .",
|
||||
"fix:prettier": "prettier --write .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"playground": "vite"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Ajay Bura",
|
||||
@@ -24,6 +25,7 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "3.0.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "2.0.0",
|
||||
"@fontsource-variable/inter": "5.2.8",
|
||||
"@fontsource/caveat": "5.3.0",
|
||||
"@fontsource/inter": "5.2.8",
|
||||
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
@@ -82,8 +84,10 @@
|
||||
"react-error-boundary": "6.1.2",
|
||||
"react-google-recaptcha": "3.1.0",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-range": "1.10.0",
|
||||
"react-router-dom": "7.18.1",
|
||||
"remark-gfm": "4.0.1",
|
||||
"sanitize-html": "2.17.5",
|
||||
"slate": "0.124.1",
|
||||
"slate-dom": "0.124.1",
|
||||
@@ -93,6 +97,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
"@types/chroma-js": "3.1.2",
|
||||
@@ -116,11 +121,16 @@
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"monaco-editor": "0.52.2",
|
||||
"prettier": "3.9.4",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "8.1.3",
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
"vite-plugin-static-copy": "4.1.1",
|
||||
"vite-plugin-top-level-await": "1.6.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"@swc/core@1.15.43": true,
|
||||
"esbuild@0.28.1": true
|
||||
}
|
||||
}
|
||||
|
||||
149
playground-liveTsxPlugin.ts
Normal file
149
playground-liveTsxPlugin.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { Plugin, ViteDevServer } from 'vite';
|
||||
|
||||
export type LiveTsxApi = {
|
||||
getSource: () => string;
|
||||
setSource: (source: string) => void;
|
||||
};
|
||||
|
||||
const ALLOWED_PREFIXES = ['src/app/components/', 'src/app/features/'];
|
||||
|
||||
function resolveAppSourceFile(projectRoot: string, relPath: string): string | null {
|
||||
const normalized = relPath.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
if (normalized.includes('..') || path.isAbsolute(normalized)) return null;
|
||||
if (!ALLOWED_PREFIXES.some((p) => normalized.startsWith(p))) return null;
|
||||
if (!/\.(tsx|ts)$/.test(normalized)) return null;
|
||||
const abs = path.resolve(projectRoot, normalized);
|
||||
const root = path.resolve(projectRoot);
|
||||
if (!abs.startsWith(root + path.sep) && abs !== root) return null;
|
||||
return abs;
|
||||
}
|
||||
|
||||
function readBody(req: NodeJS.ReadableStream): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (c) => chunks.push(c));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function bumpLivePreview(server: ViteDevServer | undefined) {
|
||||
if (!server) return;
|
||||
server.ws.send({
|
||||
type: 'custom',
|
||||
event: 'playground:live-updated',
|
||||
data: { revision: Date.now() },
|
||||
});
|
||||
}
|
||||
|
||||
export function liveTsxPlugin(projectRoot: string, initialSource: string): Plugin {
|
||||
const liveFile = path.resolve(projectRoot, '.cache/playground-live.tsx');
|
||||
|
||||
fs.mkdirSync(path.dirname(liveFile), { recursive: true });
|
||||
fs.writeFileSync(liveFile, initialSource, 'utf8');
|
||||
|
||||
let source = initialSource;
|
||||
let server: ViteDevServer | undefined;
|
||||
|
||||
const api: LiveTsxApi = {
|
||||
getSource: () => source,
|
||||
setSource: (next) => {
|
||||
source = next;
|
||||
fs.writeFileSync(liveFile, next, 'utf8');
|
||||
if (!server) return;
|
||||
for (const m of server.moduleGraph.urlToModuleMap.values()) {
|
||||
if (m.file === liveFile) server.moduleGraph.invalidateModule(m);
|
||||
}
|
||||
const byId = server.moduleGraph.getModuleById(liveFile);
|
||||
if (byId) server.moduleGraph.invalidateModule(byId);
|
||||
bumpLivePreview(server);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'playground-live-tsx',
|
||||
configureServer(devServer) {
|
||||
server = devServer;
|
||||
(devServer as ViteDevServer & { playgroundLive?: LiveTsxApi }).playgroundLive = api;
|
||||
|
||||
devServer.middlewares.use('/__playground/live', (req, res, next) => {
|
||||
if (req.method === 'GET') {
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.end(source);
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST') {
|
||||
void readBody(req).then((body) => {
|
||||
api.setSource(body);
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
});
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Read/write real Cinny component sources (strings, markup, etc.).
|
||||
devServer.middlewares.use('/__playground/source', (req, res, next) => {
|
||||
const url = new URL(req.url ?? '', 'http://playground.local');
|
||||
const rel = url.searchParams.get('path') ?? '';
|
||||
const abs = resolveAppSourceFile(projectRoot, rel);
|
||||
if (!abs) {
|
||||
res.statusCode = 400;
|
||||
res.end('Invalid or disallowed path');
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET') {
|
||||
if (!fs.existsSync(abs)) {
|
||||
res.statusCode = 404;
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.end(fs.readFileSync(abs, 'utf8'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
void readBody(req).then((body) => {
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, body, 'utf8');
|
||||
const mod =
|
||||
server?.moduleGraph.getModuleById(abs) ??
|
||||
[...(server?.moduleGraph.urlToModuleMap.values() ?? [])].find((m) => m.file === abs);
|
||||
if (mod && server) server.moduleGraph.invalidateModule(mod);
|
||||
bumpLivePreview(server);
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
},
|
||||
resolveId(id) {
|
||||
const clean = id.split('?')[0];
|
||||
if (
|
||||
clean === '/@playground/live.tsx' ||
|
||||
clean === 'virtual:playground-live' ||
|
||||
clean.endsWith('/.cache/playground-live.tsx')
|
||||
) {
|
||||
return liveFile;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function readDefaultLiveSource(projectRoot: string): string {
|
||||
return fs.readFileSync(path.join(projectRoot, 'src/playground/defaultLive.tsx'), 'utf8');
|
||||
}
|
||||
|
||||
export function projectRootDir(): string {
|
||||
return path.dirname(fileURLToPath(import.meta.url));
|
||||
}
|
||||
12
playground.html
Normal file
12
playground.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Component Playground</title>
|
||||
</head>
|
||||
<body style="margin:0;background:#0f1115;color:#e8eaed">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/playground/main.tsx?v=react-dedupe-1"></script>
|
||||
</body>
|
||||
</html>
|
||||
16
public/update/4.11.175.md
Normal file
16
public/update/4.11.175.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
version: 4.11.175
|
||||
title: Stability & polish
|
||||
date: 2026-08-10
|
||||
summary: Desktop update flow improvements and UI refinements.
|
||||
---
|
||||
|
||||
## Desktop updates
|
||||
|
||||
- Smoother download and install flow in the title bar updater.
|
||||
- Better handling when an update is already downloading.
|
||||
|
||||
## UI polish
|
||||
|
||||
- Refined spacing and contrast in settings panels.
|
||||
- Improved mobile layout for room headers and notifications.
|
||||
22
public/update/currentupdate.md
Normal file
22
public/update/currentupdate.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
version: 4.11.177
|
||||
title: Profile colors & banners
|
||||
date: 2026-08-23
|
||||
summary: Username colors now follow MSC4522 with separate dark and light theme pickers.
|
||||
---
|
||||
|
||||
## Profile colors (MSC4522)
|
||||
|
||||
Username colors are now stored on your Matrix profile instead of inside avatar images. In turn **will need to be updated in your Account settings**, this is now cross compatible with Element + Cinny
|
||||
|
||||
- Set **separate colors for dark and light themes** in Settings → Account → Profile.
|
||||
- Per-room username overrides are read from member profile data when available.
|
||||
|
||||

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