Compare commits

39 Commits

Author SHA1 Message Date
7d866404b3 Fix Android updates dialog, mobile About page, and user color crash guard.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Use stripBase for update static copy paths, show Settings detail on mobile
when nav is hidden, pause Settings focus trap during release notes, and guard
extractMemberColorPreference when room is not a Matrix Room instance.
2026-08-23 16:18:35 +10:00
c264de53b5 Fix View updates on Android: bundled notes, portal stack, tap handling.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Bundle update markdown at build time for Capacitor, portal into
portalContainer above Settings, defer open to avoid FocusTrap closing
on the same tap, and disable click-outside dismiss.
2026-08-23 08:38:23 +10:00
b6f3f5c0aa Move update manifest generator into cinny for standalone Android builds.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
2026-08-23 08:26:27 +10:00
25be638c5e Fix auto-show of What's New on first launch after an app update.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
2026-08-23 08:23:15 +10:00
a20c871726 Fix updates dialog on Android by portaling above Settings and resolving Capacitor asset URLs.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
2026-08-23 08:19:51 +10:00
e65a516350 Add MSC4522 username colors, release notes dialog, and profile layout fixes.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
Replace legacy profile style metadata with MSC4133 profile fields, add in-app update notes, tighten account profile name spacing, and wire desktop media save helpers through the client.
2026-08-23 08:06:48 +10:00
9887903f49 Add centralized saveMedia helpers for image, video, and file downloads.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Platforms can register a custom saver; viewers share one download/save path instead of FileSaver call sites.
2026-08-09 20:49:00 +10:00
898d217451 Make sick emoji confetti a fullscreen downward spew with morph.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
🤢 now fills the viewport, morphs to 🤮 while particles spawn, hides the jumbo stand-in cleanly, and keeps green droplets out of the face morph.
2026-08-09 18:35:12 +10:00
e08b4ec22e Replace image lightbox with slate-style shadowbox.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Full-viewport blur backdrop, floating chrome, wheel/click/pinch zoom and pan; drop the ModalWide card shell around timeline and profile image viewers.
2026-08-09 15:58:47 +10:00
13523fea2b Add fullscreen firework emoji confetti and fix Linux URL paste.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Sparkle 🎆/🎇 from the jumbo emoji with a lightweight particle fountain that piles on the floor. Prefer text/plain over sticky clipboard images on Linux so address-bar URLs do not also attach a leftover bitmap.
2026-08-09 15:50:19 +10:00
8a68a1e30a Add bot-driven room app UI via im.paarrot.ui state.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Bots can publish a declarative panel UI that takes over a room; user
actions are sent as im.paarrot.ui.action timeline events.
2026-08-09 13:16:43 +10:00
32bf2cbed5 Use ACTIONS_TOKEN secret to dispatch cinny-mobile builds.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
2026-08-03 16:30:50 +10:00
17706ae019 Add workflow to dispatch cinny-mobile submodule bump on push.
Some checks failed
Trigger cinny-mobile / dispatch (push) Failing after 1s
2026-08-03 16:28:35 +10:00
82b22fa739 Add local API actions for recent messages and unreads by scope. 2026-08-03 15:08:18 +10:00
0da4f0d6cb Add emoji confetti bursts and component playground.
Support app.relay.emoji_confetti on jumbo emoji clicks with canvas physics,
per-emoji particle profiles, and a fixed overlay that does not affect scroll.
Also add a Vite playground for live component previews and theme hover preview.
2026-07-29 20:40:43 +10:00
ea7642f0bb Surface OEM AUTO_START blocks in UnifiedPush settings status.
TCL App Boot can drop NEW_ENDPOINT; show a clear allow-auto-start hint when detected.
2026-07-28 02:39:35 +10:00
0fb3da20b9 Fail UnifiedPush reset when no endpoint arrives and surface lastFailure. 2026-07-28 01:38:01 +10:00
e460dbd34e Improve UnifiedPush reset UX: poll for endpoint and clearer status. 2026-07-28 00:40:02 +10:00
0de2fd942f Prefer native UnifiedPush gateway discovery to avoid WebView CORS failures. 2026-07-28 00:18:28 +10:00
2603ca8e5c Fix avatar upload failing on JPEG/WebP/GIF without a profile color.
embedMetadataInImage returned null for non-PNG when there was nothing to embed, which blocked plain avatar saves after format/library churn.
2026-07-25 20:09:22 +10:00
99a294791e Open DMs during sync catch-up instead of blocking on parent maps.
Prefer the direct route for m.direct rooms and allow joined space children before roomToParents finishes backfilling.
2026-07-24 16:52:34 +10:00
f62200ecd1 Fix Jump to Latest sticking visible while scrolled to the live bottom. 2026-07-24 16:00:59 +10:00
b52926f7d8 Fix system notifications ignoring mention-only and default room settings.
Skip non-highlight events for Mentions/Default non-DM rooms so public chats stay quiet unless they should ping.
2026-07-24 00:30:25 +10:00
d338e1c35e Show update progress as a title-bar bar instead of a popout. 2026-07-23 15:18:24 +10:00
9589680a81 Use paarrot://desktop SSO redirect so consent identifies the app. 2026-07-23 15:13:53 +10:00
27f1357fc0 Remove editor beforeinput autocorrect workaround. 2026-07-23 14:57:57 +10:00
9509a9705e Add Shared Media drawer and harden same-room navigation.
Widen the media panel, support skinny full-page mode, keep jump-to-latest and return-to-previous reliable, and stop same-room event jumps from remounting the outlet or yanking the sidebar.
2026-07-23 14:52:01 +10:00
61d41900cc Add Stationery and Stationery Dark themes with notebook chrome.
Manila folders, ruled chat, post-it nav, and folder tabs; dark variant uses Catppuccin Mocha colors with muted stickies and soft glows.
2026-07-23 00:24:10 +10:00
e7777f42d8 Fix jumbo emoji detection broken by production minifier.
Replace surrogate-pair emoji regex with Unicode property escapes so Rolldown no longer corrupts matching in production builds.
2026-07-22 20:28:59 +10:00
ae70eae0fc Fix jumbo emoji sizing in production builds.
Set jumbo font-size on the message body and inherit Text size so T400 no longer keeps emoji-only messages small.
2026-07-22 20:14:09 +10:00
c286501be8 Improve timeline scroll, media layout, avatars, and emoji coverage.
Remember room scroll position across switches, reserve image/video heights from Matrix dimensions, cache authenticated media blobs for avatars, restore jumbo emoji-only messages, and extend emoji font unicode-range for Unicode 15 glyphs.
2026-07-22 20:00:14 +10:00
154f4dfdb0 fix: stop emoji fonts claiming spaces and Latin text
Use Fontsource's real family name ('Inter Variable') so text no longer
falls through to Noto Color Emoji / Twemoji. Scope emoji @font-face to
emoji unicode-range so space metrics stay normal on Linux and elsewhere.
2026-07-15 03:19:54 +10:00
c7067021ba Fix white timestamps when Twemoji is selected
InterVariable did not match Fontsource's Inter Variable name, so digits
fell through to Twemoji COLR keycaps. Scope Twemoji off ASCII digits.
2026-07-13 02:40:03 +10:00
687f139719 Fix Slate crash after inserting emoji from picker/autocomplete 2026-07-13 02:40:02 +10:00
b711646b58 Fix mic processing toggles after LiveKit voiceIsolation default 2026-07-13 02:25:04 +10:00
bd6d753f47 update 2026-07-12 18:15:03 +10:00
3de3058ca9 Allow git deps in npm for Node/npm compatibility. 2026-07-12 08:18:57 +10:00
02d96a9758 hidden shit, and online status bars, search settings 2026-07-12 07:49:17 +10:00
5374c10c61 room shit 2026-07-10 21:35:38 +10:00
217 changed files with 22883 additions and 2971 deletions

View 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
View File

@@ -4,4 +4,5 @@ node_modules
devAssets devAssets
.DS_Store .DS_Store
.idea .idea
.cache

3
.npmrc
View File

@@ -1,2 +1,3 @@
legacy-peer-deps=true legacy-peer-deps=true
save-exact=true save-exact=true
allow-git=all

View File

@@ -55,6 +55,13 @@ curl -X POST http://127.0.0.1:33384/message/current \
| `/channels` | GET | Get list of rooms | | `/channels` | GET | Get list of rooms |
| `/channel` | POST | Switch to a room | | `/channel` | POST | Switch to a room |
| `/message/current` | POST | Send message to current room | | `/message/current` | POST | Send message to current room |
| `/room/current` | GET | Get current room info |
| `/messages/groups` | GET | Last N messages across groups (`?limit=10`) |
| `/messages/dms` | GET | Last N messages across DMs (`?limit=10`) |
| `/messages/combined` | GET | Last N messages across groups + DMs (`?limit=10`) |
| `/unreads/groups` | GET | Unread group conversations |
| `/unreads/dms` | GET | Unread DM conversations |
| `/unreads/combined` | GET | Unread groups + DMs |
## 🎮 Stream Deck Integration ## 🎮 Stream Deck Integration

View File

@@ -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 (1100, 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 (1100, 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 (1100, default `10`)
**Response**: same shape as groups, with `"scope": "combined"`.
---
### Get Unread Conversations (Groups)
List joined group channels that currently have unread activity.
**Endpoint**: `GET /unreads/groups`
**Response**:
```json
{
"success": true,
"data": {
"scope": "groups",
"count": 1,
"unreads": [
{
"roomId": "!abc123:matrix.org",
"name": "General",
"isDirect": false,
"avatar": "mxc://matrix.org/abc123",
"total": 3,
"highlight": 1,
"latest": {
"eventId": "$event1",
"sender": "@alice:matrix.org",
"senderName": "Alice",
"timestamp": 1710000000000,
"msgtype": "m.text",
"body": "Hello!"
}
}
]
}
}
```
Muted rooms are excluded. Sorted by latest activity, then highlight/total counts.
---
### Get Unread Conversations (DMs)
**Endpoint**: `GET /unreads/dms`
**Response**: same shape as groups, with `"scope": "dms"`.
---
### Get Unread Conversations (Combined)
**Endpoint**: `GET /unreads/combined`
**Response**: same shape as groups, with `"scope": "combined"`.
---
## Error Responses ## Error Responses
All endpoints return error responses in the following format: All endpoints return error responses in the following format:

View File

@@ -62,6 +62,7 @@ New feature? Copy [`handoff/TEMPLATE.md`](./handoff/TEMPLATE.md) into `handoff/f
| Sub-rooms | [sub-rooms](./handoff/features/sub-rooms/HANDOFF.md) | | Sub-rooms | [sub-rooms](./handoff/features/sub-rooms/HANDOFF.md) |
| Lobby (space card view) | [lobby-forums](./handoff/features/lobby-forums/HANDOFF.md) | | Lobby (space card view) | [lobby-forums](./handoff/features/lobby-forums/HANDOFF.md) |
| Forum spaces (post feed UI) | [forum](./handoff/features/forum/HANDOFF.md) | | Forum spaces (post feed UI) | [forum](./handoff/features/forum/HANDOFF.md) |
| Room App UI (bot-driven) | [room-app-ui](./handoff/features/room-app-ui/HANDOFF.md) |
### Settings & customization ### Settings & customization

View File

@@ -76,6 +76,7 @@ tauri.ts / electron main
- Multiple runtimes: `isTauri`, `isElectron`, `isCapacitorNative` branch logic - Multiple runtimes: `isTauri`, `isElectron`, `isCapacitorNative` branch logic
- Pusher registration races on fast login/logout - Pusher registration races on fast login/logout
- Android small icon: `ic_stat_paarrot` must exist in Android resources - Android small icon: `ic_stat_paarrot` must exist in Android resources
- Background wake (`MatrixSyncService`) must filter by push rules / unread_notifications; a push for one room used to notify for every new message in the sync batch
## Future work ## Future work
@@ -91,3 +92,4 @@ tauri.ts / electron main
- Constants: `PUSHER_APP_ID_BASE`, `PUSHER_STORAGE_PREFIX` in `backgroundSync.ts` - Constants: `PUSHER_APP_ID_BASE`, `PUSHER_STORAGE_PREFIX` in `backgroundSync.ts`
- Logo assets: `paarrot.svg`, `paarrot-unread.svg`, `paarrot-highlight.svg` in `public/res/svg/` - Logo assets: `paarrot.svg`, `paarrot-unread.svg`, `paarrot-highlight.svg` in `public/res/svg/`
- Android notify filter: mute / mentions / default-room behavior lives in `MatrixSyncService.resolveRoomNotifyMode`

View File

@@ -50,6 +50,12 @@ No persistent storage. Actions read/write ephemeral app state (current room, cal
| `send-message` | `{ roomId, message }` | send result | | `send-message` | `{ roomId, message }` | send result |
| `send-message-current` | `{ message }` | send to active room | | `send-message-current` | `{ message }` | send to active room |
| `get-current-room` | — | current room id | | `get-current-room` | — | current room id |
| `get-messages-groups` | `{ limit? }` | recent messages across group channels |
| `get-messages-dms` | `{ limit? }` | recent messages across DMs |
| `get-messages-combined` | `{ limit? }` | recent messages across groups + DMs |
| `get-unreads-groups` | — | unread group conversations |
| `get-unreads-dms` | — | unread DM conversations |
| `get-unreads-combined` | — | unread groups + DMs |
## Dependencies ## Dependencies

View 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`

View 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"
}
]
}
}

View File

@@ -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"
}
]
}
]
}
}

View File

@@ -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"
}

View File

@@ -96,6 +96,12 @@
</script> </script>
<div id="root"></div> <div id="root"></div>
<div id="portalContainer"></div> <div id="portalContainer"></div>
<style>
#portalContainer {
position: relative;
z-index: 10000;
}
</style>
<script type="module" src="./src/index.tsx"></script> <script type="module" src="./src/index.tsx"></script>
</body> </body>
</html> </html>

1515
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,13 +8,14 @@
"node": ">=16.0.0" "node": ">=16.0.0"
}, },
"scripts": { "scripts": {
"start": "vite --open false", "start": "vite",
"build": "vite build", "build": "node scripts/generate-update-manifest.mjs && vite build",
"lint": "yarn check:eslint && yarn check:prettier", "lint": "yarn check:eslint && yarn check:prettier",
"check:eslint": "eslint src/*", "check:eslint": "eslint src/*",
"check:prettier": "prettier --check .", "check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .", "fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"playground": "vite"
}, },
"keywords": [], "keywords": [],
"author": "Ajay Bura", "author": "Ajay Bura",
@@ -24,6 +25,7 @@
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "3.0.0", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "3.0.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "2.0.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "2.0.0",
"@fontsource-variable/inter": "5.2.8", "@fontsource-variable/inter": "5.2.8",
"@fontsource/caveat": "5.3.0",
"@fontsource/inter": "5.2.8", "@fontsource/inter": "5.2.8",
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git", "@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
"@tanstack/react-query": "5.101.2", "@tanstack/react-query": "5.101.2",
@@ -82,8 +84,10 @@
"react-error-boundary": "6.1.2", "react-error-boundary": "6.1.2",
"react-google-recaptcha": "3.1.0", "react-google-recaptcha": "3.1.0",
"react-i18next": "17.0.8", "react-i18next": "17.0.8",
"react-markdown": "10.1.0",
"react-range": "1.10.0", "react-range": "1.10.0",
"react-router-dom": "7.18.1", "react-router-dom": "7.18.1",
"remark-gfm": "4.0.1",
"sanitize-html": "2.17.5", "sanitize-html": "2.17.5",
"slate": "0.124.1", "slate": "0.124.1",
"slate-dom": "0.124.1", "slate-dom": "0.124.1",
@@ -93,6 +97,7 @@
}, },
"devDependencies": { "devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "0.2.3", "@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@monaco-editor/react": "4.7.0",
"@rollup/plugin-inject": "5.0.5", "@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2", "@rollup/plugin-wasm": "6.2.2",
"@types/chroma-js": "3.1.2", "@types/chroma-js": "3.1.2",
@@ -116,11 +121,16 @@
"eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react": "7.37.5", "eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-hooks": "7.1.1",
"monaco-editor": "0.52.2",
"prettier": "3.9.4", "prettier": "3.9.4",
"typescript": "6.0.3", "typescript": "6.0.3",
"vite": "8.1.3", "vite": "8.1.3",
"vite-plugin-pwa": "1.3.0", "vite-plugin-pwa": "1.3.0",
"vite-plugin-static-copy": "4.1.1", "vite-plugin-static-copy": "4.1.1",
"vite-plugin-top-level-await": "1.6.0" "vite-plugin-top-level-await": "1.6.0"
},
"allowScripts": {
"@swc/core@1.15.43": true,
"esbuild@0.28.1": true
} }
} }

149
playground-liveTsxPlugin.ts Normal file
View 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
View 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
View 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.

View 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.
![Paarrot](./images/usernamecolors.png)
## 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.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View 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"
}
]
}

View 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();

View File

@@ -1,5 +1,5 @@
import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Text, IconButton, Input, Button, TextAreaComponent as TextArea, color, Spinner, Chip, Scroll, config } from 'folds'; import { Box, Text, IconButton, Input, Button, TextArea, color, Spinner, Chip, Scroll, config } from 'folds';
import { Icon, Icons } from './icons'; import { Icon, Icons } from './icons';
import { MatrixError } from 'matrix-js-sdk'; import { MatrixError } from 'matrix-js-sdk';
import { Cursor } from '../plugins/text-area'; import { Cursor } from '../plugins/text-area';
@@ -154,7 +154,7 @@ function AccountDataEdit({
<Box shrink="No"> <Box shrink="No">
<Text size="L400">JSON Content</Text> <Text size="L400">JSON Content</Text>
</Box> </Box>
<TextAreaComponent <TextArea
ref={textAreaRef} ref={textAreaRef}
name="contentTextArea" name="contentTextArea"
style={{ style={{

View File

@@ -1,16 +1,53 @@
import React from 'react'; import React, { useMemo } from 'react';
import { Outlet, useLocation } from 'react-router-dom'; import { Outlet, useLocation } from 'react-router-dom';
const decodeSegment = (segment: string): string => {
let decoded = segment;
try {
let next = decodeURIComponent(decoded);
while (next !== decoded) {
decoded = next;
next = decodeURIComponent(decoded);
}
} catch {
// keep partially decoded value
}
return decoded;
};
/** /**
* Wrapper for Outlet that adds route-based animation * Room routes are `:roomIdOrAlias/:eventId?/`. Jumping to an event (or clearing it)
* Forces remount on route change by using location as key * changes the pathname but not the room — keep the outlet mounted so we don't replay
* the route enter animation or remount drawers like Shared Media.
*
* Path segments are decoded so `!room:server` and `%21room%3Aserver` share a key.
*/
const getOutletTransitionKey = (pathname: string): string => {
const segments = pathname.split('/').filter(Boolean).map(decodeSegment);
if (segments.length === 0) return pathname;
// Matrix event IDs start with `$`
if (segments[segments.length - 1].startsWith('$')) {
segments.pop();
}
return `/${segments.join('/')}/`;
};
/**
* Wrapper for Outlet that adds route-based animation.
* Remounts (and animates) when leaving a room / switching rooms, not on same-room event hops.
*/ */
export function AnimatedOutlet() { export function AnimatedOutlet() {
const location = useLocation(); const location = useLocation();
const transitionKey = useMemo(
() => getOutletTransitionKey(location.pathname),
[location.pathname]
);
return ( return (
<div <div
key={location.pathname} key={transitionKey}
data-route-transition="true" data-route-transition="true"
style={{ style={{
flex: 1, flex: 1,

View File

@@ -1,7 +1,9 @@
import React, { ReactNode, useCallback, useState } from 'react'; import React, { ReactNode, useCallback, useState } from 'react';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { useAtomValue } from 'jotai';
import { Modal, Overlay, OverlayBackdrop, OverlayCenter, PopOutContainerProvider } from 'folds'; import { Modal, Overlay, OverlayBackdrop, OverlayCenter, PopOutContainerProvider } from 'folds';
import { stopPropagation } from '../utils/keyboard'; import { stopPropagation } from '../utils/keyboard';
import { releaseNotesDialogAtom } from '../state/releaseNotes';
type Modal500Props = { type Modal500Props = {
requestClose: () => void; requestClose: () => void;
@@ -10,11 +12,13 @@ type Modal500Props = {
export function Modal500({ requestClose, children }: Modal500Props) { export function Modal500({ requestClose, children }: Modal500Props) {
const [modalEl, setModalEl] = useState<HTMLDivElement | null>(null); const [modalEl, setModalEl] = useState<HTMLDivElement | null>(null);
const modalRef = useCallback((el: HTMLDivElement | null) => setModalEl(el), []); const modalRef = useCallback((el: HTMLDivElement | null) => setModalEl(el), []);
const releaseNotesOpen = useAtomValue(releaseNotesDialogAtom).open;
return ( return (
<Overlay open backdrop={<OverlayBackdrop />}> <Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter> <OverlayCenter>
<FocusTrap <FocusTrap
active={!releaseNotesOpen}
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
clickOutsideDeactivates: true, clickOutsideDeactivates: true,

View File

@@ -5,7 +5,7 @@ import classNames from 'classnames';
import { Box, Button, Chip, Header, IconButton, Input, Menu, PopOut, RectCords, Scroll, Spinner, Text, as, config } from 'folds'; import { Box, Button, Chip, Header, IconButton, Input, Menu, PopOut, RectCords, Scroll, Spinner, Text, as, config } from 'folds';
import { Icon, Icons } from '../icons'; import { Icon, Icons } from '../icons';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import FileSaver from 'file-saver'; import { saveMediaBlob } from '../../utils/saveMedia';
import * as css from './PdfViewer.css'; import * as css from './PdfViewer.css';
import { AsyncStatus } from '../../hooks/useAsyncCallback'; import { AsyncStatus } from '../../hooks/useAsyncCallback';
import { useZoom } from '../../hooks/useZoom'; import { useZoom } from '../../hooks/useZoom';
@@ -61,8 +61,14 @@ export const PdfViewer = as<'div', PdfViewerProps>(
} }
}, [docState, pageNo, zoom]); }, [docState, pageNo, zoom]);
const handleDownload = () => { const handleDownload = async () => {
FileSaver.saveAs(src, name); try {
const res = await fetch(src);
if (!res.ok) throw new Error('Failed to fetch PDF');
await saveMediaBlob(await res.blob(), name);
} catch (error) {
console.warn('[PdfViewer] Failed to download:', error);
}
}; };
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => { const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { KeyboardEvent, MouseEvent } from 'react';
import { MsgType } from 'matrix-js-sdk'; import { MsgType } from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser'; import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs'; import { Opts } from 'linkifyjs';
@@ -49,6 +49,12 @@ type RenderMessageContentProps = {
linkifyOpts: Opts; linkifyOpts: Opts;
outlineAttachment?: boolean; outlineAttachment?: boolean;
disabledEmbedPatterns?: string[]; disabledEmbedPatterns?: string[];
targetEventId?: string;
onJumboEmojiClick?: (
targetEventId: string,
body: string,
event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>
) => void;
}; };
export function RenderMessageContent({ export function RenderMessageContent({
displayName, displayName,
@@ -63,6 +69,8 @@ export function RenderMessageContent({
linkifyOpts, linkifyOpts,
outlineAttachment, outlineAttachment,
disabledEmbedPatterns, disabledEmbedPatterns,
targetEventId,
onJumboEmojiClick,
}: RenderMessageContentProps) { }: RenderMessageContentProps) {
const renderUrlsPreview = (urls: string[]) => { const renderUrlsPreview = (urls: string[]) => {
let filteredUrls = urls.filter((url) => !testMatrixTo(url)); let filteredUrls = urls.filter((url) => !testMatrixTo(url));
@@ -100,6 +108,11 @@ export function RenderMessageContent({
</> </>
); );
}; };
const handleJumboEmojiClick =
targetEventId && onJumboEmojiClick
? (body: string, event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) =>
onJumboEmojiClick(targetEventId, body, event)
: undefined;
const renderCaption = () => { const renderCaption = () => {
const content: IImageContent = getContent(); const content: IImageContent = getContent();
if (content.filename && content.filename !== content.body) { if (content.filename && content.filename !== content.body) {
@@ -182,6 +195,7 @@ export function RenderMessageContent({
<MText <MText
edited={edited} edited={edited}
content={getContent()} content={getContent()}
onJumboEmojiClick={handleJumboEmojiClick}
renderBody={(props) => ( renderBody={(props) => (
<RenderBody <RenderBody
{...props} {...props}
@@ -201,6 +215,7 @@ export function RenderMessageContent({
displayName={displayName} displayName={displayName}
edited={edited} edited={edited}
content={getContent()} content={getContent()}
onJumboEmojiClick={handleJumboEmojiClick}
renderBody={(props) => ( renderBody={(props) => (
<RenderBody <RenderBody
{...props} {...props}
@@ -219,6 +234,7 @@ export function RenderMessageContent({
<MNotice <MNotice
edited={edited} edited={edited}
content={getContent()} content={getContent()}
onJumboEmojiClick={handleJumboEmojiClick}
renderBody={(props) => ( renderBody={(props) => (
<RenderBody <RenderBody
{...props} {...props}

View File

@@ -82,6 +82,12 @@ export const createRoomEncryptionState = () => ({
}, },
}); });
/**
* Partial power levels for MatrixRTC call membership (MSC3401).
* Do NOT put this in createRoom initial_state — homeservers reject incomplete
* m.room.power_levels with 403. Apply after create via sendStateEvent instead,
* or rely on TrustedPrivateChat (both users PL 100) for DMs.
*/
export const createRoomPowerLevelsState = () => ({ export const createRoomPowerLevelsState = () => ({
type: StateEvent.RoomPowerLevels, type: StateEvent.RoomPowerLevels,
state_key: '', state_key: '',

View File

@@ -16,6 +16,8 @@ export const EditorOptions = style([
DefaultReset, DefaultReset,
{ {
padding: config.space.S200, padding: config.space.S200,
display: 'flex',
alignItems: 'center',
}, },
]); ]);
@@ -26,7 +28,7 @@ export const EditorTextarea = style([
{ {
flexGrow: 1, flexGrow: 1,
height: '100%', height: '100%',
padding: `${toRem(13)} ${toRem(1)}`, padding: `${toRem(10)} ${toRem(1)}`,
selectors: { selectors: {
[`${EditorTextareaScroll}:first-child &`]: { [`${EditorTextareaScroll}:first-child &`]: {
paddingLeft: toRem(13), paddingLeft: toRem(13),
@@ -54,7 +56,7 @@ export const EditorPlaceholderTextVisual = style([
DefaultReset, DefaultReset,
{ {
display: 'block', display: 'block',
paddingTop: toRem(13), paddingTop: toRem(10),
paddingLeft: toRem(1), paddingLeft: toRem(1),
}, },
]); ]);

View File

@@ -6,11 +6,9 @@ import React, {
forwardRef, forwardRef,
useCallback, useCallback,
useState, useState,
useEffect,
useRef,
} from 'react'; } from 'react';
import { Box, Scroll, Text } from 'folds'; import { Box, Scroll, Text } from 'folds';
import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate'; import { Descendant, Editor, createEditor, Element as SlateElement, Text as SlateText } from 'slate';
import { import {
Slate, Slate,
Editable, Editable,
@@ -172,48 +170,6 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
[editor, onKeyDown] [editor, onKeyDown]
); );
const handleBeforeInput = useCallback(
(event: Event) => {
const inputEvent = event as InputEvent;
// Handle autocorrect replacement that causes text duplication
if (inputEvent.inputType === 'insertReplacementText' ||
inputEvent.inputType === 'insertFromComposition') {
const { selection } = editor;
if (!selection) return;
// Get the data being inserted
const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain');
if (data) {
event.preventDefault();
// If there's selected text, delete it first
if (selection && !Range.isCollapsed(selection)) {
Transforms.delete(editor, { at: selection });
}
// Insert the replacement text
editor.insertText(data);
}
}
},
[editor]
);
const editableRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const editableElement = editableRef.current?.querySelector('[data-slate-editor="true"]');
if (!editableElement) return;
editableElement.addEventListener('beforeinput', handleBeforeInput, { capture: true });
return () => {
editableElement.removeEventListener('beforeinput', handleBeforeInput, { capture: true });
};
}, [handleBeforeInput]);
const renderPlaceholder = useCallback( const renderPlaceholder = useCallback(
({ attributes, children }: RenderPlaceholderProps) => ( ({ attributes, children }: RenderPlaceholderProps) => (
<span {...attributes} className={css.EditorPlaceholderContainer}> <span {...attributes} className={css.EditorPlaceholderContainer}>
@@ -243,7 +199,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
<Slate editor={editor} initialValue={initialValue} onChange={onChange}> <Slate editor={editor} initialValue={initialValue} onChange={onChange}>
{top} {top}
<Box <Box
alignItems="Start" alignItems={fillHeight ? 'Start' : 'Center'}
grow={fillHeight ? 'Yes' : undefined} grow={fillHeight ? 'Yes' : undefined}
style={fillHeight ? { width: '100%', minHeight: 0, flex: '1 1 auto' } : undefined} style={fillHeight ? { width: '100%', minHeight: 0, flex: '1 1 auto' } : undefined}
> >
@@ -256,10 +212,9 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
className={css.EditorTextareaScroll} className={css.EditorTextareaScroll}
variant="SurfaceVariant" variant="SurfaceVariant"
style={scrollStyle} style={scrollStyle}
size="300" size="0"
visibility="Hover" visibility="Hover"
hideTrack hideTrack
ref={editableRef}
> >
<Editable <Editable
data-editable-name={editableName} data-editable-name={editableName}

View File

@@ -82,13 +82,21 @@ function RenderEmoticonElement({
const selected = useSelected(); const selected = useSelected();
const focused = useFocused(); const focused = useFocused();
// Void inline: attributes + contentEditable={false} on the same root, children
// as a sibling of the visual (not nested inside another non-editable span).
// Nesting {children} under an inner contentEditable={false} breaks Slate's
// DOM↔node map and crashes ReactEditor.focus after insert.
return ( return (
<span className={css.EmoticonBase} {...attributes}> <span
{...attributes}
contentEditable={false}
className={css.EmoticonBase}
style={{ userSelect: 'none' }}
>
<span <span
className={css.Emoticon({ className={css.Emoticon({
focus: selected && focused, focus: selected && focused,
})} })}
contentEditable={false}
> >
{element.key.startsWith('mxc://') ? ( {element.key.startsWith('mxc://') ? (
<img <img
@@ -99,8 +107,8 @@ function RenderEmoticonElement({
) : ( ) : (
element.key element.key
)} )}
{children}
</span> </span>
{children}
</span> </span>
); );
} }

View File

@@ -64,7 +64,7 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) {
radii="300" radii="300"
disabled={disableInline} disabled={disableInline}
> >
<Icon size="200" src={icon} /> <Icon size="100" src={icon} />
</IconButton> </IconButton>
)} )}
</TooltipProvider> </TooltipProvider>
@@ -95,7 +95,7 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
size="400" size="400"
radii="300" radii="300"
> >
<Icon size="200" src={icon} /> <Icon size="100" src={icon} />
</IconButton> </IconButton>
)} )}
</TooltipProvider> </TooltipProvider>
@@ -152,7 +152,7 @@ export function HeadingBlockButton() {
size="400" size="400"
radii="300" radii="300"
> >
<Icon size="200" src={Icons.Heading1} /> <Icon size="100" src={Icons.Heading1} />
</IconButton> </IconButton>
)} )}
</TooltipProvider> </TooltipProvider>
@@ -167,7 +167,7 @@ export function HeadingBlockButton() {
size="400" size="400"
radii="300" radii="300"
> >
<Icon size="200" src={Icons.Heading2} /> <Icon size="100" src={Icons.Heading2} />
</IconButton> </IconButton>
)} )}
</TooltipProvider> </TooltipProvider>
@@ -182,7 +182,7 @@ export function HeadingBlockButton() {
size="400" size="400"
radii="300" radii="300"
> >
<Icon size="200" src={Icons.Heading3} /> <Icon size="100" src={Icons.Heading3} />
</IconButton> </IconButton>
)} )}
</TooltipProvider> </TooltipProvider>
@@ -199,8 +199,8 @@ export function HeadingBlockButton() {
size="400" size="400"
radii="300" radii="300"
> >
<Icon size="200" src={level ? Icons[`Heading${level}`] : Icons.Heading1} /> <Icon size="100" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
<Icon size="200" src={isActive ? Icons.Cross : Icons.ChevronBottom} /> <Icon size="100" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
</IconButton> </IconButton>
</PopOut> </PopOut>
); );
@@ -336,7 +336,7 @@ export function Toolbar() {
radii="300" radii="300"
disabled={disableInline || !!isAnyMarkActive(editor)} disabled={disableInline || !!isAnyMarkActive(editor)}
> >
<Icon size="200" src={Icons.Markdown} filled={isMarkdown} /> <Icon size="100" src={Icons.Markdown} filled={isMarkdown} />
</IconButton> </IconButton>
)} )}
</TooltipProvider> </TooltipProvider>

View File

@@ -1,4 +1,5 @@
import { BasePoint, BaseRange, Editor, Element, Point, Range, Text, Transforms } from 'slate'; import { BasePoint, BaseRange, Editor, Element, Point, Range, Text, Transforms } from 'slate';
import { ReactEditor } from 'slate-react';
import { BlockType, MarkType } from './types'; import { BlockType, MarkType } from './types';
import { import {
CommandElement, CommandElement,
@@ -206,6 +207,29 @@ export const moveCursor = (editor: Editor, withSpace?: boolean) => {
if (withSpace) editor.insertText(' '); if (withSpace) editor.insertText(' ');
}; };
/**
* Focus the editor after React has committed DOM for recent transforms.
* Calling ReactEditor.focus immediately after insertNode/insertText (e.g. emoji +
* trailing space) races Slate's DOM map and throws:
* "Cannot resolve a DOM node from Slate node: {"text":" "}"
*/
export const safeFocusEditor = (editor: Editor) => {
const tryFocus = () => {
try {
ReactEditor.focus(editor);
return true;
} catch {
return false;
}
};
// Defer past the current React commit; retry once if the DOM map isn't ready yet.
requestAnimationFrame(() => {
if (tryFocus()) return;
setTimeout(tryFocus, 0);
});
};
interface PointUntilCharOptions { interface PointUntilCharOptions {
match: (char: string) => boolean; match: (char: string) => boolean;
reverse?: boolean; reverse?: boolean;

View File

@@ -29,8 +29,10 @@ export const Icon = forwardRef<SVGSVGElement, IconProps>(
? { width: pixelSize, height: pixelSize, ...style } ? { width: pixelSize, height: pixelSize, ...style }
: style : style
} }
strokeWidth={2} // Lucide icons are stroke-based; solid fill turns faces (e.g. Smile) into blobs.
fill={fill ?? (filled ? 'currentColor' : 'none')} // Emphasize selection with a heavier stroke instead.
strokeWidth={filled ? 2.75 : 2}
fill={fill ?? 'none'}
aria-hidden={props['aria-hidden'] ?? props['aria-label'] ? undefined : true} aria-hidden={props['aria-hidden'] ?? props['aria-label'] ? undefined : true}
{...props} {...props}
/> />

View File

@@ -1,12 +1,13 @@
import type { IconSize } from './types'; import type { IconSize } from './types';
/** App chrome icons; ~4% above the original folds scale after +30% then -20%. */
export const ICON_PIXEL_SIZES: Record<IconSize, number | undefined> = { export const ICON_PIXEL_SIZES: Record<IconSize, number | undefined> = {
'50': 10, '50': 10,
'100': 12, '100': 13,
'200': 16, '200': 17,
'300': 20, '300': 21,
'400': 24, '400': 25,
'500': 28, '500': 29,
'600': 32, '600': 34,
Inherit: undefined, Inherit: undefined,
}; };

View File

@@ -3,9 +3,8 @@ import { config } from 'folds';
export const Icon = recipe({ export const Icon = recipe({
base: { base: {
display: 'inline-block', display: 'block',
flexShrink: 0, flexShrink: 0,
verticalAlign: 'middle',
}, },
variants: { variants: {
size: { size: {

View File

@@ -1,42 +1,127 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config } from 'folds'; import { DefaultReset } from 'folds';
export const ImageViewer = style([ export const Root = style([
DefaultReset, DefaultReset,
{ {
height: '100%', position: 'fixed',
inset: 0,
zIndex: 200,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
boxSizing: 'border-box',
}, },
]); ]);
export const ImageViewerHeader = style([ export const Backdrop = style([
DefaultReset, DefaultReset,
{ {
paddingLeft: config.space.S200, position: 'absolute',
paddingRight: config.space.S200, inset: 0,
borderBottomWidth: config.borderWidth.B300, backgroundColor: 'rgba(0, 0, 0, 0.72)',
flexShrink: 0, backdropFilter: 'blur(18px)',
gap: config.space.S200, WebkitBackdropFilter: 'blur(18px)',
cursor: 'zoom-out',
}, },
]); ]);
export const ImageViewerContent = style([ export const Stage = style([
DefaultReset, DefaultReset,
{ {
backgroundColor: color.Background.Container, position: 'relative',
color: color.Background.OnContainer, zIndex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
gap: 10,
maxWidth: 'min(96vw, 1100px)',
maxHeight: 'min(92vh, 900px)',
width: '100%',
minWidth: 0,
pointerEvents: 'none',
},
]);
export const Chrome = style([
DefaultReset,
{
display: 'flex',
alignItems: 'center',
gap: 8,
width: '100%',
minWidth: 0,
color: '#fff',
pointerEvents: 'auto',
},
]);
export const Title = style([
DefaultReset,
{
flex: 1,
minWidth: 0,
fontSize: 13,
fontWeight: 600,
lineHeight: '18px',
color: 'rgba(255, 255, 255, 0.9)',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}, },
]); ]);
export const ImageViewerImg = style([ export const ChromeButton = style([
DefaultReset, DefaultReset,
{ {
objectFit: 'contain', display: 'inline-flex',
width: 'auto', alignItems: 'center',
height: 'auto', justifyContent: 'center',
maxWidth: '100%', flexShrink: 0,
maxHeight: '100%', padding: 6,
backgroundColor: color.Surface.Container, border: 'none',
transition: 'transform 100ms linear', borderRadius: 4,
background: 'rgba(0, 0, 0, 0.4)',
color: '#fff',
cursor: 'pointer',
},
]);
export const Frame = style([
DefaultReset,
{
position: 'relative',
alignSelf: 'center',
maxWidth: 'min(96vw, 1100px)',
maxHeight: 'min(80vh, 820px)',
overflow: 'hidden',
cursor: 'zoom-in',
touchAction: 'none',
pointerEvents: 'auto',
selectors: {
'&[data-zoomed]': {
cursor: 'zoom-out',
overflow: 'visible',
},
'&[data-pannable]': {
cursor: 'grab',
},
'&[data-dragging]': {
cursor: 'grabbing',
},
},
},
]);
export const Image = style([
DefaultReset,
{
display: 'block',
borderRadius: 8,
background: 'transparent',
transform: 'translate(var(--img-tx, 0px), var(--img-ty, 0px)) scale(var(--img-zoom, 1))',
transformOrigin: '0 0',
userSelect: 'none',
}, },
]); ]);

View File

@@ -1,16 +1,61 @@
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import React from 'react'; import React, {
import FileSaver from 'file-saver'; useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Box, Chip, Header, IconButton, Text, as } from 'folds'; import { as } from 'folds';
import { Icon, Icons } from '../icons'; import { Icon, Icons } from '../icons';
import * as css from './ImageViewer.css'; import * as css from './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom'; import { downloadAndSaveMedia } from '../../utils/saveMedia';
import { usePan } from '../../hooks/usePan';
import { downloadMedia } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getCurrentAccessToken } from '../../utils/auth'; import { getCurrentAccessToken } from '../../utils/auth';
const ZOOM_MIN = 1;
const ZOOM_MAX = 5;
const ZOOM_CLICK = 2.5;
const DRAG_CLICK_SLOP = 6;
type ZoomState = {
scale: number;
tx: number;
ty: number;
};
type PinchSession = {
startDist: number;
startScale: number;
startTx: number;
startTy: number;
startMidX: number;
startMidY: number;
};
const INITIAL_ZOOM: ZoomState = { scale: 1, tx: 0, ty: 0 };
function imageViewportMax() {
return {
w: Math.min(window.innerWidth * 0.96, 1100),
h: Math.min(window.innerHeight * 0.8, 820),
};
}
function pointerDistance(
a: { x: number; y: number },
b: { x: number; y: number }
): number {
return Math.hypot(a.x - b.x, a.y - b.y);
}
function pointerMidpoint(
a: { x: number; y: number },
b: { x: number; y: number }
): { x: number; y: number } {
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
}
export type ImageViewerProps = { export type ImageViewerProps = {
alt: string; alt: string;
src: string; src: string;
@@ -19,100 +64,382 @@ export type ImageViewerProps = {
export const ImageViewer = as<'div', ImageViewerProps>( export const ImageViewer = as<'div', ImageViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => { ({ className, alt, src, requestClose, ...props }, ref) => {
const mx = useMatrixClient(); const frameRef = useRef<HTMLDivElement>(null);
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2); const imgRef = useRef<HTMLImageElement>(null);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1); const zoomRef = useRef<ZoomState>({ ...INITIAL_ZOOM });
const pointersRef = useRef<Map<number, { x: number; y: number }>>(new Map());
const dragRef = useRef<{
id: number;
x: number;
y: number;
tx: number;
ty: number;
moved: boolean;
} | null>(null);
const pinchRef = useRef<PinchSession | null>(null);
/** Skip the next click after a pinch so we don't toggle zoom. */
const suppressClickRef = useRef(false);
const baseSizeRef = useRef({ w: 0, h: 0 });
const [zoom, setZoom] = useState<ZoomState>(INITIAL_ZOOM);
const [dragging, setDragging] = useState(false);
const applyZoomCss = useCallback((next: ZoomState) => {
const img = imgRef.current;
if (!img) return;
img.style.setProperty('--img-zoom', String(next.scale));
img.style.setProperty('--img-tx', `${next.tx}px`);
img.style.setProperty('--img-ty', `${next.ty}px`);
}, []);
const clampPan = useCallback((state: ZoomState): ZoomState => {
const img = imgRef.current;
const frame = frameRef.current;
if (!img || !frame) return state;
const baseW = img.offsetWidth || baseSizeRef.current.w;
const baseH = img.offsetHeight || baseSizeRef.current.h;
const frameW = frame.clientWidth;
const frameH = frame.clientHeight;
if (!baseW || !baseH || !frameW || !frameH) return state;
const scaledW = baseW * state.scale;
const scaledH = baseH * state.scale;
let { tx, ty } = state;
if (scaledW <= frameW) tx = (frameW - scaledW) / 2;
else tx = Math.min(0, Math.max(frameW - scaledW, tx));
if (scaledH <= frameH) ty = (frameH - scaledH) / 2;
else ty = Math.min(0, Math.max(frameH - scaledH, ty));
return { ...state, tx, ty };
}, []);
const commitZoom = useCallback(
(next: ZoomState) => {
const clamped = clampPan(next);
zoomRef.current = clamped;
applyZoomCss(clamped);
setZoom(clamped);
},
[applyZoomCss, clampPan]
);
const resetZoom = useCallback(() => {
dragRef.current = null;
pinchRef.current = null;
setDragging(false);
commitZoom({ ...INITIAL_ZOOM });
}, [commitZoom]);
const fitImage = useCallback(() => {
const img = imgRef.current;
if (!img) return;
const iw = img.naturalWidth || 0;
const ih = img.naturalHeight || 0;
if (iw <= 0 || ih <= 0) return;
const { w: maxW, h: maxH } = imageViewportMax();
const s = Math.min(1, maxW / iw, maxH / ih);
const w = Math.max(1, Math.round(iw * s));
const h = Math.max(1, Math.round(ih * s));
img.style.width = `${w}px`;
img.style.height = `${h}px`;
baseSizeRef.current = { w, h };
commitZoom({ ...INITIAL_ZOOM });
}, [commitZoom]);
const zoomAround = useCallback(
(px: number, py: number, newScale: number) => {
const z = zoomRef.current;
const scale = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newScale));
const ix = (px - z.tx) / z.scale;
const iy = (py - z.ty) / z.scale;
commitZoom({
scale,
tx: px - ix * scale,
ty: py - iy * scale,
});
},
[commitZoom]
);
const framePoint = (clientX: number, clientY: number) => {
const frame = frameRef.current;
if (!frame) return { x: 0, y: 0 };
const r = frame.getBoundingClientRect();
return { x: clientX - r.left, y: clientY - r.top };
};
const beginPinch = useCallback(() => {
const pts = [...pointersRef.current.values()];
if (pts.length < 2) return;
const [a, b] = pts;
const mid = pointerMidpoint(a, b);
const local = framePoint(mid.x, mid.y);
const z = zoomRef.current;
pinchRef.current = {
startDist: Math.max(1, pointerDistance(a, b)),
startScale: z.scale,
startTx: z.tx,
startTy: z.ty,
startMidX: local.x,
startMidY: local.y,
};
dragRef.current = null;
setDragging(true);
}, []);
const updatePinch = useCallback(() => {
const pinch = pinchRef.current;
const pts = [...pointersRef.current.values()];
if (!pinch || pts.length < 2) return;
const [a, b] = pts;
const dist = Math.max(1, pointerDistance(a, b));
const mid = pointerMidpoint(a, b);
const local = framePoint(mid.x, mid.y);
const scale = Math.min(
ZOOM_MAX,
Math.max(ZOOM_MIN, pinch.startScale * (dist / pinch.startDist))
);
// Keep the original content under the pinch midpoint while scaling + panning.
const ix = (pinch.startMidX - pinch.startTx) / pinch.startScale;
const iy = (pinch.startMidY - pinch.startTy) / pinch.startScale;
commitZoom({
scale,
tx: local.x - ix * scale,
ty: local.y - iy * scale,
});
suppressClickRef.current = true;
}, [commitZoom]);
useLayoutEffect(() => {
resetZoom();
}, [src, resetZoom]);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
requestClose();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [requestClose]);
useEffect(() => {
const frame = frameRef.current;
if (!frame) return undefined;
const onWheel = (event: WheelEvent) => {
event.preventDefault();
const img = imgRef.current;
if (!img?.src) return;
const factor = Math.exp(-event.deltaY * 0.002);
const r = frame.getBoundingClientRect();
const x = event.clientX - r.left;
const y = event.clientY - r.top;
zoomAround(x, y, zoomRef.current.scale * factor);
};
// Block browser gesture zoom / scroll while pinching on the frame.
const blockGesture = (event: Event) => {
if (pointersRef.current.size >= 2 || pinchRef.current) {
event.preventDefault();
}
};
frame.addEventListener('wheel', onWheel, { passive: false });
frame.addEventListener('touchmove', blockGesture, { passive: false });
return () => {
frame.removeEventListener('wheel', onWheel);
frame.removeEventListener('touchmove', blockGesture);
};
}, [zoomAround]);
useEffect(() => {
const onResize = () => fitImage();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [fitImage]);
const handleDownload = async () => { const handleDownload = async () => {
try { try {
// Always use current session's token to avoid stale tokens during account switches await downloadAndSaveMedia(src, alt, getCurrentAccessToken());
const fileContent = await downloadMedia(src, getCurrentAccessToken());
FileSaver.saveAs(fileContent, alt);
} catch (error) { } catch (error) {
console.warn('[ImageViewer] Failed to download media:', error); console.warn('[ImageViewer] Failed to download media:', error);
// Fallback: try to fetch via standard fetch as blob
try { try {
const response = await fetch(src);
if (response.ok) {
const blob = await response.blob();
FileSaver.saveAs(blob, alt);
}
} catch {
// If all else fails, open in new tab to let browser handle it
window.open(src, '_blank'); window.open(src, '_blank');
} catch {
// ignore
} }
} }
}; };
const handleClick = (event: React.MouseEvent) => {
if (suppressClickRef.current) {
suppressClickRef.current = false;
return;
}
if (dragRef.current?.moved) {
dragRef.current = null;
return;
}
const img = imgRef.current;
if (!img?.src) return;
event.preventDefault();
const { x, y } = framePoint(event.clientX, event.clientY);
if (zoomRef.current.scale <= 1.01) {
zoomAround(x, y, ZOOM_CLICK);
} else {
resetZoom();
}
};
const handlePointerDown = (event: React.PointerEvent) => {
if (event.button !== 0 && event.pointerType === 'mouse') return;
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
frameRef.current?.setPointerCapture(event.pointerId);
if (pointersRef.current.size >= 2) {
beginPinch();
return;
}
if (zoomRef.current.scale <= 1.01) return;
const z = zoomRef.current;
dragRef.current = {
id: event.pointerId,
x: event.clientX,
y: event.clientY,
tx: z.tx,
ty: z.ty,
moved: false,
};
setDragging(true);
};
const handlePointerMove = (event: React.PointerEvent) => {
if (!pointersRef.current.has(event.pointerId)) return;
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (pointersRef.current.size >= 2 && pinchRef.current) {
event.preventDefault();
updatePinch();
return;
}
const drag = dragRef.current;
if (!drag || drag.id !== event.pointerId) return;
const dx = event.clientX - drag.x;
const dy = event.clientY - drag.y;
if (!drag.moved && Math.hypot(dx, dy) > DRAG_CLICK_SLOP) drag.moved = true;
commitZoom({
scale: zoomRef.current.scale,
tx: drag.tx + dx,
ty: drag.ty + dy,
});
};
const handlePointerUp = (event: React.PointerEvent) => {
pointersRef.current.delete(event.pointerId);
frameRef.current?.releasePointerCapture(event.pointerId);
if (pinchRef.current) {
if (pointersRef.current.size >= 2) {
beginPinch();
return;
}
pinchRef.current = null;
setDragging(false);
// Extra Things: one finger left after pinch — hand off to pan if still zoomed.
if (pointersRef.current.size === 1 && zoomRef.current.scale > 1.01) {
const [id, pt] = [...pointersRef.current.entries()][0];
const z = zoomRef.current;
dragRef.current = {
id,
x: pt.x,
y: pt.y,
tx: z.tx,
ty: z.ty,
moved: true,
};
setDragging(true);
} else {
dragRef.current = null;
}
return;
}
const drag = dragRef.current;
if (!drag || drag.id !== event.pointerId) return;
setDragging(false);
if (!drag.moved) dragRef.current = null;
else dragRef.current = { ...drag, moved: true };
};
const zoomed = zoom.scale > 1.01;
return ( return (
<Box <div
className={classNames(css.ImageViewer, className)} className={classNames(css.Root, className)}
direction="Column" role="dialog"
aria-modal="true"
aria-label={alt || 'Image viewer'}
{...props} {...props}
ref={ref} ref={ref}
> >
<Header className={css.ImageViewerHeader} size="400"> <div className={css.Backdrop} onClick={requestClose} aria-hidden />
<Box grow="Yes" alignItems="Center" gap="200"> <div className={css.Stage}>
<IconButton size="300" radii="300" onClick={requestClose}> <div className={css.Chrome}>
<Icon size="50" src={Icons.ArrowLeft} /> <div className={css.Title} title={alt}>
</IconButton>
<Text size="T300" truncate>
{alt} {alt}
</Text> </div>
</Box> <button
<Box shrink="No" alignItems="Center" gap="200"> type="button"
<IconButton className={css.ChromeButton}
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'} aria-label="Download"
outlined={zoom < 1}
size="300"
radii="Pill"
onClick={zoomOut}
aria-label="Zoom Out"
>
<Icon size="50" src={Icons.Minus} />
</IconButton>
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
<Text size="B300">{Math.round(zoom * 100)}%</Text>
</Chip>
<IconButton
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom > 1}
size="300"
radii="Pill"
onClick={zoomIn}
aria-label="Zoom In"
>
<Icon size="50" src={Icons.Plus} />
</IconButton>
<Chip
variant="Primary"
onClick={handleDownload} onClick={handleDownload}
radii="300"
before={<Icon size="50" src={Icons.Download} />}
> >
<Text size="B300">Download</Text> <Icon size="50" src={Icons.Download} />
</Chip> </button>
</Box> <button
</Header> type="button"
<Box className={css.ChromeButton}
grow="Yes" aria-label="Close"
className={css.ImageViewerContent} onClick={requestClose}
justifyContent="Center" >
alignItems="Center" <Icon size="50" src={Icons.Cross} />
> </button>
<img </div>
className={css.ImageViewerImg} <div
style={{ ref={frameRef}
cursor, className={css.Frame}
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`, data-zoomed={zoomed || undefined}
}} data-pannable={zoomed || undefined}
src={src} data-dragging={dragging || undefined}
alt={alt} onClick={handleClick}
draggable={false} onPointerDown={handlePointerDown}
onMouseDown={onMouseDown} onPointerMove={handlePointerMove}
/> onPointerUp={handlePointerUp}
</Box> onPointerCancel={handlePointerUp}
</Box> >
<img
ref={imgRef}
className={css.Image}
src={src}
alt={alt}
draggable={false}
onLoad={fitImage}
/>
</div>
</div>
</div>
); );
} }
); );

View File

@@ -2,7 +2,6 @@ import { Badge, Box, IconButton, Spinner, Text, as, toRem } from 'folds';
import { Icon, Icons } from '../icons'; import { Icon, Icons } from '../icons';
import React, { ReactNode, useCallback } from 'react'; import React, { ReactNode, useCallback } from 'react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FileSaver from 'file-saver';
import { mimeTypeToExt } from '../../utils/mimeTypes'; import { mimeTypeToExt } from '../../utils/mimeTypes';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@@ -14,6 +13,7 @@ import {
mxcUrlToHttp, mxcUrlToHttp,
} from '../../utils/matrix'; } from '../../utils/matrix';
import { getCurrentAccessToken } from '../../utils/auth'; import { getCurrentAccessToken } from '../../utils/auth';
import { saveMediaBlob } from '../../utils/saveMedia';
const badgeStyles = { maxWidth: toRem(100) }; const badgeStyles = { maxWidth: toRem(100) };
@@ -36,9 +36,8 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken) ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
: await downloadMedia(mediaUrl, accessToken); : await downloadMedia(mediaUrl, accessToken);
const fileURL = URL.createObjectURL(fileContent); await saveMediaBlob(fileContent, filename);
FileSaver.saveAs(fileURL, filename); return true;
return fileURL;
}, [mx, url, useAuthentication, mimeType, encInfo, filename]) }, [mx, url, useAuthentication, mimeType, encInfo, filename])
); );

View File

@@ -26,8 +26,11 @@ import {
MATRIX_SPOILER_PROPERTY_NAME, MATRIX_SPOILER_PROPERTY_NAME,
MATRIX_SPOILER_REASON_PROPERTY_NAME, MATRIX_SPOILER_REASON_PROPERTY_NAME,
} from '../../../types/matrix/common'; } from '../../../types/matrix/common';
import { StationeryMedia, hashStationerySeed, stationeryMediaRot } from './StationeryMedia';
import { getJumboEmojiInteractionProps, JumboEmojiClickHandler } from '../../features/room/emoji-confetti/jumboEmojiInteraction';
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes'; import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
import { parseGeoUri, scaleYDimension } from '../../utils/common'; import { parseGeoUri, scaleYDimension } from '../../utils/common';
import { resolveAttachmentBoxSize } from '../../state/mediaDimensionCache';
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment'; import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
import { FileHeader, FileDownloadButton } from './FileHeader'; import { FileHeader, FileDownloadButton } from './FileHeader';
@@ -76,8 +79,9 @@ type MTextProps = {
renderBody: (props: RenderBodyProps) => ReactNode; renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode; renderUrlsPreview?: (urls: string[]) => ReactNode;
style?: CSSProperties; style?: CSSProperties;
onJumboEmojiClick?: JumboEmojiClickHandler;
}; };
export function MText({ edited, content, renderBody, renderUrlsPreview, style }: MTextProps) { export function MText({ edited, content, renderBody, renderUrlsPreview, style, onJumboEmojiClick }: MTextProps) {
const { body, formatted_body: customBody } = content; const { body, formatted_body: customBody } = content;
if (typeof body !== 'string') return <BrokenContent />; if (typeof body !== 'string') return <BrokenContent />;
@@ -87,13 +91,20 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
: undefined; : undefined;
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG); const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
const isJumboEmoji = JUMBO_EMOJI_REG.test(trimmedBody);
const jumboEmojiInteraction = getJumboEmojiInteractionProps({
body: trimmedBody,
isJumboEmoji,
onJumboEmojiClick,
});
return ( return (
<> <>
<MessageTextBody <MessageTextBody
preWrap={typeof customBody !== 'string'} preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)} jumboEmoji={isJumboEmoji}
style={style} style={style}
{...jumboEmojiInteraction}
> >
{renderBody({ {renderBody({
body: trimmedBody, body: trimmedBody,
@@ -112,6 +123,7 @@ type MEmoteProps = {
content: Record<string, unknown>; content: Record<string, unknown>;
renderBody: (props: RenderBodyProps) => ReactNode; renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode; renderUrlsPreview?: (urls: string[]) => ReactNode;
onJumboEmojiClick?: JumboEmojiClickHandler;
}; };
export function MEmote({ export function MEmote({
displayName, displayName,
@@ -119,6 +131,7 @@ export function MEmote({
content, content,
renderBody, renderBody,
renderUrlsPreview, renderUrlsPreview,
onJumboEmojiClick,
}: MEmoteProps) { }: MEmoteProps) {
const { body, formatted_body: customBody } = content; const { body, formatted_body: customBody } = content;
@@ -129,20 +142,28 @@ export function MEmote({
: undefined; : undefined;
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG); const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
const isJumboEmoji = JUMBO_EMOJI_REG.test(trimmedBody);
const jumboEmojiInteraction = getJumboEmojiInteractionProps({
body: trimmedBody,
isJumboEmoji,
onJumboEmojiClick,
});
return ( return (
<> <>
<MessageTextBody <MessageTextBody
emote emote
preWrap={typeof customBody !== 'string'} preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)} jumboEmoji={isJumboEmoji}
> >
<b>{`${displayName} `}</b> <b>{`${displayName} `}</b>
{renderBody({ <span {...jumboEmojiInteraction}>
body: trimmedBody, {renderBody({
customBody: trimmedCustomBody, body: trimmedBody,
})} customBody: trimmedCustomBody,
{edited && <MessageEditedContent />} })}
{edited && <MessageEditedContent />}
</span>
</MessageTextBody> </MessageTextBody>
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)} {renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
</> </>
@@ -154,8 +175,9 @@ type MNoticeProps = {
content: Record<string, unknown>; content: Record<string, unknown>;
renderBody: (props: RenderBodyProps) => ReactNode; renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode; renderUrlsPreview?: (urls: string[]) => ReactNode;
onJumboEmojiClick?: JumboEmojiClickHandler;
}; };
export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNoticeProps) { export function MNotice({ edited, content, renderBody, renderUrlsPreview, onJumboEmojiClick }: MNoticeProps) {
const { body, formatted_body: customBody } = content; const { body, formatted_body: customBody } = content;
if (typeof body !== 'string') return <BrokenContent />; if (typeof body !== 'string') return <BrokenContent />;
@@ -165,13 +187,20 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
: undefined; : undefined;
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG); const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
const isJumboEmoji = JUMBO_EMOJI_REG.test(trimmedBody);
const jumboEmojiInteraction = getJumboEmojiInteractionProps({
body: trimmedBody,
isJumboEmoji,
onJumboEmojiClick,
});
return ( return (
<> <>
<MessageTextBody <MessageTextBody
notice notice
preWrap={typeof customBody !== 'string'} preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)} jumboEmoji={isJumboEmoji}
{...jumboEmojiInteraction}
> >
{renderBody({ {renderBody({
body: trimmedBody, body: trimmedBody,
@@ -199,6 +228,20 @@ type MImageProps = {
renderImageContent: (props: RenderImageContentProps) => ReactNode; renderImageContent: (props: RenderImageContentProps) => ReactNode;
outlined?: boolean; outlined?: boolean;
}; };
const resolveMediaBoxSize = (
mxcUrl: string,
w?: number,
h?: number
): { width: number; height: number } => resolveAttachmentBoxSize(mxcUrl, w, h);
/** Extra space after media so following text stays on the 24px ruled grid — applied outside the photo chrome */
const STATIONERY_LINE_PX = 24;
const snapStationeryGridPad = (height: number): number => {
const rem = height % STATIONERY_LINE_PX;
return rem === 0 ? 0 : STATIONERY_LINE_PX - rem;
};
export function MImage({ content, renderImageContent, outlined }: MImageProps) { export function MImage({ content, renderImageContent, outlined }: MImageProps) {
const imgInfo = content?.info; const imgInfo = content?.info;
const mxcUrl = content.file?.url ?? content.url; const mxcUrl = content.file?.url ?? content.url;
@@ -206,9 +249,24 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
return <BrokenContent />; return <BrokenContent />;
} }
const { width, height } = resolveMediaBoxSize(mxcUrl, imgInfo?.w, imgInfo?.h);
const gridPad = snapStationeryGridPad(height);
return ( return (
<Attachment outlined={outlined} transparent> <StationeryMedia
<AttachmentBox> outlined={outlined}
transparent
tiltSeed={mxcUrl}
gridPad={gridPad}
>
<AttachmentBox
style={{
width: toRem(width),
height: toRem(height),
['--media-h' as string]: `${height}px`,
}}
data-paarrot-media-height={height}
>
{renderImageContent({ {renderImageContent({
body: content.body || 'Image', body: content.body || 'Image',
info: imgInfo, info: imgInfo,
@@ -219,7 +277,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME], spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
})} })}
</AttachmentBox> </AttachmentBox>
</Attachment> </StationeryMedia>
); );
} }
@@ -251,9 +309,20 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
} }
const filename = content.filename ?? content.body ?? 'Video'; const filename = content.filename ?? content.body ?? 'Video';
const { width, height } = resolveMediaBoxSize(
mxcUrl,
videoInfo.w ?? videoInfo.thumbnail_info?.w,
videoInfo.h ?? videoInfo.thumbnail_info?.h
);
const gridPad = snapStationeryGridPad(height);
return ( return (
<Attachment outlined={outlined} transparent> <StationeryMedia
outlined={outlined}
transparent
tiltSeed={mxcUrl}
gridPad={gridPad}
>
<AttachmentHeader> <AttachmentHeader>
<FileHeader <FileHeader
body={filename} body={filename}
@@ -268,7 +337,13 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
} }
/> />
</AttachmentHeader> </AttachmentHeader>
<AttachmentBox> <AttachmentBox
style={{
width: toRem(width),
height: toRem(height),
}}
data-paarrot-media-height={height}
>
{renderVideoContent({ {renderVideoContent({
body: content.body || 'Video', body: content.body || 'Video',
info: videoInfo, info: videoInfo,
@@ -279,7 +354,7 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME], spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
})} })}
</AttachmentBox> </AttachmentBox>
</Attachment> </StationeryMedia>
); );
} }
@@ -418,12 +493,16 @@ export function MSticker({ content, renderImageContent }: MStickerProps) {
return <MessageBrokenContent />; return <MessageBrokenContent />;
} }
const height = scaleYDimension(imgInfo?.w || 152, 152, imgInfo?.h || 152); const height = scaleYDimension(imgInfo?.w || 152, 152, imgInfo?.h || 152);
const tapeAlt = hashStationerySeed(mxcUrl) % 2 === 1;
return ( return (
<AttachmentBox <AttachmentBox
data-stationery-media=""
data-tape-alt={tapeAlt ? '' : undefined}
style={{ style={{
height: toRem(height < 48 ? 48 : height), height: toRem(height < 48 ? 48 : height),
width: toRem(152), width: toRem(152),
['--media-rot' as string]: stationeryMediaRot(mxcUrl),
}} }}
> >
{renderImageContent({ {renderImageContent({

View File

@@ -64,6 +64,33 @@ export const ReactionText = style([
}, },
]); ]);
export const ReactionStack = style([
DefaultReset,
{
position: 'relative',
display: 'inline-grid',
placeItems: 'center',
gridTemplateColumns: '1fr',
gridTemplateRows: '1fr',
lineHeight: toRem(20),
minWidth: 0,
},
]);
export const ReactionSticker = style([
DefaultReset,
{
minWidth: 0,
maxWidth: toRem(150),
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
lineHeight: toRem(20),
gridArea: '1 / 1',
transformOrigin: 'center center',
},
]);
export const ReactionImg = style([ export const ReactionImg = style([
DefaultReset, DefaultReset,
{ {

View File

@@ -6,6 +6,9 @@ import * as css from './Reaction.css';
import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji'; import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji';
import { getMemberDisplayName } from '../../utils/room'; import { getMemberDisplayName } from '../../utils/room';
import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
const MAX_STICKER_STACK = 4;
export const Reaction = as< export const Reaction = as<
'button', 'button',
@@ -15,35 +18,101 @@ export const Reaction = as<
reaction: string; reaction: string;
useAuthentication?: boolean; useAuthentication?: boolean;
} }
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => ( >(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => {
<Box const theme = useTheme();
as="button" const stationery = isStationeryTheme(theme);
className={classNames(css.Reaction, className)} const isCustomEmoji = reaction.startsWith('mxc://');
alignItems="Center" const customSrc = isCustomEmoji
shrink="No" ? mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
gap="200" : undefined;
{...props}
ref={ref} const emoji = isCustomEmoji ? (
> <img className={css.ReactionImg} src={customSrc} alt={reaction} />
<Text className={css.ReactionText} as="span" size="T400"> ) : (
{reaction.startsWith('mxc://') ? ( <Text as="span" size="Inherit" truncate>
<img {reaction}
className={css.ReactionImg} </Text>
src={mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction );
}
alt={reaction} // Stationery: fanned sticker stack. Everywhere else: compact emoji + count.
/> if (!stationery) {
) : ( return (
<Text as="span" size="Inherit" truncate> <Box
{reaction} as="button"
className={classNames(css.Reaction, className)}
alignItems="Center"
shrink="No"
gap="200"
data-reaction=""
aria-label={`${reaction}, ${count}`}
{...props}
ref={ref}
>
<Text className={css.ReactionText} as="span" size="T400">
{emoji}
</Text>
<Text as="span" size="T300" data-reaction-count="">
{count}
</Text>
</Box>
);
}
const stackCount = Math.min(Math.max(count, 1), MAX_STICKER_STACK);
const showCount = count > 2;
return (
<Box
as="button"
className={classNames(css.Reaction, className)}
alignItems="Center"
shrink="No"
gap="200"
data-reaction=""
data-reaction-stack={stackCount}
aria-label={`${reaction}, ${count}`}
{...props}
ref={ref}
>
<span className={css.ReactionStack} data-reaction-stack-layer="">
{Array.from({ length: stackCount }, (_, i) => {
const layer = stackCount - 1 - i;
return (
<Text
key={layer}
className={css.ReactionSticker}
as="span"
size="T400"
data-reaction-sticker=""
style={{
['--sticker-i' as string]: layer,
zIndex: i + 1,
}}
aria-hidden={i < stackCount - 1 ? true : undefined}
>
{isCustomEmoji ? (
<img
className={css.ReactionImg}
src={customSrc}
alt={i === stackCount - 1 ? reaction : ''}
/>
) : (
<Text as="span" size="Inherit" truncate>
{reaction}
</Text>
)}
</Text>
);
})}
</span>
{showCount && (
<Text as="span" size="T300" data-reaction-count="">
{count}
</Text> </Text>
)} )}
</Text> </Box>
<Text as="span" size="T300"> );
{count} });
</Text>
</Box>
));
type ReactionTooltipMsgProps = { type ReactionTooltipMsgProps = {
room: Room; room: Room;

View File

@@ -14,29 +14,54 @@ import { useRoomEvent } from '../../hooks/useRoomEvent';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag'; import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { useOtherUserColor } from '../../hooks/useUserColor'; import { useOtherUserColor } from '../../hooks/useUserColor';
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
import { STATIONERY_NAME_INK } from '../../utils/paperSafeInk';
type ReplyLayoutProps = { type ReplyLayoutProps = {
userColor?: string; userColor?: string;
username?: ReactNode; username?: ReactNode;
}; };
export const ReplyLayout = as<'div', ReplyLayoutProps>( export const ReplyLayout = as<'div', ReplyLayoutProps>(
({ username, userColor, className, children, ...props }, ref) => ( ({ username, userColor, className, children, ...props }, ref) => {
<Box const theme = useTheme();
className={classNames(css.Reply, className)} const isStationery = isStationeryTheme(theme);
alignItems="Center"
gap="100" return (
{...props} <Box
ref={ref} className={classNames(css.Reply, className)}
> alignItems="Center"
<Box style={{ color: userColor, maxWidth: toRem(200) }} alignItems="Center" shrink="No"> gap="100"
<Icon size="100" src={Icons.ReplyArrow} /> {...props}
{username} ref={ref}
>
<Box
style={
isStationery
? {
color: STATIONERY_NAME_INK,
maxWidth: toRem(200),
...(userColor
? {
['--username-accent' as string]: userColor,
textDecorationColor: userColor,
}
: null),
}
: { color: userColor, maxWidth: toRem(200) }
}
data-username-accent={isStationery && userColor ? '' : undefined}
alignItems="Center"
shrink="No"
>
<Icon size="100" src={Icons.ReplyArrow} />
{username}
</Box>
<Box grow="Yes" className={css.ReplyContent}>
{children}
</Box>
</Box> </Box>
<Box grow="Yes" className={css.ReplyContent}> );
{children} }
</Box>
</Box>
)
); );
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => ( export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
@@ -92,7 +117,7 @@ export const Reply = as<'div', ReplyProps>(
const { body } = replyEvent?.getContent() ?? {}; const { body } = replyEvent?.getContent() ?? {};
const sender = replyEvent?.getSender(); const sender = replyEvent?.getSender();
const senderAvatarMxc = sender ? getMemberAvatarMxc(room, sender) : undefined; const senderAvatarMxc = sender ? getMemberAvatarMxc(room, sender) : undefined;
const customUserColor = useOtherUserColor(sender ?? '', senderAvatarMxc); const customUserColor = useOtherUserColor(sender ?? '', room);
const powerTag = sender ? getMemberPowerTag?.(sender) : undefined; const powerTag = sender ? getMemberPowerTag?.(sender) : undefined;
const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined; const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined;

View 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>
);
}

View File

@@ -1,8 +1,8 @@
import React, { ReactNode, useCallback, useState } from 'react'; import React, { ReactNode, useCallback, useState } from 'react';
import { Box, Button, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds'; import { Box, Button, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
import { Icon, Icons } from '../../icons'; import { Icon, Icons } from '../../icons';
import FileSaver from 'file-saver';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { saveMediaBlob } from '../../../utils/saveMedia';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { IFileInfo } from '../../../../types/matrix/common'; import { IFileInfo } from '../../../../types/matrix/common';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
@@ -252,9 +252,8 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken) ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
: await downloadMedia(mediaUrl, accessToken); : await downloadMedia(mediaUrl, accessToken);
const fileURL = URL.createObjectURL(fileContent); await saveMediaBlob(fileContent, body);
FileSaver.saveAs(fileURL, body); return fileContent;
return fileURL;
}, [mx, url, useAuthentication, mimeType, encInfo, body]) }, [mx, url, useAuthentication, mimeType, encInfo, body])
); );
@@ -268,7 +267,7 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
size="400" size="400"
onClick={() => onClick={() =>
downloadState.status === AsyncStatus.Success downloadState.status === AsyncStatus.Success
? FileSaver.saveAs(downloadState.data, body) ? saveMediaBlob(downloadState.data, body)
: download() : download()
} }
disabled={downloadState.status === AsyncStatus.Loading} disabled={downloadState.status === AsyncStatus.Loading}

View File

@@ -1,8 +1,8 @@
import React, { ReactNode, useCallback, useEffect, useState } from 'react'; import React, { ReactNode, useCallback, useEffect, useState } from 'react';
import { Badge, Box, Button, Chip, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds'; import { Badge, Box, Button, Chip, Overlay, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
import { Icon, Icons } from '../../icons'; import { Icon, Icons } from '../../icons';
import classNames from 'classnames'; import classNames from 'classnames';
import { BlurhashCanvas } from 'react-blurhash'; import { Blurhash } from 'react-blurhash';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common'; import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
@@ -14,9 +14,9 @@ import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix'; import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
import { validBlurHash } from '../../../utils/blurHash'; import { validBlurHash } from '../../../utils/blurHash';
import { getCurrentAccessToken } from '../../../utils/auth'; import { getCurrentAccessToken } from '../../../utils/auth';
import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
/** /**
* Fetches media with authentication headers and returns a blob URL. * Fetches media with authentication headers and returns a blob URL.
@@ -68,7 +68,7 @@ type RenderImageProps = {
alt: string; alt: string;
title: string; title: string;
src: string; src: string;
onLoad: () => void; onLoad: (event?: React.SyntheticEvent<HTMLImageElement>) => void;
onError: () => void; onError: () => void;
onClick: () => void; onClick: () => void;
tabIndex: number; tabIndex: number;
@@ -105,12 +105,23 @@ export const ImageContent = as<'div', ImageContentProps>(
) => { ) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]); const eventBlurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
const cachedBlurHash = getMediaBlurHash(url);
const blurHash = eventBlurHash ?? cachedBlurHash;
const cachedDims = getMediaDimensions(url);
const blurResolutionX = 32;
const ratioW = info?.w || cachedDims?.w;
const ratioH = info?.h || cachedDims?.h;
const blurResolutionY =
ratioW && ratioH && ratioW > 0
? Math.max(1, Math.round(blurResolutionX * (ratioH / ratioW)))
: 32;
const [load, setLoad] = useState(false); const [load, setLoad] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [viewer, setViewer] = useState(false); const [viewer, setViewer] = useState(false);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false); const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
const [showPlaceholder, setShowPlaceholder] = useState(true);
const [srcState, loadSrc] = useAsyncCallback( const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
@@ -139,6 +150,7 @@ export const ImageContent = as<'div', ImageContentProps>(
const handleError = () => { const handleError = () => {
setLoad(false); setLoad(false);
setError(true); setError(true);
setShowPlaceholder(true);
}; };
const handleRetry = () => { const handleRetry = () => {
@@ -150,42 +162,52 @@ export const ImageContent = as<'div', ImageContentProps>(
if (autoPlay) loadSrc(); if (autoPlay) loadSrc();
}, [autoPlay, loadSrc]); }, [autoPlay, loadSrc]);
// Keep blurhash under the fade, then remove it once the image is fully opaque.
useEffect(() => {
if (!load) {
setShowPlaceholder(true);
return undefined;
}
const timer = window.setTimeout(() => setShowPlaceholder(false), 540);
return () => window.clearTimeout(timer);
}, [load]);
return ( return (
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}> <Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
{showPlaceholder &&
(typeof blurHash === 'string' ? (
<div className={css.BlurhashPlaceholder}>
<Blurhash
hash={blurHash}
width="100%"
height="100%"
resolutionX={blurResolutionX}
resolutionY={blurResolutionY}
punch={1.2}
style={{ display: 'block' }}
/>
</div>
) : (
<div className={css.MediaSkeleton} />
))}
{srcState.status === AsyncStatus.Success && ( {srcState.status === AsyncStatus.Success && (
<Overlay open={viewer} backdrop={<OverlayBackdrop />}> <Overlay open={viewer} backdrop={null}>
<OverlayCenter> <FocusTrap
<FocusTrap focusTrapOptions={{
focusTrapOptions={{ initialFocus: false,
initialFocus: false, onDeactivate: () => setViewer(false),
onDeactivate: () => setViewer(false), clickOutsideDeactivates: true,
clickOutsideDeactivates: true, escapeDeactivates: stopPropagation,
escapeDeactivates: stopPropagation, }}
}} >
> {renderViewer({
<Modal src: srcState.data,
className={ModalWide} alt: body,
size="500" requestClose: () => setViewer(false),
> })}
{renderViewer({ </FocusTrap>
src: srcState.data,
alt: body,
requestClose: () => setViewer(false),
})}
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay> </Overlay>
)} )}
{typeof blurHash === 'string' && !load && (
<BlurhashCanvas
style={{ width: '100%', height: '100%' }}
width={32}
height={32}
hash={blurHash}
punch={1}
/>
)}
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && ( {!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center"> <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Button <Button
@@ -201,12 +223,26 @@ export const ImageContent = as<'div', ImageContentProps>(
</Box> </Box>
)} )}
{srcState.status === AsyncStatus.Success && ( {srcState.status === AsyncStatus.Success && (
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}> <Box
className={classNames(
css.AbsoluteContainer,
css.MediaFadeIn,
load && css.MediaFadeInLoaded,
blurred && css.Blur
)}
>
{renderImage({ {renderImage({
alt: body, alt: body,
title: body, title: body,
src: srcState.data, src: srcState.data,
onLoad: handleLoad, onLoad: (e?: React.SyntheticEvent<HTMLImageElement>) => {
const img = e?.currentTarget;
if (img?.naturalWidth && img?.naturalHeight) {
setMediaDimensions(url, img.naturalWidth, img.naturalHeight);
}
if (img) rememberMediaBlurHash(url, img);
handleLoad();
},
onError: handleError, onError: handleError,
onClick: () => setViewer(true), onClick: () => setViewer(true),
tabIndex: 0, tabIndex: 0,
@@ -248,7 +284,8 @@ export const ImageContent = as<'div', ImageContentProps>(
)} )}
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) && {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
!load && !load &&
!blurred && ( !blurred &&
typeof blurHash !== 'string' && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center"> <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" /> <Spinner variant="Secondary" />
</Box> </Box>

View File

@@ -2,7 +2,7 @@ import React, { ReactNode, useCallback, useEffect, useState } from 'react';
import { Badge, Box, Button, Chip, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds'; import { Badge, Box, Button, Chip, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
import { Icon, Icons } from '../../icons'; import { Icon, Icons } from '../../icons';
import classNames from 'classnames'; import classNames from 'classnames';
import { BlurhashCanvas } from 'react-blurhash'; import { Blurhash } from 'react-blurhash';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { import {
@@ -25,6 +25,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { validBlurHash } from '../../../utils/blurHash'; import { validBlurHash } from '../../../utils/blurHash';
import { ModalWide } from '../../../styles/Modal.css'; import { ModalWide } from '../../../styles/Modal.css';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
type RenderViewerProps = { type RenderViewerProps = {
src: string; src: string;
@@ -34,7 +35,7 @@ type RenderViewerProps = {
type RenderVideoProps = { type RenderVideoProps = {
title: string; title: string;
src: string; src: string;
onLoadedMetadata: () => void; onLoadedMetadata: (event?: React.SyntheticEvent<HTMLVideoElement>) => void;
onError: () => void; onError: () => void;
autoPlay: boolean; autoPlay: boolean;
controls: boolean; controls: boolean;
@@ -74,12 +75,23 @@ export const VideoContent = as<'div', VideoContentProps>(
) => { ) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const blurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]); const eventBlurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
const cachedBlurHash = getMediaBlurHash(url);
const blurHash = eventBlurHash ?? cachedBlurHash;
const cachedDims = getMediaDimensions(url);
const blurResolutionX = 32;
const blurW = info.w ?? info.thumbnail_info?.w ?? cachedDims?.w;
const blurH = info.h ?? info.thumbnail_info?.h ?? cachedDims?.h;
const blurResolutionY =
blurW && blurH && blurW > 0
? Math.max(1, Math.round(blurResolutionX * (blurH / blurW)))
: 32;
const [load, setLoad] = useState(false); const [load, setLoad] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [viewer, setViewer] = useState(false); const [viewer, setViewer] = useState(false);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false); const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
const [showPlaceholder, setShowPlaceholder] = useState(true);
const [srcState, loadSrc] = useAsyncCallback( const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
@@ -95,12 +107,17 @@ export const VideoContent = as<'div', VideoContentProps>(
}, [mx, url, useAuthentication, mimeType, encInfo]) }, [mx, url, useAuthentication, mimeType, encInfo])
); );
const handleLoad = () => { const handleLoad = (video?: HTMLVideoElement) => {
if (video?.videoWidth && video?.videoHeight) {
setMediaDimensions(url, video.videoWidth, video.videoHeight);
}
if (video) rememberMediaBlurHash(url, video);
setLoad(true); setLoad(true);
}; };
const handleError = () => { const handleError = () => {
setLoad(false); setLoad(false);
setError(true); setError(true);
setShowPlaceholder(true);
}; };
const handleRetry = () => { const handleRetry = () => {
@@ -112,8 +129,33 @@ export const VideoContent = as<'div', VideoContentProps>(
if (autoPlay) loadSrc(); if (autoPlay) loadSrc();
}, [autoPlay, loadSrc]); }, [autoPlay, loadSrc]);
useEffect(() => {
if (!load) {
setShowPlaceholder(true);
return undefined;
}
const timer = window.setTimeout(() => setShowPlaceholder(false), 540);
return () => window.clearTimeout(timer);
}, [load]);
return ( return (
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}> <Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
{showPlaceholder &&
(typeof blurHash === 'string' ? (
<div className={css.BlurhashPlaceholder}>
<Blurhash
hash={blurHash}
width="100%"
height="100%"
resolutionX={blurResolutionX}
resolutionY={blurResolutionY}
punch={1.2}
style={{ display: 'block' }}
/>
</div>
) : (
<div className={css.MediaSkeleton} />
))}
{renderViewer && srcState.status === AsyncStatus.Success && ( {renderViewer && srcState.status === AsyncStatus.Success && (
<Overlay open={viewer} backdrop={<OverlayBackdrop />}> <Overlay open={viewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter> <OverlayCenter>
@@ -136,15 +178,6 @@ export const VideoContent = as<'div', VideoContentProps>(
</OverlayCenter> </OverlayCenter>
</Overlay> </Overlay>
)} )}
{typeof blurHash === 'string' && !load && (
<BlurhashCanvas
style={{ width: '100%', height: '100%' }}
width={32}
height={32}
hash={blurHash}
punch={1}
/>
)}
{renderThumbnail && !load && ( {renderThumbnail && !load && (
<Box <Box
className={classNames(css.AbsoluteContainer, blurred && css.Blur)} className={classNames(css.AbsoluteContainer, blurred && css.Blur)}
@@ -169,11 +202,20 @@ export const VideoContent = as<'div', VideoContentProps>(
</Box> </Box>
)} )}
{srcState.status === AsyncStatus.Success && ( {srcState.status === AsyncStatus.Success && (
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}> <Box
className={classNames(
css.AbsoluteContainer,
css.MediaFadeIn,
load && css.MediaFadeInLoaded,
blurred && css.Blur
)}
>
{renderVideo({ {renderVideo({
title: body, title: body,
src: srcState.data, src: srcState.data,
onLoadedMetadata: handleLoad, onLoadedMetadata: (e?: React.SyntheticEvent<HTMLVideoElement>) => {
handleLoad(e?.currentTarget);
},
onError: handleError, onError: handleError,
autoPlay: true, autoPlay: true,
controls: true, controls: true,
@@ -213,7 +255,8 @@ export const VideoContent = as<'div', VideoContentProps>(
)} )}
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) && {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
!load && !load &&
!blurred && ( !blurred &&
typeof blurHash !== 'string' && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center"> <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" /> <Spinner variant="Secondary" />
</Box> </Box>

View File

@@ -1,11 +1,13 @@
import { style } from '@vanilla-extract/css'; import { keyframes, style } from '@vanilla-extract/css';
import { DefaultReset, config } from 'folds'; import { DefaultReset, color, config } from 'folds';
export const RelativeBase = style([ export const RelativeBase = style([
DefaultReset, DefaultReset,
{ {
position: 'relative', position: 'relative',
display: 'flex', display: 'flex',
width: '100%',
height: '100%',
maxWidth: '100%', maxWidth: '100%',
maxHeight: '100%', maxHeight: '100%',
borderRadius: '7px', borderRadius: '7px',
@@ -16,9 +18,13 @@ export const RelativeBase = style([
export const AbsoluteContainer = style([ export const AbsoluteContainer = style([
DefaultReset, DefaultReset,
{ {
position: 'absolute',
inset: 0,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
width: '100%',
height: '100%',
maxWidth: '100%', maxWidth: '100%',
maxHeight: '100%', maxHeight: '100%',
borderRadius: 'inherit', borderRadius: 'inherit',
@@ -34,6 +40,7 @@ export const AbsoluteFooter = style([
bottom: config.space.S100, bottom: config.space.S100,
left: config.space.S100, left: config.space.S100,
right: config.space.S100, right: config.space.S100,
zIndex: 1,
}, },
]); ]);
@@ -43,3 +50,67 @@ export const Blur = style([
filter: 'blur(44px)', filter: 'blur(44px)',
}, },
]); ]);
const shimmer = keyframes({
'0%': {
backgroundPosition: '200% 0',
},
'100%': {
backgroundPosition: '-200% 0',
},
});
/** Soft fallback when no blurhash is available. */
export const MediaSkeleton = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
borderRadius: 'inherit',
backgroundImage: `linear-gradient(
90deg,
${color.SurfaceVariant.Container} 0%,
${color.SurfaceVariant.ContainerHover} 40%,
${color.SurfaceVariant.Container} 80%
)`,
backgroundSize: '200% 100%',
animation: `${shimmer} 1.4s ease-in-out infinite`,
},
]);
const blurhashFadeIn = keyframes({
from: {
opacity: 0,
},
to: {
opacity: 1,
},
});
/** Soft blurhash layer that fills the reserved media box. */
export const BlurhashPlaceholder = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
overflow: 'hidden',
borderRadius: 'inherit',
zIndex: 0,
// Soften the decode so it reads as color blobs; slight scale hides blur edges.
filter: 'blur(14px)',
transform: 'scale(1.06)',
opacity: 0,
animation: `${blurhashFadeIn} 560ms cubic-bezier(0.22, 1, 0.36, 1) forwards`,
},
]);
/** Media starts invisible and fades in once loaded over the placeholder. */
export const MediaFadeIn = style({
zIndex: 1,
opacity: 0,
transition: 'opacity 520ms cubic-bezier(0.22, 1, 0.36, 1)',
});
export const MediaFadeInLoaded = style({
opacity: 1,
});

View File

@@ -1,7 +1,9 @@
import React from 'react'; import React, { CSSProperties, useMemo } from 'react';
import { Text, as } from 'folds'; import { Text, as } from 'folds';
import classNames from 'classnames'; import classNames from 'classnames';
import * as css from './layout.css'; import * as css from './layout.css';
import { isStationeryTheme, useTheme } from '../../../hooks/useTheme';
import { STATIONERY_NAME_INK } from '../../../utils/paperSafeInk';
export const MessageBase = as<'div', css.MessageBaseVariants>( export const MessageBase = as<'div', css.MessageBaseVariants>(
({ className, highlight, selected, collapse, autoCollapse, space, newMessage, ...props }, ref) => ( ({ className, highlight, selected, collapse, autoCollapse, space, newMessage, ...props }, ref) => (
@@ -10,6 +12,7 @@ export const MessageBase = as<'div', css.MessageBaseVariants>(
css.MessageBase({ highlight, selected, collapse, autoCollapse, space, newMessage }), css.MessageBase({ highlight, selected, collapse, autoCollapse, space, newMessage }),
className className
)} )}
data-message-collapsed={collapse ? '' : undefined}
{...props} {...props}
ref={ref} ref={ref}
/> />
@@ -17,12 +20,45 @@ export const MessageBase = as<'div', css.MessageBaseVariants>(
); );
export const AvatarBase = as<'span'>(({ className, ...props }, ref) => ( export const AvatarBase = as<'span'>(({ className, ...props }, ref) => (
<span className={classNames(css.AvatarBase, className)} {...props} ref={ref} /> <span
className={classNames(css.AvatarBase, className)}
data-message-avatar=""
{...props}
ref={ref}
/>
)); ));
export const Username = as<'span'>(({ as: AsUsername = 'span', className, ...props }, ref) => ( export const Username = as<'span'>(({ as: AsUsername = 'span', className, style, ...props }, ref) => {
<AsUsername className={classNames(css.Username, className)} {...props} ref={ref} /> const theme = useTheme();
)); const isStationery = isStationeryTheme(theme);
const userColor =
typeof (style as CSSProperties | undefined)?.color === 'string'
? ((style as CSSProperties).color as string)
: undefined;
const safeStyle = useMemo((): CSSProperties | undefined => {
if (!isStationery) return style as CSSProperties | undefined;
return {
...(style as CSSProperties | undefined),
// Name in ink; keep their color as an accent underline
color: STATIONERY_NAME_INK,
...(userColor
? { ['--username-accent' as string]: userColor, textDecorationColor: userColor }
: null),
};
}, [style, isStationery, userColor]);
return (
<AsUsername
className={classNames(css.Username, className)}
data-message-username=""
data-username-accent={isStationery && userColor ? '' : undefined}
style={safeStyle}
{...props}
ref={ref}
/>
);
});
export const UsernameBold = as<'b'>(({ as: AsUsernameBold = 'b', className, ...props }, ref) => ( export const UsernameBold = as<'b'>(({ as: AsUsernameBold = 'b', className, ...props }, ref) => (
<AsUsernameBold className={classNames(css.UsernameBold, className)} {...props} ref={ref} /> <AsUsernameBold className={classNames(css.UsernameBold, className)} {...props} ref={ref} />
@@ -32,10 +68,12 @@ export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?
({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => ( ({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => (
<Text <Text
as={asComp} as={asComp}
size="T400" size={jumboEmoji ? 'Inherit' : 'T400'}
priority={notice ? '300' : '400'} priority={notice ? '300' : '400'}
className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)} className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)}
data-allow-text-selection="true" data-allow-text-selection="true"
data-message-body=""
data-jumbo-emoji={jumboEmoji ? '' : undefined}
{...props} {...props}
ref={ref} ref={ref}
/> />

View File

@@ -1,4 +1,5 @@
import { createVar, keyframes, style, styleVariants } from '@vanilla-extract/css'; import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css';
import * as htmlCss from '../../../styles/CustomHtml.css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes'; import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds'; import { DefaultReset, color, config, toRem } from 'folds';
@@ -117,6 +118,7 @@ export const MessageBase = recipe({
collapse: { collapse: {
true: { true: {
marginTop: 0, marginTop: 0,
paddingTop: config.space.S0,
}, },
}, },
autoCollapse: { autoCollapse: {
@@ -202,6 +204,14 @@ export const Username = style({
'button&:hover, button&:focus-visible': { 'button&:hover, button&:focus-visible': {
textDecoration: 'underline', textDecoration: 'underline',
}, },
// Stationery uses a colored border-bottom accent instead
'html.stationery &, body.stationery &': {
overflow: 'visible',
overflowY: 'visible',
},
'body.stationery button&:hover, body.stationery button&:focus-visible': {
textDecoration: 'none',
},
}, },
}); });
@@ -209,6 +219,8 @@ export const UsernameBold = style({
fontWeight: 550, fontWeight: 550,
}); });
const jumboEmojiSize = toRem(70);
export const MessageTextBody = recipe({ export const MessageTextBody = recipe({
base: { base: {
wordBreak: 'break-word', wordBreak: 'break-word',
@@ -226,8 +238,14 @@ export const MessageTextBody = recipe({
}, },
jumboEmoji: { jumboEmoji: {
true: { true: {
fontSize: '1.504em', fontSize: jumboEmojiSize,
lineHeight: '1.4962em', lineHeight: 1,
overflow: 'visible',
overflowY: 'visible',
paddingBottom: config.space.S200,
cursor: 'pointer',
position: 'relative',
zIndex: 5,
}, },
}, },
emote: { emote: {
@@ -240,3 +258,31 @@ export const MessageTextBody = recipe({
}); });
export type MessageTextBodyVariants = RecipeVariants<typeof MessageTextBody>; export type MessageTextBodyVariants = RecipeVariants<typeof MessageTextBody>;
const jumboEmojiClass = MessageTextBody.classNames.variants.jumboEmoji.true;
globalStyle(`${jumboEmojiClass} .${htmlCss.EmoticonBase}`, {
height: '1em',
padding: 0,
overflow: 'visible',
verticalAlign: 'middle',
position: 'relative',
zIndex: 1,
});
globalStyle(`${jumboEmojiClass} .${htmlCss.Emoticon.classNames.base}`, {
fontSize: '1em',
height: '1em',
minWidth: '1em',
lineHeight: 1,
position: 'static',
top: 0,
overflow: 'visible',
});
globalStyle(`${jumboEmojiClass} .${htmlCss.EmoticonImg}`, {
height: '1em',
width: '1em',
maxHeight: 'none',
objectFit: 'contain',
});

View File

@@ -7,5 +7,10 @@ type NavCategoryProps = {
children: ReactNode; children: ReactNode;
}; };
export const NavCategory = as<'div', NavCategoryProps>(({ className, ...props }, ref) => ( export const NavCategory = as<'div', NavCategoryProps>(({ className, ...props }, ref) => (
<div className={classNames(css.NavCategory, className)} {...props} ref={ref} /> <div
className={classNames(css.NavCategory, className)}
data-nav-category=""
{...props}
ref={ref}
/>
)); ));

View File

@@ -13,6 +13,7 @@ export const NavItem = as<
<AsNavItem <AsNavItem
className={classNames(css.NavItem({ variant, radii }), className)} className={classNames(css.NavItem({ variant, radii }), className)}
data-highlight={highlight} data-highlight={highlight}
data-nav-item=""
{...props} {...props}
ref={ref} ref={ref}
> >

View File

@@ -6,6 +6,7 @@ import * as css from './style.css';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel'; import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
import { useShowCompactMasterView } from '../../hooks/useCompactNav'; import { useShowCompactMasterView } from '../../hooks/useCompactNav';
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
type PageRootProps = { type PageRootProps = {
nav: ReactNode; nav: ReactNode;
@@ -15,6 +16,7 @@ type PageRootProps = {
export function PageRoot({ nav, children }: PageRootProps) { export function PageRoot({ nav, children }: PageRootProps) {
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const showCompactMaster = useShowCompactMasterView(); const showCompactMaster = useShowCompactMasterView();
const showDetail = !showCompactMaster || nav == null;
return ( return (
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}> <Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
@@ -22,7 +24,7 @@ export function PageRoot({ nav, children }: PageRootProps) {
{screenSize !== ScreenSize.Mobile && ( {screenSize !== ScreenSize.Mobile && (
<Line variant="Background" size="300" direction="Vertical" /> <Line variant="Background" size="300" direction="Vertical" />
)} )}
{!showCompactMaster && children} {showDetail && children}
</Box> </Box>
); );
} }
@@ -43,6 +45,7 @@ export function PageNav({
grow={isMobile ? 'Yes' : undefined} grow={isMobile ? 'Yes' : undefined}
className={classNames(css.PageNav({ size }), className)} className={classNames(css.PageNav({ size }), className)}
shrink={isMobile ? 'Yes' : 'No'} shrink={isMobile ? 'Yes' : 'No'}
data-page-nav=""
> >
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
{children} {children}
@@ -58,6 +61,7 @@ export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
className={classNames(css.PageNavHeader({ outlined }), className)} className={classNames(css.PageNavHeader({ outlined }), className)}
variant="Background" variant="Background"
size="600" size="600"
data-folder-tab="nav"
{...props} {...props}
ref={ref} ref={ref}
/> />
@@ -73,13 +77,16 @@ export function PageNavContent({
scrollRef?: MutableRefObject<HTMLDivElement | null>; scrollRef?: MutableRefObject<HTMLDivElement | null>;
scrollProps?: React.ComponentProps<typeof Scroll>; scrollProps?: React.ComponentProps<typeof Scroll>;
}) { }) {
const theme = useTheme();
const hideScrollbar = isStationeryTheme(theme);
return ( return (
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
<Scroll <Scroll
ref={scrollRef} ref={scrollRef}
variant="Background" variant="Background"
direction="Vertical" direction="Vertical"
size="300" size={hideScrollbar ? '0' : '300'}
hideTrack hideTrack
visibility="Hover" visibility="Hover"
{...scrollProps} {...scrollProps}
@@ -95,6 +102,7 @@ export const Page = as<'div'>(({ className, ...props }, ref) => (
grow="Yes" grow="Yes"
direction="Column" direction="Column"
className={classNames(ContainerColor({ variant: 'Surface' }), className)} className={classNames(ContainerColor({ variant: 'Surface' }), className)}
data-page=""
{...props} {...props}
ref={ref} ref={ref}
/> />
@@ -106,6 +114,7 @@ export const PageHeader = as<'div', css.PageHeaderVariants>(
as="header" as="header"
size="600" size="600"
className={classNames(css.PageHeader({ balance, outlined }), className)} className={classNames(css.PageHeader({ balance, outlined }), className)}
data-folder-tab-bar=""
{...props} {...props}
ref={ref} ref={ref}
/> />

View File

@@ -1,35 +1,76 @@
import React, { ReactNode } from 'react'; import React, { CSSProperties, ReactElement, cloneElement, isValidElement } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import * as css from './styles.css'; import { config } from 'folds';
import { Presence } from '../../hooks/useUserPresence'; import { Presence } from '../../hooks/useUserPresence';
type PresenceAvatarProps = { type PresenceAvatarProps = {
/** The presence state to display */
presence?: Presence; presence?: Presence;
/** The avatar element to wrap */ children: ReactElement;
children: ReactNode;
/** Additional className */
className?: string; className?: string;
}; };
const PRESENCE_COLOR: Record<Presence, string> = {
[Presence.Online]: '#38842b',
[Presence.Unavailable]: '#959e30',
[Presence.Offline]: '#454545',
};
const rowStyle: CSSProperties = {
display: 'inline-flex',
flexDirection: 'row',
alignItems: 'stretch',
flexShrink: 0,
lineHeight: 0,
};
const faceStyle: CSSProperties = {
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
// Keep the right side rounded to match size-200 nav avatars.
borderTopRightRadius: config.radii.R400,
borderBottomRightRadius: config.radii.R400,
};
/** /**
* Wraps an avatar with a left-side presence indicator border. * Presence strip as a real sibling (not ::before).
* Shows green for online, yellow/orange for unavailable/away, grey for offline. * Folds Avatar uses overflow:hidden, which clips outside ::before bars.
*/ */
export function PresenceAvatar({ presence, children, className }: PresenceAvatarProps) { export function PresenceAvatar({
if (!presence) { presence = Presence.Offline,
return <>{children}</>; children,
} className,
}: PresenceAvatarProps) {
const stripColor = PRESENCE_COLOR[presence] ?? PRESENCE_COLOR[Presence.Offline];
const face = isValidElement(children)
? cloneElement(children, {
className: classNames((children.props as { className?: string }).className),
style: {
...((children.props as { style?: CSSProperties }).style ?? {}),
...faceStyle,
},
// Avoid the folds radii shorthand fighting the square-left corners.
radii: '0',
} as Partial<unknown>)
: children;
return ( return (
<div <div className={className} style={rowStyle} data-presence={presence}>
className={classNames(css.PresenceAvatarContainer, css.PresenceIndicator, className, { <span
[css.PresenceOnline]: presence === Presence.Online, aria-hidden
[css.PresenceUnavailable]: presence === Presence.Unavailable, style={{
[css.PresenceOffline]: presence === Presence.Offline, display: 'block',
})} width: 4,
> minWidth: 4,
{children} flex: '0 0 4px',
alignSelf: 'stretch',
// Match folds Avatar size="200" so the strip can't collapse to 0 height.
minHeight: config.size.X200,
borderRadius: '5px 0 0 5px',
backgroundColor: stripColor,
}}
/>
{face}
</div> </div>
); );
} }

View File

@@ -1,5 +1,5 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { config, color } from 'folds'; import { config } from 'folds';
export const AvatarPresence = style({ export const AvatarPresence = style({
display: 'flex', display: 'flex',
@@ -20,41 +20,3 @@ export const AvatarPresenceBadge = style({
borderRadius: config.radii.Pill, borderRadius: config.radii.Pill,
overflow: 'hidden', overflow: 'hidden',
}); });
export const PresenceAvatarContainer = style({
display: 'flex',
position: 'relative',
flexShrink: 0,
alignSelf: 'flex-start',
'::before': {
content: '""',
position: 'absolute',
left: 0,
top: '10%',
height: '80%',
width: '3px',
borderTopRightRadius: '2px',
borderBottomRightRadius: '2px',
zIndex: 1,
},
});
export const PresenceIndicator = style({});
export const PresenceOnline = style({
'::before': {
backgroundColor: color.Success.Main,
},
});
export const PresenceUnavailable = style({
'::before': {
backgroundColor: color.Warning.Main,
},
});
export const PresenceOffline = style({
'::before': {
backgroundColor: color.Secondary.Main,
},
});

View File

@@ -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>
);
}

View File

@@ -0,0 +1 @@
export * from './RemoveInaccessiblePrompt';

View File

@@ -1,13 +1,23 @@
import { JoinRule } from 'matrix-js-sdk'; import { JoinRule } from 'matrix-js-sdk';
import { AvatarFallback, AvatarImage, color } from 'folds'; import { AvatarFallback, AvatarImage, color } from 'folds';
import { Icon, Icons } from '../icons'; import { Icon, Icons } from '../icons';
import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react'; import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useEffect, useState } from 'react';
import { Blurhash } from 'react-blurhash';
import * as css from './RoomAvatar.css'; import * as css from './RoomAvatar.css';
import { joinRuleToIconSrc } from '../../utils/room'; import { joinRuleToIconSrc } from '../../utils/room';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl'; import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache'; import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
import {
getCachedAuthenticatedMediaUrl,
isAuthenticatedMediaUrl,
} from '../../utils/authenticatedMediaCache';
import {
getMediaBlurHash,
getMediaDimensions,
rememberMediaBlurHash,
} from '../../state/mediaDimensionCache';
type RoomAvatarProps = { type RoomAvatarProps = {
roomId: string; roomId: string;
@@ -17,19 +27,36 @@ type RoomAvatarProps = {
}; };
export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps) { export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps) {
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [loaded, setLoaded] = useState(() => isAvatarCached(src));
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication); const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
const blurHash = getMediaBlurHash(src ?? '');
const dims = getMediaDimensions(src ?? '');
const blobCached =
!!src &&
(!useAuthentication ||
!isAuthenticatedMediaUrl(src) ||
!!getCachedAuthenticatedMediaUrl(src));
const [loaded, setLoaded] = useState(() => isAvatarCached(src) || blobCached);
useEffect(() => {
if (
authenticatedSrc &&
(isAvatarCached(src) ||
(src && (!useAuthentication || !isAuthenticatedMediaUrl(src) || getCachedAuthenticatedMediaUrl(src))))
) {
setLoaded(true);
}
}, [authenticatedSrc, src, useAuthentication]);
const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => { const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
evt.currentTarget.setAttribute('data-image-loaded', 'true'); evt.currentTarget.setAttribute('data-image-loaded', 'true');
setLoaded(true); setLoaded(true);
cacheAvatar(src); cacheAvatar(src);
pruneAvatarCache(); pruneAvatarCache();
rememberMediaBlurHash(src ?? '', evt.currentTarget);
}; };
// No src or error - show fallback only if (!src || error) {
if (!authenticatedSrc || error) {
return ( return (
<AvatarFallback <AvatarFallback
style={{ backgroundColor: colorMXID(roomId ?? ''), color: color.Surface.Container }} style={{ backgroundColor: colorMXID(roomId ?? ''), color: color.Surface.Container }}
@@ -40,8 +67,7 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
); );
} }
// If already cached, show image directly without fallback flash if (authenticatedSrc && loaded) {
if (loaded) {
return ( return (
<AvatarImage <AvatarImage
className={css.RoomAvatar} className={css.RoomAvatar}
@@ -55,12 +81,17 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
); );
} }
// Loading state - render fallback with image overlay const blurResolutionX = 32;
const blurResolutionY =
dims?.w && dims?.h && dims.w > 0
? Math.max(1, Math.round(blurResolutionX * (dims.h / dims.w)))
: 32;
return ( return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}> <div style={{ position: 'relative', width: '100%', height: '100%' }}>
<AvatarFallback <AvatarFallback
style={{ style={{
backgroundColor: colorMXID(roomId ?? ''), backgroundColor: colorMXID(roomId ?? ''),
color: color.Surface.Container, color: color.Surface.Container,
position: 'absolute', position: 'absolute',
inset: 0, inset: 0,
@@ -69,15 +100,39 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
> >
{renderFallback()} {renderFallback()}
</AvatarFallback> </AvatarFallback>
<AvatarImage {blurHash && (
className={css.RoomAvatar} <div
style={{ position: 'relative', zIndex: 1 }} style={{
src={authenticatedSrc} position: 'absolute',
alt={alt} inset: 0,
onError={() => setError(true)} overflow: 'hidden',
onLoad={handleLoad} borderRadius: 'inherit',
draggable={false} filter: 'blur(6px)',
/> transform: 'scale(1.08)',
}}
>
<Blurhash
hash={blurHash}
width="100%"
height="100%"
resolutionX={blurResolutionX}
resolutionY={blurResolutionY}
punch={1.1}
style={{ display: 'block' }}
/>
</div>
)}
{authenticatedSrc && (
<AvatarImage
className={css.RoomAvatar}
style={{ position: 'relative', zIndex: 1, opacity: loaded ? 1 : 0 }}
src={authenticatedSrc}
alt={alt}
onError={() => setError(true)}
onLoad={handleLoad}
draggable={false}
/>
)}
</div> </div>
); );
} }

View 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`,
});

View File

@@ -1,6 +1,9 @@
import React, { ReactNode } from 'react'; import React, { ReactNode, useEffect, useRef, useState } from 'react';
import { Box, Text } from 'folds'; import { Box, Text } from 'folds';
import classNames from 'classnames';
import { BreakWord } from '../../styles/Text.css'; import { BreakWord } from '../../styles/Text.css';
import { settingAnchorId, useSettingsFocus } from './SettingsFocus';
import { SettingTileFocus } from './SettingTile.css';
type SettingTileProps = { type SettingTileProps = {
title?: ReactNode; title?: ReactNode;
@@ -8,10 +11,65 @@ type SettingTileProps = {
before?: ReactNode; before?: ReactNode;
after?: ReactNode; after?: ReactNode;
children?: ReactNode; children?: ReactNode;
/** Override auto-generated scroll anchor. */
anchorId?: string;
}; };
export function SettingTile({ title, description, before, after, children }: SettingTileProps) { export function SettingTile({
title,
description,
before,
after,
children,
anchorId: anchorIdProp,
}: SettingTileProps) {
const ref = useRef<HTMLDivElement>(null);
const { anchorId: focusAnchorId, setAnchorId } = useSettingsFocus();
const [highlighted, setHighlighted] = useState(false);
const autoAnchorId = typeof title === 'string' ? settingAnchorId(title) : undefined;
const anchorId = anchorIdProp ?? autoAnchorId;
useEffect(() => {
if (!anchorId || !focusAnchorId || anchorId !== focusAnchorId) return;
let cancelled = false;
let attempts = 0;
const run = () => {
if (cancelled) return;
const node = ref.current;
if (node) {
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
setHighlighted(true);
setAnchorId(undefined);
return;
}
if (attempts < 40) {
attempts += 1;
window.requestAnimationFrame(run);
}
};
const timer = window.setTimeout(run, 50);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [anchorId, focusAnchorId, setAnchorId]);
useEffect(() => {
if (!highlighted) return;
const timer = window.setTimeout(() => setHighlighted(false), 1400);
return () => window.clearTimeout(timer);
}, [highlighted]);
return ( return (
<Box alignItems="Center" gap="300"> <Box
ref={ref}
id={anchorId}
alignItems="Center"
gap="300"
className={classNames(highlighted && SettingTileFocus)}
>
{before && <Box shrink="No">{before}</Box>} {before && <Box shrink="No">{before}</Box>}
<Box grow="Yes" direction="Column" gap="100"> <Box grow="Yes" direction="Column" gap="100">
{title && ( {title && (

View 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'}`;
}

View File

@@ -1 +1,2 @@
export * from './SettingTile'; export * from './SettingTile';
export * from './SettingsFocus';

View File

@@ -4,5 +4,10 @@ import React from 'react';
import * as css from './Sidebar.css'; import * as css from './Sidebar.css';
export const Sidebar = as<'div'>(({ as: AsSidebar = 'div', className, ...props }, ref) => ( export const Sidebar = as<'div'>(({ as: AsSidebar = 'div', className, ...props }, ref) => (
<AsSidebar className={classNames(css.Sidebar, className)} {...props} ref={ref} /> <AsSidebar
className={classNames(css.Sidebar, className)}
data-sidebar=""
{...props}
ref={ref}
/>
)); ));

View File

@@ -8,10 +8,10 @@ type SidebarContentProps = {
export function SidebarContent({ scrollable, sticky }: SidebarContentProps) { export function SidebarContent({ scrollable, sticky }: SidebarContentProps) {
return ( return (
<> <>
<Box direction="Column" grow="Yes"> <Box direction="Column" grow="Yes" data-sidebar-scroll-region="" style={{ overflow: 'visible', minWidth: 0 }}>
{scrollable} {scrollable}
</Box> </Box>
<Box direction="Column" shrink="No"> <Box direction="Column" shrink="No" data-sidebar-sticky="" style={{ overflow: 'visible' }}>
{sticky} {sticky}
</Box> </Box>
</> </>

View File

@@ -7,6 +7,7 @@ export const SidebarItem = as<'div', css.SidebarItemVariants>(
({ as: AsSidebarAvatarBox = 'div', className, active, ...props }, ref) => ( ({ as: AsSidebarAvatarBox = 'div', className, active, ...props }, ref) => (
<AsSidebarAvatarBox <AsSidebarAvatarBox
className={classNames(css.SidebarItem({ active }), className)} className={classNames(css.SidebarItem({ active }), className)}
data-sidebar-item=""
{...props} {...props}
ref={ref} ref={ref}
/> />
@@ -54,6 +55,7 @@ export const SidebarAvatar = as<'div', css.SidebarAvatarVariants & ComponentProp
<Avatar <Avatar
className={classNames(css.SidebarAvatar({ size, outlined }), className)} className={classNames(css.SidebarAvatar({ size, outlined }), className)}
radii={radii} radii={radii}
data-sidebar-avatar=""
{...props} {...props}
ref={ref} ref={ref}
/> />
@@ -64,6 +66,7 @@ export const SidebarFolder = as<'div', css.SidebarFolderVariants>(
({ as: AsSidebarFolder = 'div', className, state, ...props }, ref) => ( ({ as: AsSidebarFolder = 'div', className, state, ...props }, ref) => (
<AsSidebarFolder <AsSidebarFolder
className={classNames(css.SidebarFolder({ state }), className)} className={classNames(css.SidebarFolder({ state }), className)}
data-sidebar-folder=""
{...props} {...props}
ref={ref} ref={ref}
/> />

View File

@@ -1,95 +1,149 @@
import { style } from '@vanilla-extract/css'; import { keyframes, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds'; import { color, config, toRem } from 'folds';
export const CheckButtonContainer = style({ const indeterminate = keyframes({
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(250%)' },
});
export const IdleSlot = style({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
height: '32px', height: '100%',
width: '32px', WebkitAppRegion: 'no-drag',
flexShrink: 0,
}); });
export const CheckButton = style({ export const GhostCheck = style({
all: 'unset', all: 'unset',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
padding: 0, width: toRem(28),
height: toRem(28),
borderRadius: config.radii.R300, borderRadius: config.radii.R300,
cursor: 'pointer', color: color.Surface.OnContainer,
color: color.Secondary.Main,
backgroundColor: 'transparent',
transition: 'opacity 0.2s, background-color 0.15s',
height: '32px',
width: '32px',
opacity: 0, opacity: 0,
WebkitAppRegion: 'no-drag', cursor: 'pointer',
flexShrink: 0, transition: 'opacity 0.15s ease, background-color 0.15s ease',
selectors: { selectors: {
'&[data-visible="true"]': { '&[data-visible="true"]': {
opacity: 0.7, opacity: 0.65,
},
'&:hover': {
opacity: 1,
backgroundColor: color.Surface.ContainerHover,
}, },
}, },
':hover': {
backgroundColor: color.Surface.ContainerHover,
opacity: 1,
},
':active': {
backgroundColor: color.Surface.ContainerActive,
},
':focus-visible': {
outline: `2px solid ${color.Secondary.Main}`,
outlineOffset: '2px',
opacity: 1,
},
}); });
export const UpdateButton = style({ export const Bar = style({
all: 'unset',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', gap: config.space.S200,
padding: 0, height: toRem(22),
minWidth: toRem(120),
maxWidth: toRem(200),
padding: `0 ${config.space.S200}`,
borderRadius: config.radii.R300, borderRadius: config.radii.R300,
cursor: 'pointer', backgroundColor: color.SurfaceVariant.Container,
color: color.Success.Main, color: color.SurfaceVariant.OnContainer,
backgroundColor: 'transparent',
transition: 'background-color 0.15s',
height: '32px',
width: '32px',
WebkitAppRegion: 'no-drag', WebkitAppRegion: 'no-drag',
flexShrink: 0, flexShrink: 0,
boxSizing: 'border-box',
});
':hover': { export const BarTrack = style({
backgroundColor: color.Surface.ContainerHover, position: 'relative',
}, flex: 1,
height: toRem(4),
borderRadius: toRem(2),
overflow: 'hidden',
backgroundColor: color.Surface.ContainerLine,
minWidth: toRem(64),
});
':active': { export const BarFill = style({
backgroundColor: color.Surface.ContainerActive, position: 'absolute',
}, inset: '0 auto 0 0',
height: '100%',
borderRadius: 'inherit',
backgroundColor: color.Success.Main,
transition: 'width 120ms linear',
});
':focus-visible': { export const BarIndeterminate = style({
outline: `2px solid ${color.Success.Main}`, position: 'absolute',
outlineOffset: '2px', top: 0,
bottom: 0,
left: 0,
width: '40%',
borderRadius: 'inherit',
backgroundColor: color.Secondary.Main,
animation: `${indeterminate} 1s ease-in-out infinite`,
});
export const BarLabel = style({
flexShrink: 0,
fontVariantNumeric: 'tabular-nums',
opacity: 0.9,
minWidth: toRem(28),
textAlign: 'right',
});
export const Chip = style({
height: toRem(22),
maxWidth: toRem(260),
padding: `0 ${config.space.S100} 0 ${config.space.S200}`,
borderRadius: config.radii.R300,
backgroundColor: color.Success.Container,
color: color.Success.OnContainer,
WebkitAppRegion: 'no-drag',
flexShrink: 0,
boxSizing: 'border-box',
});
export const ChipText = style({
maxWidth: toRem(140),
});
export const ChipAction = style({
all: 'unset',
display: 'inline-flex',
alignItems: 'center',
height: toRem(18),
padding: `0 ${config.space.S200}`,
borderRadius: config.radii.R300,
backgroundColor: color.Success.Main,
color: color.Success.OnMain,
fontSize: toRem(11),
fontWeight: 600,
cursor: 'pointer',
whiteSpace: 'nowrap',
selectors: {
'&:hover': {
filter: 'brightness(1.05)',
},
}, },
}); });
export const UpdateMenu = style({ export const ChipDismiss = style({
minWidth: toRem(280), all: 'unset',
maxWidth: toRem(320), display: 'inline-flex',
backgroundColor: color.Surface.Container, alignItems: 'center',
borderRadius: config.radii.R400, justifyContent: 'center',
boxShadow: config.shadow.E400, width: toRem(18),
border: `1px solid ${color.Surface.ContainerLine}`, height: toRem(18),
}); borderRadius: config.radii.R300,
cursor: 'pointer',
export const ProgressText = style({ opacity: 0.7,
color: color.Success.Main, fontSize: toRem(14),
fontWeight: 500, lineHeight: 1,
minWidth: toRem(35), selectors: {
textAlign: 'center', '&:hover': {
opacity: 1,
backgroundColor: 'rgba(0,0,0,0.08)',
},
},
}); });

View File

@@ -1,138 +1,136 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { Box, Spinner, Text, Menu, PopOut, Button, config } from 'folds'; import { Box, Text } from 'folds';
import * as css from './UpdateNotification.css'; import * as css from './UpdateNotification.css';
interface UpdateInfo { interface UpdateInfo {
version: string; version: string;
releaseNotes?: string; releaseNotes?: string;
releaseDate?: string; releaseDate?: string;
mock?: boolean;
} }
interface DownloadProgress { interface DownloadProgress {
percent: number; percent: number;
transferred: number; transferred: number;
total: number; total: number;
mock?: boolean;
} }
type UpdaterPhase = 'idle' | 'checking' | 'available' | 'downloading' | 'ready';
export function UpdateNotification() { export function UpdateNotification() {
const [updateAvailable, setUpdateAvailable] = useState(false); const [phase, setPhase] = useState<UpdaterPhase>('idle');
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null); const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [downloading, setDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0); const [downloadProgress, setDownloadProgress] = useState(0);
const [updateReady, setUpdateReady] = useState(false); const [isMock, setIsMock] = useState(false);
const [checking, setChecking] = useState(false); const [hovered, setHovered] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number; width: number; height: number } | undefined>(undefined);
useEffect(() => { useEffect(() => {
// Check if we're in Electron environment const electron = (window as any).electron;
if (typeof window === 'undefined' || !(window as any).electron?.updater) { if (!electron?.updater) return undefined;
return;
const { updater } = electron;
updater.isMock?.().then((result: { success?: boolean; data?: { mock?: boolean } }) => {
if (result?.data?.mock) setIsMock(true);
}).catch(() => {});
updater.onUpdateAvailable((info: UpdateInfo) => {
setUpdateInfo(info);
if (info.mock) setIsMock(true);
setPhase('available');
setDownloadProgress(0);
});
updater.onUpdateDownloadProgress((progress: DownloadProgress) => {
setPhase('downloading');
setDownloadProgress(Math.min(100, Math.round(progress.percent)));
});
updater.onUpdateDownloaded((info: UpdateInfo) => {
setUpdateInfo(info);
setPhase('ready');
setDownloadProgress(100);
});
const onNotAvailable = () => {
setPhase('idle');
setUpdateInfo(null);
setDownloadProgress(0);
};
// Optional channel — ignore if preload doesn't expose a dedicated listener
if (electron.updater.onUpdateNotAvailable) {
electron.updater.onUpdateNotAvailable(onNotAvailable);
} }
const { updater } = (window as any).electron; return undefined;
// Listen for update available
updater.onUpdateAvailable((info: UpdateInfo) => {
console.log('Update available:', info.version);
setUpdateAvailable(true);
setUpdateInfo(info);
setDownloading(false);
setUpdateReady(false);
setChecking(false);
});
// Listen for download progress
updater.onUpdateDownloadProgress((progress: DownloadProgress) => {
setDownloadProgress(Math.round(progress.percent));
});
// Listen for update downloaded
updater.onUpdateDownloaded((info: UpdateInfo) => {
console.log('Update downloaded:', info.version);
setDownloading(false);
setUpdateReady(true);
});
// Cleanup - IPC listeners don't need manual cleanup in this case
}, []); }, []);
const handleCheckForUpdates = async () => { const handleCheck = useCallback(async () => {
setChecking(true); setPhase('checking');
try { try {
const result = await (window as any).electron.updater.checkForUpdates(); const result = await (window as any).electron.updater.checkForUpdates();
console.log('Update check result:', result); if (!result?.success) {
setPhase('idle');
// Handle error response (including dev mode error)
if (!result.success) {
console.warn('Update check failed:', result.error);
setChecking(false);
return;
} }
// available event will advance phase; if nothing comes, fall back
// If no update found, show feedback briefly window.setTimeout(() => {
setTimeout(() => { setPhase((current) => (current === 'checking' ? 'idle' : current));
if (!updateAvailable) { }, 4000);
setChecking(false); } catch {
} setPhase('idle');
}, 2000);
} catch (error) {
console.error('Failed to check for updates:', error);
setChecking(false);
} }
}; }, []);
const handleDownload = async () => { const handleDownload = useCallback(async () => {
setDownloading(true); setPhase('downloading');
setDownloadProgress(0); setDownloadProgress(0);
try { try {
await (window as any).electron.updater.downloadUpdate(); await (window as any).electron.updater.downloadUpdate();
} catch (error) { } catch {
console.error('Failed to download update:', error); setPhase('available');
setDownloading(false);
} }
setMenuAnchor(undefined); }, []);
};
const handleInstall = async () => { const handleInstall = useCallback(async () => {
try { try {
await (window as any).electron.updater.installUpdate(); await (window as any).electron.updater.installUpdate();
} catch (error) { if (isMock) {
console.error('Failed to install update:', error); setPhase('idle');
setUpdateInfo(null);
setDownloadProgress(0);
}
} catch {
// keep ready state
} }
}; }, [isMock]);
const handleMenuToggle = (event: React.MouseEvent<HTMLButtonElement>) => { const handleDismiss = useCallback(() => {
if (menuAnchor) { setPhase('idle');
setMenuAnchor(undefined); setUpdateInfo(null);
} else { setDownloadProgress(0);
const rect = event.currentTarget.getBoundingClientRect(); }, []);
setMenuAnchor({ x: rect.x, y: rect.y, width: rect.width, height: rect.height });
}
};
// Don't render anything if not in Electron
if (typeof window === 'undefined' || !(window as any).electron?.updater) { if (typeof window === 'undefined' || !(window as any).electron?.updater) {
return null; return null;
} }
// Show check button if no update status if (phase === 'idle') {
if (!updateAvailable && !updateReady && !checking) {
return ( return (
<div <div
className={css.CheckButtonContainer} className={css.IdleSlot}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setHovered(false)}
> >
<button <button
className={css.CheckButton}
data-visible={isHovered}
onClick={handleCheckForUpdates}
aria-label="Check for updates"
title="Check for updates"
type="button" type="button"
className={css.GhostCheck}
data-visible={hovered || isMock ? 'true' : undefined}
onClick={handleCheck}
title={isMock ? 'Check for updates (mock)' : 'Check for updates'}
aria-label="Check for updates"
> >
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"> <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
<path <path
d="M8 2V10M8 10L5 7M8 10L11 7" d="M8 2V10M8 10L5 7M8 10L11 7"
stroke="currentColor" stroke="currentColor"
@@ -140,109 +138,75 @@ export function UpdateNotification() {
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
/> />
<path <path d="M3 14H13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
d="M3 14H13"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg> </svg>
</button> </button>
</div> </div>
); );
} }
// Show checking state if (phase === 'checking') {
if (checking) {
return ( return (
<button className={css.UpdateButton} disabled type="button"> <div className={css.Bar} title="Checking for updates…">
<Spinner variant="Secondary" size="50" /> <div className={css.BarTrack}>
</button> <div className={css.BarIndeterminate} />
</div>
<Text className={css.BarLabel} size="L400">
Checking
</Text>
</div>
); );
} }
// Show update available/ready state if (phase === 'downloading') {
if (!updateAvailable && !updateReady) { return (
return null; <div
className={css.Bar}
title={`Downloading update${updateInfo ? ` ${updateInfo.version}` : ''}${downloadProgress}%`}
>
<div className={css.BarTrack}>
<div className={css.BarFill} style={{ width: `${downloadProgress}%` }} />
</div>
<Text className={css.BarLabel} size="L400">
{downloadProgress}%
</Text>
</div>
);
} }
return ( if (phase === 'ready') {
<> return (
<button <Box className={css.Chip} alignItems="Center" gap="100">
className={css.UpdateButton} <Text className={css.ChipText} size="L400" truncate>
onClick={handleMenuToggle} {isMock ? 'Mock ready' : 'Update ready'}
aria-label={updateReady ? 'Update ready' : 'Update available'} {updateInfo ? ` · ${updateInfo.version}` : ''}
title={updateReady ? 'Update downloaded - click to install' : 'New version available'} </Text>
type="button" <button type="button" className={css.ChipAction} onClick={handleInstall}>
> Restart
{downloading ? ( </button>
<Spinner variant="Secondary" size="50" /> </Box>
) : ( );
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"> }
<path
d="M8 2V10M8 10L5 7M8 10L11 7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 14H13"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
)}
</button>
<PopOut // available
anchor={menuAnchor} return (
position="Bottom" <Box className={css.Chip} alignItems="Center" gap="100">
align="End" <Text className={css.ChipText} size="L400" truncate>
offset={8} {isMock ? 'Mock update' : 'Update'}
content={ {updateInfo ? ` ${updateInfo.version}` : ''}
<Menu className={css.UpdateMenu}> </Text>
<Box direction="Column" gap="200" style={{ padding: config.space.S300 }}> <button type="button" className={css.ChipAction} onClick={handleDownload}>
<Box direction="Column" gap="100"> Download
<Text size="H5" priority="400"> </button>
{updateReady ? 'Update Ready' : 'Update Available'} <button
</Text> type="button"
{updateInfo && ( className={css.ChipDismiss}
<Text size="T200" priority="300"> onClick={handleDismiss}
Version {updateInfo.version} aria-label="Dismiss update"
</Text> title="Dismiss"
)}
</Box>
{updateReady ? (
<Button
variant="Primary"
size="400"
onClick={handleInstall}
fill="Solid"
>
<Text size="B400">Install and Restart</Text>
</Button>
) : (
<Button
variant="Primary"
size="400"
onClick={handleDownload}
disabled={downloading}
fill="Solid"
>
<Text size="B400">
{downloading ? `Downloading ${downloadProgress}%` : 'Download Update'}
</Text>
</Button>
)}
</Box>
</Menu>
}
> >
{null} ×
</PopOut> </button>
</> </Box>
); );
} }

View 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>
);
}

View File

@@ -0,0 +1,303 @@
import { globalStyle, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
const MOBILE_BREAKPOINT = '480px';
export const PortalLayer = style({
position: 'fixed',
inset: 0,
zIndex: config.zIndex.Max,
pointerEvents: 'auto',
});
/** Dialog + overlay padding must never exceed the viewport height. */
const DIALOG_MAX_HEIGHT =
'calc(85vh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))';
export const OverlayFrame = style({
boxSizing: 'border-box',
maxHeight: '100vh',
maxWidth: '100vw',
padding:
'env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px)',
});
export const DialogShell = style({
position: 'relative',
display: 'flex',
flexDirection: 'column',
width: '100%',
maxWidth: `min(${toRem(560)}, calc(100vw - ${config.space.S800}))`,
maxHeight: DIALOG_MAX_HEIGHT,
overflow: 'hidden',
backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer,
'@media': {
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
maxWidth: `calc(100vw - ${config.space.S400})`,
},
},
});
export const Hero = style({
display: 'flex',
flexDirection: 'column',
gap: config.space.S200,
padding: config.space.S500,
paddingBottom: config.space.S400,
backgroundColor: color.Primary.Container,
color: color.Primary.OnContainer,
borderBottom: `${config.borderWidth.B300} solid ${color.Primary.ContainerLine}`,
'@media': {
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
padding: config.space.S400,
paddingBottom: config.space.S300,
},
},
});
export const HeroRow = style({
display: 'flex',
alignItems: 'center',
gap: config.space.S300,
minWidth: 0,
});
export const HeroLogo = style({
width: toRem(48),
height: toRem(48),
flexShrink: 0,
'@media': {
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
width: toRem(40),
height: toRem(40),
},
},
});
export const HeroTitleWrap = style({
display: 'flex',
flexDirection: 'column',
gap: config.space.S100,
minWidth: 0,
});
export const BodyScroll = style({
flex: '1 1 auto',
minHeight: 0,
backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer,
});
export const BodyContent = style({
display: 'flex',
flexDirection: 'column',
padding: config.space.S500,
paddingTop: config.space.S400,
'@media': {
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
padding: config.space.S400,
paddingTop: config.space.S300,
},
},
});
export const BodyLoading = style({
padding: config.space.S800,
});
export const Markdown = style({
width: '100%',
color: color.Surface.OnContainer,
fontSize: toRem(14),
lineHeight: 1.55,
});
globalStyle(`${Markdown} h1, ${Markdown} h2, ${Markdown} h3, ${Markdown} h4`, {
margin: 0,
marginTop: config.space.S400,
marginBottom: config.space.S200,
lineHeight: 1.3,
color: color.Surface.OnContainer,
});
globalStyle(`${Markdown} h1:first-child, ${Markdown} h2:first-child, ${Markdown} h3:first-child, ${Markdown} h4:first-child`, {
marginTop: 0,
});
globalStyle(`${Markdown} h2`, { fontSize: toRem(18) });
globalStyle(`${Markdown} h3`, { fontSize: toRem(16) });
globalStyle(`${Markdown} p`, {
margin: 0,
marginBottom: config.space.S300,
});
globalStyle(`${Markdown} p:last-child`, { marginBottom: 0 });
globalStyle(`${Markdown} ul, ${Markdown} ol`, {
margin: 0,
marginBottom: config.space.S300,
paddingLeft: config.space.S500,
});
globalStyle(`${Markdown} li`, { marginBottom: config.space.S100 });
globalStyle(`${Markdown} li:last-child`, { marginBottom: 0 });
globalStyle(`${Markdown} a`, {
color: color.Primary.Main,
textDecoration: 'underline',
textUnderlineOffset: toRem(2),
});
globalStyle(`${Markdown} a:hover`, { color: color.Primary.MainHover });
globalStyle(`${Markdown} img`, {
display: 'block',
maxWidth: '100%',
height: 'auto',
marginTop: config.space.S200,
marginBottom: config.space.S300,
borderRadius: config.radii.R300,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
});
globalStyle(`${Markdown} code`, {
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: toRem(13),
padding: `0 ${config.space.S100}`,
borderRadius: config.radii.R200,
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
});
globalStyle(`${Markdown} pre`, {
margin: 0,
marginBottom: config.space.S300,
padding: config.space.S300,
borderRadius: config.radii.R300,
backgroundColor: color.SurfaceVariant.Container,
overflow: 'auto',
});
globalStyle(`${Markdown} pre code`, {
padding: 0,
backgroundColor: 'transparent',
});
globalStyle(`${Markdown} blockquote`, {
margin: 0,
marginBottom: config.space.S300,
paddingLeft: config.space.S300,
borderLeft: `${toRem(3)} solid ${color.Primary.Main}`,
color: color.Surface.OnContainer,
opacity: 0.9,
});
globalStyle(`${Markdown} hr`, {
border: 'none',
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
margin: `${config.space.S400} 0`,
});
globalStyle(`${Markdown} table`, {
width: '100%',
borderCollapse: 'collapse',
marginBottom: config.space.S300,
fontSize: toRem(13),
});
globalStyle(`${Markdown} th, ${Markdown} td`, {
padding: config.space.S200,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
textAlign: 'left',
});
globalStyle(`${Markdown} th`, {
backgroundColor: color.SurfaceVariant.Container,
});
export const OlderSection = style({
width: '100%',
marginTop: config.space.S500,
});
export const OlderToggle = style({
width: '100%',
});
export const OlderList = style({
listStyle: 'none',
margin: 0,
padding: 0,
display: 'flex',
flexDirection: 'column',
gap: config.space.S100,
width: '100%',
});
export const OlderListItem = style({
width: '100%',
});
export const OlderItem = style({
all: 'unset',
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: config.space.S100,
width: '100%',
minHeight: toRem(48),
padding: config.space.S300,
borderRadius: config.radii.R300,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
cursor: 'pointer',
textAlign: 'left',
selectors: {
'&:hover': {
backgroundColor: color.SurfaceVariant.ContainerHover,
},
'&[data-active="true"]': {
borderColor: color.Primary.Main,
backgroundColor: color.Primary.Container,
color: color.Primary.OnContainer,
},
},
});
export const Footer = style({
display: 'flex',
flexDirection: 'column',
gap: config.space.S200,
padding: config.space.S400,
paddingTop: config.space.S300,
paddingBottom: `calc(${config.space.S400} + env(safe-area-inset-bottom, 0px))`,
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
backgroundColor: color.Surface.Container,
'@media': {
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
padding: config.space.S300,
paddingTop: config.space.S300,
paddingBottom: `calc(${config.space.S300} + env(safe-area-inset-bottom, 0px))`,
},
},
});
export const FooterButton = style({
width: '100%',
});
export const CloseButton = style({
position: 'absolute',
top: config.space.S300,
right: config.space.S300,
'@media': {
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
top: config.space.S200,
right: config.space.S200,
},
},
});

View File

@@ -0,0 +1,191 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
Dialog,
IconButton,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Spinner,
Text,
usePopOutContainer,
} from 'folds';
import { Icon, Icons } from '../icons';
import { stopPropagation } from '../../utils/keyboard';
import type { ParsedUpdateDoc, UpdateManifest } from '../../data/updateNotes';
import PaarrotSVG from '../../../../public/res/svg/paarrot.svg';
import { UpdateMarkdownViewer } from './UpdateMarkdownViewer';
import * as css from './UpdatesDialog.css';
type UpdatesDialogProps = {
manifest: UpdateManifest | null;
activeDoc: ParsedUpdateDoc | null;
loading: boolean;
onSelectFile: (file: string) => void;
onClose: () => void;
};
export function UpdatesDialog({
manifest,
activeDoc,
loading,
onSelectFile,
onClose,
}: UpdatesDialogProps) {
const [showOlderList, setShowOlderList] = useState(false);
const popOutContainer = usePopOutContainer();
const displayVersion = activeDoc?.version;
const displayTitle = activeDoc?.title ?? (displayVersion ? `Version ${displayVersion}` : 'Release notes');
const isCurrent = manifest && activeDoc?.file === manifest.current;
const olderEntries = manifest?.older ?? [];
const handleSelectOlder = (file: string) => {
onSelectFile(file);
setShowOlderList(false);
};
const portalTarget =
popOutContainer ??
document.getElementById('portalContainer') ??
document.body;
return createPortal(
<div className={css.PortalLayer}>
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter className={css.OverlayFrame}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: false,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface" className={css.DialogShell}>
<Box className={css.Hero}>
<Box className={css.HeroRow}>
<img className={css.HeroLogo} src={PaarrotSVG} alt="" draggable={false} />
<Box className={css.HeroTitleWrap}>
<Text size="L400" priority="300">What&apos;s new</Text>
<Text size="H4" truncate>
{displayVersion ? `Paarrot ${displayVersion}` : 'Paarrot updates'}
</Text>
</Box>
</Box>
<Text size="H6">{displayTitle}</Text>
{activeDoc?.date && (
<Text size="T200" priority="300">
{activeDoc.date}
</Text>
)}
{!loading && !isCurrent && manifest?.current && (
<Button
variant="Secondary"
fill="Soft"
radii="300"
onClick={() => onSelectFile(manifest.current)}
>
<Text size="B300">Back to current update</Text>
</Button>
)}
</Box>
<Scroll className={css.BodyScroll} visibility="Hover">
<Box className={css.BodyContent} direction="Column">
{loading && (
<Box justifyContent="Center" alignItems="Center" className={css.BodyLoading}>
<Spinner size="400" variant="Secondary" />
</Box>
)}
{!loading && activeDoc?.markdown && (
<UpdateMarkdownViewer markdown={activeDoc.markdown} />
)}
{!loading && !activeDoc?.markdown && (
<Text size="T300" priority="400">
No update notes are available right now.
</Text>
)}
{!loading && olderEntries.length > 0 && (
<Box className={css.OlderSection} direction="Column" gap="200">
<Button
className={css.OlderToggle}
variant="Secondary"
fill="Soft"
radii="300"
aria-expanded={showOlderList}
onClick={() => setShowOlderList((open) => !open)}
>
<Text size="B300">
{showOlderList
? 'Hide older versions'
: `Older versions (${olderEntries.length})`}
</Text>
</Button>
{showOlderList && (
<ul className={css.OlderList}>
{olderEntries.map((entry) => {
const isActive = activeDoc?.file === entry.file;
const label = entry.version ?? entry.file.replace(/\.md$/i, '');
return (
<li key={entry.file} className={css.OlderListItem}>
<button
type="button"
className={css.OlderItem}
data-active={isActive ? 'true' : 'false'}
onClick={() => handleSelectOlder(entry.file)}
>
<Text size="B300">{label}</Text>
{entry.title && (
<Text size="T200" priority="400">
{entry.title}
</Text>
)}
{entry.date && (
<Text size="T200" priority="300">
{entry.date}
</Text>
)}
</button>
</li>
);
})}
</ul>
)}
</Box>
)}
</Box>
</Scroll>
<Box className={css.Footer}>
<Button
className={css.FooterButton}
variant="Primary"
fill="Solid"
radii="300"
onClick={onClose}
>
<Text size="B400">Got it</Text>
</Button>
</Box>
<Box className={css.CloseButton}>
<IconButton variant="Surface" onClick={onClose} aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
</div>,
portalTarget
);
}

View File

@@ -0,0 +1,153 @@
import { useCallback, useEffect, useState } from 'react';
import { useAtom, useSetAtom } from 'jotai';
import {
loadCurrentUpdateDocument,
loadUpdateDocument,
loadUpdateManifest,
type ParsedUpdateDoc,
type UpdateManifest,
} from '../../data/updateNotes';
import {
clearPendingReleaseNotes,
getLastSeenAppVersion,
releaseNotesDialogAtom,
setLastSeenAppVersion,
storePendingReleaseNotes,
} from '../../state/releaseNotes';
import { getAppVersion } from '../../utils/appVersion';
import { UpdatesDialog } from './UpdatesDialog';
type UpdaterInfo = {
version: string;
releaseNotes?: unknown;
};
/** Open the What's New dialog from Settings → About (or anywhere else). */
export function useOpenReleaseNotesDialog(): () => void {
const setDialogState = useSetAtom(releaseNotesDialogAtom);
return useCallback(() => {
// Defer so the opening tap does not trip FocusTrap click-outside on Android.
window.requestAnimationFrame(() => {
setDialogState({ open: true, manual: true });
});
}, [setDialogState]);
}
export function UpdatesDialogHost() {
const [dialogState, setDialogState] = useAtom(releaseNotesDialogAtom);
const [manifest, setManifest] = useState<UpdateManifest | null>(null);
const [activeDoc, setActiveDoc] = useState<ParsedUpdateDoc | null>(null);
const [loading, setLoading] = useState(false);
const closeDialog = useCallback(() => {
setDialogState({ open: false, manual: false });
setManifest(null);
setActiveDoc(null);
setLoading(false);
getAppVersion().then((version) => {
if (version !== 'unknown') {
setLastSeenAppVersion(version);
}
});
}, [setDialogState]);
const selectFile = useCallback(async (file: string) => {
setLoading(true);
const doc = await loadUpdateDocument(file);
setActiveDoc(doc);
setLoading(false);
}, []);
// Persist updater metadata when a download finishes (version tracking after restart).
useEffect(() => {
const electron = window.electron;
if (!electron?.updater?.onUpdateDownloaded) return undefined;
const onDownloaded = (info: UpdaterInfo) => {
if (!info?.version) return;
storePendingReleaseNotes(info.version, info.releaseNotes);
};
electron.updater.onUpdateDownloaded(onDownloaded);
return undefined;
}, []);
// Show What's New after the app version changes (post-update restart).
useEffect(() => {
let cancelled = false;
const checkVersion = async () => {
const currentVersion = await getAppVersion();
if (cancelled || currentVersion === 'unknown') return;
const lastSeen = getLastSeenAppVersion();
const shouldAutoShow = lastSeen !== currentVersion;
if (shouldAutoShow) {
const loadedManifest = await loadUpdateManifest();
if (cancelled || !loadedManifest) return;
const doc = await loadUpdateDocument(loadedManifest.current);
if (cancelled || !doc) return;
setManifest(loadedManifest);
setActiveDoc(doc);
setDialogState({ open: true, manual: false });
clearPendingReleaseNotes();
}
};
const timer = window.setTimeout(checkVersion, 1500);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [setDialogState]);
// Load markdown when the dialog opens (manual open from About, etc.).
useEffect(() => {
if (!dialogState.open) return undefined;
let cancelled = false;
const load = async () => {
setLoading(true);
const loadedManifest = await loadUpdateManifest();
if (cancelled) return;
if (!loadedManifest) {
setManifest(null);
setActiveDoc(null);
setLoading(false);
return;
}
const doc = await loadUpdateDocument(loadedManifest.current);
if (cancelled) return;
setManifest(loadedManifest);
setActiveDoc(doc);
setLoading(false);
};
load();
return () => {
cancelled = true;
};
}, [dialogState.open]);
if (!dialogState.open) {
return null;
}
return (
<UpdatesDialog
manifest={manifest}
activeDoc={activeDoc}
loading={loading}
onSelectFile={selectFile}
onClose={closeDialog}
/>
);
}

View File

@@ -0,0 +1,3 @@
export * from './UpdatesDialog';
export * from './UpdatesDialogHost';
export * from './UpdateMarkdownViewer';

View File

@@ -1,18 +1,21 @@
import { AvatarFallback, AvatarImage, color } from 'folds'; import { AvatarFallback, AvatarImage, color } from 'folds';
import React, { ReactEventHandler, ReactNode, useEffect, useRef, useState } from 'react'; import React, { ReactEventHandler, ReactNode, useEffect, useRef, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Blurhash } from 'react-blurhash';
import * as css from './UserAvatar.css'; import * as css from './UserAvatar.css';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl'; import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache'; import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
import {
// Check if image is already in browser cache getCachedAuthenticatedMediaUrl,
function isImageInBrowserCache(url: string): boolean { isAuthenticatedMediaUrl,
const img = new Image(); } from '../../utils/authenticatedMediaCache';
img.src = url; import {
return img.complete && img.naturalWidth > 0; getMediaBlurHash,
} getMediaDimensions,
rememberMediaBlurHash,
} from '../../state/mediaDimensionCache';
type UserAvatarProps = { type UserAvatarProps = {
className?: string; className?: string;
@@ -25,20 +28,28 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
const [error, setError] = useState(false); const [error, setError] = useState(false);
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication); const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
const blurHash = getMediaBlurHash(src ?? '');
// Check both our cache and browser's native cache const dims = getMediaDimensions(src ?? '');
const [loaded, setLoaded] = useState(() => { const blobCached =
if (isAvatarCached(src)) return true; !!src &&
if (authenticatedSrc && isImageInBrowserCache(authenticatedSrc)) { (!useAuthentication ||
cacheAvatar(src); !isAuthenticatedMediaUrl(src) ||
return true; !!getCachedAuthenticatedMediaUrl(src));
const [loaded, setLoaded] = useState(() => isAvatarCached(src) || blobCached);
useEffect(() => {
if (
authenticatedSrc &&
(isAvatarCached(src) ||
(src && (!useAuthentication || !isAuthenticatedMediaUrl(src) || getCachedAuthenticatedMediaUrl(src))))
) {
setLoaded(true);
} }
return false; }, [authenticatedSrc, src, useAuthentication]);
});
const imgRef = useRef<HTMLImageElement>(null); const imgRef = useRef<HTMLImageElement>(null);
// Also check on mount if image is already complete (browser cached)
useEffect(() => { useEffect(() => {
if (!loaded && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) { if (!loaded && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
setLoaded(true); setLoaded(true);
@@ -51,10 +62,10 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
setLoaded(true); setLoaded(true);
cacheAvatar(src); cacheAvatar(src);
pruneAvatarCache(); pruneAvatarCache();
rememberMediaBlurHash(src ?? '', evt.currentTarget);
}; };
// No src or error - show fallback only if (!src || error) {
if (!authenticatedSrc || error) {
return ( return (
<AvatarFallback <AvatarFallback
style={{ backgroundColor: colorMXID(userId), color: color.Surface.Container }} style={{ backgroundColor: colorMXID(userId), color: color.Surface.Container }}
@@ -65,8 +76,7 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
); );
} }
// If already cached, show image directly without fallback flash if (authenticatedSrc && loaded) {
if (loaded) {
return ( return (
<AvatarImage <AvatarImage
className={classNames(css.UserAvatar, className)} className={classNames(css.UserAvatar, className)}
@@ -80,13 +90,17 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
); );
} }
// Loading state - render image with hidden fallback behind it const blurResolutionX = 32;
// Image loads invisibly, then we show it once complete const blurResolutionY =
dims?.w && dims?.h && dims.w > 0
? Math.max(1, Math.round(blurResolutionX * (dims.h / dims.w)))
: 32;
return ( return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}> <div style={{ position: 'relative', width: '100%', height: '100%' }}>
<AvatarFallback <AvatarFallback
style={{ style={{
backgroundColor: colorMXID(userId), backgroundColor: colorMXID(userId),
color: color.Surface.Container, color: color.Surface.Container,
position: 'absolute', position: 'absolute',
inset: 0, inset: 0,
@@ -95,16 +109,40 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
> >
{renderFallback()} {renderFallback()}
</AvatarFallback> </AvatarFallback>
<AvatarImage {blurHash && (
ref={imgRef} <div
className={classNames(css.UserAvatar, className)} style={{
style={{ position: 'relative', zIndex: 1 }} position: 'absolute',
src={authenticatedSrc} inset: 0,
alt={alt} overflow: 'hidden',
onError={() => setError(true)} borderRadius: 'inherit',
onLoad={handleLoad} filter: 'blur(6px)',
draggable={false} transform: 'scale(1.08)',
/> }}
>
<Blurhash
hash={blurHash}
width="100%"
height="100%"
resolutionX={blurResolutionX}
resolutionY={blurResolutionY}
punch={1.1}
style={{ display: 'block' }}
/>
</div>
)}
{authenticatedSrc && (
<AvatarImage
ref={imgRef}
className={classNames(css.UserAvatar, className)}
style={{ position: 'relative', zIndex: 1, opacity: loaded ? 1 : 0 }}
src={authenticatedSrc}
alt={alt}
onError={() => setError(true)}
onLoad={handleLoad}
draggable={false}
/>
)}
</div> </div>
); );
} }

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Avatar, Box, Modal, Overlay, OverlayBackdrop, OverlayCenter, Text, toRem } from 'folds'; import { Avatar, Box, Overlay, Text, toRem } from 'folds';
import { Icon, Icons } from '../icons'; import { Icon, Icons } from '../icons';
import classNames from 'classnames'; import classNames from 'classnames';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
@@ -14,7 +14,6 @@ import { ImageViewer } from '../image-viewer';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { useOtherUserColor } from '../../hooks/useUserColor'; import { useOtherUserColor } from '../../hooks/useUserColor';
import { useOtherUserBanner } from '../../hooks/useUserBanner'; import { useOtherUserBanner } from '../../hooks/useUserBanner';
import { useOtherUserProfileStyle } from '../../hooks/useUserProfileStyle';
type UserHeroProps = { type UserHeroProps = {
userId: string; userId: string;
@@ -24,15 +23,7 @@ type UserHeroProps = {
}; };
export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroProps) { export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroProps) {
const [viewAvatar, setViewAvatar] = useState<string>(); const [viewAvatar, setViewAvatar] = useState<string>();
const bannerUrl = useOtherUserBanner(userId, avatarMxc); // Now returns blob URL directly const bannerUrl = useOtherUserBanner(userId);
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
console.log('[UserHero]', {
userId,
avatarMxc,
bannerUrl,
profileStyle,
});
return ( return (
<Box <Box
@@ -57,8 +48,6 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
<div className={css.UserHeroAvatarContainer}> <div className={css.UserHeroAvatarContainer}>
<AvatarPresence <AvatarPresence
className={css.UserAvatarContainer} className={css.UserAvatarContainer}
style={profileStyle.avatarBorderColor ? { backgroundColor: profileStyle.avatarBorderColor } : undefined}
badgeBackgroundColor={profileStyle.avatarBorderColor}
badge={ badge={
presence && <PresenceBadge presence={presence.presence} status={presence.status} /> presence && <PresenceBadge presence={presence.presence} status={presence.status} />
} }
@@ -71,10 +60,6 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
style={{ style={{
width: toRem(72), width: toRem(72),
height: toRem(72), height: toRem(72),
...(profileStyle.avatarBorderColor ? {
outline: `${toRem(4)} solid ${profileStyle.avatarBorderColor}`,
outlineOffset: toRem(-1),
} : {}),
}} }}
> >
<UserAvatar <UserAvatar
@@ -87,25 +72,21 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
</Avatar> </Avatar>
</AvatarPresence> </AvatarPresence>
{viewAvatar && ( {viewAvatar && (
<Overlay open backdrop={<OverlayBackdrop />}> <Overlay open backdrop={null}>
<OverlayCenter> <FocusTrap
<FocusTrap focusTrapOptions={{
focusTrapOptions={{ initialFocus: false,
initialFocus: false, onDeactivate: () => setViewAvatar(undefined),
onDeactivate: () => setViewAvatar(undefined), clickOutsideDeactivates: true,
clickOutsideDeactivates: true, escapeDeactivates: stopPropagation,
escapeDeactivates: stopPropagation, }}
}} >
> <ImageViewer
<Modal size="500" onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}> src={viewAvatar}
<ImageViewer alt={userId}
src={viewAvatar} requestClose={() => setViewAvatar(undefined)}
alt={userId} />
requestClose={() => setViewAvatar(undefined)} </FocusTrap>
/>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay> </Overlay>
)} )}
</div> </div>
@@ -118,16 +99,9 @@ type UserHeroNameProps = {
userId: string; userId: string;
avatarMxc?: string; avatarMxc?: string;
}; };
export function UserHeroName({ displayName, userId, avatarMxc }: UserHeroNameProps) { export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
const username = getMxIdLocalPart(userId); const username = getMxIdLocalPart(userId);
const userColor = useOtherUserColor(userId, avatarMxc); const userColor = useOtherUserColor(userId);
console.log('[UserHeroName]', {
userId,
avatarMxc,
userColor,
fallbackColor: colorMXID(userId),
});
return ( return (
<Box grow="Yes" direction="Column" gap="100"> <Box grow="Yes" direction="Column" gap="100">

View File

@@ -10,7 +10,6 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { usePowerLevels } from '../../hooks/usePowerLevels'; import { usePowerLevels } from '../../hooks/usePowerLevels';
import { useRoom } from '../../hooks/useRoom'; import { useRoom } from '../../hooks/useRoom';
import { useUserPresence } from '../../hooks/useUserPresence'; import { useUserPresence } from '../../hooks/useUserPresence';
import { useOtherUserProfileStyle } from '../../hooks/useUserProfileStyle';
import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips'; import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips';
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { PowerChip } from './PowerChip'; import { PowerChip } from './PowerChip';
@@ -24,7 +23,7 @@ import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
import { CreatorChip } from './CreatorChip'; import { CreatorChip } from './CreatorChip';
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils'; import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
import { DirectCreateSearchParams } from '../../pages/paths'; import { DirectCreateSearchParams } from '../../pages/paths';
import { getContrastingTextColor, stripAlphaFromColor, getTextShadowColor } from '../../utils/common'; import { getTextShadowColor } from '../../utils/common';
import classNames from 'classnames'; import classNames from 'classnames';
import { BreakWord, LineClamp3 } from '../../styles/Text.css'; import { BreakWord, LineClamp3 } from '../../styles/Text.css';
import colorMXID, { getColorMXIDValue } from '../../../util/colorMXID'; import colorMXID, { getColorMXIDValue } from '../../../util/colorMXID';
@@ -65,13 +64,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined; const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
const presence = useUserPresence(userId); const presence = useUserPresence(userId);
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc); const userColor = useOtherUserColor(userId);
const userColor = useOtherUserColor(userId, avatarMxc);
// Build gradient CSS if configured
const gradientStyle = profileStyle.gradient
? `linear-gradient(${profileStyle.gradient.direction}, ${profileStyle.gradient.startColor}, ${profileStyle.gradient.stopColor})`
: undefined;
const handleMessage = () => { const handleMessage = () => {
closeUserRoomProfile(); closeUserRoomProfile();
@@ -93,22 +86,6 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
// Use userColor for text, or fall back to colorMXID // Use userColor for text, or fall back to colorMXID
const profileColor = userColor ? userColor : getColorMXIDValue(userId); const profileColor = userColor ? userColor : getColorMXIDValue(userId);
const profileTextShadow = `0 1px 4px ${getTextShadowColor(profileColor)}`; const profileTextShadow = `0 1px 4px ${getTextShadowColor(profileColor)}`;
// Use avatarBorderColor for pill background only if it has visible alpha (not fully transparent)
const hasVisibleBorder = profileStyle.avatarBorderColor && !profileStyle.avatarBorderColor.endsWith('00');
const pillBgColor = hasVisibleBorder ? stripAlphaFromColor(profileStyle.avatarBorderColor!) : undefined;
console.log('[UserRoomProfile]', {
userId,
avatarMxc,
userColor,
profileStyle,
profileColor,
profileTextShadow,
hasVisibleBorder,
pillBgColor,
fallbackColorMXID: getColorMXIDValue(userId),
});
return ( return (
<Box direction="Column"> <Box direction="Column">
@@ -122,12 +99,11 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
direction="Column" direction="Column"
gap="200" gap="200"
alignItems="Center" alignItems="Center"
style={{ style={{
padding: config.space.S400, padding: config.space.S400,
paddingTop: `calc(${config.space.S200} + ${toRem(36)})`, paddingTop: `calc(${config.space.S200} + ${toRem(36)})`,
marginTop: toRem(-36), marginTop: toRem(-36),
background: gradientStyle, textAlign: 'center',
textAlign: 'center'
}} }}
> >
{/* Display Name */} {/* Display Name */}
@@ -152,20 +128,14 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
{presence?.status && ( {presence?.status && (
<Box <Box
style={{ style={{
backgroundColor: pillBgColor || color.Surface.Container, backgroundColor: color.Surface.Container,
border: pillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`, border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(6)} ${toRem(10)}`, padding: `${toRem(6)} ${toRem(10)}`,
borderRadius: toRem(16), borderRadius: toRem(16),
maxWidth: toRem(250), maxWidth: toRem(250),
}} }}
> >
<Text <Text size="T300" className={BreakWord}>
size="T300"
className={BreakWord}
style={{
color: pillBgColor ? getContrastingTextColor(pillBgColor) : undefined
}}
>
{presence.status} {presence.status}
</Text> </Text>
</Box> </Box>
@@ -173,9 +143,9 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
{/* Chips Row */} {/* Chips Row */}
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center"> <Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
{server && <ServerChip server={server} borderColor={pillBgColor} />} {server && <ServerChip server={server} />}
<ShareChip userId={userId} borderColor={pillBgColor} /> <ShareChip userId={userId} />
{creator ? <CreatorChip borderColor={pillBgColor} /> : <PowerChip userId={userId} borderColor={pillBgColor} />} {creator ? <CreatorChip /> : <PowerChip userId={userId} />}
</Box> </Box>
{/* Message Button */} {/* Message Button */}
@@ -195,8 +165,8 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
{/* Additional Chips if needed */} {/* Additional Chips if needed */}
{userId !== myUserId && ( {userId !== myUserId && (
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center"> <Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
<MutualRoomsChip userId={userId} borderColor={pillBgColor} /> <MutualRoomsChip userId={userId} />
<OptionsChip userId={userId} borderColor={pillBgColor} /> <OptionsChip userId={userId} />
</Box> </Box>
)} )}
</Box> </Box>

View File

@@ -1,10 +1,9 @@
import React from 'react'; import React from 'react';
import FileSaver from 'file-saver';
import classNames from 'classnames'; import classNames from 'classnames';
import { Box, Chip, Header, IconButton, Text, as } from 'folds'; import { Box, Chip, Header, IconButton, Text, as } from 'folds';
import { Icon, Icons } from '../icons'; import { Icon, Icons } from '../icons';
import * as css from './VideoViewer.css'; import * as css from './VideoViewer.css';
import { downloadMedia } from '../../utils/matrix'; import { downloadAndSaveMedia } from '../../utils/saveMedia';
import { getCurrentAccessToken } from '../../utils/auth'; import { getCurrentAccessToken } from '../../utils/auth';
export type VideoViewerProps = { export type VideoViewerProps = {
@@ -14,20 +13,16 @@ export type VideoViewerProps = {
}; };
export const VideoViewer = as<'div', VideoViewerProps>( export const VideoViewer = as<'div', VideoViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => { const handleDownload = async () => { ({ className, alt, src, requestClose, ...props }, ref) => {
const handleDownload = async () => {
try { try {
const fileContent = await downloadMedia(src, getCurrentAccessToken()); await downloadAndSaveMedia(src, alt, getCurrentAccessToken());
FileSaver.saveAs(fileContent, alt);
} catch (error) { } catch (error) {
console.warn('[VideoViewer] Failed to download media:', error); console.warn('[VideoViewer] Failed to download media:', error);
try { try {
const response = await fetch(src);
if (response.ok) {
const blob = await response.blob();
FileSaver.saveAs(blob, alt);
}
} catch {
window.open(src, '_blank'); window.open(src, '_blank');
} catch {
// ignore
} }
} }
}; };

View File

@@ -11,7 +11,7 @@ export const VirtualTile = as<'div', VirtualTileProps>(
({ className, virtualItem, style, ...props }, ref) => ( ({ className, virtualItem, style, ...props }, ref) => (
<div <div
className={classNames(css.VirtualTile, className)} className={classNames(css.VirtualTile, className)}
style={{ top: virtualItem.start, ...style }} style={{ top: virtualItem.start, zIndex: virtualItem.index, ...style }}
data-index={virtualItem.index} data-index={virtualItem.index}
{...props} {...props}
ref={ref} ref={ref}

166
src/app/data/updateNotes.ts Normal file
View File

@@ -0,0 +1,166 @@
import bundledManifest from '../../../public/update/manifest.json';
import { trimTrailingSlash } from '../utils/common';
const bundledMarkdownByFile = import.meta.glob('../../../public/update/*.md', {
query: '?raw',
import: 'default',
eager: true,
}) as Record<string, string>;
function getUpdateBaseUrl(): string {
const basePath = trimTrailingSlash(import.meta.env.BASE_URL || './');
const relative =
basePath === '.' || basePath === '' ? 'update/' : `${basePath}/update/`;
return new URL(relative, window.location.href).href.replace(/\/$/, '');
}
function getUpdateFileUrl(file: string): string {
return new URL(file, `${getUpdateBaseUrl()}/`).href;
}
export type UpdateManifestEntry = {
file: string;
version?: string;
title?: string;
date?: string;
};
export type UpdateManifest = {
current: string;
older: UpdateManifestEntry[];
};
export type ParsedUpdateDoc = {
file: string;
version?: string;
title?: string;
date?: string;
summary?: string;
markdown: string;
};
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
function parseFrontmatter(source: string): { frontmatter: Record<string, string>; body: string } {
const match = FRONTMATTER_RE.exec(source.trimStart());
if (!match) {
return { frontmatter: {}, body: source };
}
const frontmatter: Record<string, string> = {};
match[1].split('\n').forEach((line) => {
const colon = line.indexOf(':');
if (colon <= 0) return;
const key = line.slice(0, colon).trim();
const raw = line.slice(colon + 1).trim();
frontmatter[key] = raw.replace(/^['"]|['"]$/g, '');
});
return { frontmatter, body: match[2] };
}
function parseUpdateMarkdown(file: string, source: string): ParsedUpdateDoc {
const { frontmatter, body } = parseFrontmatter(source);
return {
file,
version: frontmatter.version,
title: frontmatter.title,
date: frontmatter.date,
summary: frontmatter.summary,
markdown: body.trim(),
};
}
function loadBundledManifest(): UpdateManifest {
const manifest = bundledManifest as UpdateManifest;
return {
current: manifest.current,
older: (manifest.older ?? []).filter((entry) => entry?.file),
};
}
function loadBundledDocument(file: string): ParsedUpdateDoc | null {
const safeFile = file.replace(/^\/+/, '');
const entry = Object.entries(bundledMarkdownByFile).find(([path]) =>
path.endsWith(`/${safeFile}`)
);
if (!entry) return null;
return parseUpdateMarkdown(safeFile, entry[1]);
}
export function resolveUpdateAssetUrl(src: string | undefined): string | undefined {
if (!src) return undefined;
if (/^(https?:|data:|blob:|capacitor:)/i.test(src)) return src;
if (src.startsWith('/')) return src;
const normalized = src.replace(/^\.\//, '');
return new URL(normalized, `${getUpdateBaseUrl()}/`).href;
}
export async function loadUpdateManifest(): Promise<UpdateManifest | null> {
try {
const response = await fetch(getUpdateFileUrl('manifest.json'), { cache: 'no-cache' });
if (response.ok) {
const data = (await response.json()) as UpdateManifest;
if (data?.current && Array.isArray(data.older)) {
return {
current: data.current,
older: data.older.filter((entry) => entry?.file),
};
}
}
} catch {
// fall through to bundled copy (Capacitor / offline)
}
try {
return loadBundledManifest();
} catch {
return null;
}
}
export async function loadUpdateDocument(file: string): Promise<ParsedUpdateDoc | null> {
const safeFile = file.replace(/^\/+/, '');
if (!safeFile || safeFile.includes('..')) return null;
try {
const response = await fetch(getUpdateFileUrl(safeFile), { cache: 'no-cache' });
if (response.ok) {
const source = await response.text();
return parseUpdateMarkdown(safeFile, source);
}
} catch {
// fall through to bundled copy
}
return loadBundledDocument(safeFile);
}
export async function loadCurrentUpdateDocument(): Promise<ParsedUpdateDoc | null> {
const manifest = await loadUpdateManifest();
if (!manifest) return null;
return loadUpdateDocument(manifest.current);
}
export async function getCurrentUpdatePreview(): Promise<{
title: string;
description: string;
} | null> {
const doc = await loadCurrentUpdateDocument();
if (!doc) return null;
const title = doc.title ?? (doc.version ? `Paarrot ${doc.version}` : 'Release notes');
const description =
doc.summary ??
doc.markdown
.replace(/^#+\s+/gm, '')
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.split('\n')
.map((line) => line.replace(/^[-*•]\s*/, '').trim())
.find(Boolean) ??
'See what changed in this version.';
return { title, description };
}

View File

@@ -9,6 +9,7 @@ import {
ParticipantEvent, ParticipantEvent,
Participant, Participant,
LocalTrackPublication, LocalTrackPublication,
LocalAudioTrack,
Track, Track,
} from 'livekit-client'; } from 'livekit-client';
import { MatrixClient } from 'matrix-js-sdk'; import { MatrixClient } from 'matrix-js-sdk';
@@ -31,7 +32,7 @@ import {
getLiveKitHomeserverPriority, getLiveKitHomeserverPriority,
} from './useRtcConfig'; } from './useRtcConfig';
import { getActiveCallMembers } from './useRoomCallMembers'; import { getActiveCallMembers } from './useRoomCallMembers';
import { getAudioSettings, SCREEN_SHARE_RESOLUTIONS, SCREEN_SHARE_BITRATES } from '../settings/audio/Audio'; import { getAudioCaptureOptions, getAudioSettings, AUDIO_SETTINGS_CHANGED_EVENT, SCREEN_SHARE_RESOLUTIONS, SCREEN_SHARE_BITRATES } from '../settings/audio/Audio';
import { getCallSounds, CallSoundType } from './CallSounds'; import { getCallSounds, CallSoundType } from './CallSounds';
type CallEventListener = (event: CallEvent) => void; type CallEventListener = (event: CallEvent) => void;
@@ -102,6 +103,10 @@ export class CallService {
private wasMutedBeforeDeafen = false; private wasMutedBeforeDeafen = false;
private readonly onAudioSettingsChanged = () => {
void this.applyAudioCaptureSettings();
};
/** /**
* Creates a new CallService instance * Creates a new CallService instance
* @param config - Service configuration including homeserver and LiveKit URLs * @param config - Service configuration including homeserver and LiveKit URLs
@@ -110,6 +115,7 @@ export class CallService {
constructor(config: CallServiceConfig, matrixClient: MatrixClient) { constructor(config: CallServiceConfig, matrixClient: MatrixClient) {
this.config = config; this.config = config;
this.matrixClient = matrixClient; this.matrixClient = matrixClient;
window.addEventListener(AUDIO_SETTINGS_CHANGED_EVENT, this.onAudioSettingsChanged);
} }
/** /**
@@ -386,9 +392,13 @@ export class CallService {
} }
// Step 4: Create and connect to LiveKit room // Step 4: Create and connect to LiveKit room
// Pin audioCaptureDefaults so mute/unmute republish doesn't re-enable LiveKit's
// voiceIsolation:true default (which ignores noiseSuppression).
const audioCaptureOptions = getAudioCaptureOptions();
this.livekitRoom = new Room({ this.livekitRoom = new Room({
adaptiveStream: true, adaptiveStream: true,
dynacast: true, dynacast: true,
audioCaptureDefaults: audioCaptureOptions,
}); });
this.setupLiveKitEventHandlers(); this.setupLiveKitEventHandlers();
@@ -400,19 +410,7 @@ export class CallService {
console.log('Local participant identity:', this.livekitRoom.localParticipant.identity); console.log('Local participant identity:', this.livekitRoom.localParticipant.identity);
console.log('Remote participants:', this.livekitRoom.remoteParticipants.size); console.log('Remote participants:', this.livekitRoom.remoteParticipants.size);
// Get user's audio device preferences console.log('Using audio capture options:', audioCaptureOptions);
const audioSettings = getAudioSettings();
console.log('Using audio settings:', audioSettings);
// Build audio capture options with processing settings
const audioCaptureOptions = {
deviceId: audioSettings.microphoneId || undefined,
noiseSuppression: audioSettings.noiseSuppression,
echoCancellation: audioSettings.echoCancellation,
autoGainControl: audioSettings.autoGainControl,
};
console.log('Audio capture options:', audioCaptureOptions);
// Step 5: Publish local tracks based on call type with selected devices and processing // Step 5: Publish local tracks based on call type with selected devices and processing
if (callType === CallType.Video) { if (callType === CallType.Video) {
@@ -881,7 +879,10 @@ export class CallService {
getCallSounds().playSound(CallSoundType.Undeafen); getCallSounds().playSound(CallSoundType.Undeafen);
} }
this.livekitRoom.localParticipant.setMicrophoneEnabled(!newMuteState); this.livekitRoom.localParticipant.setMicrophoneEnabled(
!newMuteState,
getAudioCaptureOptions()
);
this.activeCall.isMuted = newMuteState; this.activeCall.isMuted = newMuteState;
// Play mute/unmute sound // Play mute/unmute sound
@@ -929,7 +930,7 @@ export class CallService {
} else { } else {
// Undeafening - only unmute if user wasn't muted before deafening // Undeafening - only unmute if user wasn't muted before deafening
if (!this.wasMutedBeforeDeafen && this.activeCall.isMuted) { if (!this.wasMutedBeforeDeafen && this.activeCall.isMuted) {
this.livekitRoom.localParticipant.setMicrophoneEnabled(true); this.livekitRoom.localParticipant.setMicrophoneEnabled(true, getAudioCaptureOptions());
this.activeCall.isMuted = false; this.activeCall.isMuted = false;
} }
this.wasMutedBeforeDeafen = false; this.wasMutedBeforeDeafen = false;
@@ -1129,10 +1130,39 @@ export class CallService {
return this.activeCall?.isScreenSharing ?? false; return this.activeCall?.isScreenSharing ?? false;
} }
/**
* Re-apply stored mic processing settings to the live published track.
* Needed because LiveKit merges voiceIsolation:true by default, and because
* settings changes mid-call previously only wrote localStorage.
*/
async applyAudioCaptureSettings(): Promise<void> {
if (!this.livekitRoom || !this.activeCall) return;
const options = getAudioCaptureOptions();
this.livekitRoom.options.audioCaptureDefaults = {
...this.livekitRoom.options.audioCaptureDefaults,
...options,
};
const publication = this.livekitRoom.localParticipant.getTrackPublication(
Track.Source.Microphone
);
const track = publication?.track;
if (!(track instanceof LocalAudioTrack)) return;
try {
await track.restartTrack(options);
console.log('Reapplied audio capture settings:', options);
} catch (error) {
console.error('Failed to reapply audio capture settings:', error);
}
}
/** /**
* Cleans up resources * Cleans up resources
*/ */
dispose(): void { dispose(): void {
window.removeEventListener(AUDIO_SETTINGS_CHANGED_EVENT, this.onAudioSettingsChanged);
this.stopMembershipRefresh(); this.stopMembershipRefresh();
this.endCall(); this.endCall();
this.listeners.clear(); this.listeners.clear();

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react'; import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
import { MatrixError } from 'matrix-js-sdk'; import { MatrixError } from 'matrix-js-sdk';
import { Box, Chip, IconButton, Text, config, Button, Spinner, color, TextAreaComponent as TextArea, Input } from 'folds'; import { Box, Chip, IconButton, Text, config, Button, Spinner, color, TextArea, Input } from 'folds';
import { Icon, Icons } from '../../../components/icons'; import { Icon, Icons } from '../../../components/icons';
import { Page, PageHeader } from '../../../components/page'; import { Page, PageHeader } from '../../../components/page';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
@@ -171,7 +171,7 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro
<Box shrink="No"> <Box shrink="No">
<Text size="L400">JSON Content</Text> <Text size="L400">JSON Content</Text>
</Box> </Box>
<TextAreaComponent <TextArea
ref={textAreaRef} ref={textAreaRef}
name="contentTextArea" name="contentTextArea"
style={{ fontFamily: 'monospace' }} style={{ fontFamily: 'monospace' }}

View File

@@ -1,5 +1,5 @@
import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Text, IconButton, Chip, Scroll, config, TextAreaComponent as TextArea, color, Spinner, Button } from 'folds'; import { Box, Text, IconButton, Chip, Scroll, config, TextArea, color, Spinner, Button } from 'folds';
import { Icon, Icons } from '../../../components/icons'; import { Icon, Icons } from '../../../components/icons';
import { MatrixError } from 'matrix-js-sdk'; import { MatrixError } from 'matrix-js-sdk';
import { Page, PageHeader } from '../../../components/page'; import { Page, PageHeader } from '../../../components/page';
@@ -153,7 +153,7 @@ function StateEventEdit({ type, stateKey, content, requestClose }: StateEventEdi
<Box shrink="No"> <Box shrink="No">
<Text size="L400">JSON Content</Text> <Text size="L400">JSON Content</Text>
</Box> </Box>
<TextAreaComponent <TextArea
ref={textAreaRef} ref={textAreaRef}
name="contentTextArea" name="contentTextArea"
style={{ fontFamily: 'monospace' }} style={{ fontFamily: 'monospace' }}

View File

@@ -10,7 +10,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { ErrorCode } from '../../cs-errorcode'; import { ErrorCode } from '../../cs-errorcode';
import { millisecondsToMinutes } from '../../utils/common'; import { millisecondsToMinutes } from '../../utils/common';
import { createRoomEncryptionState, createRoomPowerLevelsState } from '../../components/create-room'; import { createRoomEncryptionState } from '../../components/create-room';
import { useAlive } from '../../hooks/useAlive'; import { useAlive } from '../../hooks/useAlive';
import { getDirectRoomPath } from '../../pages/pathUtils'; import { getDirectRoomPath } from '../../pages/pathUtils';
@@ -28,9 +28,10 @@ export function CreateChat({ defaultUserId }: CreateChatProps) {
const [createState, create] = useAsyncCallback<string, Error | MatrixError, [string, boolean]>( const [createState, create] = useAsyncCallback<string, Error | MatrixError, [string, boolean]>(
useCallback( useCallback(
async (userId, encrypted) => { async (userId, encrypted) => {
// Do not put m.room.power_levels in initial_state — incomplete PL events
// (used for org.matrix.msc3401.call.member) cause M_FORBIDDEN / 403 on create.
// TrustedPrivateChat already gives both users PL 100, so call membership works.
const initialState: ICreateRoomStateEvent[] = []; const initialState: ICreateRoomStateEvent[] = [];
initialState.push(createRoomPowerLevelsState());
if (encrypted) initialState.push(createRoomEncryptionState()); if (encrypted) initialState.push(createRoomEncryptionState());
const result = await mx.createRoom({ const result = await mx.createRoom({

View File

@@ -1,7 +1,6 @@
import React, { KeyboardEventHandler, useCallback, useRef, useState } from 'react'; import React, { KeyboardEventHandler, useCallback, useRef, useState } from 'react';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { Editor } from 'slate'; import { Editor } from 'slate';
import { ReactEditor } from 'slate-react';
import { IconButton, Line, PopOut } from 'folds'; import { IconButton, Line, PopOut } from 'folds';
import { Icon, Icons } from '../../components/icons'; import { Icon, Icons } from '../../components/icons';
import { import {
@@ -13,6 +12,7 @@ import {
getAutocompleteQuery, getAutocompleteQuery,
createEmoticonElement, createEmoticonElement,
moveCursor, moveCursor,
safeFocusEditor,
} from '../../components/editor'; } from '../../components/editor';
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board'; import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
@@ -84,7 +84,7 @@ export function ForumChatComposer({
const handleCloseAutocomplete = useCallback(() => { const handleCloseAutocomplete = useCallback(() => {
setAutocompleteQuery(undefined); setAutocompleteQuery(undefined);
ReactEditor.focus(editor); safeFocusEditor(editor);
}, [editor]); }, [editor]);
return ( return (
@@ -114,7 +114,7 @@ export function ForumChatComposer({
onClick={() => setToolbar(!toolbar)} onClick={() => setToolbar(!toolbar)}
aria-label="Formatting" aria-label="Formatting"
> >
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} /> <Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} size="200" />
</IconButton> </IconButton>
<UseStateProvider initial={undefined}> <UseStateProvider initial={undefined}>
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => ( {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
@@ -136,15 +136,15 @@ export function ForumChatComposer({
returnFocusOnDeactivate={false} returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect} onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect} onCustomEmojiSelect={handleEmoticonSelect}
requestClose={() => { requestClose={() => {
setEmojiBoardTab((t) => { setEmojiBoardTab((t) => {
if (t) { if (t) {
if (!mobileOrTablet()) ReactEditor.focus(editor); if (!mobileOrTablet()) safeFocusEditor(editor);
return undefined; return undefined;
} }
return t; return t;
}); });
}} }}
/> />
} }
> >
@@ -158,7 +158,11 @@ export function ForumChatComposer({
type="button" type="button"
aria-label="Emoji" aria-label="Emoji"
> >
<Icon src={Icons.Smile} filled={emojiBoardTab === EmojiBoardTab.Emoji} /> <Icon
src={Icons.Smile}
size="200"
filled={emojiBoardTab === EmojiBoardTab.Emoji}
/>
</IconButton> </IconButton>
</PopOut> </PopOut>
)} )}
@@ -173,7 +177,7 @@ export function ForumChatComposer({
radii="300" radii="300"
aria-label="Send" aria-label="Send"
> >
<Icon src={Icons.Send} /> <Icon src={Icons.Send} size="200" />
</IconButton> </IconButton>
)} )}
</> </>

View File

@@ -14,6 +14,7 @@ import * as css from './LobbyHeader.css';
import { IPowerLevels } from '../../hooks/usePowerLevels'; import { IPowerLevels } from '../../hooks/usePowerLevels';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../components/leave-space-prompt'; import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
import { RemoveInaccessiblePrompt } from '../../components/remove-inaccessible-prompt';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useBackRoute } from '../../hooks/useBackRoute'; import { useBackRoute } from '../../hooks/useBackRoute';
@@ -48,6 +49,7 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
const permissions = useRoomPermissions(creators, powerLevels); const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId()); const canInvite = permissions.action('invite', mx.getSafeUserId());
const canEditChildren = permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId());
const openSpaceSettings = useOpenSpaceSettings(); const openSpaceSettings = useOpenSpaceSettings();
const [invitePrompt, setInvitePrompt] = useState(false); const [invitePrompt, setInvitePrompt] = useState(false);
@@ -117,6 +119,34 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
</Box> </Box>
<Line variant="Surface" size="300" /> <Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}> <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{canEditChildren && (
<UseStateProvider initial={false}>
{(promptRemove, setPromptRemove) => (
<>
<MenuItem
onClick={() => setPromptRemove(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.Delete} />}
radii="300"
aria-pressed={promptRemove}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Remove Inaccessible
</Text>
</MenuItem>
{promptRemove && (
<RemoveInaccessiblePrompt
roomId={space.roomId}
onDone={requestClose}
onCancel={() => setPromptRemove(false)}
/>
)}
</>
)}
</UseStateProvider>
)}
<UseStateProvider initial={false}> <UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => ( {(promptLeave, setPromptLeave) => (
<> <>

View File

@@ -133,7 +133,11 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
}, [mx, roomItems]); }, [mx, roomItems]);
let childItems = roomItems?.filter((i) => !subspaces.has(i.roomId) && !globalSubRoomIds.has(i.roomId)); let childItems = roomItems?.filter((i) => !subspaces.has(i.roomId) && !globalSubRoomIds.has(i.roomId));
if (!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())) { // Only hide when we know the user cannot edit children. If power levels are
// still loading (spacePermissions undefined), keep inaccessible rows visible
// so admins can clean them up.
const canEditChild = spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId());
if (spacePermissions && !canEditChild) {
// hide unknown rooms for normal user // hide unknown rooms for normal user
childItems = childItems?.filter((i) => { childItems = childItems?.filter((i) => {
const forbidden = error instanceof MatrixError ? error.errcode === 'M_FORBIDDEN' : false; const forbidden = error instanceof MatrixError ? error.errcode === 'M_FORBIDDEN' : false;

View File

@@ -16,7 +16,7 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { ScrollTopContainer } from '../../components/scroll-top-container'; import { ScrollTopContainer } from '../../components/scroll-top-container';
import { ContainerColor } from '../../styles/ContainerColor.css'; import { ContainerColor } from '../../styles/ContainerColor.css';
import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../pages/pathUtils'; import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../pages/pathUtils';
import { useRooms } from '../../state/hooks/roomList'; import { useRooms, useDirects } from '../../state/hooks/roomList';
import { allRoomsAtom } from '../../state/room-list/roomList'; import { allRoomsAtom } from '../../state/room-list/roomList';
import { mDirectAtom } from '../../state/mDirectList'; import { mDirectAtom } from '../../state/mDirectList';
import { MessageSearchParams, useMessageSearch } from './useMessageSearch'; import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
@@ -53,7 +53,13 @@ export function MessageSearch({
}: MessageSearchProps) { }: MessageSearchProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const allRooms = useRooms(mx, allRoomsAtom, mDirects); const nonDirectRooms = useRooms(mx, allRoomsAtom, mDirects);
const directRooms = useDirects(mx, allRoomsAtom, mDirects);
// Include DMs — previously useRooms alone stripped them and broke DM search
const allRooms = useMemo(
() => [...nonDirectRooms, ...directRooms],
[nonDirectRooms, directRooms]
);
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');

View File

@@ -105,7 +105,7 @@ function SearchResultItem({
relation?.rel_type === RelationType.Thread ? relation.event_id : undefined; relation?.rel_type === RelationType.Thread ? relation.event_id : undefined;
// Get custom user color from avatar metadata // Get custom user color from avatar metadata
const customUserColor = useOtherUserColor(event.sender, senderAvatarMxc); const customUserColor = useOtherUserColor(event.sender, room);
const memberPowerTag = getMemberPowerTag(event.sender); const memberPowerTag = getMemberPowerTag(event.sender);
const tagColor = memberPowerTag?.color const tagColor = memberPowerTag?.color

View File

@@ -4,6 +4,9 @@ import {
ISearchRequestBody, ISearchRequestBody,
ISearchResponse, ISearchResponse,
ISearchResult, ISearchResult,
MatrixClient,
MatrixEvent,
Room,
SearchOrderBy, SearchOrderBy,
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import { useCallback } from 'react'; import { useCallback } from 'react';
@@ -26,6 +29,12 @@ export type SearchResult = {
groups: ResultGroup[]; groups: ResultGroup[];
}; };
const EMPTY_CONTEXT: IResultContext = {
events_before: [],
events_after: [],
profile_info: {},
};
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => { const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
const groups: ResultGroup[] = []; const groups: ResultGroup[] = [];
@@ -54,13 +63,108 @@ const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
const parseSearchResult = (result: ISearchResponse): SearchResult => { const parseSearchResult = (result: ISearchResponse): SearchResult => {
const roomEvents = result.search_categories.room_events; const roomEvents = result.search_categories.room_events;
const searchResult: SearchResult = { return {
nextToken: roomEvents?.next_batch, nextToken: roomEvents?.next_batch,
highlights: roomEvents?.highlights ?? [], highlights: roomEvents?.highlights ?? [],
groups: groupSearchResult(roomEvents?.results ?? []), groups: groupSearchResult(roomEvents?.results ?? []),
}; };
};
return searchResult; const eventToSearchEvent = (event: MatrixEvent, roomId: string): IEventWithRoomId | undefined => {
const eventId = event.getId();
if (!eventId) return undefined;
const content = event.getClearContent() ?? event.getContent();
return {
event_id: eventId,
type: event.getWireType() === 'm.room.encrypted' ? 'm.room.message' : event.getType(),
sender: event.getSender() ?? '',
origin_server_ts: event.getTs(),
content,
room_id: roomId,
unsigned: event.getUnsigned(),
};
};
const getSearchableBody = (event: MatrixEvent): string | undefined => {
if (event.isRedacted()) return undefined;
// After decryption, clear content is available even if wire type was encrypted
const content = event.getClearContent() ?? event.getContent();
if (!content || typeof content !== 'object') return undefined;
// Skip still-encrypted payloads
if (event.isEncrypted() && !event.isDecryptionFailure() && !event.getClearContent()) {
return undefined;
}
const msgType = content.msgtype;
if (msgType && msgType !== 'm.text' && msgType !== 'm.notice' && msgType !== 'm.emote') {
// Still allow filename / body on media
}
const body = typeof content.body === 'string' ? content.body : undefined;
const formatted =
typeof content.formatted_body === 'string' ? content.formatted_body : undefined;
return [body, formatted].filter(Boolean).join('\n') || undefined;
};
const highlightsFromTerm = (term: string): string[] =>
term
.split(/\s+/)
.map((part) => part.trim())
.filter((part) => part.length > 1);
/**
* Search decrypted timeline events already loaded for a room.
* Server-side search cannot match E2EE message bodies.
*/
const searchLocalRoomTimeline = (room: Room, term: string): ResultItem[] => {
const needle = term.trim().toLowerCase();
if (!needle) return [];
const events = room.getLiveTimeline().getEvents();
const matches: ResultItem[] = [];
for (let i = events.length - 1; i >= 0; i -= 1) {
const event = events[i];
const haystack = getSearchableBody(event);
if (!haystack || !haystack.toLowerCase().includes(needle)) continue;
const searchEvent = eventToSearchEvent(event, room.roomId);
if (!searchEvent) continue;
matches.push({
rank: 1,
event: searchEvent,
context: EMPTY_CONTEXT,
});
}
return matches;
};
const isEncryptedRoom = (mx: MatrixClient, roomId: string): boolean => {
const room = mx.getRoom(roomId);
return !!room?.hasEncryptionStateEvent();
};
const searchLocalRooms = (mx: MatrixClient, roomIds: string[], term: string): SearchResult => {
const groups: ResultGroup[] = [];
roomIds.forEach((roomId) => {
const room = mx.getRoom(roomId);
if (!room) return;
const items = searchLocalRoomTimeline(room, term);
if (items.length > 0) {
groups.push({ roomId, items });
}
});
return {
highlights: highlightsFromTerm(term),
groups,
};
}; };
export type MessageSearchParams = { export type MessageSearchParams = {
@@ -69,19 +173,30 @@ export type MessageSearchParams = {
rooms?: string[]; rooms?: string[];
senders?: string[]; senders?: string[];
}; };
export const useMessageSearch = (params: MessageSearchParams) => { export const useMessageSearch = (params: MessageSearchParams) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const { term, order, rooms, senders } = params; const { term, order, rooms, senders } = params;
const searchMessages = useCallback( const searchMessages = useCallback(
async (nextBatch?: string) => { async (nextBatch?: string) => {
if (!term) if (!term) {
return { return {
highlights: [], highlights: [],
groups: [], groups: [],
}; };
const limit = 20; }
const scopedRooms = rooms?.filter(Boolean) ?? [];
const encryptedScoped =
scopedRooms.length > 0 && scopedRooms.every((roomId) => isEncryptedRoom(mx, roomId));
// Encrypted-only scope: server cannot see bodies — search loaded timeline locally
if (encryptedScoped && !nextBatch) {
return searchLocalRooms(mx, scopedRooms, term);
}
const limit = 20;
const requestBody: ISearchRequestBody = { const requestBody: ISearchRequestBody = {
search_categories: { search_categories: {
room_events: { room_events: {
@@ -102,11 +217,33 @@ export const useMessageSearch = (params: MessageSearchParams) => {
}, },
}; };
const r = await mx.search({ try {
body: requestBody, const r = await mx.search({
next_batch: nextBatch === '' ? undefined : nextBatch, body: requestBody,
}); next_batch: nextBatch === '' ? undefined : nextBatch,
return parseSearchResult(r); });
const parsed = parseSearchResult(r);
if (
parsed.groups.length === 0 &&
!nextBatch &&
scopedRooms.length > 0 &&
scopedRooms.some((roomId) => isEncryptedRoom(mx, roomId))
) {
return searchLocalRooms(
mx,
scopedRooms.filter((id) => isEncryptedRoom(mx, id)),
term
);
}
return parsed;
} catch {
if (!nextBatch && scopedRooms.length > 0) {
return searchLocalRooms(mx, scopedRooms, term);
}
throw new Error('Message search failed');
}
}, },
[mx, term, order, rooms, senders] [mx, term, order, rooms, senders]
); );

View 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>
);
}

View 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',
},
});

View 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 rooms app UI is missing or invalid.</Text>
)}
</Box>
</Page>
);
}

View File

@@ -0,0 +1,7 @@
export { RoomAppView } from './RoomAppView';
export { RoomAppSchema } from './RoomAppSchema';
export {
sanitizeRoomAppCss,
scopeRoomAppCss,
isAllowedRoomAppImageSrc,
} from './sanitizeRoomAppCss';

View 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://')
);
}

View File

@@ -10,6 +10,7 @@ export const RoomNavCategoryButton = as<'button', { closed?: boolean }>(
className={classNames(css.CategoryButton, className)} className={classNames(css.CategoryButton, className)}
variant="Background" variant="Background"
radii="Pill" radii="Pill"
data-nav-category-btn=""
before={ before={
<Icon <Icon
className={css.CategoryButtonIcon} className={css.CategoryButtonIcon}

View File

@@ -24,7 +24,7 @@ import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
import { TypingIndicator } from '../../components/typing-indicator'; import { TypingIndicator } from '../../components/typing-indicator';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { getMatrixToRoom } from '../../plugins/matrix-to'; import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix'; import { getCanonicalAliasOrRoomId, guessDmRoomUserId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
import { getViaServers } from '../../plugins/via-servers'; import { getViaServers } from '../../plugins/via-servers';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
@@ -46,6 +46,8 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { mDirectAtom } from '../../state/mDirectList'; import { mDirectAtom } from '../../state/mDirectList';
import { useOpenCreateSubRoomModal } from '../../state/hooks/createRoomModal'; import { useOpenCreateSubRoomModal } from '../../state/hooks/createRoomModal';
import { PresenceAvatar } from '../../components/presence';
import { Presence, useUserPresence } from '../../hooks/useUserPresence';
type RoomNavItemMenuProps = { type RoomNavItemMenuProps = {
room: Room; room: Room;
@@ -289,13 +291,17 @@ export function RoomNavItem({
const avatarUrl = direct const avatarUrl = direct
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication); : getRoomAvatarUrl(mx, room, 96, useAuthentication);
const mDirects = useAtomValue(mDirectAtom);
const isDirect = Boolean(direct || mDirects.has(room.roomId));
const dmUserId = isDirect ? guessDmRoomUserId(room, mx.getSafeUserId()) : undefined;
const dmPresence = useUserPresence(dmUserId ?? '');
const shouldShowIcon = !hideIcon || (hideIcon && avatarUrl); const shouldShowIcon = !hideIcon || (hideIcon && avatarUrl);
const typingMember = useRoomTypingMember(room.roomId).filter( const typingMember = useRoomTypingMember(room.roomId).filter(
(receipt) => receipt.userId !== mx.getUserId() (receipt) => receipt.userId !== mx.getUserId()
); );
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom);
// Get first participated thread for preview // Get first participated thread for preview
const firstThreadPreview = useMemo(() => { const firstThreadPreview = useMemo(() => {
@@ -317,7 +323,7 @@ export function RoomNavItem({
// Get parent space for DMs // Get parent space for DMs
const parentSpaceInfo = (() => { const parentSpaceInfo = (() => {
if (!direct || !mDirects.has(room.roomId)) return undefined; if (!isDirect || !mDirects.has(room.roomId)) return undefined;
const orphanParents = getOrphanParents(roomToParents, room.roomId); const orphanParents = getOrphanParents(roomToParents, room.roomId);
if (orphanParents.length === 0) return undefined; if (orphanParents.length === 0) return undefined;
@@ -383,29 +389,60 @@ export function RoomNavItem({
<NavLink to={linkPath} onClick={handleRoomClick}> <NavLink to={linkPath} onClick={handleRoomClick}>
<NavItemContent> <NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap={shouldShowIcon ? "200" : "100"}> <Box as="span" grow="Yes" alignItems="Center" gap={shouldShowIcon ? "200" : "100"}>
{shouldShowIcon && ( {shouldShowIcon &&
<Avatar size="200" radii="400"> (isDirect ? (
{showAvatar || avatarUrl ? ( <PresenceAvatar
<RoomAvatar presence={
roomId={room.roomId} dmUserId && dmUserId !== mx.getSafeUserId()
src={avatarUrl} ? dmPresence?.presence ?? Presence.Offline
alt={room.name} : Presence.Offline
renderFallback={() => ( }
<Text as="span" size="H6"> >
{nameInitials(room.name)} <Avatar size="200" radii="400">
</Text> {showAvatar || avatarUrl ? (
)} <RoomAvatar
/> roomId={room.roomId}
src={avatarUrl}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
) : (
<RoomIcon
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
filled={selected}
size="100"
joinRule={room.getJoinRule()}
/>
)}
</Avatar>
</PresenceAvatar>
) : ( ) : (
<RoomIcon <Avatar size="200" radii="400">
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }} {showAvatar || avatarUrl ? (
filled={selected} <RoomAvatar
size="100" roomId={room.roomId}
joinRule={room.getJoinRule()} src={avatarUrl}
/> alt={room.name}
)} renderFallback={() => (
</Avatar> <Text as="span" size="H6">
)} {nameInitials(room.name)}
</Text>
)}
/>
) : (
<RoomIcon
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
filled={selected}
size="100"
joinRule={room.getJoinRule()}
/>
)}
</Avatar>
))}
<Box as="span" grow="Yes" direction="Column" style={{ overflow: 'hidden', minWidth: 0 }}> <Box as="span" grow="Yes" direction="Column" style={{ overflow: 'hidden', minWidth: 0 }}>
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate> <Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
{room.name} {room.name}

View File

@@ -41,7 +41,13 @@ export function UnjoinedSubRoomItem({ roomId, depth, isLast = false }: UnjoinedS
return ( return (
<Box <Box
style={{ paddingLeft, padding: `${config.space.S100} ${config.space.S200}`, minHeight: '1.5rem' }} data-nav-depth={depth}
style={{
paddingLeft,
padding: `${config.space.S100} ${config.space.S200}`,
minHeight: '1.5rem',
position: 'relative',
}}
alignItems="Center" alignItems="Center"
gap="200" gap="200"
> >

View File

@@ -204,6 +204,19 @@ export const usePermissionGroups = (): PermissionGroup[] => {
}, },
name: 'Modify Widgets', name: 'Modify Widgets',
}, },
{
location: {
state: true,
key: StateEvent.PaarrotUi,
},
name: 'Set Room App UI',
},
{
location: {
key: MessageEvent.PaarrotUiAction,
},
name: 'Send Room App Actions',
},
], ],
}; };

View File

@@ -1,11 +1,12 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Line } from 'folds'; import { Box, Button, Line, Text, config } from 'folds';
import { decodeRouteParam } from '../../pages/pathUtils'; import { decodeRouteParam } from '../../pages/pathUtils';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { useSetAtom } from 'jotai'; import { useAtom, useSetAtom } from 'jotai';
import { RoomView } from './RoomView'; import { RoomView } from './RoomView';
import { MembersDrawer } from './MembersDrawer'; import { MembersDrawer } from './MembersDrawer';
import { MediaDrawer } from './room-media-menu';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
@@ -16,8 +17,12 @@ import { useMarkAsRead } from '../../hooks/useMarkAsRead';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomMembers } from '../../hooks/useRoomMembers'; import { useRoomMembers } from '../../hooks/useRoomMembers';
import { activeRoomIdAtom } from '../../state/activeRoom'; import { activeRoomIdAtom } from '../../state/activeRoom';
import { isForum } from '../../utils/room'; import { isMediaDrawerAtom } from '../../state/mediaDrawer';
import { isForum, parsePaarrotUiContent } from '../../utils/room';
import { ForumRoomView } from './ForumRoomView'; import { ForumRoomView } from './ForumRoomView';
import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room';
import { RoomAppView } from '../room-app';
export function Room() { export function Room() {
const { eventId: rawEventId } = useParams(); const { eventId: rawEventId } = useParams();
@@ -26,13 +31,28 @@ export function Room() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const setActiveRoomId = useSetAtom(activeRoomIdAtom); const setActiveRoomId = useSetAtom(activeRoomIdAtom);
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); const [isPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [isMediaDrawer] = useAtom(isMediaDrawerAtom);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const powerLevels = usePowerLevels(room); const powerLevels = usePowerLevels(room);
const members = useRoomMembers(mx, room.roomId); const members = useRoomMembers(mx, room.roomId);
const markAsRead = useMarkAsRead(mx); const markAsRead = useMarkAsRead(mx);
const forumRoom = isForum(room); const forumRoom = isForum(room);
const skinny = screenSize !== ScreenSize.Desktop;
const showMediaSolo = skinny && isMediaDrawer;
const showRightDrawer =
!skinny && (isPeopleDrawer || isMediaDrawer);
const uiEvent = useStateEvent(room, StateEvent.PaarrotUi);
const hasAppUi = useMemo(() => !!parsePaarrotUiContent(uiEvent?.getContent()), [uiEvent]);
const [preferChat, setPreferChat] = useState(false);
useEffect(() => {
setPreferChat(false);
}, [room.roomId]);
const showAppView = !forumRoom && hasAppUi && !preferChat;
// Update titlebar with current room ID // Update titlebar with current room ID
useEffect(() => { useEffect(() => {
@@ -55,11 +75,49 @@ export function Room() {
return ( return (
<PowerLevelsContextProvider value={powerLevels}> <PowerLevelsContextProvider value={powerLevels}>
<Box grow="Yes"> <Box grow="Yes">
{forumRoom ? <ForumRoomView room={room} eventId={eventId} /> : <RoomView room={room} eventId={eventId} />} {!showMediaSolo &&
{screenSize === ScreenSize.Desktop && isDrawer && ( (showAppView ? (
<RoomAppView room={room} onShowChat={() => setPreferChat(true)} />
) : forumRoom ? (
<ForumRoomView room={room} eventId={eventId} />
) : (
<Box grow="Yes" direction="Column" style={{ minWidth: 0, minHeight: 0 }}>
{hasAppUi && preferChat && (
<Box
shrink="No"
alignItems="Center"
justifyContent="SpaceBetween"
gap="200"
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderBottom: '1px solid var(--bq-surface-border, CurrentColor)',
}}
>
<Text size="T300">Room app available</Text>
<Button
size="300"
variant="Primary"
radii="300"
onClick={() => setPreferChat(false)}
>
<Text size="B300">Show app</Text>
</Button>
</Box>
)}
<Box grow="Yes" direction="Column" style={{ minWidth: 0, minHeight: 0 }}>
<RoomView room={room} eventId={eventId} />
</Box>
</Box>
))}
{showMediaSolo && <MediaDrawer room={room} solo />}
{!showMediaSolo && showRightDrawer && (
<> <>
<Line variant="Background" direction="Vertical" size="300" /> <Line variant="Background" direction="Vertical" size="300" />
<MembersDrawer key={room.roomId} room={room} members={members} /> {isMediaDrawer ? (
<MediaDrawer room={room} />
) : (
<MembersDrawer key={room.roomId} room={room} members={members} />
)}
</> </>
)} )}
</Box> </Box>

View File

@@ -0,0 +1,17 @@
import { globalStyle, style } from '@vanilla-extract/css';
import { color, config } from 'folds';
import * as editorCss from '../../components/editor/Editor.css';
export const RoomInputWrap = style({
width: '100%',
});
/* Notebook-flat composer chrome — Stationery only (other themes keep Editor inset border) */
globalStyle(`.stationery ${RoomInputWrap} .${editorCss.Editor}`, {
borderRadius: 0,
boxShadow: 'none',
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderLeft: 'none',
borderRight: 'none',
borderBottom: 'none',
});

Some files were not shown because too many files have changed in this diff Show More