Compare commits

24 Commits

Author SHA1 Message Date
0cbc5d9a1c Add Discord collectibles to profiles, messages, and settings.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
Integrate profile effects, nameplates, and avatar decorations with live catalog browsing, Matrix profile storage, and rendering across user heroes, DMs, messages, and the sidebar avatar.
2026-08-24 00:54:48 +10:00
6cb1e14632 Fix What's New dialog layout and improve dev styling/debugging.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
Portal the updates dialog to document.body with stable data attributes,
fix max-width (invalid S800 token), bundle update images for Capacitor,
and add readable vanilla-extract class names plus portalContainer tap fixes.
2026-08-23 16:54:59 +10:00
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
139 changed files with 18337 additions and 1679 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

1
.gitignore vendored
View File

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

View File

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

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
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) |
| Lobby (space card view) | [lobby-forums](./handoff/features/lobby-forums/HANDOFF.md) |
| Forum spaces (post feed UI) | [forum](./handoff/features/forum/HANDOFF.md) |
| Room App UI (bot-driven) | [room-app-ui](./handoff/features/room-app-ui/HANDOFF.md) |
### Settings & customization

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-current` | `{ message }` | send to active room |
| `get-current-room` | — | current room id |
| `get-messages-groups` | `{ limit? }` | recent messages across group channels |
| `get-messages-dms` | `{ limit? }` | recent messages across DMs |
| `get-messages-combined` | `{ limit? }` | recent messages across groups + DMs |
| `get-unreads-groups` | — | unread group conversations |
| `get-unreads-dms` | — | unread DM conversations |
| `get-unreads-combined` | — | unread groups + DMs |
## Dependencies

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

1505
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,12 +9,13 @@
},
"scripts": {
"start": "vite",
"build": "vite build",
"build": "node scripts/generate-update-manifest.mjs && vite build",
"lint": "yarn check:eslint && yarn check:prettier",
"check:eslint": "eslint src/*",
"check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"playground": "vite"
},
"keywords": [],
"author": "Ajay Bura",
@@ -83,8 +84,10 @@
"react-error-boundary": "6.1.2",
"react-google-recaptcha": "3.1.0",
"react-i18next": "17.0.8",
"react-markdown": "10.1.0",
"react-range": "1.10.0",
"react-router-dom": "7.18.1",
"remark-gfm": "4.0.1",
"sanitize-html": "2.17.5",
"slate": "0.124.1",
"slate-dom": "0.124.1",
@@ -94,6 +97,7 @@
},
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@monaco-editor/react": "4.7.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
"@types/chroma-js": "3.1.2",
@@ -117,6 +121,7 @@
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"monaco-editor": "0.52.2",
"prettier": "3.9.4",
"typescript": "6.0.3",
"vite": "8.1.3",

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

View File

@@ -1,7 +1,9 @@
import React, { ReactNode, useCallback, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { useAtomValue } from 'jotai';
import { Modal, Overlay, OverlayBackdrop, OverlayCenter, PopOutContainerProvider } from 'folds';
import { stopPropagation } from '../utils/keyboard';
import { releaseNotesDialogAtom } from '../state/releaseNotes';
type Modal500Props = {
requestClose: () => void;
@@ -10,11 +12,13 @@ type Modal500Props = {
export function Modal500({ requestClose, children }: Modal500Props) {
const [modalEl, setModalEl] = useState<HTMLDivElement | null>(null);
const modalRef = useCallback((el: HTMLDivElement | null) => setModalEl(el), []);
const releaseNotesOpen = useAtomValue(releaseNotesDialogAtom).open;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
active={!releaseNotesOpen}
focusTrapOptions={{
initialFocus: false,
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 { Icon, Icons } from '../icons';
import FocusTrap from 'focus-trap-react';
import FileSaver from 'file-saver';
import { saveMediaBlob } from '../../utils/saveMedia';
import * as css from './PdfViewer.css';
import { AsyncStatus } from '../../hooks/useAsyncCallback';
import { useZoom } from '../../hooks/useZoom';
@@ -61,8 +61,14 @@ export const PdfViewer = as<'div', PdfViewerProps>(
}
}, [docState, pageNo, zoom]);
const handleDownload = () => {
FileSaver.saveAs(src, name);
const handleDownload = async () => {
try {
const res = await fetch(src);
if (!res.ok) throw new Error('Failed to fetch PDF');
await saveMediaBlob(await res.blob(), name);
} catch (error) {
console.warn('[PdfViewer] Failed to download:', error);
}
};
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {

View File

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

View File

@@ -34,7 +34,7 @@ function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState })
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ width: toRem(340) }}>
<Menu style={{ width: toRem(260) }}>
<SpaceProvider value={space ?? null}>
<RoomProvider value={room}>
<UserRoomProfile userId={userId} />

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ import {
MATRIX_SPOILER_REASON_PROPERTY_NAME,
} from '../../../types/matrix/common';
import { StationeryMedia, hashStationerySeed, stationeryMediaRot } from './StationeryMedia';
import { getJumboEmojiInteractionProps, JumboEmojiClickHandler } from '../../features/room/emoji-confetti/jumboEmojiInteraction';
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
import { parseGeoUri, scaleYDimension } from '../../utils/common';
import { resolveAttachmentBoxSize } from '../../state/mediaDimensionCache';
@@ -78,8 +79,9 @@ type MTextProps = {
renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode;
style?: CSSProperties;
onJumboEmojiClick?: JumboEmojiClickHandler;
};
export function MText({ edited, content, renderBody, renderUrlsPreview, style }: MTextProps) {
export function MText({ edited, content, renderBody, renderUrlsPreview, style, onJumboEmojiClick }: MTextProps) {
const { body, formatted_body: customBody } = content;
if (typeof body !== 'string') return <BrokenContent />;
@@ -89,13 +91,20 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
: undefined;
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
const isJumboEmoji = JUMBO_EMOJI_REG.test(trimmedBody);
const jumboEmojiInteraction = getJumboEmojiInteractionProps({
body: trimmedBody,
isJumboEmoji,
onJumboEmojiClick,
});
return (
<>
<MessageTextBody
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
jumboEmoji={isJumboEmoji}
style={style}
{...jumboEmojiInteraction}
>
{renderBody({
body: trimmedBody,
@@ -114,6 +123,7 @@ type MEmoteProps = {
content: Record<string, unknown>;
renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode;
onJumboEmojiClick?: JumboEmojiClickHandler;
};
export function MEmote({
displayName,
@@ -121,6 +131,7 @@ export function MEmote({
content,
renderBody,
renderUrlsPreview,
onJumboEmojiClick,
}: MEmoteProps) {
const { body, formatted_body: customBody } = content;
@@ -131,20 +142,28 @@ export function MEmote({
: undefined;
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
const isJumboEmoji = JUMBO_EMOJI_REG.test(trimmedBody);
const jumboEmojiInteraction = getJumboEmojiInteractionProps({
body: trimmedBody,
isJumboEmoji,
onJumboEmojiClick,
});
return (
<>
<MessageTextBody
emote
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
jumboEmoji={isJumboEmoji}
>
<b>{`${displayName} `}</b>
{renderBody({
body: trimmedBody,
customBody: trimmedCustomBody,
})}
{edited && <MessageEditedContent />}
<span {...jumboEmojiInteraction}>
{renderBody({
body: trimmedBody,
customBody: trimmedCustomBody,
})}
{edited && <MessageEditedContent />}
</span>
</MessageTextBody>
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
</>
@@ -156,8 +175,9 @@ type MNoticeProps = {
content: Record<string, unknown>;
renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode;
onJumboEmojiClick?: JumboEmojiClickHandler;
};
export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNoticeProps) {
export function MNotice({ edited, content, renderBody, renderUrlsPreview, onJumboEmojiClick }: MNoticeProps) {
const { body, formatted_body: customBody } = content;
if (typeof body !== 'string') return <BrokenContent />;
@@ -167,13 +187,20 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
: undefined;
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
const isJumboEmoji = JUMBO_EMOJI_REG.test(trimmedBody);
const jumboEmojiInteraction = getJumboEmojiInteractionProps({
body: trimmedBody,
isJumboEmoji,
onJumboEmojiClick,
});
return (
<>
<MessageTextBody
notice
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
jumboEmoji={isJumboEmoji}
{...jumboEmojiInteraction}
>
{renderBody({
body: trimmedBody,

View File

@@ -117,7 +117,7 @@ export const Reply = as<'div', ReplyProps>(
const { body } = replyEvent?.getContent() ?? {};
const sender = replyEvent?.getSender();
const senderAvatarMxc = sender ? getMemberAvatarMxc(room, sender) : undefined;
const customUserColor = useOtherUserColor(sender ?? '', senderAvatarMxc);
const customUserColor = useOtherUserColor(sender ?? '', room);
const powerTag = sender ? getMemberPowerTag?.(sender) : undefined;
const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined;

View File

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

View File

@@ -1,5 +1,5 @@
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
import { Badge, Box, Button, Chip, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
import { Badge, Box, Button, Chip, Overlay, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
import { Icon, Icons } from '../../icons';
import classNames from 'classnames';
import { Blurhash } from 'react-blurhash';
@@ -14,7 +14,6 @@ import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
import { stopPropagation } from '../../../utils/keyboard';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
import { validBlurHash } from '../../../utils/blurHash';
import { getCurrentAccessToken } from '../../../utils/auth';
import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
@@ -192,28 +191,21 @@ export const ImageContent = as<'div', ImageContentProps>(
<div className={css.MediaSkeleton} />
))}
{srcState.status === AsyncStatus.Success && (
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setViewer(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal
className={ModalWide}
size="500"
>
{renderViewer({
src: srcState.data,
alt: body,
requestClose: () => setViewer(false),
})}
</Modal>
</FocusTrap>
</OverlayCenter>
<Overlay open={viewer} backdrop={null}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setViewer(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
{renderViewer({
src: srcState.data,
alt: body,
requestClose: () => setViewer(false),
})}
</FocusTrap>
</Overlay>
)}
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (

View File

@@ -243,6 +243,9 @@ export const MessageTextBody = recipe({
overflow: 'visible',
overflowY: 'visible',
paddingBottom: config.space.S200,
cursor: 'pointer',
position: 'relative',
zIndex: 5,
},
},
emote: {
@@ -263,6 +266,8 @@ globalStyle(`${jumboEmojiClass} .${htmlCss.EmoticonBase}`, {
padding: 0,
overflow: 'visible',
verticalAlign: 'middle',
position: 'relative',
zIndex: 1,
});
globalStyle(`${jumboEmojiClass} .${htmlCss.Emoticon.classNames.base}`, {

View File

@@ -16,6 +16,7 @@ type PageRootProps = {
export function PageRoot({ nav, children }: PageRootProps) {
const screenSize = useScreenSizeContext();
const showCompactMaster = useShowCompactMasterView();
const showDetail = !showCompactMaster || nav == null;
return (
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
@@ -23,7 +24,7 @@ export function PageRoot({ nav, children }: PageRootProps) {
{screenSize !== ScreenSize.Mobile && (
<Line variant="Background" size="300" direction="Vertical" />
)}
{!showCompactMaster && children}
{showDetail && children}
</Box>
);
}

View File

@@ -67,6 +67,7 @@ type AvatarPresenceProps = {
export const AvatarPresence = as<'div', AvatarPresenceProps>(
({ as: AsAvatarPresence, badge, variant = 'Surface', badgeBackgroundColor, children, ...props }, ref) => (
<Box as={AsAvatarPresence} className={css.AvatarPresence} {...props} ref={ref}>
{children}
{badge && (
<div
className={css.AvatarPresenceBadge}
@@ -75,7 +76,6 @@ export const AvatarPresence = as<'div', AvatarPresenceProps>(
{badge}
</div>
)}
{children}
</Box>
)
);

View File

@@ -12,7 +12,7 @@ export const AvatarPresenceBadge = style({
bottom: 0,
right: 0,
transform: 'translate(25%, 25%)',
zIndex: 1,
zIndex: 10,
display: 'flex',
padding: config.borderWidth.B600,

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,329 @@
import { globalStyle, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
const MOBILE_BREAKPOINT = '480px';
/** Above folds overlays (9999) and Settings modals. */
const UPDATES_DIALOG_Z = 10001;
const DIALOG_MAX_HEIGHT =
'calc(85vh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))';
/** [data-updates-dialog] — full-screen host portaled to document.body */
export const Root = style({
position: 'fixed',
inset: 0,
zIndex: UPDATES_DIALOG_Z,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'auto',
});
/** [data-updates-dialog-backdrop] */
export const Backdrop = style({
position: 'absolute',
inset: 0,
zIndex: 0,
backgroundColor: 'rgba(0, 0, 0, 0.55)',
});
/** [data-updates-dialog-frame] */
export const Frame = style({
position: 'relative',
zIndex: 1,
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
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)',
pointerEvents: 'none',
});
/** [data-updates-dialog-panel] */
export const Panel = style({
position: 'relative',
display: 'flex',
flexDirection: 'column',
width: '100%',
maxWidth: toRem(560),
maxHeight: DIALOG_MAX_HEIGHT,
overflow: 'hidden',
pointerEvents: 'auto',
borderRadius: config.radii.R400,
backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer,
boxShadow: config.shadow.E400,
'@media': {
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
maxWidth: `calc(100vw - 2 * ${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.S600,
});
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.R300,
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,183 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import FocusTrap from 'focus-trap-react';
import { Box, Button, IconButton, Scroll, Spinner, Text } from 'folds';
import { Icon, Icons } from '../icons';
import { stopPropagation } from '../../utils/keyboard';
import type { ParsedUpdateDoc, UpdateManifest } from '../../data/updateNotes';
import PaarrotSVG from '../../../../public/res/svg/paarrot.svg';
import { UpdateMarkdownViewer } from './UpdateMarkdownViewer';
import * as css from './UpdatesDialog.css';
type UpdatesDialogProps = {
manifest: UpdateManifest | null;
activeDoc: ParsedUpdateDoc | null;
loading: boolean;
onSelectFile: (file: string) => void;
onClose: () => void;
};
export function UpdatesDialog({
manifest,
activeDoc,
loading,
onSelectFile,
onClose,
}: UpdatesDialogProps) {
const [showOlderList, setShowOlderList] = useState(false);
const displayVersion = activeDoc?.version;
const displayTitle = activeDoc?.title ?? (displayVersion ? `Version ${displayVersion}` : 'Release notes');
const isCurrent = manifest && activeDoc?.file === manifest.current;
const olderEntries = manifest?.older ?? [];
const handleSelectOlder = (file: string) => {
onSelectFile(file);
setShowOlderList(false);
};
return createPortal(
<div
data-updates-dialog=""
className={css.Root}
data-disable-swipe-back="true"
data-disable-swipe-reply="true"
>
<div data-updates-dialog-backdrop="" className={css.Backdrop} aria-hidden="true" />
<div data-updates-dialog-frame="" className={css.Frame}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: false,
escapeDeactivates: stopPropagation,
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="updates-dialog-title"
data-updates-dialog-panel=""
className={css.Panel}
>
<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 id="updates-dialog-title" 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>
</div>
</FocusTrap>
</div>
</div>,
document.body
);
}

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

@@ -0,0 +1,28 @@
import React from 'react';
import {
pickStoredProfileEffectIntroUrl,
pickStoredProfileEffectLoopUrl,
} from '../../utils/collectibleAssets';
import { ProfileEffectMedia } from './ProfileEffectMedia';
import * as css from './styles.css';
type ProfileCollectibleOverlaysProps = {
assetUrls: Record<string, string | undefined>;
};
export function ProfileCollectibleOverlays({ assetUrls }: ProfileCollectibleOverlaysProps) {
const introUrl = pickStoredProfileEffectIntroUrl(assetUrls);
const loopUrl = pickStoredProfileEffectLoopUrl(assetUrls);
if (!introUrl && !loopUrl) return null;
return (
<div className={css.UserHeroCollectibleLayer} aria-hidden="true">
<ProfileEffectMedia
introUrl={introUrl}
loopUrl={loopUrl}
className={css.ProfileEffectOverlay}
/>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import React, { useEffect, useState } from 'react';
import { isCatalogVideoPreview } from '../../utils/collectibleAssets';
const DEFAULT_INTRO_DURATION_MS = 3000;
type ProfileEffectMediaProps = {
introUrl?: string;
loopUrl?: string;
introDurationMs?: number;
className?: string;
restartToken?: number;
};
export function ProfileEffectMedia({
introUrl,
loopUrl,
introDurationMs,
className,
restartToken = 0,
}: ProfileEffectMediaProps) {
const resolvedLoopUrl = loopUrl ?? introUrl;
const hasSequence = Boolean(introUrl && resolvedLoopUrl && introUrl !== resolvedLoopUrl);
const [phase, setPhase] = useState<'intro' | 'loop'>(hasSequence ? 'intro' : 'loop');
const [playbackEpoch, setPlaybackEpoch] = useState(0);
useEffect(() => {
setPhase(hasSequence ? 'intro' : 'loop');
}, [introUrl, resolvedLoopUrl, hasSequence]);
useEffect(() => {
setPhase(hasSequence ? 'intro' : 'loop');
setPlaybackEpoch((epoch) => epoch + 1);
}, [restartToken, hasSequence]);
const activeUrl = phase === 'intro' && introUrl ? introUrl : resolvedLoopUrl;
const shouldLoop = !hasSequence || phase === 'loop';
useEffect(() => {
if (!hasSequence || phase !== 'intro' || !introUrl) return undefined;
if (isCatalogVideoPreview(introUrl)) return undefined;
const duration = introDurationMs ?? DEFAULT_INTRO_DURATION_MS;
const timer = window.setTimeout(() => setPhase('loop'), duration);
return () => window.clearTimeout(timer);
}, [hasSequence, phase, introUrl, introDurationMs, playbackEpoch]);
if (!activeUrl) return null;
if (isCatalogVideoPreview(activeUrl)) {
return (
<video
key={`${playbackEpoch}:${activeUrl}`}
className={className}
src={activeUrl}
autoPlay
loop={shouldLoop}
muted
playsInline
onEnded={() => {
if (hasSequence && phase === 'intro') {
setPhase('loop');
}
}}
/>
);
}
return (
<img
key={`${playbackEpoch}:${activeUrl}`}
className={className}
src={activeUrl}
alt=""
draggable={false}
/>
);
}

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Avatar, Box, Modal, Overlay, OverlayBackdrop, OverlayCenter, Text, toRem } from 'folds';
import React, { ReactNode, useState } from 'react';
import { Avatar, Box, Overlay, Text, toRem } from 'folds';
import { Icon, Icons } from '../icons';
import classNames from 'classnames';
import FocusTrap from 'focus-trap-react';
@@ -14,31 +14,36 @@ import { ImageViewer } from '../image-viewer';
import { stopPropagation } from '../../utils/keyboard';
import { useOtherUserColor } from '../../hooks/useUserColor';
import { useOtherUserBanner } from '../../hooks/useUserBanner';
import { useOtherUserProfileStyle } from '../../hooks/useUserProfileStyle';
import { useOtherUserCollectibles } from '../../hooks/useUserCollectibles';
import { pickAvatarDecorationUrl } from '../../utils/collectibleAssets';
import * as avatarDecorationCss from '../../styles/AvatarDecoration.css';
import { ProfileCollectibleOverlays } from './ProfileCollectibleOverlays';
type UserHeroProps = {
userId: string;
avatarUrl?: string;
avatarMxc?: string;
presence?: UserPresence;
children?: ReactNode;
};
export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroProps) {
export function UserHero({ userId, avatarUrl, avatarMxc, presence, children }: UserHeroProps) {
const [viewAvatar, setViewAvatar] = useState<string>();
const bannerUrl = useOtherUserBanner(userId, avatarMxc); // Now returns blob URL directly
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
console.log('[UserHero]', {
userId,
avatarMxc,
bannerUrl,
profileStyle,
});
const bannerUrl = useOtherUserBanner(userId);
const { assetUrls } = useOtherUserCollectibles(userId);
const avatarDecorationUrl = pickAvatarDecorationUrl(assetUrls);
return (
<Box
direction="Column"
className={css.UserHero}
>
<Box direction="Column" className={css.UserHeroZone}>
{bannerUrl && (
<div className={css.UserHeroBannerReflection} aria-hidden="true">
<img
className={css.UserHeroBannerReflectionImg}
src={bannerUrl}
alt=""
draggable={false}
/>
</div>
)}
<div
className={css.UserHeroCoverContainer}
style={{
@@ -56,59 +61,71 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
</div>
<div className={css.UserHeroAvatarContainer}>
<AvatarPresence
className={css.UserAvatarContainer}
style={profileStyle.avatarBorderColor ? { backgroundColor: profileStyle.avatarBorderColor } : undefined}
badgeBackgroundColor={profileStyle.avatarBorderColor}
className={classNames(
css.UserAvatarContainer,
!avatarUrl && css.UserAvatarContainerFallback
)}
badge={
presence && <PresenceBadge presence={presence.presence} status={presence.status} />
}
>
<Avatar
as={avatarUrl ? 'button' : 'div'}
onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined}
className={css.UserHeroAvatar}
size="500"
style={{
width: toRem(72),
height: toRem(72),
...(profileStyle.avatarBorderColor ? {
outline: `${toRem(4)} solid ${profileStyle.avatarBorderColor}`,
outlineOffset: toRem(-1),
} : {}),
}}
>
<UserAvatar
className={css.UserHeroAvatarImg}
userId={userId}
src={avatarUrl}
alt={userId}
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
/>
</Avatar>
<div className={css.UserHeroAvatarStack}>
<Avatar
as={avatarUrl ? 'button' : 'div'}
onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined}
className={classNames(
css.UserHeroAvatar,
avatarUrl ? css.UserHeroAvatarWithImage : css.UserHeroAvatarBorder
)}
size="500"
style={{
width: toRem(72),
height: toRem(72),
}}
>
<UserAvatar
className={css.UserHeroAvatarImg}
userId={userId}
src={avatarUrl}
alt={userId}
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
/>
</Avatar>
{avatarDecorationUrl && (
<img
className={avatarDecorationCss.AvatarDecorationOverlay}
src={avatarDecorationUrl}
alt=""
draggable={false}
/>
)}
</div>
</AvatarPresence>
{viewAvatar && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setViewAvatar(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="500" onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}>
<ImageViewer
src={viewAvatar}
alt={userId}
requestClose={() => setViewAvatar(undefined)}
/>
</Modal>
</FocusTrap>
</OverlayCenter>
<Overlay open backdrop={null}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setViewAvatar(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<ImageViewer
src={viewAvatar}
alt={userId}
requestClose={() => setViewAvatar(undefined)}
/>
</FocusTrap>
</Overlay>
)}
</div>
{children && (
<Box direction="Column" className={css.UserHeroInfo}>
{children}
</Box>
)}
<ProfileCollectibleOverlays assetUrls={assetUrls} />
</Box>
);
}
@@ -118,16 +135,9 @@ type UserHeroNameProps = {
userId: string;
avatarMxc?: string;
};
export function UserHeroName({ displayName, userId, avatarMxc }: UserHeroNameProps) {
export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
const username = getMxIdLocalPart(userId);
const userColor = useOtherUserColor(userId, avatarMxc);
console.log('[UserHeroName]', {
userId,
avatarMxc,
userColor,
fallbackColor: colorMXID(userId),
});
const userColor = useOtherUserColor(userId);
return (
<Box grow="Yes" direction="Column" gap="100">

View File

@@ -2,7 +2,7 @@ import { Box, Button, color, config, Text, toRem } from 'folds';
import { Icon, Icons } from '../icons';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { UserHero, UserHeroName } from './UserHero';
import { UserHero } from './UserHero';
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -10,7 +10,6 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { usePowerLevels } from '../../hooks/usePowerLevels';
import { useRoom } from '../../hooks/useRoom';
import { useUserPresence } from '../../hooks/useUserPresence';
import { useOtherUserProfileStyle } from '../../hooks/useUserProfileStyle';
import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips';
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { PowerChip } from './PowerChip';
@@ -24,7 +23,7 @@ import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
import { CreatorChip } from './CreatorChip';
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
import { DirectCreateSearchParams } from '../../pages/paths';
import { getContrastingTextColor, stripAlphaFromColor, getTextShadowColor } from '../../utils/common';
import { getTextShadowColor } from '../../utils/common';
import classNames from 'classnames';
import { BreakWord, LineClamp3 } from '../../styles/Text.css';
import colorMXID, { getColorMXIDValue } from '../../../util/colorMXID';
@@ -65,13 +64,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
const presence = useUserPresence(userId);
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
const userColor = useOtherUserColor(userId, avatarMxc);
// Build gradient CSS if configured
const gradientStyle = profileStyle.gradient
? `linear-gradient(${profileStyle.gradient.direction}, ${profileStyle.gradient.startColor}, ${profileStyle.gradient.stopColor})`
: undefined;
const userColor = useOtherUserColor(userId);
const handleMessage = () => {
closeUserRoomProfile();
@@ -94,22 +87,6 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
const profileColor = userColor ? userColor : getColorMXIDValue(userId);
const profileTextShadow = `0 1px 4px ${getTextShadowColor(profileColor)}`;
// Use avatarBorderColor for pill background only if it has visible alpha (not fully transparent)
const hasVisibleBorder = profileStyle.avatarBorderColor && !profileStyle.avatarBorderColor.endsWith('00');
const pillBgColor = hasVisibleBorder ? stripAlphaFromColor(profileStyle.avatarBorderColor!) : undefined;
console.log('[UserRoomProfile]', {
userId,
avatarMxc,
userColor,
profileStyle,
profileColor,
profileTextShadow,
hasVisibleBorder,
pillBgColor,
fallbackColorMXID: getColorMXIDValue(userId),
});
return (
<Box direction="Column">
<UserHero
@@ -117,65 +94,60 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
avatarUrl={avatarUrl}
avatarMxc={avatarMxc}
presence={presence && presence.lastActiveTs !== 0 ? presence : undefined}
/>
<Box
direction="Column"
gap="200"
alignItems="Center"
style={{
padding: config.space.S400,
paddingTop: `calc(${config.space.S200} + ${toRem(36)})`,
marginTop: toRem(-36),
background: gradientStyle,
textAlign: 'center'
}}
>
{/* Display Name */}
<Text
size="H4"
className={classNames(BreakWord, LineClamp3)}
style={{ color: getMemberDisplayName(room, userId) !== userId ? profileColor : undefined, textShadow: profileTextShadow }}
<Box
direction="Column"
gap="200"
alignItems="Center"
style={{
padding: config.space.S400,
paddingTop: `calc(${config.space.S200} + ${toRem(36)})`,
marginTop: toRem(-36),
}}
>
{getMemberDisplayName(room, userId)}
</Text>
{/* Username */}
<Text
size="T200"
className={BreakWord}
style={{ color: profileColor, textShadow: profileTextShadow }}
>
{userId}
</Text>
{/* Status Pill */}
{presence?.status && (
<Box
<Text
size="H4"
className={classNames(BreakWord, LineClamp3)}
style={{
backgroundColor: pillBgColor || color.Surface.Container,
border: pillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(6)} ${toRem(10)}`,
borderRadius: toRem(16),
maxWidth: toRem(250),
color: getMemberDisplayName(room, userId) !== userId ? profileColor : undefined,
textShadow: profileTextShadow,
}}
>
<Text
size="T300"
className={BreakWord}
{getMemberDisplayName(room, userId)}
</Text>
<Text
size="T200"
className={BreakWord}
style={{ color: profileColor, textShadow: profileTextShadow }}
>
{userId}
</Text>
{presence?.status && (
<Box
style={{
color: pillBgColor ? getContrastingTextColor(pillBgColor) : undefined
backgroundColor: color.Surface.Container,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(6)} ${toRem(10)}`,
borderRadius: toRem(16),
maxWidth: toRem(250),
}}
>
{presence.status}
</Text>
</Box>
)}
<Text size="T300" className={BreakWord}>
{presence.status}
</Text>
</Box>
)}
</Box>
</UserHero>
<Box direction="Column" gap="200" alignItems="Center" style={{ padding: config.space.S400, textAlign: 'center' }}>
{/* Chips Row */}
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
{server && <ServerChip server={server} borderColor={pillBgColor} />}
<ShareChip userId={userId} borderColor={pillBgColor} />
{creator ? <CreatorChip borderColor={pillBgColor} /> : <PowerChip userId={userId} borderColor={pillBgColor} />}
{server && <ServerChip server={server} />}
<ShareChip userId={userId} />
{creator ? <CreatorChip /> : <PowerChip userId={userId} />}
</Box>
{/* Message Button */}
@@ -195,12 +167,13 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
{/* Additional Chips if needed */}
{userId !== myUserId && (
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
<MutualRoomsChip userId={userId} borderColor={pillBgColor} />
<OptionsChip userId={userId} borderColor={pillBgColor} />
<MutualRoomsChip userId={userId} />
<OptionsChip userId={userId} />
</Box>
)}
</Box>
{hasBottomContent && <Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
{hasBottomContent && (
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
{ignored && <IgnoredUserAlert />}
{member && membership === Membership.Ban && (
<UserBanAlert
@@ -236,7 +209,8 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
canKick={canKickUser && membership === Membership.Join}
canBan={canBanUser && membership !== Membership.Ban}
/>
</Box>}
</Box>
)}
</Box>
);
}

View File

@@ -1,4 +1,4 @@
import { style } from '@vanilla-extract/css';
import { globalStyle, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
export const UserHeader = style({
@@ -10,14 +10,26 @@ export const UserHeader = style({
padding: config.space.S200,
});
export const UserHero = style({
export const UserHeroZone = style({
position: 'relative',
display: 'flex',
flexDirection: 'column',
isolation: 'isolate',
});
export const UserHeroCollectibleLayer = style({
position: 'absolute',
inset: 0,
zIndex: 10,
pointerEvents: 'none',
overflow: 'visible',
});
export const UserHeroCoverContainer = style({
position: 'relative',
height: toRem(140),
overflow: 'hidden',
zIndex: 1,
});
export const UserHeroCover = style({
height: '100%',
@@ -36,12 +48,68 @@ export const UserHeroBanner = style({
export const UserHeroAvatarContainer = style({
position: 'relative',
height: toRem(29),
zIndex: 4,
overflow: 'visible',
});
export const UserHeroBannerReflection = style({
position: 'absolute',
left: 0,
right: 0,
top: toRem(140),
bottom: 0,
overflow: 'hidden',
zIndex: 0,
pointerEvents: 'none',
});
export const UserHeroBannerReflectionImg = style({
position: 'absolute',
top: toRem(12),
left: '-10%',
width: '120%',
height: toRem(160),
objectFit: 'cover',
transformOrigin: 'top center',
transform: 'scaleY(-1) scale(1.2)',
filter: 'blur(48px) saturate(1.15)',
});
export const UserHeroInfo = style({
position: 'relative',
zIndex: 4,
textAlign: 'center',
backgroundColor: 'transparent',
});
globalStyle(`${UserHeroBannerReflection}::after`, {
content: '',
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: toRem(72),
background: `linear-gradient(
to bottom,
transparent 0%,
color-mix(in srgb, ${color.Surface.Container} 55%, transparent) 55%,
${color.Surface.Container} 100%
)`,
pointerEvents: 'none',
});
export const UserAvatarContainer = style({
position: 'absolute',
left: '50%',
top: 0,
transform: 'translate(-50%, -50%)',
zIndex: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const UserAvatarContainerFallback = style({
backgroundColor: color.Surface.Container,
});
@@ -53,14 +121,31 @@ export const UserStatusBubble = style({
zIndex: 2,
});
export const UserHeroAvatarStack = style({
position: 'relative',
width: toRem(72),
height: toRem(72),
flexShrink: 0,
});
export const UserHeroAvatar = style({
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
selectors: {
'button&': {
cursor: 'pointer',
},
},
});
export const UserHeroAvatarBorder = style({
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
border: 'none',
});
export const UserHeroAvatarWithImage = style({
outline: 'none',
border: 'none',
boxShadow: 'none',
});
export const UserHeroAvatarImg = style({
selectors: {
[`button${UserHeroAvatar}:hover &`]: {
@@ -68,3 +153,15 @@ export const UserHeroAvatarImg = style({
},
},
});
export const ProfileEffectOverlay = style({
position: 'absolute',
left: 0,
right: 0,
top: 0,
width: '100%',
aspectRatio: '450 / 880',
objectFit: 'contain',
objectPosition: 'top center',
pointerEvents: 'none',
});

View File

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

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

@@ -0,0 +1,187 @@
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>;
const bundledImageByFile = import.meta.glob('../../../public/update/images/*', {
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]);
}
function resolveBundledImageUrl(normalizedPath: string): string | undefined {
const needle = normalizedPath.replace(/^\/+/, '');
if (!needle) return undefined;
const entry = Object.entries(bundledImageByFile).find(([path]) => {
const normalized = path.replace(/\\/g, '/');
return normalized.endsWith(`/${needle}`) || normalized.endsWith(`/${needle.split('/').pop() ?? ''}`);
});
return entry?.[1];
}
export function resolveUpdateAssetUrl(src: string | undefined): string | undefined {
if (!src) return undefined;
if (/^(https?:|data:|blob:|capacitor:)/i.test(src)) return src;
const normalized = src.replace(/^\.\//, '').replace(/^\/+/, '');
const bundled = resolveBundledImageUrl(normalized);
if (bundled) return bundled;
if (src.startsWith('/')) return src;
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

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

View File

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

View File

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

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

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

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect } from 'react';
import { Box, Line } from 'folds';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Button, Line, Text, config } from 'folds';
import { decodeRouteParam } from '../../pages/pathUtils';
import { useParams } from 'react-router-dom';
import { isKeyHotkey } from 'is-hotkey';
@@ -18,8 +18,11 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomMembers } from '../../hooks/useRoomMembers';
import { activeRoomIdAtom } from '../../state/activeRoom';
import { isMediaDrawerAtom } from '../../state/mediaDrawer';
import { isForum } from '../../utils/room';
import { isForum, parsePaarrotUiContent } from '../../utils/room';
import { ForumRoomView } from './ForumRoomView';
import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room';
import { RoomAppView } from '../room-app';
export function Room() {
const { eventId: rawEventId } = useParams();
@@ -41,6 +44,16 @@ export function Room() {
const showRightDrawer =
!skinny && (isPeopleDrawer || isMediaDrawer);
const uiEvent = useStateEvent(room, StateEvent.PaarrotUi);
const hasAppUi = useMemo(() => !!parsePaarrotUiContent(uiEvent?.getContent()), [uiEvent]);
const [preferChat, setPreferChat] = useState(false);
useEffect(() => {
setPreferChat(false);
}, [room.roomId]);
const showAppView = !forumRoom && hasAppUi && !preferChat;
// Update titlebar with current room ID
useEffect(() => {
setActiveRoomId(room.roomId);
@@ -63,10 +76,38 @@ export function Room() {
<PowerLevelsContextProvider value={powerLevels}>
<Box grow="Yes">
{!showMediaSolo &&
(forumRoom ? (
(showAppView ? (
<RoomAppView room={room} onShowChat={() => setPreferChat(true)} />
) : forumRoom ? (
<ForumRoomView room={room} eventId={eventId} />
) : (
<RoomView room={room} eventId={eventId} />
<Box grow="Yes" direction="Column" style={{ minWidth: 0, minHeight: 0 }}>
{hasAppUi && preferChat && (
<Box
shrink="No"
alignItems="Center"
justifyContent="SpaceBetween"
gap="200"
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderBottom: '1px solid var(--bq-surface-border, CurrentColor)',
}}
>
<Text size="T300">Room app available</Text>
<Button
size="300"
variant="Primary"
radii="300"
onClick={() => setPreferChat(false)}
>
<Text size="B300">Show app</Text>
</Button>
</Box>
)}
<Box grow="Yes" direction="Column" style={{ minWidth: 0, minHeight: 0 }}>
<RoomView room={room} eventId={eventId} />
</Box>
</Box>
))}
{showMediaSolo && <MediaDrawer room={room} solo />}
{!showMediaSolo && showRightDrawer && (

View File

@@ -183,7 +183,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
);
// Get custom user color from avatar metadata
const replyCustomColor = useOtherUserColor(replyUserID ?? '', replyAvatarMxc);
const replyCustomColor = useOtherUserColor(replyUserID ?? '', room);
const replyPowerTag = replyUserID ? getMemberPowerTag(replyUserID) : undefined;
const replyPowerColor = replyPowerTag?.color

View File

@@ -29,7 +29,26 @@ export const TimelineFloat = recipe({
},
});
export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>;
export const DmNameplateBackground = style({
position: 'absolute',
inset: 0,
pointerEvents: 'none',
zIndex: 0,
overflow: 'hidden',
});
export const DmNameplateBackgroundMedia = style({
position: 'absolute',
top: 0,
right: 0,
left: 'auto',
width: toRem(280),
maxWidth: '70%',
height: toRem(64),
objectFit: 'cover',
objectPosition: 'right top',
opacity: 0.12,
});
export const CarouselScroller = style([
DefaultReset,

View File

@@ -117,6 +117,8 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { useIsDirectRoom } from '../../hooks/useRoom';
import { useOtherUserCollectibles } from '../../hooks/useUserCollectibles';
import { pickNameplateUrl } from '../../utils/collectibleAssets';
import { setupCopyHandler } from '../../utils/copyHandler';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace';
@@ -137,6 +139,8 @@ import {
PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME,
PAARROT_CAROUSEL_UUID_PROPERTY_NAME,
} from '../../../types/matrix/common';
import { EmojiConfettiOverlay } from './emoji-confetti/EmojiConfettiOverlay';
import { useJumboEmojiConfetti } from './emoji-confetti/useJumboEmojiConfetti';
/** Information about a call member event for grouping */
interface CallEventInfo {
@@ -568,6 +572,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const direct = useIsDirectRoom();
const dmUserId = direct ? room.guessDMUserId() ?? undefined : undefined;
const [timelineHovered, setTimelineHovered] = useState(false);
const { assetUrls: dmAssetUrls } = useOtherUserCollectibles(dmUserId ?? '');
const dmNameplateUrl = pickNameplateUrl(dmAssetUrls);
const dmNameplateIsVideo = Boolean(
dmAssetUrls['nameplate:animated'] || dmAssetUrls['nameplate:asset.webm']
);
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
@@ -582,6 +593,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
);
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const { bursts: emojiConfettiBursts, removeBurst: removeEmojiConfettiBurst, handleJumboEmojiClick } =
useJumboEmojiConfetti(room);
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
@@ -785,9 +798,16 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
atLiveEndRef.current = liveTimelineLinked && rangeAtEnd;
// Historical / non-live windows are never "at bottom" of the room.
// When we return to the live end, re-check scroll — IntersectionObserver may
// not re-fire if the anchor stayed intersecting across the transition.
useEffect(() => {
if (!liveTimelineLinked || !rangeAtEnd) {
setAtBottom(false);
return;
}
const scrollEl = scrollRef.current;
if (scrollEl && isScrolledToBottom(scrollEl, 100)) {
setAtBottom(true);
}
}, [liveTimelineLinked, rangeAtEnd]);
@@ -1034,11 +1054,16 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (!targetEntry) return;
// Leave the live bottom immediately so Jump to Latest stays available.
if (!targetEntry.isIntersecting || !atLiveEndRef.current) {
// Only intersection clears atBottom here; !atLiveEnd is handled by the
// liveTimelineLinked/rangeAtEnd effect so we don't stick false when the
// live end reconnects without a new intersection event.
if (!targetEntry.isIntersecting) {
setAtBottom(false);
return;
}
if (!atLiveEndRef.current) return;
setAtBottom(true);
setLatestUnreadMessage(null);
if (document.hasFocus()) {
@@ -1145,7 +1170,16 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const onScroll = () => {
if (restoringScrollRef.current) return;
window.cancelAnimationFrame(raf);
raf = window.requestAnimationFrame(() => persistTimelineScroll(roomId));
raf = window.requestAnimationFrame(() => {
persistTimelineScroll(roomId);
// Keep Jump to Latest in sync with real scroll position when on the live end.
// IntersectionObserver alone can miss transitions and leave atBottom stuck.
if (atLiveEndRef.current) {
const at = isScrolledToBottom(scrollEl, 100);
setAtBottom(at);
if (at) setLatestUnreadMessage(null);
}
});
};
scrollEl.addEventListener('scroll', onScroll, { passive: true });
@@ -1250,6 +1284,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
scrollToBottomRef.current.smooth,
scrollToBottomRef.current.force
);
if (atLiveEndRef.current) {
setAtBottom(true);
}
}
}
}, [scrollToBottomCount]);
@@ -1573,6 +1610,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
disabledEmbedPatterns={combinedEmbedFilters}
targetEventId={mEventId}
onJumboEmojiClick={handleJumboEmojiClick}
/>
)}
</Message>
@@ -1681,6 +1720,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
disabledEmbedPatterns={combinedEmbedFilters}
targetEventId={mEventId}
onJumboEmojiClick={handleJumboEmojiClick}
/>
);
}
@@ -1978,6 +2019,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// Return special marker for call event (will be handled by eventRenderer)
return 'CALL_EVENT_PENDING' as unknown as React.ReactNode;
},
[MessageEvent.RelayEmojiConfetti]: () => null,
[MessageEvent.PaarrotUiAction]: () => null,
},
(mEventId, mEvent, item) => {
if (!showHiddenEvents) return null;
@@ -2496,7 +2539,33 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const atLiveBottom = atBottom && liveTimelineLinked && rangeAtEnd;
return (
<Box grow="Yes" style={{ position: 'relative' }}>
<Box
grow="Yes"
style={{ position: 'relative' }}
onMouseEnter={() => setTimelineHovered(true)}
onMouseLeave={() => setTimelineHovered(false)}
>
{direct && timelineHovered && dmNameplateUrl && (
<div className={css.DmNameplateBackground} aria-hidden="true">
{dmNameplateIsVideo ? (
<video
className={css.DmNameplateBackgroundMedia}
src={dmNameplateUrl}
autoPlay
loop
muted
playsInline
/>
) : (
<img
className={css.DmNameplateBackgroundMedia}
src={dmNameplateUrl}
alt=""
draggable={false}
/>
)}
</div>
)}
<Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll="">
<Box
ref={timelineContentRef}
@@ -2588,7 +2657,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</MessageBase>
</>
))}
<span ref={atBottomAnchorRef} />
{/* 1×1 so IntersectionObserver reliably detects the live bottom */}
<span
ref={atBottomAnchorRef}
aria-hidden
style={{ display: 'block', width: 1, height: 1 }}
/>
</Box>
</Scroll>
{(showUnreadTop || returnToEventId) && (
@@ -2675,6 +2749,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</Box>
</TimelineFloat>
)}
<EmojiConfettiOverlay bursts={emojiConfettiBursts} onBurstComplete={removeEmojiConfettiBurst} />
</Box>
);
}

View File

@@ -489,7 +489,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
const dmAvatarMxc =
(dmUserId ? room.getMember(dmUserId)?.getMxcAvatarUrl() : undefined) ||
(isDirect ? avatarMxc : undefined);
const dmCustomColor = useOtherUserColor(dmUserId ?? '', dmAvatarMxc);
const dmCustomColor = useOtherUserColor(dmUserId ?? '');
const dmFolderTabColor =
stationery && isDirect
? `color-mix(in srgb, ${dmCustomColor ?? colorMXID(dmUserId ?? room.roomId)} 50%, var(--folder-tab-room, #f3e6c8))`

View File

@@ -65,6 +65,8 @@ import * as roomViewCss from './RoomViewFollowing.css';
import { Message, Reactions, EncryptedContent } from './message';
import { Reply } from '../../components/message';
import { RenderMessageContent } from '../../components/RenderMessageContent';
import { EmojiConfettiOverlay } from './emoji-confetti/EmojiConfettiOverlay';
import { useJumboEmojiConfetti } from './emoji-confetti/useJumboEmojiConfetti';
import {
LINKIFY_OPTS,
factoryRenderLinkifyWithMention,
@@ -156,6 +158,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const { bursts: emojiConfettiBursts, removeBurst: removeEmojiConfettiBurst, handleJumboEmojiClick } =
useJumboEmojiConfetti(room);
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
const direct = useIsDirectRoom();
@@ -638,6 +642,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
disabledEmbedPatterns={combinedEmbedFilters}
targetEventId={mEventId}
onJumboEmojiClick={handleJumboEmojiClick}
/>
)}
</EncryptedContent>
@@ -654,6 +660,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
disabledEmbedPatterns={combinedEmbedFilters}
targetEventId={mEventId}
onJumboEmojiClick={handleJumboEmojiClick}
/>
)}
</Message>
@@ -759,6 +767,10 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
{events.map((evt, idx, arr) => renderEvent(evt, idx, arr))}
</Box>
</Scroll>
<EmojiConfettiOverlay
bursts={emojiConfettiBursts}
onBurstComplete={removeEmojiConfettiBurst}
/>
</Box>
<div className={roomViewCss.RoomViewBottomFloat}>
<RoomViewTyping room={room} />

View File

@@ -0,0 +1,375 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { EmojiConfettiBurst } from './types';
import { findJumboEmojiElement, getLocalBurstCanvasSize, measureJumboGlyph, setJumboEmojiHidden } from './findJumboMount';
import {
BurstParticle,
drawBurstParticles,
spawnEmojiBurst,
stepBurstParticles,
} from './emojiBurstEngine';
import { getEmojiBurstProfile, isFullscreenBurstEmoji } from './emojiParticleProfiles';
import {
createFireworkSim,
drawFireworkSim,
FireworkSim,
getFireworkDpr,
stepFireworkSim,
syncFireworkHero,
} from './fireworkParticleEngine';
const MAX_DPR = 2;
type EmojiConfettiBurstCanvasProps = {
burst: EmojiConfettiBurst;
onComplete: (burstId: string) => void;
};
export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBurstCanvasProps) {
const primaryEmoji = burst.emojis[0] ?? '🎉';
const fullscreen = isFullscreenBurstEmoji(primaryEmoji);
if (fullscreen) {
return <FireworkBurstCanvas burst={burst} primaryEmoji={primaryEmoji} onComplete={onComplete} />;
}
return <LocalBurstCanvas burst={burst} primaryEmoji={primaryEmoji} onComplete={onComplete} />;
}
type BurstCanvasProps = {
burst: EmojiConfettiBurst;
primaryEmoji: string;
onComplete: (burstId: string) => void;
};
function FireworkBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const simRef = useRef<FireworkSim | null>(null);
const frameRef = useRef<number | null>(null);
const lastFrameTimeRef = useRef<number | null>(null);
const spawnedRef = useRef(false);
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
useEffect(() => {
if (!canvasRef.current || spawnedRef.current) return undefined;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reducedMotion) {
onCompleteRef.current(burst.id);
return undefined;
}
const canvas = canvasRef.current;
const context = canvas.getContext('2d', { alpha: true });
if (!context) {
onCompleteRef.current(burst.id);
return undefined;
}
context.imageSmoothingEnabled = false;
spawnedRef.current = true;
const resize = () => {
const dpr = getFireworkDpr();
const width = window.innerWidth;
const height = window.innerHeight;
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
return { width, height, dpr };
};
const { width, height } = resize();
const profile = getEmojiBurstProfile(primaryEmoji);
const glyph = measureJumboGlyph(burst.targetEventId);
const pinToJumbo = Boolean(profile.morphTo && glyph);
const origin = {
x: glyph ? glyph.x : burst.origin.x,
y: glyph ? glyph.y : burst.origin.y,
maskRadius: glyph ? glyph.size * 0.5 : burst.origin.maskRadius ?? 36,
};
// Keep morph heroes glued to the message emoji; only clamp undirected fireworks.
if (!pinToJumbo) {
origin.x = Math.min(width - 24, Math.max(24, origin.x));
origin.y = Math.min(height - 24, Math.max(24, origin.y));
}
simRef.current = createFireworkSim(
width,
height,
origin,
primaryEmoji,
profile,
performance.now(),
glyph?.size
);
const revealJumbo = () => {
setJumboEmojiHidden(burst.targetEventId, false);
};
const syncHero = () => {
const sim = simRef.current;
if (!sim) return;
if (!sim.hero) {
revealJumbo();
return;
}
const metrics = measureJumboGlyph(burst.targetEventId);
syncFireworkHero(sim, metrics);
// Re-query every frame so React remounts still stay hidden.
setJumboEmojiHidden(burst.targetEventId, true);
};
const onResize = () => {
// Extra Things: match CSS size; sim keeps its launch-time world size.
resize();
syncHero();
};
window.addEventListener('resize', onResize);
window.addEventListener('scroll', syncHero, true);
const finish = () => {
revealJumbo();
onCompleteRef.current(burst.id);
frameRef.current = null;
lastFrameTimeRef.current = null;
};
// Hide immediately before first paint of the stand-in.
if (pinToJumbo) {
setJumboEmojiHidden(burst.targetEventId, true);
}
const tick = (now: number) => {
const sim = simRef.current;
if (!sim) return;
syncHero();
const last = lastFrameTimeRef.current ?? now;
const dtSeconds = Math.min((now - last) / 1000, 0.05);
lastFrameTimeRef.current = now;
const alive = stepFireworkSim(sim, now, dtSeconds);
// Hero may have just been dismissed — reveal real jumbo immediately.
if (!sim.hero) {
revealJumbo();
}
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, canvas.width, canvas.height);
if (alive) {
drawFireworkSim(context, sim, now);
frameRef.current = requestAnimationFrame(tick);
return;
}
finish();
};
lastFrameTimeRef.current = performance.now();
frameRef.current = requestAnimationFrame(tick);
return () => {
window.removeEventListener('resize', onResize);
window.removeEventListener('scroll', syncHero, true);
revealJumbo();
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
simRef.current = null;
// Extra Things: allow Strict Mode remount to start a fresh burst.
spawnedRef.current = false;
};
}, [burst.id, burst.origin.maskRadius, burst.origin.x, burst.origin.y, burst.targetEventId, primaryEmoji]);
return createPortal(
<div
aria-hidden
style={{
position: 'fixed',
inset: 0,
width: '100vw',
height: '100vh',
pointerEvents: 'none',
zIndex: 40,
}}
>
<canvas
ref={canvasRef}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
}}
/>
</div>,
document.body
);
}
function LocalBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const layerRef = useRef<HTMLDivElement>(null);
const particlesRef = useRef<BurstParticle[]>([]);
const frameRef = useRef<number | null>(null);
const lastFrameTimeRef = useRef<number | null>(null);
const spawnedRef = useRef(false);
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
const canvasSize = getLocalBurstCanvasSize(burst.origin.maskRadius ?? 36);
const localOrigin = useMemo(
() => ({
x: canvasSize / 2,
y: canvasSize / 2,
maskRadius: burst.origin.maskRadius ?? 36,
}),
[burst.origin.maskRadius, canvasSize]
);
const updateLayerPosition = () => {
const layer = layerRef.current;
if (!layer) return false;
const jumbo = findJumboEmojiElement(burst.targetEventId);
if (!jumbo) return false;
const rect = jumbo.getBoundingClientRect();
layer.style.left = `${rect.left + rect.width / 2 - canvasSize / 2}px`;
layer.style.top = `${rect.top + rect.height / 2 - canvasSize / 2}px`;
return true;
};
useEffect(() => {
const onScrollOrResize = () => {
updateLayerPosition();
};
window.addEventListener('scroll', onScrollOrResize, true);
window.addEventListener('resize', onScrollOrResize);
return () => {
window.removeEventListener('scroll', onScrollOrResize, true);
window.removeEventListener('resize', onScrollOrResize);
};
}, [burst.targetEventId, canvasSize]);
useEffect(() => {
if (!canvasRef.current || spawnedRef.current) return undefined;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reducedMotion) {
onCompleteRef.current(burst.id);
return undefined;
}
let frameCleanup: (() => void) | undefined;
const start = (): boolean => {
if (spawnedRef.current || !canvasRef.current) return false;
if (!updateLayerPosition()) return false;
spawnedRef.current = true;
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
if (!context) return false;
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
canvas.width = Math.round(canvasSize * dpr);
canvas.height = Math.round(canvasSize * dpr);
canvas.style.width = `${canvasSize}px`;
canvas.style.height = `${canvasSize}px`;
spawnEmojiBurst(particlesRef.current, burst.id, localOrigin, primaryEmoji, performance.now());
const tick = (now: number) => {
updateLayerPosition();
const last = lastFrameTimeRef.current ?? now;
const dtSeconds = Math.min((now - last) / 1000, 0.05);
lastFrameTimeRef.current = now;
stepBurstParticles(particlesRef.current, now, dtSeconds);
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, canvas.width, canvas.height);
if (particlesRef.current.length > 0) {
drawBurstParticles(context, particlesRef.current, dpr, now);
frameRef.current = requestAnimationFrame(tick);
return;
}
onCompleteRef.current(burst.id);
frameRef.current = null;
lastFrameTimeRef.current = null;
};
lastFrameTimeRef.current = performance.now();
frameRef.current = requestAnimationFrame(tick);
frameCleanup = () => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
};
return true;
};
if (start()) {
return frameCleanup;
}
let attempt = 0;
const retry = window.setInterval(() => {
attempt += 1;
if (start() || attempt >= 10) {
window.clearInterval(retry);
if (attempt >= 10 && !spawnedRef.current) {
onCompleteRef.current(burst.id);
}
}
}, 50);
return () => {
window.clearInterval(retry);
frameCleanup?.();
};
}, [burst.id, burst.targetEventId, canvasSize, localOrigin, primaryEmoji]);
return createPortal(
<div
ref={layerRef}
aria-hidden
style={{
position: 'fixed',
width: canvasSize,
height: canvasSize,
pointerEvents: 'none',
zIndex: 4,
}}
>
<canvas
ref={canvasRef}
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
}}
/>
</div>,
document.body
);
}

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { EmojiConfettiBurst } from './types';
import { EmojiConfettiBurstCanvas } from './EmojiConfettiBurstCanvas';
type EmojiConfettiOverlayProps = {
bursts: EmojiConfettiBurst[];
onBurstComplete: (burstId: string) => void;
};
export function EmojiConfettiOverlay({ bursts, onBurstComplete }: EmojiConfettiOverlayProps) {
if (bursts.length === 0) return null;
return (
<>
{bursts.map((burst) => (
<EmojiConfettiBurstCanvas
key={burst.id}
burst={burst}
onComplete={onBurstComplete}
/>
))}
</>
);
}

View File

@@ -0,0 +1,74 @@
export type BurstPoint = {
x: number;
y: number;
maskRadius?: number;
};
export function getBurstPointFromElement(element: Element): BurstPoint {
const rect = element.getBoundingClientRect();
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
maskRadius: Math.max(rect.width, rect.height) * 0.5,
};
}
export function getElementCenter(element: Element): BurstPoint {
return getBurstPointFromElement(element);
}
export function findEmojiOriginInMessage(
targetEventId: string,
emoji: string
): BurstPoint | undefined {
const message = document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
if (!message) return undefined;
const matches = message.querySelectorAll('[data-emoticon]');
for (const element of matches) {
if (element.getAttribute('data-emoticon') === emoji) {
return getBurstPointFromElement(element);
}
}
return undefined;
}
export function getMessageFallbackOrigin(targetEventId: string): BurstPoint | undefined {
const message = document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
if (!message) return undefined;
const jumboBody = message.querySelector('[data-jumbo-emoji]');
if (jumboBody) {
return getBurstPointFromElement(jumboBody);
}
const body = message.querySelector('[data-message-body]');
if (body) {
return getBurstPointFromElement(body);
}
return getBurstPointFromElement(message);
}
export function resolveBurstOrigin(
targetEventId: string | undefined,
emojis: string[],
explicitOrigin?: BurstPoint
): BurstPoint {
if (explicitOrigin) return explicitOrigin;
const primaryEmoji = emojis[0];
if (targetEventId && primaryEmoji) {
const emojiOrigin = findEmojiOriginInMessage(targetEventId, primaryEmoji);
if (emojiOrigin) return emojiOrigin;
const messageOrigin = getMessageFallbackOrigin(targetEventId);
if (messageOrigin) return messageOrigin;
}
return {
x: window.innerWidth / 2,
y: window.innerHeight * 0.35,
};
}

View File

@@ -0,0 +1,357 @@
import { BurstPoint } from './burstOrigin';
import {
BurstMotionStyle,
EmojiBurstProfile,
getEmojiBurstProfile,
pickParticleEmoji,
sampleBurstAngle,
} from './emojiParticleProfiles';
/** Particles spawn over this window. */
export const BURST_SPAWN_MS = 500;
/** Global fade runs from 0.5s → 2s after burst start. */
export const BURST_FADE_START_MS = 500;
export const BURST_FADE_END_MS = 2000;
export const BURST_TOTAL_MS = BURST_FADE_END_MS;
export type BurstParticle = {
burstId: string;
burstStartMs: number;
emoji: string;
fontSize: number;
spawnX: number;
spawnY: number;
maskRadius: number;
x: number;
y: number;
vx: number;
vy: number;
bornAt: number;
rotation: number;
spin: number;
peakScale: number;
style: BurstMotionStyle;
gravity: number;
drag: number;
phase: number;
twinkle?: boolean;
wobble?: boolean;
orbitRadius: number;
orbitSpeed: number;
orbitAngle: number;
bounceDamping: number;
bouncesLeft: number;
};
const EMOJI_CACHE_PX = 64;
const EMOJI_CACHE_SCALE = 2;
const MAX_DPR = 2;
const POP_IN_MS = 70;
const emojiCanvasCache = new Map<string, HTMLCanvasElement>();
function easeOutBack(t: number): number {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2;
}
function easeOutCubic(t: number): number {
return 1 - (1 - t) ** 3;
}
function lerp(min: number, max: number): number {
return min + Math.random() * (max - min);
}
function getEmojiCanvas(emoji: string): HTMLCanvasElement {
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
const cacheKey = `${emoji}:${dpr}`;
const cached = emojiCanvasCache.get(cacheKey);
if (cached) return cached;
const fontSize = Math.ceil(EMOJI_CACHE_PX * dpr);
const size = Math.ceil(fontSize * EMOJI_CACHE_SCALE);
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
if (context) {
context.textAlign = 'center';
context.textBaseline = 'middle';
context.font = `${fontSize}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
context.fillText(emoji, size / 2, size / 2);
}
emojiCanvasCache.set(cacheKey, canvas);
return canvas;
}
function emergeOpacity(
x: number,
y: number,
spawnX: number,
spawnY: number,
maskRadius: number
): number {
const dist = Math.hypot(x - spawnX, y - spawnY);
const inner = maskRadius * 0.55;
const outer = maskRadius * 1.05;
if (dist <= inner) return 0;
if (dist >= outer) return 1;
return (dist - inner) / (outer - inner);
}
function spawnParticle(
particles: BurstParticle[],
burstId: string,
burstStartMs: number,
point: BurstPoint,
primaryEmoji: string,
profile: EmojiBurstProfile,
bornAt: number,
options?: { hero?: boolean }
) {
const hero = options?.hero ?? false;
const angle = sampleBurstAngle(profile);
const speed = hero
? lerp(profile.speedMin * 0.65, profile.speedMax * 0.55)
: lerp(profile.speedMin, profile.speedMax);
const launchUp = lerp(profile.launchUpMin, profile.launchUpMax);
const maskRadius = point.maskRadius ?? 28;
const jitter = hero ? 3 : 12;
const emoji = pickParticleEmoji(profile, primaryEmoji);
let vx = Math.cos(angle) * speed + (Math.random() - 0.5) * 60;
let vy = Math.sin(angle) * speed - launchUp;
if (profile.style === 'spiral') {
vx *= 0.35;
vy *= 0.35;
}
if (profile.style === 'sparkle') {
vx *= 0.75;
vy *= 0.75;
}
particles.push({
burstId,
burstStartMs,
emoji,
fontSize: hero
? lerp(profile.heroFontSizeMin, profile.heroFontSizeMax)
: lerp(profile.fontSizeMin, profile.fontSizeMax),
spawnX: point.x,
spawnY: point.y,
maskRadius,
x: point.x + (Math.random() - 0.5) * jitter,
y: point.y + (Math.random() - 0.5) * jitter,
vx,
vy,
bornAt,
rotation: (Math.random() - 0.5) * 40,
spin: lerp(profile.spinMin, profile.spinMax),
peakScale: hero ? 1.1 + Math.random() * 0.2 : 0.9 + Math.random() * 0.4,
style: profile.style,
gravity: profile.gravity,
drag: profile.drag,
phase: Math.random() * Math.PI * 2,
twinkle: profile.twinkle,
wobble: profile.wobble,
orbitRadius: hero ? 8 : 14 + Math.random() * 28,
orbitSpeed: (Math.random() < 0.5 ? -1 : 1) * (1.8 + Math.random() * 2.4),
orbitAngle: Math.random() * Math.PI * 2,
bounceDamping: 0.42 + Math.random() * 0.18,
bouncesLeft: profile.style === 'bounce' ? 2 : 0,
});
}
/** Fountain from behind the emoji for 0.5s, fade 0.5s→2s. */
export function spawnEmojiBurst(
particles: BurstParticle[],
burstId: string,
point: BurstPoint,
emoji: string,
now = performance.now()
) {
const profile = getEmojiBurstProfile(emoji);
const burstStartMs = now;
spawnParticle(particles, burstId, burstStartMs, point, emoji, profile, burstStartMs, {
hero: true,
});
const particleCount = profile.particleCount;
for (let i = 0; i < particleCount; i += 1) {
const slot = i / particleCount;
const bornAt =
burstStartMs + slot * BURST_SPAWN_MS + Math.random() * (BURST_SPAWN_MS / particleCount);
spawnParticle(particles, burstId, burstStartMs, point, emoji, profile, bornAt);
}
}
function stepSpiralParticle(particle: BurstParticle, dtSeconds: number) {
particle.orbitRadius += 42 * dtSeconds;
particle.orbitAngle += particle.orbitSpeed * dtSeconds;
particle.x = particle.spawnX + Math.cos(particle.orbitAngle) * particle.orbitRadius;
particle.y =
particle.spawnY + Math.sin(particle.orbitAngle) * particle.orbitRadius * 0.65 + particle.vy * dtSeconds * 8;
particle.vy += particle.gravity * dtSeconds * 0.35;
particle.rotation += particle.spin * dtSeconds;
}
function stepBounceParticle(particle: BurstParticle, dtSeconds: number, dragFactor: number) {
particle.vy += particle.gravity * dtSeconds;
particle.vx *= dragFactor;
particle.vy *= dragFactor;
particle.x += particle.vx * dtSeconds;
particle.y += particle.vy * dtSeconds;
particle.rotation += particle.spin * dtSeconds;
if (particle.bouncesLeft > 0 && particle.vy > 0 && particle.y >= particle.spawnY + 6) {
particle.y = particle.spawnY + 6;
particle.vy = -Math.abs(particle.vy) * particle.bounceDamping;
particle.vx *= 0.82;
particle.bouncesLeft -= 1;
particle.spin += (Math.random() - 0.5) * 120;
}
}
function stepDefaultParticle(particle: BurstParticle, dtSeconds: number, dragFactor: number) {
particle.vy += particle.gravity * dtSeconds;
particle.vx *= dragFactor;
particle.vy *= dragFactor;
particle.x += particle.vx * dtSeconds;
particle.y += particle.vy * dtSeconds;
particle.rotation += particle.spin * dtSeconds;
}
export function stepBurstParticles(
particles: BurstParticle[],
now: number,
dtSeconds: number
): void {
for (let i = particles.length - 1; i >= 0; i -= 1) {
const particle = particles[i];
const burstElapsed = now - particle.burstStartMs;
if (burstElapsed > BURST_TOTAL_MS) {
particles[i] = particles[particles.length - 1];
particles.pop();
continue;
}
const ageMs = now - particle.bornAt;
if (ageMs < 0) continue;
const dragFactor = particle.drag ** (dtSeconds * 60);
switch (particle.style) {
case 'spiral':
stepSpiralParticle(particle, dtSeconds);
break;
case 'bounce':
stepBounceParticle(particle, dtSeconds, dragFactor);
break;
default:
stepDefaultParticle(particle, dtSeconds, dragFactor);
break;
}
particle.phase += dtSeconds * (particle.twinkle ? 9 : 5);
}
}
type DrawState = {
x: number;
y: number;
scale: number;
opacity: number;
rotation: number;
};
function particleDrawState(particle: BurstParticle, now: number): DrawState | null {
const burstElapsed = now - particle.burstStartMs;
if (burstElapsed > BURST_TOTAL_MS) return null;
const ageMs = now - particle.bornAt;
if (ageMs < 0) return null;
let scale = particle.peakScale;
let opacity = emergeOpacity(
particle.x,
particle.y,
particle.spawnX,
particle.spawnY,
particle.maskRadius
);
if (opacity <= 0) return null;
if (ageMs < POP_IN_MS) {
const pop = easeOutBack(ageMs / POP_IN_MS);
scale = 0.1 + pop * particle.peakScale;
opacity *= pop;
}
if (particle.twinkle) {
opacity *= 0.55 + 0.45 * Math.sin(particle.phase * 1.6);
}
if (particle.wobble) {
scale *= 1 + 0.12 * Math.sin(particle.phase * 2.2);
}
if (particle.style === 'sparkle' && burstElapsed < BURST_FADE_START_MS) {
scale *= 0.85 + 0.3 * Math.sin(particle.phase * 3);
}
if (burstElapsed >= BURST_FADE_START_MS) {
const fadeT = (burstElapsed - BURST_FADE_START_MS) / (BURST_FADE_END_MS - BURST_FADE_START_MS);
opacity *= 1 - easeOutCubic(Math.min(1, fadeT));
scale *= 1 - easeOutCubic(Math.min(1, fadeT)) * 0.2;
}
if (opacity <= 0.02) return null;
return {
x: particle.x,
y: particle.y,
scale,
opacity,
rotation: particle.rotation,
};
}
export function drawBurstParticles(
context: CanvasRenderingContext2D,
particles: BurstParticle[],
dpr: number,
now: number
) {
for (const particle of particles) {
const state = particleDrawState(particle, now);
if (!state) continue;
context.globalAlpha = state.opacity;
const emojiCanvas = getEmojiCanvas(particle.emoji);
const drawSize = particle.fontSize * state.scale * EMOJI_CACHE_SCALE;
const halfSize = drawSize / 2;
const radians = (state.rotation * Math.PI) / 180;
const cos = Math.cos(radians) * dpr;
const sin = Math.sin(radians) * dpr;
context.setTransform(cos, sin, -sin, cos, state.x * dpr, state.y * dpr);
context.drawImage(emojiCanvas, -halfSize, -halfSize, drawSize, drawSize);
}
context.globalAlpha = 1;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
}
export function hasBurstParticles(particles: BurstParticle[], burstId: string): boolean {
return particles.some((particle) => particle.burstId === burstId);
}

View File

@@ -0,0 +1,500 @@
export type BurstMotionStyle =
| 'explode'
| 'bounce'
| 'rise'
| 'sparkle'
| 'splash'
| 'spiral'
| 'punch'
| 'scatter'
| 'shower'
| 'firework';
export type EmojiBurstProfile = {
style: BurstMotionStyle;
particleCount: number;
gravity: number;
drag: number;
speedMin: number;
speedMax: number;
launchUpMin: number;
launchUpMax: number;
spinMin: number;
spinMax: number;
fontSizeMin: number;
fontSizeMax: number;
heroFontSizeMin: number;
heroFontSizeMax: number;
companions?: string[];
companionChance?: number;
/** Bias burst angle in radians. 0 = right, -π/2 = up. */
angleBias?: number;
/** Limit spawn to a cone (radians). Omit for full 360°. */
angleSpread?: number;
twinkle?: boolean;
wobble?: boolean;
/** Full-viewport overlay (Box2D pile for fireworks). */
fullscreen?: boolean;
/** Soft color wash applied to matching particle glyphs (e.g. green spit). */
tintColor?: string;
/** Emojis that receive `tintColor` when drawn. */
tintEmojis?: string[];
/** Swap the primary face to this emoji mid-burst (e.g. 🤢 → 🤮). */
morphTo?: string;
/** Delay before `morphTo` kicks in. */
morphAfterMs?: number;
/** How long the stand-in hero face stays before the real jumbo returns.
* Omit to keep it up until the last particle spawns. */
heroDurationMs?: number;
};
const DEFAULT_PROFILE: EmojiBurstProfile = {
style: 'explode',
particleCount: 36,
gravity: 400,
drag: 0.935,
speedMin: 240,
speedMax: 560,
launchUpMin: 40,
launchUpMax: 130,
spinMin: -180,
spinMax: 180,
fontSizeMin: 14,
fontSizeMax: 26,
heroFontSizeMin: 26,
heroFontSizeMax: 34,
};
const PROFILE_OVERRIDES: Record<string, Partial<EmojiBurstProfile>> = {
'😆': {
style: 'bounce',
particleCount: 28,
gravity: 520,
speedMin: 180,
speedMax: 360,
launchUpMin: 120,
launchUpMax: 220,
spinMin: -240,
spinMax: 240,
wobble: true,
},
'😂': {
style: 'bounce',
particleCount: 30,
gravity: 500,
speedMin: 160,
speedMax: 340,
launchUpMin: 110,
launchUpMax: 210,
spinMin: -220,
spinMax: 220,
wobble: true,
},
'🤣': {
style: 'bounce',
particleCount: 32,
gravity: 480,
speedMin: 200,
speedMax: 380,
launchUpMin: 130,
launchUpMax: 240,
wobble: true,
},
'🔥': {
style: 'rise',
particleCount: 24,
gravity: -120,
drag: 0.96,
speedMin: 80,
speedMax: 200,
launchUpMin: 60,
launchUpMax: 160,
spinMin: -90,
spinMax: 90,
fontSizeMin: 16,
fontSizeMax: 28,
twinkle: true,
wobble: true,
companions: ['✨'],
companionChance: 0.35,
},
'❤️': {
style: 'rise',
particleCount: 22,
gravity: -80,
drag: 0.97,
speedMin: 60,
speedMax: 150,
launchUpMin: 40,
launchUpMax: 110,
spinMin: -40,
spinMax: 40,
fontSizeMin: 14,
fontSizeMax: 24,
companions: ['💕', '💖'],
companionChance: 0.4,
},
'💕': {
style: 'rise',
particleCount: 20,
gravity: -70,
drag: 0.97,
speedMin: 50,
speedMax: 140,
launchUpMin: 35,
launchUpMax: 100,
spinMin: -35,
spinMax: 35,
},
'💖': {
style: 'rise',
particleCount: 20,
gravity: -90,
drag: 0.968,
speedMin: 55,
speedMax: 150,
launchUpMin: 45,
launchUpMax: 115,
twinkle: true,
},
'⭐': {
style: 'sparkle',
particleCount: 26,
gravity: 40,
drag: 0.94,
speedMin: 100,
speedMax: 260,
launchUpMin: 20,
launchUpMax: 80,
spinMin: -120,
spinMax: 120,
twinkle: true,
companions: ['✨'],
companionChance: 0.5,
},
'✨': {
style: 'sparkle',
particleCount: 30,
gravity: 30,
drag: 0.945,
speedMin: 90,
speedMax: 240,
launchUpMin: 15,
launchUpMax: 70,
spinMin: -200,
spinMax: 200,
twinkle: true,
},
'💀': {
style: 'scatter',
particleCount: 20,
gravity: 620,
drag: 0.92,
speedMin: 200,
speedMax: 420,
launchUpMin: 20,
launchUpMax: 90,
spinMin: -360,
spinMax: 360,
fontSizeMin: 16,
fontSizeMax: 28,
},
'🎉': {
style: 'shower',
particleCount: 40,
gravity: 280,
drag: 0.93,
speedMin: 200,
speedMax: 480,
launchUpMin: 80,
launchUpMax: 200,
companions: ['🎊', '✨', '🎈'],
companionChance: 0.45,
},
'🎊': {
style: 'shower',
particleCount: 38,
gravity: 260,
drag: 0.932,
speedMin: 190,
speedMax: 460,
launchUpMin: 70,
launchUpMax: 190,
companions: ['🎉', '✨'],
companionChance: 0.4,
},
'👏': {
style: 'punch',
particleCount: 18,
gravity: 320,
speedMin: 220,
speedMax: 400,
launchUpMin: 30,
launchUpMax: 100,
angleBias: -Math.PI / 2,
angleSpread: Math.PI * 0.85,
spinMin: -100,
spinMax: 100,
},
'👍': {
style: 'punch',
particleCount: 14,
gravity: 380,
speedMin: 260,
speedMax: 440,
launchUpMin: 100,
launchUpMax: 200,
angleBias: -Math.PI / 2,
angleSpread: Math.PI * 0.55,
spinMin: -60,
spinMax: 60,
},
'💯': {
style: 'punch',
particleCount: 16,
gravity: 300,
speedMin: 200,
speedMax: 380,
launchUpMin: 140,
launchUpMax: 240,
angleBias: -Math.PI / 2,
angleSpread: Math.PI * 0.45,
fontSizeMin: 16,
fontSizeMax: 30,
wobble: true,
},
'💦': {
style: 'splash',
particleCount: 32,
gravity: 540,
drag: 0.925,
speedMin: 280,
speedMax: 520,
launchUpMin: 160,
launchUpMax: 300,
spinMin: -140,
spinMax: 140,
angleBias: -Math.PI / 2,
angleSpread: Math.PI * 1.1,
},
'🌊': {
style: 'splash',
particleCount: 28,
gravity: 420,
drag: 0.93,
speedMin: 220,
speedMax: 460,
launchUpMin: 100,
launchUpMax: 220,
angleBias: -Math.PI / 2,
angleSpread: Math.PI,
companions: ['💧'],
companionChance: 0.3,
},
'⚡': {
style: 'scatter',
particleCount: 14,
gravity: 180,
drag: 0.88,
speedMin: 380,
speedMax: 680,
launchUpMin: 10,
launchUpMax: 60,
spinMin: -30,
spinMax: 30,
fontSizeMin: 18,
fontSizeMax: 32,
angleSpread: Math.PI * 1.2,
},
'🌀': {
style: 'spiral',
particleCount: 24,
gravity: 60,
drag: 0.96,
speedMin: 120,
speedMax: 260,
launchUpMin: 0,
launchUpMax: 40,
spinMin: -420,
spinMax: 420,
},
'🤯': {
style: 'explode',
particleCount: 34,
gravity: 360,
speedMin: 280,
speedMax: 580,
launchUpMin: 60,
launchUpMax: 160,
companions: ['💥', '✨', '⭐'],
companionChance: 0.5,
},
'😭': {
style: 'splash',
particleCount: 26,
gravity: 480,
drag: 0.94,
speedMin: 140,
speedMax: 300,
launchUpMin: 40,
launchUpMax: 120,
angleBias: Math.PI / 2,
angleSpread: Math.PI * 0.7,
companions: ['💧'],
companionChance: 0.55,
},
'🥳': {
style: 'shower',
particleCount: 36,
gravity: 250,
speedMin: 180,
speedMax: 440,
launchUpMin: 90,
launchUpMax: 210,
companions: ['🎉', '🎊', '🎈'],
companionChance: 0.4,
},
'🎆': {
style: 'firework',
fullscreen: true,
particleCount: 320,
gravity: 980,
drag: 1,
speedMin: 420,
speedMax: 980,
launchUpMin: 180,
launchUpMax: 420,
spinMin: -520,
spinMax: 520,
fontSizeMin: 16,
fontSizeMax: 30,
heroFontSizeMin: 40,
heroFontSizeMax: 52,
companions: ['🎇', '✨', '💥', '⭐', '🎉'],
companionChance: 0.55,
},
'🎇': {
style: 'firework',
fullscreen: true,
particleCount: 280,
gravity: 960,
drag: 1,
speedMin: 380,
speedMax: 920,
launchUpMin: 160,
launchUpMax: 400,
spinMin: -480,
spinMax: 480,
fontSizeMin: 14,
fontSizeMax: 28,
heroFontSizeMin: 36,
heroFontSizeMax: 48,
companions: ['🎆', '✨', '⭐', '💥'],
companionChance: 0.5,
twinkle: true,
},
// Full-viewport spew — downward cone, green-tinted droplets, piles on the floor.
'🤢': {
style: 'firework',
fullscreen: true,
particleCount: 420,
gravity: 1200,
drag: 1,
speedMin: 380,
speedMax: 980,
launchUpMin: 0,
launchUpMax: 40,
spinMin: -560,
spinMax: 560,
fontSizeMin: 18,
fontSizeMax: 34,
heroFontSizeMin: 44,
heroFontSizeMax: 58,
// Downward throw-up cone (π/2 = down in canvas space).
angleBias: Math.PI / 2,
angleSpread: Math.PI * 1.05,
companions: ['💦', '💧', '💚'],
companionChance: 1,
tintColor: 'rgba(72, 190, 48, 0.72)',
tintEmojis: ['💦', '💧'],
morphTo: '🤮',
morphAfterMs: 220,
wobble: true,
},
'🤮': {
style: 'firework',
fullscreen: true,
particleCount: 460,
gravity: 1250,
drag: 1,
speedMin: 420,
speedMax: 1050,
launchUpMin: 0,
launchUpMax: 30,
spinMin: -600,
spinMax: 600,
fontSizeMin: 18,
fontSizeMax: 36,
heroFontSizeMin: 46,
heroFontSizeMax: 60,
angleBias: Math.PI / 2,
angleSpread: Math.PI * 1.15,
companions: ['💦', '💧', '💚'],
companionChance: 1,
tintColor: 'rgba(72, 190, 48, 0.72)',
tintEmojis: ['💦', '💧'],
wobble: true,
},
'🐱': {
style: 'bounce',
particleCount: 20,
gravity: 440,
speedMin: 150,
speedMax: 320,
launchUpMin: 90,
launchUpMax: 180,
spinMin: -160,
spinMax: 160,
},
'🐶': {
style: 'bounce',
particleCount: 20,
gravity: 460,
speedMin: 160,
speedMax: 330,
launchUpMin: 85,
launchUpMax: 175,
wobble: true,
},
};
export function getEmojiBurstProfile(emoji: string): EmojiBurstProfile {
const override = PROFILE_OVERRIDES[emoji];
if (!override) return DEFAULT_PROFILE;
return { ...DEFAULT_PROFILE, ...override };
}
export function isFullscreenBurstEmoji(emoji: string): boolean {
return getEmojiBurstProfile(emoji).fullscreen === true;
}
export function pickParticleEmoji(profile: EmojiBurstProfile, primaryEmoji: string): string {
if (!profile.companions?.length || !profile.companionChance) {
return primaryEmoji;
}
if (Math.random() >= profile.companionChance) {
return primaryEmoji;
}
const companion = profile.companions[Math.floor(Math.random() * profile.companions.length)];
return companion ?? primaryEmoji;
}
export function sampleBurstAngle(profile: EmojiBurstProfile): number {
if (profile.angleSpread === undefined) {
return Math.random() * Math.PI * 2;
}
const bias = profile.angleBias ?? -Math.PI / 2;
const halfSpread = profile.angleSpread / 2;
return bias + (Math.random() - 0.5) * profile.angleSpread * (0.6 + Math.random() * 0.4);
}

View File

@@ -0,0 +1,65 @@
export function findJumboEmojiElement(targetEventId: string): HTMLElement | null {
const message = document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
if (!message) return null;
const jumbo = message.querySelector('[data-jumbo-emoji]');
return jumbo instanceof HTMLElement ? jumbo : null;
}
export type JumboGlyphMetrics = {
/** Outer jumbo mount (message body) — hide/show this. */
mount: HTMLElement;
/** Visual glyph center in viewport coords. */
x: number;
y: number;
/** CSS px size matching the rendered emoji (usually computed font-size). */
size: number;
};
/**
* Measure the on-screen jumbo glyph. Prefer computed font-size so the canvas
* stand-in matches unicode emoji; fall back to the glyph box for images.
*/
export function measureJumboGlyph(targetEventId: string): JumboGlyphMetrics | null {
const mount = findJumboEmojiElement(targetEventId);
if (!mount) return null;
const glyph =
(mount.querySelector('[data-emoticon]') as HTMLElement | null) ||
(mount.querySelector('img') as HTMLElement | null) ||
mount;
const rect = glyph.getBoundingClientRect();
const fontSize =
parseFloat(getComputedStyle(glyph).fontSize) ||
parseFloat(getComputedStyle(mount).fontSize) ||
0;
const isImg = glyph instanceof HTMLImageElement || glyph.tagName === 'IMG';
const size = isImg
? Math.max(rect.width, rect.height)
: fontSize > 0
? fontSize
: Math.max(rect.width, rect.height);
return {
mount,
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
size,
};
}
export function setJumboEmojiHidden(targetEventId: string, hidden: boolean) {
const mount = findJumboEmojiElement(targetEventId);
if (!mount) return;
if (hidden) {
mount.style.visibility = 'hidden';
} else {
mount.style.removeProperty('visibility');
}
}
export function getLocalBurstCanvasSize(maskRadius: number): number {
return Math.max(300, Math.round(maskRadius * 6.5));
}

View File

@@ -0,0 +1,515 @@
/**
* Old-school fullscreen firework fountain — no physics engine.
* Position/velocity arrays, gravity, floor bounce, settle into a pile.
* Same vibe as late-90s/MSN page effects, just with emoji.
*/
import { BurstPoint } from './burstOrigin';
import {
pickParticleEmoji,
sampleBurstAngle,
type EmojiBurstProfile,
} from './emojiParticleProfiles';
const EMOJI_CACHE_PX = 40;
const EMOJI_CACHE_SCALE = 2;
const SPAWN_WINDOW_MS = 2200;
const SETTLE_HOLD_MS = 7800;
const FADE_MS = 800;
export const FIREWORK_TOTAL_MS = SETTLE_HOLD_MS + FADE_MS;
const SPARKLE_IN_MS = 120;
const MAX_SPAWNS_PER_FRAME = 6;
const FLOOR_PAD = 8;
const BOUNCE = 0.38;
const DRAG = 0.992;
const SETTLE_SPEED = 55;
const PILE_CELL = 22;
type SpawnItem = {
atMs: number;
emoji: string;
fontSize: number;
};
export type FireworkParticle = {
x: number;
y: number;
vx: number;
vy: number;
spin: number;
angle: number;
emoji: string;
emojiCanvas: HTMLCanvasElement;
drawSize: number;
halfSize: number;
bornAt: number;
settled: boolean;
};
type HeroFace = {
emoji: string;
emojiCanvas: HTMLCanvasElement;
/** Target CSS pixel size of the glyph (matches jumbo font-size). */
drawSize: number;
halfSize: number;
/** canvasPx / fontPx — draw box is drawSize * canvasScale so the glyph isn't cropped. */
canvasScale: number;
morphed: boolean;
};
export type FireworkSim = {
particles: FireworkParticle[];
profile: EmojiBurstProfile;
primaryEmoji: string;
morphAtMs: number | null;
/** When to drop the stand-in hero and reveal the real jumbo again. */
heroUntilMs: number | null;
hero: HeroFace | null;
width: number;
height: number;
origin: BurstPoint;
startMs: number;
spawnPlan: SpawnItem[];
spawnCursor: number;
floorY: number;
/** Column stack heights for the settled pile (in cells). */
pileCols: Uint16Array;
pileCanvas: HTMLCanvasElement | null;
pileCtx: CanvasRenderingContext2D | null;
flyingCount: number;
};
const emojiCanvasCache = new Map<string, HTMLCanvasElement>();
function lerp(min: number, max: number): number {
return min + Math.random() * (max - min);
}
const HERO_PAD_RATIO = 0.24;
type EmojiBitmap = {
canvas: HTMLCanvasElement;
/** Multiply CSS glyph size by this when drawImage'ing to keep padding uncropped. */
canvasScale: number;
};
function getEmojiCanvas(emoji: string, tintColor?: string, cssPx?: number): HTMLCanvasElement {
return getEmojiBitmap(emoji, tintColor, cssPx, 0).canvas;
}
function getEmojiBitmap(
emoji: string,
tintColor: string | undefined,
cssPx: number | undefined,
padRatio: number
): EmojiBitmap {
const targetCss = Math.max(16, Math.round(cssPx ?? EMOJI_CACHE_PX));
const dpr = typeof window !== 'undefined' ? Math.min(window.devicePixelRatio || 1, 2) : 1;
const fontPx = Math.round(targetCss * dpr);
const pad = Math.ceil(fontPx * padRatio);
const size = fontPx + pad * 2;
const cacheKey = `${emoji}|px:${fontPx}|pad:${pad}|tint:${tintColor ?? ''}`;
const cached = emojiCanvasCache.get(cacheKey);
if (cached) {
return { canvas: cached, canvasScale: size / fontPx };
}
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.imageSmoothingEnabled = false;
ctx.font = `${fontPx}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
// Slight optical nudge — color emoji fonts sit high in the em box.
ctx.fillText(emoji, size / 2, size / 2 + fontPx * 0.06);
if (tintColor) {
ctx.globalCompositeOperation = 'source-atop';
ctx.fillStyle = tintColor;
ctx.fillRect(0, 0, size, size);
ctx.globalCompositeOperation = 'source-over';
}
}
emojiCanvasCache.set(cacheKey, canvas);
return { canvas, canvasScale: size / Math.max(fontPx, 1) };
}
function setHeroEmoji(hero: HeroFace, emoji: string) {
const bitmap = getEmojiBitmap(emoji, undefined, hero.drawSize, HERO_PAD_RATIO);
hero.emoji = emoji;
hero.emojiCanvas = bitmap.canvas;
hero.canvasScale = bitmap.canvasScale;
}
function setHeroSize(hero: HeroFace, cssPx: number) {
if (!(cssPx > 0)) return;
if (Math.abs(cssPx - hero.drawSize) < 0.5) return;
hero.drawSize = cssPx;
hero.halfSize = cssPx / 2;
const bitmap = getEmojiBitmap(hero.emoji, undefined, cssPx, HERO_PAD_RATIO);
hero.emojiCanvas = bitmap.canvas;
hero.canvasScale = bitmap.canvasScale;
}
function particleTint(profile: EmojiBurstProfile, emoji: string): string | undefined {
if (!profile.tintColor || !profile.tintEmojis?.length) return undefined;
return profile.tintEmojis.includes(emoji) ? profile.tintColor : undefined;
}
/** Spew particles for morph bursts — never the morphTo face (that stays on the hero). */
function pickSpewEmoji(profile: EmojiBurstProfile, primaryEmoji: string): string {
const morphTo = profile.morphTo;
const companions = (profile.companions ?? []).filter((e) => e !== morphTo);
if (morphTo && companions.length) {
return companions[Math.floor(Math.random() * companions.length)] ?? primaryEmoji;
}
const picked = pickParticleEmoji(profile, primaryEmoji);
return picked === morphTo ? primaryEmoji : picked;
}
function buildSpawnPlan(
profile: EmojiBurstProfile,
primaryEmoji: string,
startMs: number
): SpawnItem[] {
// Anchored morphing hero is drawn separately — skip a flying hero twin.
const plan: SpawnItem[] = profile.morphTo
? []
: [
{
atMs: startMs,
emoji: primaryEmoji,
fontSize: lerp(profile.heroFontSizeMin, profile.heroFontSizeMax),
},
];
const count = profile.particleCount;
for (let i = 0; i < count; i += 1) {
const slot = (i + 0.5) / count;
const eased = slot * slot;
plan.push({
atMs:
startMs +
80 +
eased * SPAWN_WINDOW_MS +
(Math.random() - 0.5) * 70,
emoji: pickSpewEmoji(profile, primaryEmoji),
fontSize: lerp(profile.fontSizeMin, profile.fontSizeMax),
});
}
plan.sort((a, b) => a.atMs - b.atMs);
return plan;
}
function applyMorph(sim: FireworkSim, now: number) {
const to = sim.profile.morphTo;
if (!to || sim.morphAtMs === null || now < sim.morphAtMs) return;
if (sim.hero?.morphed) return;
// Only the stand-in face morphs — spray stays droplets / companions.
if (sim.hero) {
setHeroEmoji(sim.hero, to);
sim.hero.morphed = true;
}
}
function ensurePile(sim: FireworkSim): CanvasRenderingContext2D {
if (sim.pileCanvas && sim.pileCtx) return sim.pileCtx;
const canvas = document.createElement('canvas');
canvas.width = sim.width;
canvas.height = sim.height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('firework pile unsupported');
ctx.imageSmoothingEnabled = false;
sim.pileCanvas = canvas;
sim.pileCtx = ctx;
return ctx;
}
function stampSettled(sim: FireworkSim, p: FireworkParticle) {
const ctx = ensurePile(sim);
const rad = (p.angle * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
ctx.setTransform(cos, sin, -sin, cos, p.x, p.y);
ctx.globalAlpha = 1;
ctx.drawImage(p.emojiCanvas, -p.halfSize, -p.halfSize, p.drawSize, p.drawSize);
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
function spawnOne(sim: FireworkSim, item: SpawnItem, now: number) {
const { profile, origin } = sim;
const jitter = Math.max(4, (origin.maskRadius ?? 28) * 0.25);
const directed = profile.angleSpread !== undefined;
const angle = directed ? sampleBurstAngle(profile) : Math.random() * Math.PI * 2;
const speed = lerp(profile.speedMin, profile.speedMax);
const launchUp = lerp(profile.launchUpMin, profile.launchUpMax);
const drawSize = item.fontSize * EMOJI_CACHE_SCALE * 0.85;
const emoji = item.emoji;
// Directed cones (e.g. throw-up) push along the sampled angle.
// Undirected fireworks keep the classic upward launch kick.
let vx = Math.cos(angle) * speed + (Math.random() - 0.5) * 40;
let vy = Math.sin(angle) * speed;
if (directed) {
const bias = profile.angleBias ?? angle;
vx += Math.cos(bias) * launchUp * 0.25;
vy += Math.sin(bias) * launchUp;
} else {
vy -= launchUp;
}
const tint = particleTint(profile, emoji);
sim.particles.push({
x: origin.x + (Math.random() - 0.5) * jitter,
y: origin.y + (Math.random() - 0.5) * jitter,
vx,
vy,
spin: lerp(profile.spinMin, profile.spinMax) * 0.35,
angle: (Math.random() - 0.5) * 40,
emoji,
emojiCanvas: getEmojiCanvas(emoji, tint),
drawSize,
halfSize: drawSize / 2,
bornAt: now,
settled: false,
});
sim.flyingCount += 1;
}
function settleParticle(sim: FireworkSim, p: FireworkParticle) {
const cols = sim.pileCols.length;
let col = Math.floor(p.x / PILE_CELL);
if (col < 0) col = 0;
if (col >= cols) col = cols - 1;
// Prefer the intended column; spill to neighbors if that stack is already tall.
let bestCol = col;
let bestH = sim.pileCols[col];
for (const delta of [0, -1, 1, -2, 2]) {
const c = col + delta;
if (c < 0 || c >= cols) continue;
if (sim.pileCols[c] < bestH) {
bestH = sim.pileCols[c];
bestCol = c;
}
}
const stack = sim.pileCols[bestCol];
sim.pileCols[bestCol] = stack + 1;
p.x = bestCol * PILE_CELL + PILE_CELL * 0.5 + (Math.random() - 0.5) * 6;
p.y = sim.floorY - stack * (PILE_CELL * 0.72) - p.halfSize;
p.vx = 0;
p.vy = 0;
p.spin = 0;
p.settled = true;
sim.flyingCount = Math.max(0, sim.flyingCount - 1);
stampSettled(sim, p);
}
export function createFireworkSim(
widthPx: number,
heightPx: number,
originPx: BurstPoint,
primaryEmoji: string,
profile: EmojiBurstProfile,
startMs = performance.now(),
/** Pixel size of the source jumbo emoji — hero face matches this when set. */
heroSizePx?: number
): FireworkSim {
const colCount = Math.max(8, Math.ceil(widthPx / PILE_CELL));
const fallbackHero =
lerp(profile.heroFontSizeMin, profile.heroFontSizeMax) * EMOJI_CACHE_SCALE * 0.95;
const heroSize = heroSizePx && heroSizePx > 0 ? heroSizePx : fallbackHero;
const hero: HeroFace | null = profile.morphTo
? (() => {
const bitmap = getEmojiBitmap(primaryEmoji, undefined, heroSize, HERO_PAD_RATIO);
return {
emoji: primaryEmoji,
emojiCanvas: bitmap.canvas,
drawSize: heroSize,
halfSize: heroSize / 2,
canvasScale: bitmap.canvasScale,
morphed: false,
};
})()
: null;
const spawnPlan = buildSpawnPlan(profile, primaryEmoji, startMs);
// Keep the spewing stand-in up for the whole fountain — not just a short flash.
const lastSpawnAt = spawnPlan.length > 0 ? spawnPlan[spawnPlan.length - 1].atMs : startMs;
const heroUntilMs = profile.morphTo
? profile.heroDurationMs != null
? startMs + profile.heroDurationMs
: lastSpawnAt + 120
: null;
return {
particles: [],
profile,
primaryEmoji,
morphAtMs: profile.morphTo ? startMs + (profile.morphAfterMs ?? 220) : null,
heroUntilMs,
hero,
width: widthPx,
height: heightPx,
origin: originPx,
startMs,
spawnPlan,
spawnCursor: 0,
floorY: heightPx - FLOOR_PAD,
pileCols: new Uint16Array(colCount),
pileCanvas: null,
pileCtx: null,
flyingCount: 0,
};
}
/** Keep the stand-in face glued to the live jumbo metrics. */
export function syncFireworkHero(
sim: FireworkSim,
metrics: { x: number; y: number; size: number } | null
) {
if (!sim.hero || !metrics) return;
sim.origin.x = metrics.x;
sim.origin.y = metrics.y;
setHeroSize(sim.hero, metrics.size);
}
/** @returns false when the burst should be removed. */
export function stepFireworkSim(sim: FireworkSim, now: number, dtSeconds: number): boolean {
if (now - sim.startMs > FIREWORK_TOTAL_MS) return false;
applyMorph(sim, now);
if (sim.hero && sim.heroUntilMs !== null && now >= sim.heroUntilMs) {
sim.hero = null;
}
const dt = Math.min(dtSeconds, 0.05);
const g = sim.profile.gravity;
let spawned = 0;
while (
spawned < MAX_SPAWNS_PER_FRAME &&
sim.spawnCursor < sim.spawnPlan.length &&
sim.spawnPlan[sim.spawnCursor].atMs <= now
) {
spawnOne(sim, sim.spawnPlan[sim.spawnCursor], now);
sim.spawnCursor += 1;
spawned += 1;
}
const floor = sim.floorY;
const left = 4;
const right = sim.width - 4;
for (let i = 0; i < sim.particles.length; i += 1) {
const p = sim.particles[i];
if (p.settled) continue;
p.vy += g * dt;
p.vx *= DRAG;
p.vy *= DRAG;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.angle += p.spin * dt;
if (p.x < left) {
p.x = left;
p.vx = Math.abs(p.vx) * BOUNCE;
} else if (p.x > right) {
p.x = right;
p.vx = -Math.abs(p.vx) * BOUNCE;
}
if (p.y >= floor - p.halfSize) {
p.y = floor - p.halfSize;
const speed = Math.hypot(p.vx, p.vy);
if (speed < SETTLE_SPEED || Math.abs(p.vy) < SETTLE_SPEED * 0.55) {
settleParticle(sim, p);
} else {
p.vy = -Math.abs(p.vy) * BOUNCE;
p.vx *= 0.85;
p.spin *= 0.7;
}
}
}
return true;
}
export function drawFireworkSim(
context: CanvasRenderingContext2D,
sim: FireworkSim,
now: number
) {
const elapsed = now - sim.startMs;
let fade = 1;
if (elapsed >= SETTLE_HOLD_MS) {
fade = 1 - Math.min(1, (elapsed - SETTLE_HOLD_MS) / FADE_MS);
}
context.setTransform(1, 0, 0, 1, 0, 0);
context.imageSmoothingEnabled = false;
if (sim.pileCanvas) {
context.globalAlpha = fade;
context.drawImage(sim.pileCanvas, 0, 0);
}
// Anchored face at the spew origin — morphs 🤢 → 🤮 mid-throw, then drops away.
if (sim.hero && fade > 0.02) {
const hero = sim.hero;
const heaveAmp = Math.max(1.5, hero.drawSize * 0.02);
const heave = Math.sin(elapsed / 70) * (hero.morphed ? heaveAmp * 1.25 : heaveAmp);
// Scale includes bitmap padding so the glyph matches jumbo size without clipping.
const box = hero.drawSize * hero.canvasScale;
const half = box / 2;
context.globalAlpha = fade;
context.setTransform(1, 0, 0, 1, sim.origin.x, sim.origin.y + heave);
context.drawImage(hero.emojiCanvas, -half, -half, box, box);
context.setTransform(1, 0, 0, 1, 0, 0);
}
for (let i = 0; i < sim.particles.length; i += 1) {
const p = sim.particles[i];
if (p.settled) continue;
const ageMs = now - p.bornAt;
if (ageMs < 0) continue;
let opacity = fade;
let scale = 1;
if (ageMs < SPARKLE_IN_MS) {
const t = ageMs / SPARKLE_IN_MS;
const appear = t * t * (3 - 2 * t);
scale = 0.5 + 0.65 * appear;
opacity *= appear;
}
if (opacity <= 0.02) continue;
const rad = (p.angle * Math.PI) / 180;
const size = p.drawSize * scale;
const half = size / 2;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
context.globalAlpha = opacity;
context.setTransform(cos, sin, -sin, cos, p.x, p.y);
context.drawImage(p.emojiCanvas, -half, -half, size, size);
}
context.globalAlpha = 1;
context.setTransform(1, 0, 0, 1, 0, 0);
}
export function getFireworkDpr(): number {
return 1;
}

View File

@@ -0,0 +1,34 @@
import { KeyboardEvent, MouseEvent } from 'react';
export type JumboEmojiClickHandler = (body: string, event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => void;
export function getJumboEmojiInteractionProps({
body,
isJumboEmoji,
onJumboEmojiClick,
}: {
body: string;
isJumboEmoji: boolean;
onJumboEmojiClick?: JumboEmojiClickHandler;
}) {
if (!isJumboEmoji || !onJumboEmojiClick) {
return {};
}
const activate = (event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => {
if ('key' in event) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
}
event.stopPropagation();
onJumboEmojiClick(body, event);
};
return {
role: 'button' as const,
tabIndex: 0,
title: 'Throw emoji confetti',
onClick: activate,
onKeyDown: activate,
};
}

View File

@@ -0,0 +1,54 @@
import { KeyboardEvent, MouseEvent } from 'react';
import { extractJumboEmojis } from './sendEmojiConfetti';
import { BurstPoint, getBurstPointFromElement } from './burstOrigin';
export function resolveClickedEmoji(
event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>,
fallbackBody: string
): { emoji: string; origin: BurstPoint } | null {
const currentTarget = event.currentTarget as HTMLElement;
const target = event.target as HTMLElement;
const emoticonEl = target.closest('[data-emoticon]') ?? currentTarget.querySelector('[data-emoticon]');
if (emoticonEl instanceof HTMLElement) {
const emoji = emoticonEl.getAttribute('data-emoticon');
if (emoji) {
return {
emoji,
origin: getBurstPointFromElement(emoticonEl),
};
}
}
if (!currentTarget.closest('[data-jumbo-emoji]') && !currentTarget.hasAttribute('data-jumbo-emoji')) {
return null;
}
const emojis = extractJumboEmojis(fallbackBody);
if (emojis.length === 0) return null;
if ('clientX' in event) {
return {
emoji: emojis[0],
origin: {
x: event.clientX,
y: event.clientY,
},
};
}
const firstEmoticon = currentTarget.querySelector('[data-emoticon]');
if (firstEmoticon instanceof HTMLElement) {
const emoji = firstEmoticon.getAttribute('data-emoticon');
if (emoji) {
return {
emoji,
origin: getBurstPointFromElement(firstEmoticon),
};
}
}
return {
emoji: emojis[0],
origin: getBurstPointFromElement(currentTarget),
};
}

View File

@@ -0,0 +1,42 @@
import { MatrixClient } from 'matrix-js-sdk';
import { EMOJI_REG_G, JUMBO_EMOJI_REG } from '../../../utils/regex';
import { trimReplyFromBody } from '../../../utils/room';
import { emojis } from '../../../plugins/emoji';
import { EMOJI_CONFETTI_EVENT_TYPE, EmojiConfettiContent } from './types';
const SHORTCODE_PATTERN = /:([^\s:]+):/g;
export function extractJumboEmojis(body: string): string[] {
const trimmedBody = trimReplyFromBody(body).trim();
if (!JUMBO_EMOJI_REG.test(trimmedBody)) return [];
const found: string[] = [];
for (const match of trimmedBody.matchAll(EMOJI_REG_G)) {
const emoji = match[1];
if (emoji) found.push(emoji);
}
for (const match of trimmedBody.matchAll(SHORTCODE_PATTERN)) {
const shortcode = match[1];
const resolved = emojis.find((emoji) => emoji.shortcode === shortcode);
if (resolved) found.push(resolved.unicode);
}
return found.length > 0 ? found : ['🎉'];
}
export function sendEmojiConfettiEvent(
mx: MatrixClient,
roomId: string,
targetEventId: string,
emojis: string[]
) {
const content: EmojiConfettiContent = {
emojis,
msgtype: EMOJI_CONFETTI_EVENT_TYPE,
target_event_id: targetEventId,
};
return mx.sendEvent(roomId, EMOJI_CONFETTI_EVENT_TYPE as never, content);
}

View File

@@ -0,0 +1,16 @@
import type { BurstPoint } from './burstOrigin';
export const EMOJI_CONFETTI_EVENT_TYPE = 'app.relay.emoji_confetti';
export type EmojiConfettiContent = {
emojis?: string[];
msgtype?: string;
target_event_id?: string;
};
export type EmojiConfettiBurst = {
id: string;
targetEventId: string;
emojis: string[];
origin: BurstPoint;
};

View File

@@ -0,0 +1,137 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
import { BurstPoint, resolveBurstOrigin } from './burstOrigin';
import {
EMOJI_CONFETTI_EVENT_TYPE,
EmojiConfettiBurst,
EmojiConfettiContent,
} from './types';
function scheduleBurstFromValues(
burstId: string,
emojis: string[],
targetEventId: string | undefined,
onBurst: (burst: EmojiConfettiBurst) => void,
attempt = 0,
explicitOrigin?: BurstPoint
) {
const targetElement =
targetEventId &&
document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
if (targetEventId && !targetElement && attempt < 8) {
window.setTimeout(
() =>
scheduleBurstFromValues(
burstId,
emojis,
targetEventId,
onBurst,
attempt + 1,
explicitOrigin
),
50
);
return;
}
onBurst({
id: burstId,
targetEventId: targetEventId ?? '',
emojis: emojis.length > 0 ? emojis : ['🎉'],
origin: resolveBurstOrigin(targetEventId, emojis, explicitOrigin),
});
}
function scheduleBurst(
event: MatrixEvent,
onBurst: (burst: EmojiConfettiBurst) => void,
attempt = 0
) {
const eventId = event.getId();
if (!eventId) return;
const { emojis, targetEventId } = parseEmojiConfettiContent(event.getContent());
scheduleBurstFromValues(eventId, emojis, targetEventId, onBurst, attempt);
}
function parseEmojiConfettiContent(content: unknown): { emojis: string[]; targetEventId?: string } {
const confettiContent = content as EmojiConfettiContent;
const emojis = Array.isArray(confettiContent.emojis)
? confettiContent.emojis.filter((emoji): emoji is string => typeof emoji === 'string' && emoji.length > 0)
: [];
return {
emojis: emojis.length > 0 ? emojis : ['🎉'],
targetEventId:
typeof confettiContent.target_event_id === 'string'
? confettiContent.target_event_id
: undefined,
};
}
export function useEmojiConfetti(room: Room) {
const [bursts, setBursts] = useState<EmojiConfettiBurst[]>([]);
const seenEventIdsRef = useRef(new Set<string>());
const ownEventIdsRef = useRef(new Set<string>());
const addBurst = useCallback((burst: EmojiConfettiBurst) => {
setBursts((current) => [...current, burst]);
}, []);
const registerOwnEventId = useCallback((eventId: string) => {
ownEventIdsRef.current.add(eventId);
}, []);
const queueBurst = useCallback((event: MatrixEvent) => {
const eventId = event.getId();
if (!eventId || seenEventIdsRef.current.has(eventId)) return;
seenEventIdsRef.current.add(eventId);
if (ownEventIdsRef.current.has(eventId)) {
ownEventIdsRef.current.delete(eventId);
return;
}
scheduleBurst(event, addBurst);
}, [addBurst]);
const triggerLocalBurst = useCallback(
(targetEventId: string, emojis: string[], origin?: BurstPoint) => {
const burstId = `local-${targetEventId}-${Date.now()}`;
scheduleBurstFromValues(burstId, emojis, targetEventId, addBurst, 0, origin);
},
[addBurst]
);
useEffect(() => {
const handleTimeline: (
event: MatrixEvent,
eventRoom: Room | undefined,
toStartOfTimeline?: boolean,
removed?: boolean
) => void = (event, eventRoom, toStartOfTimeline, removed) => {
if (removed || toStartOfTimeline || eventRoom?.roomId !== room.roomId) return;
if (event.getType() !== EMOJI_CONFETTI_EVENT_TYPE) return;
queueBurst(event);
};
room.on(RoomEvent.Timeline, handleTimeline);
return () => {
room.off(RoomEvent.Timeline, handleTimeline);
};
}, [queueBurst, room]);
const removeBurst = useCallback((burstId: string) => {
setBursts((current) => current.filter((burst) => burst.id !== burstId));
}, []);
return {
bursts,
removeBurst,
triggerLocalBurst,
registerOwnEventId,
};
}

View File

@@ -0,0 +1,37 @@
import { KeyboardEvent, MouseEvent, useCallback } from 'react';
import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { resolveClickedEmoji } from './resolveClickedEmoji';
import { sendEmojiConfettiEvent } from './sendEmojiConfetti';
import { useEmojiConfetti } from './useEmojiConfetti';
export function useJumboEmojiConfetti(room: Room) {
const mx = useMatrixClient();
const { bursts, removeBurst, triggerLocalBurst, registerOwnEventId } = useEmojiConfetti(room);
const handleJumboEmojiClick = useCallback(
(targetEventId: string, body: string, event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => {
const clicked = resolveClickedEmoji(event, body);
if (!clicked) return;
const emojis = [clicked.emoji];
triggerLocalBurst(targetEventId, emojis, clicked.origin);
sendEmojiConfettiEvent(mx, room.roomId, targetEventId, emojis)
.then((response) => {
const eventId = response?.event_id;
if (eventId) registerOwnEventId(eventId);
})
.catch((error) => {
console.error('[emoji-confetti] Failed to send confetti event:', error);
});
},
[mx, registerOwnEventId, room.roomId, triggerLocalBurst]
);
return {
bursts,
removeBurst,
handleJumboEmojiClick,
};
}

View File

@@ -62,6 +62,10 @@ import colorMXID from '../../../../util/colorMXID';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { Presence, useUserPresence } from '../../../hooks/useUserPresence';
import { useOtherUserColor } from '../../../hooks/useUserColor';
import { useIsDirectRoom } from '../../../hooks/useRoom';
import { useOtherUserCollectibles } from '../../../hooks/useUserCollectibles';
import { pickAvatarDecorationUrl, pickNameplateUrl } from '../../../utils/collectibleAssets';
import * as avatarDecorationCss from '../../../styles/AvatarDecoration.css';
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
@@ -754,8 +758,15 @@ export const Message = as<'div', MessageProps>(
) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const direct = useIsDirectRoom();
const senderId = mEvent.getSender() ?? '';
const senderPresence = useUserPresence(senderId);
const { assetUrls } = useOtherUserCollectibles(senderId);
const nameplateUrl = pickNameplateUrl(assetUrls);
const nameplateIsVideo = Boolean(
assetUrls['nameplate:animated'] || assetUrls['nameplate:asset.webm']
);
const avatarDecorationUrl = pickAvatarDecorationUrl(assetUrls);
const [hover, setHover] = useState(false);
const { hoverProps } = useHover({ onHoverChange: setHover });
@@ -785,7 +796,7 @@ export const Message = as<'div', MessageProps>(
const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
// Get custom user color from avatar metadata (takes priority)
const customUserColor = useOtherUserColor(senderId, senderAvatarMxc);
const customUserColor = useOtherUserColor(senderId, room);
const tagColor = memberPowerTag?.color
? accessibleTagColors?.get(memberPowerTag.color)
@@ -796,6 +807,7 @@ export const Message = as<'div', MessageProps>(
// Priority: custom user color > tag color (non-legacy) > colorMXID (legacy)
const usernameColor = customUserColor ?? (legacyUsernameColor ? colorMXID(senderId) : tagColor);
const showNameplate = direct && hover && Boolean(nameplateUrl);
const headerJSX = !collapse && (
<Box
@@ -824,23 +836,41 @@ export const Message = as<'div', MessageProps>(
</Username>
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
</Box>
<Box shrink="No" gap="100">
{messageLayout === MessageLayout.Modern && hover && (
<>
<Text as="span" size="T200" priority="300">
{senderId}
</Text>
<Text as="span" size="T200" priority="300">
|
</Text>
</>
<Box shrink="No" className={css.MessageNameplateAnchor}>
<Box shrink="No" gap="100" alignItems="Center">
{messageLayout === MessageLayout.Modern && hover && (
<>
<Text as="span" size="T200" priority="300">
{senderId}
</Text>
<Text as="span" size="T200" priority="300">
|
</Text>
</>
)}
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</Box>
{showNameplate && (
<div className={css.MessageNameplateWrap}>
{nameplateIsVideo ? (
<video
className={css.MessageNameplateOverlay}
src={nameplateUrl}
autoPlay
loop
muted
playsInline
/>
) : (
<img className={css.MessageNameplateOverlay} src={nameplateUrl} alt="" draggable={false} />
)}
</div>
)}
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</Box>
</Box>
);
@@ -857,24 +887,38 @@ export const Message = as<'div', MessageProps>(
<AvatarBase
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
>
<Avatar
className={classNames(css.MessageAvatar, presenceClass)}
as="button"
size="300"
data-user-id={senderId}
onClick={onUserClick}
>
<UserAvatar
userId={senderId}
src={
senderAvatarMxc
? mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined
: undefined
}
alt={senderDisplayName}
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
/>
</Avatar>
<div className={css.MessageAvatarStack}>
<Avatar
className={classNames(
avatarDecorationUrl ? css.MessageAvatarCircular : css.MessageAvatar,
!avatarDecorationUrl && presenceClass
)}
radii={avatarDecorationUrl ? 'Pill' : undefined}
as="button"
size="300"
data-user-id={senderId}
onClick={onUserClick}
>
<UserAvatar
userId={senderId}
src={
senderAvatarMxc
? mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined
: undefined
}
alt={senderDisplayName}
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
/>
</Avatar>
{avatarDecorationUrl && (
<img
className={avatarDecorationCss.AvatarDecorationOverlay}
src={avatarDecorationUrl}
alt=""
draggable={false}
/>
)}
</div>
</AvatarBase>
);
@@ -954,6 +998,10 @@ export const Message = as<'div', MessageProps>(
{...focusWithinProps}
ref={ref}
>
<div
className={css.MessageContentLayer}
data-message-nameplate-readable={showNameplate ? '' : undefined}
>
{!edit && (hover || !!menuAnchor || !!emojiBoardAnchor) && (
<div className={css.MessageOptionsBase}>
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
@@ -1242,6 +1290,7 @@ export const Message = as<'div', MessageProps>(
{msgContentJSX}
</ModernLayout>
)}
</div>
</MessageBase>
);
}

View File

@@ -1,5 +1,35 @@
import { style } from '@vanilla-extract/css';
import { globalStyle, style } from '@vanilla-extract/css';
import { DefaultReset, config, toRem, color } from 'folds';
import * as layoutCss from '../../../components/message/layout/layout.css';
const NAMEPLATE_READABLE_TEXT_OUTLINE = [
`0 0 2px ${color.Surface.ContainerHover}`,
`0 0 4px ${color.Surface.ContainerHover}`,
`-1px -1px 0 ${color.Surface.ContainerHover}`,
`1px -1px 0 ${color.Surface.ContainerHover}`,
`-1px 1px 0 ${color.Surface.ContainerHover}`,
`1px 1px 0 ${color.Surface.ContainerHover}`,
`0 -1px 0 ${color.Surface.ContainerHover}`,
`0 1px 0 ${color.Surface.ContainerHover}`,
`-1px 0 0 ${color.Surface.ContainerHover}`,
`1px 0 0 ${color.Surface.ContainerHover}`,
].join(', ');
globalStyle(`[data-message-nameplate-readable] [data-message-header] button`, {
textShadow: NAMEPLATE_READABLE_TEXT_OUTLINE,
});
globalStyle(`[data-message-nameplate-readable] [data-message-header] time`, {
textShadow: NAMEPLATE_READABLE_TEXT_OUTLINE,
});
globalStyle(`[data-message-nameplate-readable] [data-message-header] span`, {
textShadow: NAMEPLATE_READABLE_TEXT_OUTLINE,
});
globalStyle(`[data-message-nameplate-readable] .${layoutCss.MessageTextBody.classNames.base}`, {
textShadow: NAMEPLATE_READABLE_TEXT_OUTLINE,
});
export const MessageBase = style({
position: 'relative',
@@ -51,6 +81,12 @@ export const MessageAvatar = style({
},
});
export const MessageAvatarCircular = style({
cursor: 'pointer',
position: 'relative',
overflow: 'visible',
});
export const MessageAvatarOnline = style({
'::before': {
backgroundColor: '#38842b',
@@ -69,8 +105,41 @@ export const MessageAvatarOffline = style({
},
});
export const MessageQuickReaction = style({
minWidth: toRem(32),
export const MessageAvatarStack = style({
position: 'relative',
display: 'inline-flex',
lineHeight: 0,
overflow: 'visible',
});
export const MessageNameplateAnchor = style({
position: 'relative',
flexShrink: 0,
});
export const MessageNameplateWrap = style({
position: 'absolute',
top: `calc(100% + ${toRem(6)})`,
right: 0,
width: toRem(220),
maxWidth: toRem(280),
height: toRem(42),
pointerEvents: 'none',
zIndex: 0,
});
export const MessageNameplateOverlay = style({
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'right center',
pointerEvents: 'none',
borderRadius: toRem(6),
});
export const MessageContentLayer = style({
position: 'relative',
zIndex: 1,
});
export const MessageMenuGroup = style({

View File

@@ -116,7 +116,7 @@ function PinnedMessage({
const sender = pinnedEvent?.getSender();
const senderAvatarMxc = sender ? getMemberAvatarMxc(room, sender) : undefined;
const customUserColor = useOtherUserColor(sender ?? '', senderAvatarMxc);
const customUserColor = useOtherUserColor(sender ?? '', room);
const handleOpenClick: MouseEventHandler = (evt) => {
evt.stopPropagation();

View File

@@ -1,5 +1,7 @@
import React, { useCallback, useEffect, useState } from 'react';
import { getVersion } from '@tauri-apps/api/app';
import { getAppVersion } from '../../../utils/appVersion';
import { useOpenReleaseNotesDialog } from '../../../components/updates-dialog/UpdatesDialogHost';
import { getCurrentUpdatePreview } from '../../../data/updateNotes';
import { Box, Text, IconButton, Scroll, Button, config, toRem } from 'folds';
import { Icon, Icons } from '../../../components/icons';
import { Page, PageContent, PageHeader } from '../../../components/page';
@@ -15,9 +17,13 @@ type AboutProps = {
};
export function About({ requestClose }: AboutProps) {
const mx = useMatrixClient();
const openReleaseNotes = useOpenReleaseNotesDialog();
const [version, setVersion] = useState<string>('');
const [protocolStatus, setProtocolStatus] = useState<string>('Checking desktop protocol integration...');
const [protocolBusy, setProtocolBusy] = useState<boolean>(false);
const [updatePreview, setUpdatePreview] = useState<{ title: string; description: string } | null>(
null
);
const formatProtocolStatus = useCallback((data: {
scheme: string;
@@ -93,8 +99,9 @@ export function About({ requestClose }: AboutProps) {
};
useEffect(() => {
getVersion().then(setVersion).catch(() => setVersion('unknown'));
getAppVersion().then(setVersion).catch(() => setVersion('unknown'));
refreshProtocolStatus();
getCurrentUpdatePreview().then(setUpdatePreview).catch(() => setUpdatePreview(null));
}, [refreshProtocolStatus]);
return (
@@ -151,6 +158,36 @@ export function About({ requestClose }: AboutProps) {
</Box>
</Box>
</Box>
<Box direction="Column" gap="100">
<Text size="L400">What&apos;s new</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="300"
>
<SettingTile
title={updatePreview?.title ?? 'Release notes'}
description={
updatePreview
? `Paarrot ${version}${updatePreview.description}`
: 'View highlights from the latest Paarrot update.'
}
after={
<Button
onClick={openReleaseNotes}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
>
<Text size="B300">View updates</Text>
</Button>
}
/>
</SequenceCard>
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Options</Text>
<SequenceCard

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { DiscordCollectibleItem } from '../../../utils/discordCollectibles';
import {
isCatalogVideoPreview,
pickCatalogAnimatedPreviewUrl,
pickCatalogStaticPreviewUrl,
} from '../../../utils/collectibleAssets';
type CollectiblePreviewMediaProps = {
item: DiscordCollectibleItem;
className?: string;
restartToken?: number;
};
export function CollectiblePreviewMedia({
item,
className,
restartToken = 0,
}: CollectiblePreviewMediaProps) {
const staticUrl = pickCatalogStaticPreviewUrl(item);
const animatedUrl = pickCatalogAnimatedPreviewUrl(item);
const activeUrl = animatedUrl ?? staticUrl;
if (!activeUrl) return null;
if (animatedUrl && isCatalogVideoPreview(animatedUrl)) {
return (
<video
key={restartToken}
className={className}
src={animatedUrl}
autoPlay
loop
muted
playsInline
/>
);
}
return (
<img
key={restartToken}
className={className}
src={activeUrl}
alt=""
loading="lazy"
draggable={false}
/>
);
}

View File

@@ -0,0 +1,332 @@
import { style } from '@vanilla-extract/css';
import { color, toRem } from 'folds';
/** Discord nameplate static.png assets are 448×84. */
export const NAMEPLATE_ASPECT_RATIO = '448 / 84';
/**
* Mini profile hero preview — matches UserRoomProfile menu width (260) and UserHero layout,
* with extra vertical space below the name so effects have room to play.
*/
export const PROFILE_HERO_PREVIEW_WIDTH = 260;
export const PROFILE_HERO_CONTENT_HEIGHT = 235;
export const PROFILE_HERO_PREVIEW_EXTRA_HEIGHT = Math.round(PROFILE_HERO_CONTENT_HEIGHT * 0.5);
export const PROFILE_HERO_PREVIEW_HEIGHT = PROFILE_HERO_CONTENT_HEIGHT + PROFILE_HERO_PREVIEW_EXTRA_HEIGHT;
export const PROFILE_HERO_PREVIEW_ASPECT_RATIO = `${PROFILE_HERO_PREVIEW_WIDTH} / ${PROFILE_HERO_PREVIEW_HEIGHT}`;
export const CollectibleGroupGrid = style({
display: 'grid',
width: '100%',
});
export const CollectibleGroupGridNameplate = style({
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
gap: toRem(3),
});
export const CollectibleGroupGridEffects = style({
gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))',
gap: toRem(12),
});
export const CollectibleGroupGridDecorations = style({
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: toRem(12),
});
export const CollectibleGroupCard = style({
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: toRem(4),
width: '100%',
});
export const CollectibleGroupPreview = style({
position: 'relative',
display: 'block',
width: '100%',
border: 'none',
padding: 0,
overflow: 'visible',
background: 'transparent',
cursor: 'pointer',
textAlign: 'left',
});
export const CollectibleGroupPreviewDisabled = style({
cursor: 'wait',
});
export const CollectibleGroupPreviewDimmed = style({
opacity: 0.5,
});
export const CollectiblePreviewFrame = style({
width: '100%',
borderRadius: toRem(6),
overflow: 'hidden',
backgroundColor: color.Surface.ContainerLine,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const CollectiblePreviewFrameDecoration = style({
overflow: 'visible',
backgroundColor: 'transparent',
});
export const CollectiblePreviewFrameProfileEffect = style({
backgroundColor: color.Surface.Container,
alignItems: 'stretch',
justifyContent: 'stretch',
});
export const ProfileEffectHeroPreview = style({
position: 'relative',
width: '100%',
height: '100%',
overflow: 'hidden',
backgroundColor: color.Surface.Container,
});
export const ProfileEffectHeroCover = style({
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: `${(140 / PROFILE_HERO_PREVIEW_HEIGHT) * 100}%`,
overflow: 'hidden',
zIndex: 1,
});
export const ProfileEffectHeroBanner = style({
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
});
export const ProfileEffectHeroCoverBlur = style({
width: '100%',
height: '100%',
objectFit: 'cover',
filter: 'blur(16px)',
transform: 'scale(2)',
display: 'block',
});
export const ProfileEffectHeroAvatarSlot = style({
position: 'absolute',
left: 0,
right: 0,
top: `${(140 / PROFILE_HERO_PREVIEW_HEIGHT) * 100}%`,
height: `${(29 / PROFILE_HERO_PREVIEW_HEIGHT) * 100}%`,
zIndex: 4,
pointerEvents: 'none',
});
export const ProfileEffectHeroAvatar = style({
position: 'absolute',
left: '50%',
top: 0,
transform: 'translate(-50%, -50%)',
width: `${(72 / PROFILE_HERO_PREVIEW_WIDTH) * 100}%`,
aspectRatio: '1',
borderRadius: '50%',
overflow: 'hidden',
border: `${toRem(1)} solid ${color.Surface.Container}`,
backgroundColor: color.Surface.Container,
});
export const ProfileEffectHeroAvatarImg = style({
width: '100%',
height: '100%',
display: 'block',
objectFit: 'cover',
});
export const ProfileEffectHeroInfo = style({
position: 'absolute',
left: 0,
right: 0,
top: `${((140 + 29) / PROFILE_HERO_PREVIEW_HEIGHT) * 100}%`,
marginTop: `${(-36 / PROFILE_HERO_PREVIEW_HEIGHT) * 100}%`,
paddingTop: `${(44 / PROFILE_HERO_PREVIEW_HEIGHT) * 100}%`,
paddingLeft: `${(16 / PROFILE_HERO_PREVIEW_WIDTH) * 100}%`,
paddingRight: `${(16 / PROFILE_HERO_PREVIEW_WIDTH) * 100}%`,
paddingBottom: `${(16 / PROFILE_HERO_PREVIEW_HEIGHT) * 100}%`,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: `${(8 / PROFILE_HERO_PREVIEW_HEIGHT) * 100}%`,
textAlign: 'center',
zIndex: 4,
pointerEvents: 'none',
});
export const ProfileEffectHeroName = style({
width: '100%',
fontSize: toRem(11),
lineHeight: 1.2,
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
export const ProfileEffectHeroEffectLayer = style({
position: 'absolute',
inset: 0,
zIndex: 10,
pointerEvents: 'none',
overflow: 'visible',
});
export const ProfileEffectHeroEffect = style({
position: 'absolute',
left: 0,
right: 0,
top: 0,
width: '100%',
aspectRatio: '450 / 880',
objectFit: 'contain',
objectPosition: 'top center',
});
export const NameplatePreview = style({
display: 'block',
width: '100%',
aspectRatio: NAMEPLATE_ASPECT_RATIO,
objectFit: 'cover',
objectPosition: 'center',
borderRadius: toRem(4),
});
export const NameplatePreviewFallback = style({
width: '100%',
aspectRatio: NAMEPLATE_ASPECT_RATIO,
borderRadius: toRem(4),
});
export const CollectibleHoverCaption = style({
position: 'absolute',
top: `calc(100% + ${toRem(2)})`,
left: 0,
right: 0,
display: 'flex',
flexDirection: 'column',
gap: toRem(1),
padding: `${toRem(4)} ${toRem(6)}`,
background: color.Surface.Container,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
borderRadius: toRem(4),
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.12)',
opacity: 0,
transition: 'opacity 0.15s ease',
pointerEvents: 'none',
zIndex: 2,
selectors: {
[`${CollectibleGroupPreview}:hover &`]: {
opacity: 1,
},
[`${CollectibleGroupPreview}:focus-visible &`]: {
opacity: 1,
},
},
});
export const CollectibleHoverLabel = style({
color: color.Surface.OnContainer,
fontSize: toRem(12),
fontWeight: 500,
lineHeight: 1.3,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
export const CollectibleHoverMeta = style({
color: color.Surface.OnContainer,
fontSize: toRem(10),
lineHeight: 1.3,
opacity: 0.7,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
export const VariantRow = style({
display: 'flex',
flexWrap: 'wrap',
gap: toRem(4),
justifyContent: 'center',
padding: `0 ${toRem(2)}`,
});
export const VariantChip = style({
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
borderRadius: toRem(4),
padding: 0,
background: color.Surface.Container,
cursor: 'pointer',
overflow: 'hidden',
flexShrink: 0,
});
export const VariantChipSelected = style({
border: `${toRem(2)} solid ${color.Primary.Main}`,
});
export const VariantChipNameplate = style({
width: toRem(28),
height: toRem(10),
borderRadius: toRem(3),
});
export const VariantChipThumbnail = style({
width: toRem(28),
height: toRem(28),
objectFit: 'cover',
display: 'block',
});
export const VariantChipDecorationStack = style({
position: 'relative',
width: toRem(28),
height: toRem(28),
display: 'block',
});
export const VariantChipDecorationAvatar = style({
width: '62.5%',
height: '62.5%',
borderRadius: '50%',
overflow: 'hidden',
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
});
export const DecorationPreviewAvatar = style({
width: '100%',
height: '100%',
display: 'block',
objectFit: 'cover',
});
export const GridPreviewMedia = style({
width: '100%',
height: '100%',
objectFit: 'contain',
objectPosition: 'top center',
});
export const GridPreviewMediaCover = style({
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'top center',
});

View File

@@ -0,0 +1,613 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import classNames from 'classnames';
import { Box, Button, Input, Spinner, Text, color, toRem } from 'folds';
import { Icon, Icons } from '../../../components/icons';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useUserProfile } from '../../../hooks/useUserProfile';
import { useUserBanner } from '../../../hooks/useUserBanner';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { UserAvatar } from '../../../components/user-avatar';
import {
CollectibleKind,
CollectibleVariantGroup,
DiscordCollectibleItem,
collectibleKindLabel,
defaultPreviewAspectRatio,
groupCollectibleItems,
isElectronCollectiblesAvailable,
uploadCollectibleToMatrix,
variantItemLabel,
} from '../../../utils/discordCollectibles';
import { nameplatePaletteGradient } from '../../../utils/discordNameplatePalettes';
import {
AVATAR_DECORATION_INNER_PERCENT,
AvatarDecorationOverlay,
} from '../../../styles/AvatarDecoration.css';
import { pickCatalogStaticPreviewUrl } from '../../../utils/collectibleAssets';
import * as css from './CollectiblesSection.css';
import { CollectiblePreviewMedia } from './CollectiblePreviewMedia';
import { ProfileEffectHeroPreview } from './ProfileEffectHeroPreview';
import { StoredCollectible, CollectibleProfileFields } from '../../../utils/profileFields';
type CollectiblesSectionProps = {
collectibles: CollectibleProfileFields;
onApply: (kind: CollectibleKind, collectible: StoredCollectible | undefined) => Promise<void>;
};
const KIND_TABS: CollectibleKind[] = ['profile_effect', 'nameplate', 'avatar_decoration'];
const KIND_FIELD: Record<CollectibleKind, keyof CollectibleProfileFields> = {
profile_effect: 'profile_effect',
nameplate: 'nameplate',
avatar_decoration: 'avatar_decoration',
};
function getItemAspectRatio(item: DiscordCollectibleItem): number {
return item.previewAspectRatio ?? defaultPreviewAspectRatio(item.type);
}
function CollectiblePreview({
item,
kind,
previewAvatarUrl,
previewBannerUrl,
previewDisplayName,
previewUserId,
restartToken,
}: {
item: DiscordCollectibleItem;
kind: CollectibleKind;
previewAvatarUrl?: string;
previewBannerUrl?: string;
previewDisplayName?: string;
previewUserId: string;
restartToken: number;
}) {
if (kind === 'nameplate') {
const swatchBackground =
item.previewGradient || nameplatePaletteGradient(item.palette) || color.Surface.ContainerLine;
if (item.thumbnailUrl || item.assets.length > 0) {
return (
<CollectiblePreviewMedia
item={item}
className={css.NameplatePreview}
restartToken={restartToken}
/>
);
}
return (
<div className={css.NameplatePreviewFallback} style={{ background: swatchBackground }} />
);
}
if (kind === 'avatar_decoration') {
return (
<Box
style={{
position: 'relative',
width: `${AVATAR_DECORATION_INNER_PERCENT}%`,
aspectRatio: '1',
}}
>
<Box
style={{
width: '100%',
height: '100%',
borderRadius: '50%',
overflow: 'hidden',
backgroundColor: color.Surface.Container,
}}
>
<UserAvatar
className={css.DecorationPreviewAvatar}
userId={previewUserId}
src={previewAvatarUrl}
alt=""
renderFallback={() => <Icon size="300" src={Icons.User} filled />}
/>
</Box>
<CollectiblePreviewMedia
item={item}
className={AvatarDecorationOverlay}
restartToken={restartToken}
/>
</Box>
);
}
if (kind === 'profile_effect') {
return (
<ProfileEffectHeroPreview
item={item}
userId={previewUserId}
avatarUrl={previewAvatarUrl}
bannerUrl={previewBannerUrl}
displayName={previewDisplayName}
restartToken={restartToken}
/>
);
}
return null;
}
function VariantChip({
item,
kind,
selected,
previewAvatarUrl,
previewUserId,
onSelect,
}: {
item: DiscordCollectibleItem;
kind: CollectibleKind;
selected: boolean;
previewAvatarUrl?: string;
previewUserId: string;
onSelect: () => void;
}) {
const label = variantItemLabel(item);
if (kind === 'nameplate') {
const swatchBackground =
item.previewGradient || nameplatePaletteGradient(item.palette) || color.Surface.ContainerLine;
return (
<button
type="button"
className={classNames(css.VariantChip, selected && css.VariantChipSelected)}
onClick={onSelect}
title={label}
aria-label={label}
aria-pressed={selected}
>
<div className={css.VariantChipNameplate} style={{ background: swatchBackground }} />
</button>
);
}
if (kind === 'avatar_decoration') {
const thumbUrl = pickCatalogStaticPreviewUrl(item);
return (
<button
type="button"
className={classNames(css.VariantChip, selected && css.VariantChipSelected)}
onClick={onSelect}
title={label}
aria-label={label}
aria-pressed={selected}
>
<div className={css.VariantChipDecorationStack}>
<div className={css.VariantChipDecorationAvatar}>
<UserAvatar
className={css.DecorationPreviewAvatar}
userId={previewUserId}
src={previewAvatarUrl}
alt=""
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
/>
</div>
{thumbUrl && (
<img
className={AvatarDecorationOverlay}
src={thumbUrl}
alt=""
draggable={false}
/>
)}
</div>
</button>
);
}
const thumbUrl = pickCatalogStaticPreviewUrl(item);
return (
<button
type="button"
className={classNames(css.VariantChip, selected && css.VariantChipSelected)}
onClick={onSelect}
title={label}
aria-label={label}
aria-pressed={selected}
>
{thumbUrl ? (
<img className={css.VariantChipThumbnail} src={thumbUrl} alt="" loading="lazy" />
) : (
<div className={css.VariantChipThumbnail} />
)}
</button>
);
}
function CollectibleVariantGroupCard({
group,
kind,
selectedItem,
onSelectItem,
applyingId,
onApply,
previewAvatarUrl,
previewBannerUrl,
previewDisplayName,
previewUserId,
}: {
group: CollectibleVariantGroup;
kind: CollectibleKind;
selectedItem: DiscordCollectibleItem;
onSelectItem: (item: DiscordCollectibleItem) => void;
applyingId?: string;
onApply: (item: DiscordCollectibleItem) => void;
previewAvatarUrl?: string;
previewBannerUrl?: string;
previewDisplayName?: string;
previewUserId: string;
}) {
const aspectRatio =
kind === 'profile_effect'
? css.PROFILE_HERO_PREVIEW_ASPECT_RATIO
: String(getItemAspectRatio(selectedItem));
const meta = [variantItemLabel(selectedItem), group.category].filter(Boolean).join(' · ');
const [restartToken, setRestartToken] = useState(0);
const handlePreviewHover = () => {
setRestartToken((token) => token + 1);
};
return (
<div className={css.CollectibleGroupCard}>
<button
type="button"
className={classNames(
css.CollectibleGroupPreview,
applyingId && css.CollectibleGroupPreviewDisabled,
applyingId && applyingId !== selectedItem.id && css.CollectibleGroupPreviewDimmed
)}
onMouseEnter={handlePreviewHover}
onClick={() => onApply(selectedItem)}
disabled={Boolean(applyingId)}
title={selectedItem.label}
aria-label={selectedItem.label}
>
<div
className={classNames(
css.CollectiblePreviewFrame,
kind === 'avatar_decoration' && css.CollectiblePreviewFrameDecoration,
kind === 'profile_effect' && css.CollectiblePreviewFrameProfileEffect
)}
style={{
aspectRatio: kind === 'nameplate' ? undefined : aspectRatio,
}}
>
<CollectiblePreview
item={selectedItem}
kind={kind}
previewAvatarUrl={previewAvatarUrl}
previewBannerUrl={previewBannerUrl}
previewDisplayName={previewDisplayName}
previewUserId={previewUserId}
restartToken={restartToken}
/>
</div>
<div className={css.CollectibleHoverCaption}>
<span className={css.CollectibleHoverLabel}>{selectedItem.label}</span>
<span className={css.CollectibleHoverMeta}>{meta}</span>
</div>
</button>
{group.items.length > 1 && (
<div className={css.VariantRow}>
{group.items.map((item) => (
<VariantChip
key={item.id}
item={item}
kind={kind}
selected={item.skuId === selectedItem.skuId}
previewAvatarUrl={previewAvatarUrl}
previewUserId={previewUserId}
onSelect={() => onSelectItem(item)}
/>
))}
</div>
)}
</div>
);
}
export function CollectiblesSection({ collectibles, onApply }: CollectiblesSectionProps) {
const mx = useMatrixClient();
const userId = mx.getUserId() ?? '';
const profile = useUserProfile(userId);
const [bannerMxc] = useUserBanner();
const useAuthentication = useMediaAuthentication();
const previewAvatarUrl = useMemo(
() =>
profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined,
[mx, profile.avatarUrl, useAuthentication]
);
const previewBannerUrl = useMemo(
() =>
bannerMxc ? mxcUrlToHttp(mx, bannerMxc, useAuthentication) ?? undefined : undefined,
[bannerMxc, mx, useAuthentication]
);
const previewDisplayName = profile.displayName;
const [kind, setKind] = useState<CollectibleKind>('profile_effect');
const [hasToken, setHasToken] = useState(false);
const [tokenInput, setTokenInput] = useState('');
const [tokenSaving, setTokenSaving] = useState(false);
const [catalogLoading, setCatalogLoading] = useState(false);
const [catalogError, setCatalogError] = useState<string>();
const [items, setItems] = useState<DiscordCollectibleItem[]>([]);
const [search, setSearch] = useState('');
const [applyingId, setApplyingId] = useState<string>();
const [applyProgress, setApplyProgress] = useState<string>();
const [error, setError] = useState<string>();
const [selectedByGroup, setSelectedByGroup] = useState<Record<string, string>>({});
const desktopAvailable = isElectronCollectiblesAvailable();
const refreshTokenState = useCallback(async () => {
if (!desktopAvailable) return;
const result = await window.electron!.discordCollectibles!.hasToken();
setHasToken(Boolean(result?.data));
}, [desktopAvailable]);
const loadCatalog = useCallback(async (force = false) => {
if (!desktopAvailable) return;
setCatalogLoading(true);
setCatalogError(undefined);
try {
const result = await window.electron!.discordCollectibles!.fetchCatalog(force);
if (!result?.success || !result.data?.items) {
throw new Error(result?.error || 'Failed to load Discord catalog.');
}
setItems(result.data.items as DiscordCollectibleItem[]);
} catch (e) {
setCatalogError(e instanceof Error ? e.message : 'Failed to load catalog.');
setItems([]);
}
setCatalogLoading(false);
}, [desktopAvailable]);
useEffect(() => {
refreshTokenState();
}, [refreshTokenState]);
useEffect(() => {
if (hasToken) {
loadCatalog();
}
}, [hasToken, loadCatalog]);
const current = collectibles[KIND_FIELD[kind]];
const filteredGroups = useMemo(() => {
const query = search.trim().toLowerCase();
const filtered = items
.filter((item) => item.type === kind)
.filter((item) => {
if (!query) return true;
return (
item.name.toLowerCase().includes(query) ||
item.label.toLowerCase().includes(query) ||
item.category.toLowerCase().includes(query) ||
variantItemLabel(item).toLowerCase().includes(query)
);
});
return groupCollectibleItems(filtered);
}, [items, kind, search]);
useEffect(() => {
setSelectedByGroup((prev) => {
const next = { ...prev };
for (const group of filteredGroups) {
if (!next[group.id] || !group.items.some((item) => item.skuId === next[group.id])) {
next[group.id] = group.items[0]?.skuId ?? '';
}
}
return next;
});
}, [filteredGroups]);
const handleSaveToken = async () => {
if (!desktopAvailable) return;
setTokenSaving(true);
setCatalogError(undefined);
try {
await window.electron!.discordCollectibles!.setToken(tokenInput);
setTokenInput('');
await refreshTokenState();
} catch (e) {
setCatalogError(e instanceof Error ? e.message : 'Failed to save token.');
}
setTokenSaving(false);
};
const handleClearToken = async () => {
if (!desktopAvailable) return;
await window.electron!.discordCollectibles!.clearToken();
setItems([]);
await refreshTokenState();
};
const handleApply = async (item: DiscordCollectibleItem) => {
setApplyingId(item.id);
setError(undefined);
setApplyProgress(undefined);
try {
const stored = await uploadCollectibleToMatrix(mx, item, setApplyProgress);
await onApply(item.type, stored);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to apply collectible.');
}
setApplyingId(undefined);
setApplyProgress(undefined);
};
const handleRemove = async () => {
setError(undefined);
try {
await onApply(kind, undefined);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to remove collectible.');
}
};
const gridClassName = classNames(
css.CollectibleGroupGrid,
kind === 'nameplate' && css.CollectibleGroupGridNameplate,
kind === 'profile_effect' && css.CollectibleGroupGridEffects,
kind === 'avatar_decoration' && css.CollectibleGroupGridDecorations
);
if (!desktopAvailable) {
return (
<Box direction="Column" gap="200">
<Text size="T200" style={{ opacity: 0.8 }}>
Discord profile overlays are available in the Paarrot desktop app. Assets are fetched live from
Discord and uploaded to your Matrix profile when you pick one.
</Text>
</Box>
);
}
return (
<Box direction="Column" gap="300">
<Text size="T200" style={{ opacity: 0.8 }}>
Browse Discord shop collectibles live, then download and upload only what you pick to Matrix.
Your Discord token stays on this device and is only used to list the shop catalog.
</Text>
{!hasToken ? (
<Box direction="Column" gap="200">
<Text size="T300">Discord token (required to list the shop)</Text>
<Input
size="300"
variant="Secondary"
style={{ width: '100%' }}
type="password"
placeholder="User token or Bot xxxxx"
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
/>
<Button
size="300"
variant="Primary"
fill="Solid"
radii="300"
onClick={handleSaveToken}
disabled={tokenSaving || !tokenInput.trim()}
>
<Text size="B300">{tokenSaving ? 'Saving…' : 'Save token'}</Text>
</Button>
{catalogError && <Text size="T200" style={{ color: color.Critical.Main }}>{catalogError}</Text>}
</Box>
) : (
<Box gap="200" alignItems="Center" wrap="Wrap">
<Text size="T200" style={{ opacity: 0.8 }}>Discord token saved on this device.</Text>
<Button size="300" variant="Secondary" fill="Soft" radii="300" onClick={() => loadCatalog(true)} disabled={catalogLoading}>
<Text size="B300">{catalogLoading ? 'Refreshing…' : 'Refresh catalog'}</Text>
</Button>
<Button size="300" variant="Critical" fill="Soft" radii="300" onClick={handleClearToken}>
<Text size="B300">Remove token</Text>
</Button>
</Box>
)}
{hasToken && (
<>
<Box gap="200" wrap="Wrap">
{KIND_TABS.map((tab) => (
<Button
key={tab}
size="300"
variant={tab === kind ? 'Primary' : 'Secondary'}
fill={tab === kind ? 'Solid' : 'Soft'}
radii="300"
onClick={() => setKind(tab)}
>
<Text size="B300">{collectibleKindLabel(tab)}</Text>
</Button>
))}
</Box>
<Input
size="300"
variant="Secondary"
style={{ width: '100%' }}
placeholder={`Search ${collectibleKindLabel(kind).toLowerCase()}s…`}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{current && (
<Box gap="200" alignItems="Center" wrap="Wrap">
<Text size="T300">Active: {current.name}</Text>
<Button size="300" variant="Critical" fill="Soft" radii="300" onClick={handleRemove}>
<Text size="B300">Remove</Text>
</Button>
</Box>
)}
{catalogLoading && (
<Box gap="200" alignItems="Center">
<Spinner size="100" variant="Secondary" />
<Text size="T200">Loading catalog from Discord</Text>
</Box>
)}
{catalogError && !catalogLoading && (
<Text size="T200" style={{ color: color.Critical.Main }}>{catalogError}</Text>
)}
{applyProgress && (
<Box gap="200" alignItems="Center">
<Spinner size="100" variant="Secondary" />
<Text size="T200">{applyProgress}</Text>
</Box>
)}
{error && <Text size="T200" style={{ color: color.Critical.Main }}>{error}</Text>}
<div className={gridClassName}>
{filteredGroups.map((group) => {
const selectedSku = selectedByGroup[group.id] ?? group.items[0]?.skuId;
const selectedItem =
group.items.find((item) => item.skuId === selectedSku) ?? group.items[0];
if (!selectedItem) return null;
return (
<CollectibleVariantGroupCard
key={group.id}
group={group}
kind={kind}
selectedItem={selectedItem}
onSelectItem={(item) =>
setSelectedByGroup((prev) => ({ ...prev, [group.id]: item.skuId }))
}
applyingId={applyingId}
onApply={handleApply}
previewAvatarUrl={previewAvatarUrl}
previewBannerUrl={previewBannerUrl}
previewDisplayName={previewDisplayName}
previewUserId={userId}
/>
);
})}
</div>
{!catalogLoading && filteredGroups.length === 0 && (
<Text size="T200" style={{ opacity: 0.7 }}>No items match your search.</Text>
)}
</>
)}
</Box>
);
}

View File

@@ -11,7 +11,7 @@ import React, {
import classNames from 'classnames';
import { Box, Text, IconButton, Input, Avatar, Button, Overlay, OverlayBackdrop, OverlayCenter, Modal, Dialog, Header, config, Spinner, color, toRem } from 'folds';
import { Icon, Icons } from '../../../components/icons';
import { HexColorPicker, RgbaColorPicker, RgbaColor } from 'react-colorful';
import { HexColorPicker } from 'react-colorful';
import FocusTrap from 'focus-trap-react';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
@@ -19,7 +19,7 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { UserAvatar } from '../../../components/user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useAuthenticatedMediaUrl } from '../../../hooks/useAuthenticatedMediaUrl';
import { nameInitials, getContrastingTextColor, stripAlphaFromColor, getTextShadowColor } from '../../../utils/common';
import { getTextShadowColor } from '../../../utils/common';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useFilePicker } from '../../../hooks/useFilePicker';
import { useObjectURL } from '../../../hooks/useObjectURL';
@@ -29,28 +29,26 @@ import { ModalWide } from '../../../styles/Modal.css';
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useCapabilities } from '../../../hooks/useCapabilities';
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
import { AngleSelector } from '../../../components/AngleSelector';
import { useUserColor, useOtherUserColor } from '../../../hooks/useUserColor';
import { useUserBanner, useOtherUserBanner } from '../../../hooks/useUserBanner';
import { useUserProfileStyle } from '../../../hooks/useUserProfileStyle';
import { useUserColorPreference } from '../../../hooks/useUserColor';
import { useUserBanner } from '../../../hooks/useUserBanner';
import { useUserPresence } from '../../../hooks/useUserPresence';
import { useTheme, ThemeKind } from '../../../hooks/useTheme';
import { AvatarPresence, PresenceBadge } from '../../../components/presence';
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
import colorMXID, { getColorMXIDValue } from '../../../../util/colorMXID';
import {
extractMetadataFromImage,
embedMetadataInImage,
detectImageFormat,
getMimeType,
getExtension,
ImageMetadata,
} from '../../../utils/imageMetadata';
import { getCurrentAccessToken } from '../../../utils/auth';
import { CollectiblesSection } from './CollectiblesSection';
import { useUserCollectibles } from '../../../hooks/useUserCollectibles';
import { pickAvatarDecorationUrl } from '../../../utils/collectibleAssets';
import * as avatarDecorationCss from '../../../styles/AvatarDecoration.css';
import {
ColorPreference,
hasColorPreference,
resolveColorForTheme,
} from '../../../utils/profileFields';
/**
* Banner upload component for user's profile banner
* Stored in avatar image metadata, visible to other Paarrot users
* Banner upload component for user's profile banner (MSC4427 via MSC4133).
*/
function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTMLDivElement>; nameRef: React.RefObject<HTMLDivElement> }) {
const mx = useMatrixClient();
@@ -59,6 +57,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
const profile = useUserProfile(userId);
const presence = useUserPresence(userId);
const [userBanner, setUserBanner, loading] = useUserBanner();
const [collectibles, updateCollectible] = useUserCollectibles();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string>();
const [hoveredArea, setHoveredArea] = useState<string | null>(null);
@@ -90,32 +89,42 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const avatarDecorationUrl = useMemo(() => {
const decoration = collectibles.avatar_decoration;
if (!decoration) return undefined;
const assetUrls: Record<string, string | undefined> = {};
for (const [role, mxc] of Object.entries(decoration.assets)) {
assetUrls[`avatar_decoration:${role}`] =
mxcUrlToHttp(mx, mxc, useAuthentication) ?? undefined;
}
return pickAvatarDecorationUrl(assetUrls);
}, [collectibles.avatar_decoration, mx, useAuthentication]);
// Larger avatar URL for the blurred cover fallback
const avatarCoverUrl = profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication) ?? undefined
: undefined;
const authenticatedCoverUrl = useAuthenticatedMediaUrl(avatarCoverUrl, useAuthentication);
const [userColor, setUserColor] = useUserColor();
const [localColor, setLocalColor] = useState(userColor ?? '#3b82f6');
const theme = useTheme();
const [colorPreference, setColorPreference, colorLoading] = useUserColorPreference();
const [localOnDark, setLocalOnDark] = useState('#ffd9f5');
const [localOnLight, setLocalOnLight] = useState('#440000');
const [savingColor, setSavingColor] = useState(false);
const [colorError, setColorError] = useState<string>();
useEffect(() => {
if (userColor) {
setLocalColor(userColor);
}
}, [userColor]);
if (colorPreference?.on_dark) setLocalOnDark(colorPreference.on_dark);
if (colorPreference?.on_light) setLocalOnLight(colorPreference.on_light);
}, [colorPreference]);
const handleColorSave = async () => {
setSavingColor(true);
setColorError(undefined);
try {
await setUserColor(localColor);
// Sync user object to propagate changes throughout app
await syncUserAvatar();
await setColorPreference({ on_dark: localOnDark, on_light: localOnLight });
} catch (e) {
setColorError('Failed to save color');
setColorError('Failed to save colors. Your server may not support profile fields (MSC4133).');
}
setSavingColor(false);
};
@@ -124,141 +133,15 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
setSavingColor(true);
setColorError(undefined);
try {
await setUserColor(undefined);
await setColorPreference(undefined);
setLocalOnDark('#ffd9f5');
setLocalOnLight('#440000');
} catch (e) {
setColorError('Failed to remove color');
setColorError('Failed to remove colors');
}
setSavingColor(false);
};
// Profile style settings (border color and gradient)
const [profileStyle, setProfileStyle, styleLoading] = useUserProfileStyle();
// Initialize with transparent alpha so saved values show in preview until user edits
const [localBorderColor, setLocalBorderColor] = useState<RgbaColor>({ r: 59, g: 130, b: 246, a: 0 });
const [localGradientStart, setLocalGradientStart] = useState<RgbaColor>({ r: 0, g: 0, b: 0, a: 0 });
const [localGradientStop, setLocalGradientStop] = useState<RgbaColor>({ r: 0, g: 0, b: 0, a: 0 });
const [localGradientDirection, setLocalGradientDirection] = useState(180); // degrees (180 = top to bottom)
const [savingStyle, setSavingStyle] = useState(false);
const [styleError, setStyleError] = useState<string>();
// Track if user has started editing (to show local values in preview)
const [editingBorder, setEditingBorder] = useState(false);
const [editingGradient, setEditingGradient] = useState(false);
// Helper to convert RGBA to hex with alpha (#RRGGBBAA)
const rgbaToHex = (rgba: RgbaColor): string => {
const r = rgba.r.toString(16).padStart(2, '0');
const g = rgba.g.toString(16).padStart(2, '0');
const b = rgba.b.toString(16).padStart(2, '0');
const a = Math.round(rgba.a * 255).toString(16).padStart(2, '0');
return `#${r}${g}${b}${a}`;
};
// Helper to convert hex with alpha to RGBA
const hexToRgba = (hex: string): RgbaColor => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i.exec(hex);
if (result) {
return {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
a: result[4] ? parseInt(result[4], 16) / 255 : 1,
};
}
return { r: 59, g: 130, b: 246, a: 1 };
};
// Helper to parse degrees from direction string (e.g., "180deg" -> 180)
const parseDirectionDegrees = (direction: string): number => {
const degMatch = direction.match(/^(\d+)deg$/i);
if (degMatch) return parseInt(degMatch[1], 10);
// Fallback for legacy "to X" format
const keywordMap: Record<string, number> = {
'to top': 0,
'to top right': 45,
'to right': 90,
'to bottom right': 135,
'to bottom': 180,
'to bottom left': 225,
'to left': 270,
'to top left': 315,
};
return keywordMap[direction.toLowerCase()] ?? 180;
};
// Sync local style state with loaded profile style
useEffect(() => {
if (profileStyle.avatarBorderColor) {
setLocalBorderColor(hexToRgba(profileStyle.avatarBorderColor));
}
if (profileStyle.gradient) {
setLocalGradientStart(hexToRgba(profileStyle.gradient.startColor));
setLocalGradientStop(hexToRgba(profileStyle.gradient.stopColor));
setLocalGradientDirection(parseDirectionDegrees(profileStyle.gradient.direction));
}
}, [profileStyle]);
const handleBorderColorSave = async () => {
setSavingStyle(true);
setStyleError(undefined);
try {
await setProfileStyle({ avatarBorderColor: rgbaToHex(localBorderColor) });
await syncUserAvatar();
setEditingBorder(false);
} catch (e) {
setStyleError('Failed to save border color');
}
setSavingStyle(false);
};
const handleBorderColorRemove = async () => {
setSavingStyle(true);
setStyleError(undefined);
try {
await setProfileStyle({ avatarBorderColor: undefined });
await syncUserAvatar();
setEditingBorder(false);
setLocalBorderColor({ r: 59, g: 130, b: 246, a: 0 });
} catch (e) {
setStyleError('Failed to remove border color');
}
setSavingStyle(false);
};
const handleGradientSave = async () => {
setSavingStyle(true);
setStyleError(undefined);
try {
await setProfileStyle({
gradient: {
direction: `${localGradientDirection}deg`,
startColor: rgbaToHex(localGradientStart),
stopColor: rgbaToHex(localGradientStop),
},
});
await syncUserAvatar();
setEditingGradient(false);
} catch (e) {
setStyleError('Failed to save gradient');
}
setSavingStyle(false);
};
const handleGradientRemove = async () => {
setSavingStyle(true);
setStyleError(undefined);
try {
await setProfileStyle({ gradient: undefined });
await syncUserAvatar();
setEditingGradient(false);
setLocalGradientStart({ r: 0, g: 0, b: 0, a: 0 });
setLocalGradientStop({ r: 0, g: 0, b: 0, a: 0 });
setLocalGradientDirection(180);
} catch (e) {
setStyleError('Failed to remove gradient');
}
setSavingStyle(false);
};
const [isEditingName, setIsEditingName] = useState(false);
const [editedName, setEditedName] = useState(profile.displayName || '');
const [savingName, setSavingName] = useState(false);
@@ -473,51 +356,14 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
setSaving(true);
setError(undefined);
try {
// Fetch the raw uploaded image so we can embed existing banner/color metadata
const httpUrl = mxcUrlToHttp(mx, upload.mxc, useAuthentication);
if (!httpUrl) throw new Error('Could not resolve uploaded avatar URL');
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
let response = await fetch(httpUrl, {
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
// If we got a 401 and we tried with auth, fallback to unauthenticated request
if (!response.ok && response.status === 401 && accessToken && useAuthentication) {
console.warn('[Profile] Auth failed (401), attempting unauthenticated fallback for avatar fetch');
response = await fetch(httpUrl);
}
if (!response.ok) throw new Error('Failed to fetch uploaded avatar');
const avatarData = await response.arrayBuffer();
const format = detectImageFormat(avatarData);
if (format === 'unknown') throw new Error('Unsupported avatar image format');
// Re-embed the existing banner URL and profile color into the new avatar
const metadata: ImageMetadata = {
banner: userBanner,
color: userColor,
};
const modifiedData = embedMetadataInImage(avatarData, metadata);
if (!modifiedData) throw new Error('Failed to embed metadata in avatar');
const mimeType = getMimeType(format);
const extension = getExtension(format);
const blob = new Blob([modifiedData], { type: mimeType });
const uploadResponse = await mx.uploadContent(blob, { name: `avatar.${extension}`, type: mimeType });
await mx.setAvatarUrl(uploadResponse.content_uri);
await mx.setAvatarUrl(upload.mxc);
const userId = mx.getUserId();
if (userId) {
const user = mx.getUser(userId);
if (user && user.avatarUrl !== uploadResponse.content_uri) {
user.setAvatarUrl(uploadResponse.content_uri);
if (user && user.avatarUrl !== upload.mxc) {
user.setAvatarUrl(upload.mxc);
}
}
setAvatarFile(undefined);
await syncUserAvatar();
} catch (e: any) {
@@ -525,7 +371,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
}
setSaving(false);
},
[mx, useAuthentication, userBanner, userColor, syncUserAvatar]
[mx, syncUserAvatar]
);
const handleRemoveBanner = async () => {
@@ -541,33 +387,33 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
setSaving(false);
};
// Build gradient CSS for preview (shows local editing values when user is editing,
// otherwise falls back to saved profile style)
const previewGradient = editingGradient
? `linear-gradient(${localGradientDirection}deg, ${rgbaToHex(localGradientStart)}, ${rgbaToHex(localGradientStop)})`
: profileStyle.gradient
? `linear-gradient(${profileStyle.gradient.direction}, ${profileStyle.gradient.startColor}, ${profileStyle.gradient.stopColor})`
: undefined;
// Build border color for preview (shows local editing value when user is editing,
// otherwise falls back to saved profile style)
const previewBorderColor = editingBorder
? rgbaToHex(localBorderColor)
: profileStyle.avatarBorderColor;
const previewPillBgColor = previewBorderColor ? stripAlphaFromColor(previewBorderColor) : undefined;
const previewProfileColor = userColor || getColorMXIDValue(userId);
const previewPreference: ColorPreference = { on_dark: localOnDark, on_light: localOnLight };
const previewProfileColor =
resolveColorForTheme(previewPreference, theme.kind) || getColorMXIDValue(userId, theme.kind === ThemeKind.Dark);
const previewTextShadow = `0 1px 4px ${getTextShadowColor(previewProfileColor)}`;
const hasSavedColors = hasColorPreference(colorPreference);
const colorPickerSize = toRem(200);
const colorPreviewPlateStyle = {
width: '100%',
boxSizing: 'border-box' as const,
padding: `${toRem(4)} ${toRem(8)}`,
borderRadius: toRem(8),
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
lineHeight: 1.2,
};
const displayName = profile.displayName || getMxIdLocalPart(userId) || userId;
return (
<Box direction="Column" gap="300">
<Box direction="Column" gap="300" style={{ width: '100%', alignItems: 'center' }}>
<Box
direction="Column"
style={{
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
borderRadius: toRem(8),
width: toRem(340),
width: '60%',
overflow: 'hidden',
position: 'relative',
}}
@@ -594,7 +440,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
cursor: 'pointer',
border: 'none',
padding: 0,
backgroundColor: bannerBlobUrl ? 'transparent' : (userColor || colorMXID(userId)),
backgroundColor: bannerBlobUrl ? 'transparent' : colorMXID(userId),
filter: bannerBlobUrl || authenticatedCoverUrl ? 'none' : 'brightness(50%)',
display: 'flex',
alignItems: 'center',
@@ -704,41 +550,61 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
onMouseLeave={() => setHoveredArea(null)}
>
<Box
as="button"
onClick={handleAvatarClick}
style={{
backgroundColor: previewBorderColor || color.Surface.Container,
border: 'none',
padding: 0,
cursor: 'pointer',
borderRadius: '50%',
position: 'relative',
width: toRem(76),
height: toRem(76),
}}
>
<AvatarPresence
badgeBackgroundColor={previewBorderColor}
badge={
presence && (
<PresenceBadge presence={presence.presence} status={presence.status} />
)
}
>
<Avatar
size="500"
<Box
as="button"
onClick={handleAvatarClick}
style={{
width: toRem(72),
height: toRem(72),
outline: previewBorderColor
? `${toRem(4)} solid ${previewBorderColor}`
: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
position: 'relative',
width: '100%',
height: '100%',
backgroundColor: avatarUrl ? 'transparent' : color.Surface.Container,
border: 'none',
padding: 0,
cursor: 'pointer',
borderRadius: '50%',
}}
>
<UserAvatar
userId={userId}
src={avatarUrl}
alt={userId}
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
/>
</Avatar>
<Avatar
size="500"
style={{
width: toRem(76),
height: toRem(76),
...(avatarUrl
? { outline: 'none', border: 'none', boxShadow: 'none' }
: {
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
}),
}}
>
<UserAvatar
userId={userId}
src={avatarUrl}
alt={userId}
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
/>
</Avatar>
{avatarDecorationUrl && (
<img
className={avatarDecorationCss.AvatarDecorationOverlay}
src={avatarDecorationUrl}
alt=""
draggable={false}
/>
)}
</Box>
</AvatarPresence>
</Box>
{/* Avatar action icons - shown on hover */}
@@ -799,73 +665,20 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
</Box>
</Box>
{/* Profile Info Section - matches UserRoomProfile gradient section */}
{/* Profile Info Section */}
<Box
direction="Column"
gap="400"
gap="200"
alignItems="Center"
style={{
padding: config.space.S400,
paddingTop: `calc(${config.space.S400} + ${toRem(36)})`,
marginTop: toRem(-36),
background: previewGradient,
textAlign: 'center',
}}
>
{/* Display Name with Color Picker */}
<Box alignItems="Center" gap="200" justifyContent="Center">
<HexColorPickerPopOut
picker={
<Box direction="Column" gap="200">
<HexColorPicker color={localColor} onChange={setLocalColor} />
<Box gap="100" alignItems="Center">
<Input
size="300"
variant="Secondary"
style={{ width: toRem(100) }}
value={localColor}
onChange={(e) => {
setLocalColor(e.target.value);
setColorError(undefined);
}}
/>
<Button
size="300"
variant="Primary"
fill="Solid"
radii="300"
onClick={handleColorSave}
disabled={savingColor}
>
<Text size="B300">{savingColor ? 'Saving...' : 'Save'}</Text>
</Button>
</Box>
{colorError && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{colorError}
</Text>
)}
</Box>
}
onRemove={userColor ? handleColorRemove : undefined}
>
{(onOpen) => (
<Box
as="button"
onClick={onOpen}
disabled={savingColor}
style={{
width: toRem(24),
height: toRem(24),
borderRadius: toRem(6),
backgroundColor: userColor ?? localColor,
border: 'none',
cursor: 'pointer',
flexShrink: 0,
}}
/>
)}
</HexColorPickerPopOut>
{/* Display Name */}
<Box style={{ width: '100%' }}>
{isEditingName ? (
<Input
autoFocus
@@ -877,6 +690,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
size="400"
disabled={savingName}
style={{
width: '100%',
fontSize: 'var(--token.font-size.H400)',
fontWeight: 'var(--token.font-weight.H400)',
padding: `${toRem(4)} ${toRem(8)}`,
@@ -893,19 +707,28 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
onMouseEnter={() => setHoveredArea('name')}
onMouseLeave={() => setHoveredArea(null)}
style={{
width: '100%',
border: 'none',
background: 'none',
cursor: 'pointer',
padding: `${toRem(4)} ${toRem(8)}`,
margin: `${toRem(-4)} ${toRem(-8)}`,
position: 'relative',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
lineHeight: 1.2,
}}
>
<Text
size="H4"
className={classNames(BreakWord, LineClamp3)}
title={profile.displayName || getMxIdLocalPart(userId)}
style={{ color: previewProfileColor, textShadow: previewTextShadow }}
style={{
color: previewProfileColor,
textShadow: previewTextShadow,
textAlign: 'center',
maxWidth: '100%',
}}
>
{profile.displayName || getMxIdLocalPart(userId) || userId}
</Text>
@@ -913,8 +736,9 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
<Box
style={{
position: 'absolute',
top: 0,
right: toRem(-32),
top: '50%',
right: 0,
transform: 'translateY(-50%)',
backgroundColor: color.Surface.Container,
borderRadius: toRem(20),
padding: toRem(6),
@@ -947,8 +771,8 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
onMouseEnter={() => setHoveredArea('status')}
onMouseLeave={() => setHoveredArea(null)}
style={{
backgroundColor: previewPillBgColor || color.Surface.Container,
border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
backgroundColor: color.Surface.Container,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(6)} ${toRem(10)}`,
borderRadius: toRem(16),
maxWidth: toRem(250),
@@ -999,14 +823,13 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
className={BreakWord}
style={{
fontStyle: presence?.status ? 'normal' : 'italic',
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
}}
>
{presence?.status || 'Click to set a custom status...'}</Text>
</Box>
{presence?.status && hoveredArea === 'status' && (
<>
<Icon size="50" src={Icons.Pencil} style={{ color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined }} />
<Icon size="50" src={Icons.Pencil} />
<Box
as="button"
onClick={(e) => {
@@ -1037,178 +860,185 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
<Box
style={{
backgroundColor: previewPillBgColor || color.Surface.Container,
border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
backgroundColor: color.Surface.Container,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(4)} ${toRem(8)}`,
borderRadius: toRem(16),
}}
>
<Text
size="B300"
style={{
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
}}
>
ruv.wtf
</Text>
<Text size="B300">ruv.wtf</Text>
</Box>
<Box
style={{
backgroundColor: previewPillBgColor || color.Surface.Container,
border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
backgroundColor: color.Surface.Container,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(4)} ${toRem(8)}`,
borderRadius: toRem(16),
}}
>
<Text
size="B300"
style={{
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
}}
>
Share
</Text>
<Text size="B300">Share</Text>
</Box>
<Box
style={{
backgroundColor: previewPillBgColor || color.Surface.Container,
border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
backgroundColor: color.Surface.Container,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(4)} ${toRem(8)}`,
borderRadius: toRem(16),
}}
>
<Text
size="B300"
style={{
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
}}
>
Admin
</Text>
<Text size="B300">Admin</Text>
</Box>
</Box>
</Box>
</Box>
{/* Profile Style Settings */}
{/* MSC4522 username colors */}
<Box
direction="Column"
gap="200"
gap="300"
style={{
padding: config.space.S300,
padding: config.space.S400,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
borderRadius: toRem(8),
width: '100%',
boxSizing: 'border-box',
}}
>
<Text size="H6">Profile Style</Text>
<Text size="H6">Username colors</Text>
{/* Avatar Border Color */}
<Box direction="Column" gap="100">
<Text size="T300">Avatar Border Color</Text>
<Box gap="200" alignItems="Center" wrap="Wrap">
<RgbaColorPicker color={localBorderColor} onChange={(c) => { setLocalBorderColor(c); setEditingBorder(true); }} />
<Box direction="Column" gap="100">
<Text size="T200" style={{ opacity: 0.7 }}>
Preview: {rgbaToHex(localBorderColor)}
<Box direction="Row" gap="400" wrap="Wrap" alignItems="Start">
<Box direction="Column" gap="200" style={{ flex: '1 1 0', minWidth: toRem(140) }}>
<Text size="T300">On dark themes</Text>
<Text size="T200" style={{ opacity: 0.7 }}>Bright colors work best</Text>
<Box
style={{
...colorPreviewPlateStyle,
backgroundColor: '#262626',
}}
>
<Text
size="H4"
className={classNames(BreakWord, LineClamp3)}
title={displayName}
style={{
color: localOnDark,
textShadow: `0 1px 4px ${getTextShadowColor(localOnDark)}`,
textAlign: 'center',
maxWidth: '100%',
}}
>
{displayName}
</Text>
<Box
style={{
width: toRem(48),
height: toRem(48),
borderRadius: '50%',
border: `${toRem(4)} solid ${rgbaToHex(localBorderColor)}`,
backgroundColor: color.Surface.Container,
}}
/>
<Box gap="100">
<Button
size="300"
variant="Primary"
fill="Solid"
radii="300"
onClick={handleBorderColorSave}
disabled={savingStyle}
>
<Text size="B300">Save</Text>
</Button>
{profileStyle.avatarBorderColor && (
<Button
size="300"
variant="Critical"
fill="Soft"
radii="300"
onClick={handleBorderColorRemove}
disabled={savingStyle}
>
<Text size="B300">Remove</Text>
</Button>
)}
</Box>
</Box>
<HexColorPicker
color={localOnDark}
onChange={(c) => {
setLocalOnDark(c);
setColorError(undefined);
}}
style={{ width: '100%', height: colorPickerSize }}
/>
<Input
size="300"
variant="Secondary"
style={{ width: '100%' }}
value={localOnDark}
onChange={(e) => {
setLocalOnDark(e.target.value);
setColorError(undefined);
}}
/>
</Box>
<Box direction="Column" gap="200" style={{ flex: '1 1 0', minWidth: toRem(140) }}>
<Text size="T300">On light themes</Text>
<Text size="T200" style={{ opacity: 0.7 }}>Darker colors work best</Text>
<Box
style={{
...colorPreviewPlateStyle,
backgroundColor: '#F0F0F0',
}}
>
<Text
size="H4"
className={classNames(BreakWord, LineClamp3)}
title={displayName}
style={{
color: localOnLight,
textShadow: `0 1px 4px ${getTextShadowColor(localOnLight)}`,
textAlign: 'center',
maxWidth: '100%',
}}
>
{displayName}
</Text>
</Box>
<HexColorPicker
color={localOnLight}
onChange={(c) => {
setLocalOnLight(c);
setColorError(undefined);
}}
style={{ width: '100%', height: colorPickerSize }}
/>
<Input
size="300"
variant="Secondary"
style={{ width: '100%' }}
value={localOnLight}
onChange={(e) => {
setLocalOnLight(e.target.value);
setColorError(undefined);
}}
/>
</Box>
</Box>
{/* Profile Gradient */}
<Box direction="Column" gap="100">
<Text size="T300">Profile Card Gradient</Text>
<Box gap="200" alignItems="Start" wrap="Wrap">
<Box direction="Column" gap="100">
<Text size="T200">Start Color</Text>
<RgbaColorPicker color={localGradientStart} onChange={(c) => { setLocalGradientStart(c); setEditingGradient(true); }} />
</Box>
<Box direction="Column" gap="100">
<Text size="T200">End Color</Text>
<RgbaColorPicker color={localGradientStop} onChange={(c) => { setLocalGradientStop(c); setEditingGradient(true); }} />
</Box>
<Box direction="Column" gap="100">
<Text size="T200">Direction</Text>
<AngleSelector
value={localGradientDirection}
onChange={(deg) => { setLocalGradientDirection(deg); setEditingGradient(true); }}
/>
<Box
style={{
width: toRem(100),
height: toRem(60),
borderRadius: toRem(8),
background: `linear-gradient(${localGradientDirection}deg, ${rgbaToHex(localGradientStart)}, ${rgbaToHex(localGradientStop)})`,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
}}
/>
<Box gap="100">
<Button
size="300"
variant="Primary"
fill="Solid"
radii="300"
onClick={handleGradientSave}
disabled={savingStyle}
>
<Text size="B300">Save</Text>
</Button>
{profileStyle.gradient && (
<Button
size="300"
variant="Critical"
fill="Soft"
radii="300"
onClick={handleGradientRemove}
disabled={savingStyle}
>
<Text size="B300">Remove</Text>
</Button>
)}
</Box>
</Box>
</Box>
<Box gap="200" alignItems="Center" wrap="Wrap">
<Button
size="300"
variant="Primary"
fill="Solid"
radii="300"
onClick={handleColorSave}
disabled={savingColor || colorLoading}
>
<Text size="B300">{savingColor ? 'Saving...' : 'Save colors'}</Text>
</Button>
{hasSavedColors && (
<Button
size="300"
variant="Critical"
fill="Soft"
radii="300"
onClick={handleColorRemove}
disabled={savingColor}
>
<Text size="B300">Remove colors</Text>
</Button>
)}
</Box>
{styleError && (
<Text size="T200" style={{ color: color.Critical.Main }}>{styleError}</Text>
{colorError && (
<Text size="T200" style={{ color: color.Critical.Main }}>{colorError}</Text>
)}
</Box>
<Box
direction="Column"
gap="300"
style={{
padding: config.space.S400,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
borderRadius: toRem(8),
width: '100%',
boxSizing: 'border-box',
}}
>
<Text size="H6">Discord profile overlays</Text>
<CollectiblesSection collectibles={collectibles} onApply={updateCollectible} />
</Box>
{uploadAtom && (
<Box gap="200" direction="Column" style={{ width: '100%' }}>
<CompactUploadCardRenderer

View File

@@ -0,0 +1,95 @@
import React from 'react';
import { Text } from 'folds';
import { UserAvatar } from '../../../components/user-avatar';
import { Icon, Icons } from '../../../components/icons';
import colorMXID from '../../../../util/colorMXID';
import { getMxIdLocalPart } from '../../../utils/matrix';
import { DiscordCollectibleItem } from '../../../utils/discordCollectibles';
import {
catalogProfileEffectIntroDurationMs,
pickCatalogProfileEffectIntroUrl,
pickCatalogProfileEffectLoopUrl,
} from '../../../utils/collectibleAssets';
import { ProfileEffectMedia } from '../../../components/user-profile/ProfileEffectMedia';
import * as css from './CollectiblesSection.css';
type ProfileEffectHeroPreviewProps = {
item: DiscordCollectibleItem;
userId: string;
avatarUrl?: string;
bannerUrl?: string;
displayName?: string;
profileColor?: string;
restartToken?: number;
};
export function ProfileEffectHeroPreview({
item,
userId,
avatarUrl,
bannerUrl,
displayName,
profileColor,
restartToken,
}: ProfileEffectHeroPreviewProps) {
const coverColor = colorMXID(userId);
const nameColor = profileColor ?? coverColor;
const username = getMxIdLocalPart(userId) ?? userId;
const resolvedName = displayName?.trim() || username;
const introUrl = pickCatalogProfileEffectIntroUrl(item);
const loopUrl = pickCatalogProfileEffectLoopUrl(item);
const introDurationMs = catalogProfileEffectIntroDurationMs(item);
return (
<div className={css.ProfileEffectHeroPreview} aria-hidden="true">
<div
className={css.ProfileEffectHeroCover}
style={{
backgroundColor: bannerUrl ? undefined : coverColor,
filter: bannerUrl || avatarUrl ? undefined : 'brightness(50%)',
}}
>
{bannerUrl ? (
<img className={css.ProfileEffectHeroBanner} src={bannerUrl} alt="" draggable={false} />
) : (
avatarUrl && (
<img className={css.ProfileEffectHeroCoverBlur} src={avatarUrl} alt="" draggable={false} />
)
)}
</div>
<div className={css.ProfileEffectHeroAvatarSlot}>
<div className={css.ProfileEffectHeroAvatar}>
<UserAvatar
className={css.ProfileEffectHeroAvatarImg}
userId={userId}
src={avatarUrl}
alt=""
renderFallback={() => <Icon size="300" src={Icons.User} filled />}
/>
</div>
</div>
<div className={css.ProfileEffectHeroInfo}>
<Text
size="T100"
className={css.ProfileEffectHeroName}
title={resolvedName}
style={{ color: nameColor }}
>
{resolvedName}
</Text>
</div>
<div className={css.ProfileEffectHeroEffectLayer}>
<ProfileEffectMedia
introUrl={introUrl}
loopUrl={loopUrl}
introDurationMs={introDurationMs}
restartToken={restartToken}
className={css.ProfileEffectHeroEffect}
/>
</div>
</div>
);
}

View File

@@ -8,6 +8,7 @@ import React, {
} from 'react';
import dayjs from 'dayjs';
import { as, Box, Button, Chip, color, config, Header, IconButton, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Switch, Text, toRem } from 'folds';
import { useSetAtom } from 'jotai';
import { Icon, Icons } from '../../../components/icons';
import { HexColorPicker } from 'react-colorful';
import { isKeyHotkey } from 'is-hotkey';
@@ -24,6 +25,7 @@ import {
LightTheme,
Theme,
ThemeKind,
themePreviewIdAtom,
useSystemThemeKind,
useThemeNames,
useThemes,
@@ -41,28 +43,49 @@ type ThemeSelectorProps = {
onSelect: (theme: Theme) => void;
};
const ThemeSelector = as<'div', ThemeSelectorProps>(
({ themeNames, themes, selected, onSelect, ...props }, ref) => (
<Menu {...props} ref={ref}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{themes.map((theme) => (
<MenuItem
key={theme.id}
size="300"
variant={theme.id === selected.id ? 'Primary' : 'Surface'}
radii="300"
onClick={() => onSelect(theme)}
>
<Text size="T300">{themeNames[theme.id] ?? theme.id}</Text>
</MenuItem>
))}
</Box>
</Menu>
)
({ themeNames, themes, selected, onSelect, ...props }, ref) => {
const setPreviewId = useSetAtom(themePreviewIdAtom);
useEffect(
() => () => {
setPreviewId(undefined);
},
[setPreviewId]
);
const clearPreview = () => setPreviewId(undefined);
const previewTheme = (theme: Theme) => setPreviewId(theme.id);
return (
<Menu
{...props}
ref={ref}
onMouseLeave={clearPreview}
>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{themes.map((theme) => (
<MenuItem
key={theme.id}
size="300"
variant={theme.id === selected.id ? 'Primary' : 'Surface'}
radii="300"
onMouseEnter={() => previewTheme(theme)}
onFocus={() => previewTheme(theme)}
onClick={() => onSelect(theme)}
>
<Text size="T300">{themeNames[theme.id] ?? theme.id}</Text>
</MenuItem>
))}
</Box>
</Menu>
);
}
);
function SelectTheme({ disabled }: { disabled?: boolean }) {
const themes = useThemes();
const themeNames = useThemeNames();
const setPreviewId = useSetAtom(themePreviewIdAtom);
const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
const [menuCords, setMenuCords] = useState<RectCords>();
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
@@ -72,10 +95,16 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
};
const handleThemeSelect = (theme: Theme) => {
setPreviewId(undefined);
setThemeId(theme.id);
setMenuCords(undefined);
};
const closeMenu = () => {
setPreviewId(undefined);
setMenuCords(undefined);
};
return (
<>
<Button
@@ -99,7 +128,7 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
onDeactivate: closeMenu,
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
@@ -125,6 +154,7 @@ function SystemThemePreferences() {
const themeKind = useSystemThemeKind();
const themeNames = useThemeNames();
const themes = useThemes();
const setPreviewId = useSetAtom(themePreviewIdAtom);
const [lightThemeId, setLightThemeId] = useSetting(settingsAtom, 'lightThemeId');
const [darkThemeId, setDarkThemeId] = useSetting(settingsAtom, 'darkThemeId');
@@ -145,15 +175,27 @@ function SystemThemePreferences() {
};
const handleLightThemeSelect = (theme: Theme) => {
setPreviewId(undefined);
setLightThemeId(theme.id);
setLTCords(undefined);
};
const handleDarkThemeSelect = (theme: Theme) => {
setPreviewId(undefined);
setDarkThemeId(theme.id);
setDTCords(undefined);
};
const closeLightMenu = () => {
setPreviewId(undefined);
setLTCords(undefined);
};
const closeDarkMenu = () => {
setPreviewId(undefined);
setDTCords(undefined);
};
return (
<Box wrap="Wrap" gap="400">
<SettingTile
@@ -179,7 +221,7 @@ function SystemThemePreferences() {
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setLTCords(undefined),
onDeactivate: closeLightMenu,
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
@@ -220,7 +262,7 @@ function SystemThemePreferences() {
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setDTCords(undefined),
onDeactivate: closeDarkMenu,
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',

View File

@@ -170,22 +170,34 @@ type PushStatus = {
registered: boolean;
distributor: string;
endpoint: string;
distributors: string[];
lastFailure: string;
autoStartBlocked: boolean;
};
/** Android-only section showing UnifiedPush registration status and controls. */
function AndroidPushNotifications() {
const [status, setStatus] = useState<PushStatus | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [lastError, setLastError] = useState<string | undefined>(undefined);
const refresh = useCallback(async () => {
setLoading(true);
const s = await getBackgroundSyncStatus();
const distributors = Array.isArray(s?.distributors)
? s.distributors
: typeof s?.distributors === 'string' && s.distributors && s.distributors !== '[]'
? [s.distributors]
: [];
setStatus(
s
? {
registered: s.registered,
distributor: s.distributor || '',
endpoint: s.endpoint || '',
distributors,
lastFailure: s.lastFailure || '',
autoStartBlocked: Boolean(s.autoStartBlocked),
}
: undefined
);
@@ -198,8 +210,14 @@ function AndroidPushNotifications() {
const [resetState, reset] = useAsyncCallback(
useCallback(async () => {
await requestResetPushRegistration();
setLastError(undefined);
const result = await requestResetPushRegistration();
await refresh();
if (!result.success) {
setLastError(
'Selected distributor but did not get a push endpoint. In ntfy: enable UnifiedPush, allow unrestricted battery, then try Reset again.'
);
}
}, [refresh])
);
@@ -214,6 +232,50 @@ function AndroidPushNotifications() {
resetState.status === AsyncStatus.Loading ||
pingState.status === AsyncStatus.Loading;
const statusDescription = (() => {
if (loading) return 'Loading status…';
if (status === undefined) {
return (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
Failed to read push status.
</Text>
);
}
if (status.registered) {
return (
<Text as="span" size="T200">
{`Distributor: ${status.distributor || 'unknown'}`}
</Text>
);
}
if (status.lastFailure === 'AUTO_START_BLOCKED' || status.autoStartBlocked) {
return (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
Phone is blocking auto-start for Paarrot. Open App Boot / Auto-start settings, allow Paarrot (and ntfy), then tap Reset.
</Text>
);
}
if (status.lastFailure) {
return (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
{`Registration failed (${status.lastFailure}). Tap Reset and pick ntfy again.`}
</Text>
);
}
if (status.distributors.length === 0) {
return (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
No UnifiedPush distributor found. Install ntfy from Play Store/F-Droid and enable UnifiedPush in ntfy settings.
</Text>
);
}
return (
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
{`Found ${status.distributors.join(', ')} but not registered yet. Tap Reset and pick ntfy.`}
</Text>
);
})();
return (
<Box direction="Column" gap="100">
<Text size="L400">Android Push (UnifiedPush)</Text>
@@ -226,29 +288,20 @@ function AndroidPushNotifications() {
<SettingTile
title="Background Notifications"
description={
loading ? (
'Loading status…'
) : status === undefined ? (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
Failed to read push status.
</Text>
) : status.registered ? (
<>
<Text as="span" size="T200">
{`Distributor: ${status.distributor || 'unknown'}`}
<>
{statusDescription}
{lastError ? (
<Text as="span" style={{ color: color.Critical.Main, display: 'block' }} size="T200">
{lastError}
</Text>
</>
) : (
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
Not registered. Tap &quot;Reset&quot; to choose a distributor app.
</Text>
)
) : null}
</>
}
after={loading ? <Spinner variant="Secondary" /> : undefined}
/>
<SettingTile
title="Change Distributor"
description="Re-open the UnifiedPush distributor selection dialog."
description="Shows a list of installed UnifiedPush apps (ntfy, etc.) and registers the one you pick."
after={
<Button
size="300"

View File

@@ -4,21 +4,30 @@ import { readClipboardImage, isTauri, isLinux } from '../utils/tauri';
export const useFilePasteHandler = (onPaste: (file: File[]) => void): ClipboardEventHandler =>
useCallback(
async (evt) => {
(evt) => {
const files = getDataTransferFiles(evt.clipboardData);
if (files && files.length > 0) {
evt.preventDefault();
onPaste(files);
return;
}
// On Linux with Tauri, browser clipboard API doesn't work for images
// Use our custom Tauri command with arboard/Wayland support
if (isTauri() && isLinux()) {
const clipboardImage = await readClipboardImage();
if (clipboardImage) {
onPaste([clipboardImage]);
}
// Address-bar / plain-text copies still win. On Linux the native clipboard
// often still has a previous image (or a junk bitmap) alongside text/plain;
// don't treat those as an image paste.
const text = evt.clipboardData?.getData('text/plain')?.trim() ?? '';
if (text.length > 0) {
return;
}
// Browser clipboardData.files is empty for raw image copies on Linux Tauri/Electron.
if (!(isTauri() && isLinux())) return;
// preventDefault before the async read, or the editor also inserts text.
evt.preventDefault();
void readClipboardImage().then((clipboardImage) => {
if (clipboardImage) onPaste([clipboardImage]);
});
},
[onPaste]
);

View File

@@ -56,6 +56,19 @@ export const useRoomNavigate = () => {
}
const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId);
// Prefer Direct for m.direct rooms unless we are already browsing a parent space
// of this room (keep space chrome). Opening via space while roomToParents is still
// catching up used to hit JoinBeforeNavigate even though the DM was listed.
if (mDirects.has(roomId)) {
const stayInSelectedSpace =
Boolean(spaceSelectedId) && orphanParents.includes(spaceSelectedId!);
if (!stayInSelectedSpace) {
navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
return;
}
}
if (orphanParents.length > 0) {
let parentSpace: string;
if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) {
@@ -73,11 +86,6 @@ export const useRoomNavigate = () => {
return;
}
if (mDirects.has(roomId)) {
navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
return;
}
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
},
[

View File

@@ -1,4 +1,5 @@
import { lightTheme } from 'folds';
import { atom, useAtomValue } from 'jotai';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
import { butterTheme, catppuccinMochaTheme, darkTheme, discordTheme, discordDarkerTheme, mochaTheme, silverTheme, stationeryDarkTheme, stationeryTheme, twilightTheme } from '../../colors.css';
@@ -6,6 +7,9 @@ import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
import { pluginRegistry } from '../features/settings/plugins/PluginAPI';
/** Ephemeral theme id while hovering theme options in settings (not persisted). */
export const themePreviewIdAtom = atom<string | undefined>(undefined);
export enum ThemeKind {
Light = 'light',
Dark = 'dark',
@@ -190,6 +194,16 @@ export const useActiveTheme = (): Theme => {
return selectedTheme;
};
/** Active theme, or hover-preview theme when browsing the theme menu. */
export const useDisplayTheme = (): Theme => {
const activeTheme = useActiveTheme();
const themes = useThemes();
const previewId = useAtomValue(themePreviewIdAtom);
if (!previewId) return activeTheme;
return themes.find((theme) => theme.id === previewId) ?? activeTheme;
};
const ThemeContext = createContext<Theme | null>(null);
export const ThemeContextProvider = ThemeContext.Provider;

View File

@@ -1,54 +1,17 @@
import { useCallback, useEffect, useState } from 'react';
import { MatrixClient, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useMediaAuthentication } from './useMediaAuthentication';
import { mxcUrlToHttp } from '../utils/matrix';
import { getCurrentAccessToken } from '../utils/auth';
import {
fetchAndExtractMetadata,
extractMetadataFromImage,
embedMetadataInImage,
detectImageFormat,
getMimeType,
getExtension,
ImageMetadata,
} from '../utils/imageMetadata';
deleteBannerUrl,
loadBannerUrl,
registerBannerCacheClear,
saveBannerUrl,
} from '../utils/profileFields';
/**
* Fetches the user's current avatar as raw image data
*/
async function fetchAvatarData(
mx: MatrixClient,
avatarMxc: string,
useAuthentication: boolean
): Promise<ArrayBuffer | null> {
const url = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
if (!url) return null;
try {
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
let response = await fetch(url, {
method: 'GET',
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
// If we got a 401 and we tried with auth, fallback to unauthenticated request
if (!response.ok && response.status === 401 && accessToken && useAuthentication) {
response = await fetch(url, { method: 'GET' });
}
if (!response.ok) return null;
return await response.arrayBuffer();
} catch {
return null;
}
}
/**
* Hook to manage the user's chosen profile banner, stored in avatar image metadata
* The banner URL is embedded in avatar metadata and syncs via the avatar
* @returns The current banner URL, a setter function, and loading state
* Hook to manage the user's profile banner (MSC4427 via MSC4133).
*/
export function useUserBanner(): [
string | undefined,
@@ -56,268 +19,124 @@ export function useUserBanner(): [
boolean
] {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [banner, setBanner] = useState<string | undefined>();
const [loading, setLoading] = useState(true);
// Extract banner from current avatar on mount and when profile changes
useEffect(() => {
const loadBanner = async () => {
setLoading(true);
try {
const userId = mx.getUserId();
if (!userId) {
setLoading(false);
return;
}
const profile = await mx.getProfileInfo(userId);
const avatarUrl = profile.avatar_url;
console.log('[useUserBanner.loadBanner] Loading banner from avatar:', avatarUrl);
if (!avatarUrl) {
setBanner(undefined);
setLoading(false);
return;
}
const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication);
if (!httpUrl) {
setBanner(undefined);
setLoading(false);
return;
}
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
console.log('[useUserBanner.loadBanner] Extracted banner:', metadata.banner);
setBanner(metadata.banner);
} catch {
setBanner(undefined);
}
setLoading(false);
};
loadBanner();
// Listen for avatar changes and reload banner
const userId = mx.getUserId();
if (!userId) return undefined;
const user = mx.getUser(userId);
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = (newAvatarUrl) => {
console.log('[useUserBanner] Avatar changed event fired, new URL:', newAvatarUrl);
loadBanner();
};
user?.on(UserEvent.AvatarUrl, onAvatarChange);
return () => {
user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
};
}, [mx, useAuthentication]);
const updateBanner = useCallback(async (newBanner: string | undefined) => {
const userId = mx.getUserId();
if (!userId) {
throw new Error('No user ID');
setLoading(false);
return undefined;
}
const profile = await mx.getProfileInfo(userId);
const avatarUrl = profile.avatar_url;
let cancelled = false;
if (!avatarUrl) {
throw new Error('No avatar set. Please upload an avatar first.');
}
// Fetch current avatar
const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication);
if (!avatarData) {
throw new Error('Failed to fetch current avatar');
}
// Detect image format
const format = detectImageFormat(avatarData);
if (format === 'unknown') {
throw new Error('Unsupported avatar image format');
}
// Get existing metadata to preserve
const existingMetadata = extractMetadataFromImage(avatarData);
console.log('[updateBanner] Existing metadata:', existingMetadata);
// Modify image metadata with new banner, preserving color
const newMetadata: ImageMetadata = {
color: existingMetadata.color,
banner: newBanner,
avatarBorderColor: existingMetadata.avatarBorderColor,
gradient: existingMetadata.gradient,
};
console.log('[updateBanner] New metadata to embed:', newMetadata);
const newAvatarData = embedMetadataInImage(avatarData, newMetadata);
if (!newAvatarData) {
throw new Error('Failed to embed banner in avatar metadata');
}
// Verify the banner was embedded correctly before uploading
const verifyMetadata = extractMetadataFromImage(newAvatarData);
console.log('[updateBanner] Verification - extracted metadata from new avatar:', verifyMetadata);
if (newBanner && verifyMetadata.banner !== newBanner) {
console.error('[updateBanner] Banner verification FAILED!', 'Expected:', newBanner, 'Got:', verifyMetadata.banner);
throw new Error('Banner verification failed');
}
console.log('[updateBanner] Banner verification passed');
// Upload modified avatar with correct MIME type
const mimeType = getMimeType(format);
const extension = getExtension(format);
const blob = new Blob([newAvatarData], { type: mimeType });
const uploadResponse = await mx.uploadContent(blob, {
name: `avatar.${extension}`,
type: mimeType,
});
console.log('[updateBanner] Avatar uploaded successfully, MXC:', uploadResponse.content_uri);
// Update profile with new avatar
try {
console.log('[updateBanner] Setting avatar URL to:', uploadResponse.content_uri);
await Promise.race([
mx.setAvatarUrl(uploadResponse.content_uri),
new Promise((_, reject) => {
setTimeout(() => reject(new Error('setAvatarUrl timeout')), 30000);
})
]);
console.log('[updateBanner] setAvatarUrl completed');
// Manually sync user object to ensure event listeners are triggered
const user = mx.getUser(userId);
if (user && user.avatarUrl !== uploadResponse.content_uri) {
console.log('[updateBanner] Manually syncing user avatar URL');
user.setAvatarUrl(uploadResponse.content_uri);
const load = async () => {
setLoading(true);
try {
const bannerMxc = await loadBannerUrl(mx, userId);
if (!cancelled) setBanner(bannerMxc);
} catch {
if (!cancelled) setBanner(undefined);
}
console.log('[updateBanner] Banner update complete');
} catch (error) {
throw new Error(`Failed to update avatar URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
if (!cancelled) setLoading(false);
};
// Note: Don't set banner here - let the avatar change event handle it
// setBanner(newBanner);
}, [mx, useAuthentication]);
load();
return () => {
cancelled = true;
};
}, [mx]);
const updateBanner = useCallback(
async (newBanner: string | undefined) => {
if (!newBanner) {
await deleteBannerUrl(mx);
setBanner(undefined);
return;
}
await saveBannerUrl(mx, newBanner);
setBanner(newBanner);
},
[mx]
);
return [banner, updateBanner, loading];
}
// Cache for user banners to avoid refetching for each message
const userBannerCache = new Map<string, { bannerMxc: string | undefined; timestamp: number }>();
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const CACHE_TTL_MS = 5 * 60 * 1000;
registerBannerCacheClear((userId: string) => {
userBannerCache.delete(userId);
});
async function fetchBannerBlobUrl(
mx: ReturnType<typeof useMatrixClient>,
bannerMxc: string,
useAuthentication: boolean
): Promise<string | undefined> {
const bannerHttpUrl = mxcUrlToHttp(mx, bannerMxc, useAuthentication);
if (!bannerHttpUrl) return undefined;
const accessToken = getCurrentAccessToken();
const headers: HeadersInit = {};
if (useAuthentication && accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
const response = await fetch(bannerHttpUrl, { headers });
if (!response.ok) return undefined;
const blob = await response.blob();
return URL.createObjectURL(blob);
}
/**
* Hook to get another user's chosen profile banner from their avatar metadata
* @param userId - The user ID to get banner for
* @param avatarMxc - The user's avatar MXC URL
* @returns The user's chosen banner as a blob URL or undefined
* Hook to get another user's profile banner as a blob URL.
*/
export function useOtherUserBanner(userId: string, avatarMxc: string | undefined): string | undefined {
export function useOtherUserBanner(userId: string): string | undefined {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [bannerBlobUrl, setBannerBlobUrl] = useState<string | undefined>();
useEffect(() => {
let blobUrl: string | undefined;
if (!avatarMxc) {
console.log('[useOtherUserBanner] No avatarMxc provided for', userId);
if (!userId) {
setBannerBlobUrl(undefined);
return undefined;
}
const loadBanner = async () => {
console.log('[useOtherUserBanner] Loading banner for', userId, 'avatarMxc:', avatarMxc);
// Check cache first
const cacheKey = `${userId}:${avatarMxc}`;
const cached = userBannerCache.get(cacheKey);
let blobUrl: string | undefined;
let cancelled = false;
const load = async () => {
const cached = userBannerCache.get(userId);
let bannerMxc: string | undefined;
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
// Use cached banner MXC
console.log('[useOtherUserBanner] Using cached bannerMxc:', cached.bannerMxc);
bannerMxc = cached.bannerMxc;
} else {
// Fetch banner MXC from avatar metadata
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
console.log('[useOtherUserBanner] Fetching metadata from:', httpUrl);
if (!httpUrl) {
console.log('[useOtherUserBanner] Failed to get HTTP URL');
setBannerBlobUrl(undefined);
return;
}
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
console.log('[useOtherUserBanner] Extracted metadata:', metadata);
bannerMxc = metadata.banner;
// Cache the banner MXC (not the blob URL)
userBannerCache.set(cacheKey, { bannerMxc, timestamp: Date.now() });
bannerMxc = await loadBannerUrl(mx, userId);
userBannerCache.set(userId, { bannerMxc, timestamp: Date.now() });
}
if (!bannerMxc) {
console.log('[useOtherUserBanner] No banner MXC found');
setBannerBlobUrl(undefined);
if (!cancelled) setBannerBlobUrl(undefined);
return;
}
console.log('[useOtherUserBanner] Fetching banner image from MXC:', bannerMxc);
// Fetch the banner image data with authentication
const bannerHttpUrl = mxcUrlToHttp(mx, bannerMxc, useAuthentication);
if (!bannerHttpUrl) {
console.log('[useOtherUserBanner] Failed to convert banner MXC to HTTP URL');
setBannerBlobUrl(undefined);
return;
}
console.log('[useOtherUserBanner] Banner HTTP URL:', bannerHttpUrl);
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const headers: HeadersInit = {};
if (useAuthentication && accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
const response = await fetch(bannerHttpUrl, { headers });
if (!response.ok) {
setBannerBlobUrl(undefined);
return;
}
const blob = await response.blob();
blobUrl = URL.createObjectURL(blob);
setBannerBlobUrl(blobUrl);
blobUrl = await fetchBannerBlobUrl(mx, bannerMxc, useAuthentication);
if (!cancelled) setBannerBlobUrl(blobUrl);
};
loadBanner();
load();
// Cleanup blob URL when component unmounts or deps change
return () => {
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
}
cancelled = true;
if (blobUrl) URL.revokeObjectURL(blobUrl);
};
}, [mx, useAuthentication, avatarMxc, userId]);
}, [mx, useAuthentication, userId]);
return bannerBlobUrl;
}

View File

@@ -0,0 +1,158 @@
import { useCallback, useEffect, useState } from 'react';
import { useMatrixClient } from './useMatrixClient';
import { useMediaAuthentication } from './useMediaAuthentication';
import { mxcUrlToHttp } from '../utils/matrix';
import { getCurrentAccessToken } from '../utils/auth';
import {
CollectibleProfileFields,
StoredCollectible,
loadUserCollectibles,
saveAvatarDecoration,
saveNameplate,
saveProfileEffect,
} from '../utils/profileFields';
import { CollectibleKind } from '../utils/discordCollectibles';
export function useUserCollectibles(): [
CollectibleProfileFields,
(kind: CollectibleKind, collectible: StoredCollectible | undefined) => Promise<void>,
boolean
] {
const mx = useMatrixClient();
const [collectibles, setCollectibles] = useState<CollectibleProfileFields>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
const userId = mx.getUserId();
if (!userId) {
setLoading(false);
return undefined;
}
let cancelled = false;
const load = async () => {
setLoading(true);
try {
const data = await loadUserCollectibles(mx, userId);
if (!cancelled) setCollectibles(data);
} catch {
if (!cancelled) setCollectibles({});
}
if (!cancelled) setLoading(false);
};
load();
return () => {
cancelled = true;
};
}, [mx]);
const updateCollectible = useCallback(
async (kind: CollectibleKind, collectible: StoredCollectible | undefined) => {
if (kind === 'profile_effect') {
await saveProfileEffect(mx, collectible);
setCollectibles((prev) => ({ ...prev, profile_effect: collectible }));
return;
}
if (kind === 'nameplate') {
await saveNameplate(mx, collectible);
setCollectibles((prev) => ({ ...prev, nameplate: collectible }));
return;
}
await saveAvatarDecoration(mx, collectible);
setCollectibles((prev) => ({ ...prev, avatar_decoration: collectible }));
},
[mx]
);
return [collectibles, updateCollectible, loading];
}
const collectibleCache = new Map<string, { data: CollectibleProfileFields; timestamp: number }>();
const CACHE_TTL_MS = 5 * 60 * 1000;
async function fetchMxcBlobUrl(
mx: ReturnType<typeof useMatrixClient>,
mxc: string,
useAuthentication: boolean
): Promise<string | undefined> {
const httpUrl = mxcUrlToHttp(mx, mxc, useAuthentication);
if (!httpUrl) return undefined;
const accessToken = getCurrentAccessToken();
const headers: HeadersInit = {};
if (useAuthentication && accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
const response = await fetch(httpUrl, { headers });
if (!response.ok) return undefined;
const blob = await response.blob();
return URL.createObjectURL(blob);
}
export function useOtherUserCollectibles(userId: string): CollectibleProfileFields & {
assetUrls: Record<string, string | undefined>;
} {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [collectibles, setCollectibles] = useState<CollectibleProfileFields>({});
const [assetUrls, setAssetUrls] = useState<Record<string, string | undefined>>({});
useEffect(() => {
if (!userId) {
setCollectibles({});
setAssetUrls({});
return undefined;
}
let cancelled = false;
const blobUrls: string[] = [];
const load = async () => {
const cached = collectibleCache.get(userId);
let data: CollectibleProfileFields;
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
data = cached.data;
} else {
data = await loadUserCollectibles(mx, userId);
collectibleCache.set(userId, { data, timestamp: Date.now() });
}
if (cancelled) return;
setCollectibles(data);
const urls: Record<string, string | undefined> = {};
const entries = [
['profile_effect', data.profile_effect],
['nameplate', data.nameplate],
['avatar_decoration', data.avatar_decoration],
] as const;
for (const [kind, collectible] of entries) {
if (!collectible) continue;
for (const [role, mxc] of Object.entries(collectible.assets)) {
const blobUrl = await fetchMxcBlobUrl(mx, mxc, useAuthentication);
if (blobUrl) {
blobUrls.push(blobUrl);
urls[`${kind}:${role}`] = blobUrl;
}
}
}
if (!cancelled) setAssetUrls(urls);
};
load();
return () => {
cancelled = true;
blobUrls.forEach((url) => URL.revokeObjectURL(url));
};
}, [mx, useAuthentication, userId]);
return { ...collectibles, assetUrls };
}

View File

@@ -1,226 +1,82 @@
import { useCallback, useEffect, useState } from 'react';
import { MatrixClient, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useMediaAuthentication } from './useMediaAuthentication';
import { mxcUrlToHttp } from '../utils/matrix';
import { getCurrentAccessToken } from '../utils/auth';
import { ThemeKind, useTheme } from './useTheme';
import {
embedColorInImage,
extractColorFromImage,
removeColorFromImage,
fetchAndExtractColor,
detectImageFormat,
getMimeType,
getExtension,
} from '../utils/imageMetadata';
ColorPreference,
deleteColorPreference,
extractMemberColorPreference,
hasColorPreference,
loadColorPreference,
registerPreferenceCacheClear,
resolveColorForTheme,
saveColorPreference,
} from '../utils/profileFields';
/**
* Fetches the user's current avatar as raw image data
* Hook to manage the user's MSC4522 color preference on their global profile.
*/
async function fetchAvatarData(
mx: MatrixClient,
avatarMxc: string,
useAuthentication: boolean
): Promise<ArrayBuffer | null> {
const url = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
if (!url) return null;
try {
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
let response = await fetch(url, {
method: 'GET',
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
// If we got a 401 and we tried with auth, fallback to unauthenticated request
if (!response.ok && response.status === 401 && accessToken && useAuthentication) {
console.warn('[fetchAvatarData] Auth failed (401), attempting unauthenticated fallback');
response = await fetch(url, { method: 'GET' });
}
if (!response.ok) return null;
return await response.arrayBuffer();
} catch {
return null;
}
}
/**
* Hook to manage the user's chosen profile color, stored in avatar image metadata
* The color is embedded in PNG tEXt chunk or WebP XMP chunk and syncs via the avatar
* @returns The current color, a setter function, and loading state
*/
export function useUserColor(): [
string | undefined,
(color: string | undefined) => Promise<void>,
export function useUserColorPreference(): [
ColorPreference | undefined,
(preference: ColorPreference | undefined) => Promise<void>,
boolean
] {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [color, setColor] = useState<string | undefined>();
const [preference, setPreference] = useState<ColorPreference | undefined>();
const [loading, setLoading] = useState(true);
// Extract color from current avatar on mount and when profile changes
useEffect(() => {
const loadColor = async () => {
const userId = mx.getUserId();
if (!userId) {
setLoading(false);
return undefined;
}
let cancelled = false;
const load = async () => {
setLoading(true);
try {
const userId = mx.getUserId();
if (!userId) {
setLoading(false);
return;
}
const profile = await mx.getProfileInfo(userId);
const avatarUrl = profile.avatar_url;
if (!avatarUrl) {
setColor(undefined);
setLoading(false);
return;
}
const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication);
if (!httpUrl) {
setColor(undefined);
setLoading(false);
return;
}
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null);
setColor(extractedColor);
const loaded = await loadColorPreference(mx, userId);
if (!cancelled) setPreference(loaded);
} catch (e) {
console.error('Failed to load user color from avatar:', e);
setColor(undefined);
console.error('Failed to load color preference:', e);
if (!cancelled) setPreference(undefined);
}
setLoading(false);
if (!cancelled) setLoading(false);
};
loadColor();
// Listen for avatar changes and reload color
const userId = mx.getUserId();
if (!userId) return undefined;
const user = mx.getUser(userId);
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = () => {
loadColor();
};
user?.on(UserEvent.AvatarUrl, onAvatarChange);
load();
return () => {
user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
cancelled = true;
};
}, [mx, useAuthentication]);
}, [mx]);
const updateColor = useCallback(async (newColor: string | undefined) => {
const userId = mx.getUserId();
if (!userId) return;
try {
const profile = await mx.getProfileInfo(userId);
const avatarUrl = profile.avatar_url;
if (!avatarUrl) {
// No avatar to embed color in
console.warn('Cannot set color: no avatar image set');
throw new Error('No avatar set');
const updatePreference = useCallback(
async (newPreference: ColorPreference | undefined) => {
if (!hasColorPreference(newPreference)) {
await deleteColorPreference(mx);
setPreference(undefined);
return;
}
// Fetch current avatar
const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication);
if (!avatarData) {
console.error('Failed to fetch current avatar');
throw new Error('Failed to fetch avatar');
}
await saveColorPreference(mx, newPreference!);
setPreference(newPreference);
},
[mx]
);
console.log('Original avatar size:', avatarData.byteLength, 'bytes');
// Detect image format
const format = detectImageFormat(avatarData);
console.log('Detected image format:', format);
if (format === 'unknown') {
console.error('Unsupported avatar image format - must be PNG, WebP, JPEG, or GIF');
throw new Error('Unsupported image format');
}
// Modify image metadata
let newAvatarData: Uint8Array | null;
if (newColor) {
newAvatarData = embedColorInImage(avatarData, newColor);
} else {
newAvatarData = removeColorFromImage(avatarData);
}
if (!newAvatarData) {
console.error('Failed to modify avatar image metadata');
throw new Error('Failed to modify image metadata');
}
console.log('Modified avatar size:', newAvatarData.byteLength, 'bytes');
// Verify the color was embedded correctly before uploading
const verifyColor = extractColorFromImage(newAvatarData);
console.log('Verification - embedded color:', verifyColor);
if (newColor && verifyColor !== newColor) {
console.error('Color verification failed! Expected:', newColor, 'Got:', verifyColor);
throw new Error('Color embedding verification failed');
}
// Upload modified avatar with correct MIME type
const mimeType = getMimeType(format);
const extension = getExtension(format);
const blob = new Blob([newAvatarData], { type: mimeType });
const uploadResponse = await mx.uploadContent(blob, {
name: `avatar.${extension}`,
type: mimeType,
});
console.log('Uploaded new avatar:', uploadResponse.content_uri);
// Update profile with new avatar
await mx.setAvatarUrl(uploadResponse.content_uri);
// Manually sync user object to ensure event listeners are triggered
const user = mx.getUser(userId);
if (user && user.avatarUrl !== uploadResponse.content_uri) {
user.setAvatarUrl(uploadResponse.content_uri);
}
setColor(newColor);
console.log('User color updated in avatar:', newColor);
} catch (e) {
console.error('Failed to update user color in avatar:', e);
throw e;
}
}, [mx, useAuthentication]);
return [color, updateColor, loading];
return [preference, updatePreference, loading];
}
/**
* LRU Cache implementation with max size limit
*/
class LRUCache<K, V> {
private cache: Map<K, V>;
private cache = new Map<K, V>();
private maxSize: number;
constructor(maxSize: number) {
this.cache = new Map();
this.maxSize = maxSize;
}
constructor(private maxSize: number) {}
get(key: K): V | undefined {
const value = this.cache.get(key);
if (value !== undefined) {
// Move to end (most recently used)
this.cache.delete(key);
this.cache.set(key, value);
}
@@ -228,88 +84,106 @@ class LRUCache<K, V> {
}
set(key: K, value: V): void {
// If key exists, delete it first to re-add at end
if (this.cache.has(key)) {
this.cache.delete(key);
}
// If at max size, remove oldest (first) entry
else if (this.cache.size >= this.maxSize) {
} else if (this.cache.size >= this.maxSize) {
const firstKey = this.cache.keys().next().value;
if (firstKey !== undefined) {
this.cache.delete(firstKey);
}
if (firstKey !== undefined) this.cache.delete(firstKey);
}
this.cache.set(key, value);
}
clear(): void {
this.cache.clear();
delete(key: K): void {
this.cache.delete(key);
}
}
// LRU cache for user colors to avoid refetching for each message
const userColorCache = new LRUCache<string, { color: string | undefined; timestamp: number }>(100);
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
const preferenceCache = new LRUCache<string, { preference: ColorPreference | undefined; timestamp: number }>(200);
const CACHE_TTL_MS = 30 * 60 * 1000;
registerPreferenceCacheClear((userId: string) => {
preferenceCache.delete(userId);
});
/**
* Hook to get another user's chosen profile color from their avatar metadata
* @param userId - The user ID to get color for
* @param avatarMxc - The user's avatar MXC URL
* @returns The user's chosen color or undefined
* Hook to get another user's MSC4522 color preference from their global profile.
*/
export function useOtherUserColor(userId: string, avatarMxc: string | undefined): string | undefined {
export function useOtherUserColorPreference(userId: string): ColorPreference | undefined {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const cachedEntry = preferenceCache.get(userId);
const cachedPreference =
cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL_MS ? cachedEntry.preference : undefined;
// Check cache synchronously BEFORE initializing state to avoid flash
const cacheKey = avatarMxc ? `${userId}:${avatarMxc}` : '';
const cachedEntry = cacheKey ? userColorCache.get(cacheKey) : undefined;
const cachedColor = cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL_MS
? cachedEntry.color
: undefined;
const [color, setColor] = useState<string | undefined>(cachedColor);
const [preference, setPreference] = useState<ColorPreference | undefined>(cachedPreference);
useEffect(() => {
if (!avatarMxc) {
console.log('[useOtherUserColor] No avatarMxc provided for', userId);
setColor(undefined);
if (!userId) {
setPreference(undefined);
return;
}
// Check cache first
const cacheKey = `${userId}:${avatarMxc}`;
const cached = userColorCache.get(cacheKey);
const cached = preferenceCache.get(userId);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
console.log('[useOtherUserColor] Using cached color for', userId, ':', cached.color);
setColor(cached.color);
setPreference(cached.preference);
return;
}
const loadColor = async () => {
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
console.log('[useOtherUserColor] Loading color for', userId, 'from', httpUrl);
if (!httpUrl) {
console.log('[useOtherUserColor] Failed to get HTTP URL for', avatarMxc);
setColor(undefined);
return;
let cancelled = false;
const load = async () => {
try {
const loaded = await loadColorPreference(mx, userId);
preferenceCache.set(userId, { preference: loaded, timestamp: Date.now() });
if (!cancelled) setPreference(loaded);
} catch {
if (!cancelled) setPreference(undefined);
}
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null);
console.log('[useOtherUserColor] Extracted color for', userId, ':', extractedColor);
// Cache the result
userColorCache.set(cacheKey, { color: extractedColor, timestamp: Date.now() });
setColor(extractedColor);
};
loadColor();
}, [mx, useAuthentication, avatarMxc, userId]);
load();
return () => {
cancelled = true;
};
}, [mx, userId]);
return color;
return preference;
}
/**
* MSC4522 per-room member color preference (overrides global profile when set).
*/
export function useMemberColorPreference(userId: string, room?: Room): ColorPreference | undefined {
const [preference, setPreference] = useState<ColorPreference | undefined>(() =>
extractMemberColorPreference(room, userId)
);
useEffect(() => {
setPreference(extractMemberColorPreference(room, userId));
}, [room, userId]);
return preference;
}
/**
* Resolve a user's username color for the active theme (MSC4522).
* Per-room member colors take precedence over global profile colors.
*/
export function useOtherUserColor(userId: string, room?: Room): string | undefined {
const theme = useTheme();
const profilePreference = useOtherUserColorPreference(userId);
const memberPreference = useMemberColorPreference(userId, room);
const preference = memberPreference ?? profilePreference;
return resolveColorForTheme(preference, theme.kind);
}
/**
* Resolve the current user's username color for the active theme.
*/
export function useUsernameColor(userId: string, room?: Room, themeKind?: ThemeKind): string | undefined {
const theme = useTheme();
const kind = themeKind ?? theme.kind;
const profilePreference = useOtherUserColorPreference(userId);
const memberPreference = useMemberColorPreference(userId, room);
const preference = memberPreference ?? profilePreference;
return resolveColorForTheme(preference, kind);
}

View File

@@ -1,264 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useMediaAuthentication } from './useMediaAuthentication';
import { mxcUrlToHttp } from '../utils/matrix';import { getCurrentAccessToken } from '../utils/auth';import {
fetchAndExtractMetadata,
extractMetadataFromImage,
embedMetadataInImage,
detectImageFormat,
getMimeType,
getExtension,
ImageMetadata,
ProfileGradient,
} from '../utils/imageMetadata';
/**
* Fetches the user's current avatar as raw image data
*/
async function fetchAvatarData(
mx: ReturnType<typeof useMatrixClient>,
avatarMxc: string,
useAuthentication: boolean
): Promise<ArrayBuffer | null> {
const url = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
if (!url) return null;
try {
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
let response = await fetch(url, {
method: 'GET',
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
// If we got a 401 and we tried with auth, fallback to unauthenticated request
if (!response.ok && response.status === 401 && accessToken && useAuthentication) {
response = await fetch(url, { method: 'GET' });
}
if (!response.ok) return null;
return await response.arrayBuffer();
} catch {
return null;
}
}
export type ProfileStyleData = {
avatarBorderColor?: string;
gradient?: ProfileGradient;
};
/**
* Hook to manage the user's profile style (avatar border color and gradient)
* Stored in avatar image metadata
* @returns Current style data, a setter function, and loading state
*/
export function useUserProfileStyle(): [
ProfileStyleData,
(style: Partial<ProfileStyleData>) => Promise<void>,
boolean
] {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [style, setStyle] = useState<ProfileStyleData>({});
const [loading, setLoading] = useState(true);
// Extract style from current avatar on mount and when profile changes
useEffect(() => {
const loadStyle = async () => {
setLoading(true);
try {
const userId = mx.getUserId();
if (!userId) {
setLoading(false);
return;
}
const profile = await mx.getProfileInfo(userId);
const avatarUrl = profile.avatar_url;
if (!avatarUrl) {
setStyle({});
setLoading(false);
return;
}
const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication);
if (!httpUrl) {
setStyle({});
setLoading(false);
return;
}
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
setStyle({
avatarBorderColor: metadata.avatarBorderColor,
gradient: metadata.gradient,
});
} catch {
setStyle({});
}
setLoading(false);
};
loadStyle();
// Listen for avatar changes and reload style
const userId = mx.getUserId();
if (!userId) return undefined;
const user = mx.getUser(userId);
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = () => {
loadStyle();
};
user?.on(UserEvent.AvatarUrl, onAvatarChange);
return () => {
user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
};
}, [mx, useAuthentication]);
const updateStyle = useCallback(
async (newStyle: Partial<ProfileStyleData>) => {
const userId = mx.getUserId();
if (!userId) {
throw new Error('No user ID');
}
const profile = await mx.getProfileInfo(userId);
const avatarUrl = profile.avatar_url;
if (!avatarUrl) {
throw new Error('No avatar set. Please upload an avatar first.');
}
// Fetch current avatar
const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication);
if (!avatarData) {
throw new Error('Failed to fetch current avatar');
}
// Detect image format
const format = detectImageFormat(avatarData);
if (format === 'unknown') {
throw new Error('Unsupported avatar image format');
}
// Get existing metadata to preserve
const existingMetadata = extractMetadataFromImage(avatarData);
// Merge with new style
const newMetadata: ImageMetadata = {
...existingMetadata,
avatarBorderColor: newStyle.avatarBorderColor !== undefined ? newStyle.avatarBorderColor : existingMetadata.avatarBorderColor,
gradient: newStyle.gradient !== undefined ? newStyle.gradient : existingMetadata.gradient,
};
// Embed updated metadata
const newAvatarData = embedMetadataInImage(avatarData, newMetadata);
if (!newAvatarData) {
throw new Error('Failed to embed style in avatar metadata');
}
// Upload modified avatar with correct MIME type
const mimeType = getMimeType(format);
const extension = getExtension(format);
const blob = new Blob([newAvatarData], { type: mimeType });
const uploadResponse = await mx.uploadContent(blob, {
name: `avatar.${extension}`,
type: mimeType,
});
// Update profile with new avatar
await mx.setAvatarUrl(uploadResponse.content_uri);
// Manually sync user object
const user = mx.getUser(userId);
if (user && user.avatarUrl !== uploadResponse.content_uri) {
user.setAvatarUrl(uploadResponse.content_uri);
}
// Update local state
setStyle((prev) => ({
...prev,
...newStyle,
}));
},
[mx, useAuthentication]
);
return [style, updateStyle, loading];
}
// Cache for other users' profile styles
const userStyleCache = new Map<string, { style: ProfileStyleData; timestamp: number }>();
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
/**
* Hook to get another user's profile style from their avatar metadata
* @param userId - The user ID to get style for
* @param avatarMxc - The user's avatar MXC URL
* @returns The user's profile style data
*/
export function useOtherUserProfileStyle(userId: string, avatarMxc: string | undefined): ProfileStyleData {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [style, setStyle] = useState<ProfileStyleData>({});
useEffect(() => {
if (!avatarMxc) {
console.log('[useOtherUserProfileStyle] No avatarMxc provided for', userId);
setStyle({});
return;
}
const loadStyle = async () => {
console.log('[useOtherUserProfileStyle] Loading style for', userId, 'avatarMxc:', avatarMxc);
// Check cache first
const cacheKey = `${userId}:${avatarMxc}`;
const cached = userStyleCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
console.log('[useOtherUserProfileStyle] Using cached style:', cached.style);
setStyle(cached.style);
return;
}
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
console.log('[useOtherUserProfileStyle] Fetching metadata from:', httpUrl);
if (!httpUrl) {
console.log('[useOtherUserProfileStyle] Failed to get HTTP URL');
setStyle({});
return;
}
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
console.log('[useOtherUserProfileStyle] Extracted metadata:', metadata);
const styleData: ProfileStyleData = {
avatarBorderColor: metadata.avatarBorderColor,
gradient: metadata.gradient,
};
console.log('[useOtherUserProfileStyle] Final styleData:', styleData);
// Cache the result
userStyleCache.set(cacheKey, { style: styleData, timestamp: Date.now() });
setStyle(styleData);
};
loadStyle();
}, [mx, useAuthentication, avatarMxc, userId]);
return style;
}

View File

@@ -11,7 +11,14 @@ import { MatrixClient } from 'matrix-js-sdk';
import { getHomeRoomPath, getDirectRoomPath, getSpaceRoomPath } from './pages/pathUtils';
import { getCanonicalAliasOrRoomId, isRoomId } from './utils/matrix';
import { getCallService } from './features/call/useCall';
import { RoomType, StateEvent } from '../types/matrix/room';
import {
getNotificationType,
getUnreadInfo,
reactionOrEditEvent,
roomHaveNotification,
roomHaveUnread,
} from './utils/room';
import { MessageEvent, NotificationType, RoomType, StateEvent } from '../types/matrix/room';
/**
* Global reference to the router navigate function
@@ -120,6 +127,30 @@ export function initPaarrotAPI(matrixClient: MatrixClient) {
result = await getCurrentRoom(matrixClient);
break;
case 'get-messages-groups':
result = await getMessagesByScope(matrixClient, 'groups', action.params.limit);
break;
case 'get-messages-dms':
result = await getMessagesByScope(matrixClient, 'dms', action.params.limit);
break;
case 'get-messages-combined':
result = await getMessagesByScope(matrixClient, 'combined', action.params.limit);
break;
case 'get-unreads-groups':
result = await getUnreadsByScope(matrixClient, 'groups');
break;
case 'get-unreads-dms':
result = await getUnreadsByScope(matrixClient, 'dms');
break;
case 'get-unreads-combined':
result = await getUnreadsByScope(matrixClient, 'combined');
break;
default:
throw new Error(`Unknown action: ${action.action}`);
}
@@ -410,6 +441,207 @@ async function getCurrentRoom(matrixClient: any) {
};
}
const DEFAULT_MESSAGE_LIMIT = 10;
const MAX_MESSAGE_LIMIT = 100;
type MessageScope = 'groups' | 'dms' | 'combined';
type ApiMessage = {
eventId: string;
roomId: string;
roomName: string;
isDirect: boolean;
sender: string;
senderName: string;
timestamp: number;
msgtype: string;
body: string;
};
function isDirectRoom(room: any): boolean {
return room?.guessDMUserId?.() !== null;
}
function parseMessageLimit(limit?: number | string): number {
const parsedLimit = Number(limit);
return Math.min(
Math.max(Number.isFinite(parsedLimit) ? Math.floor(parsedLimit) : DEFAULT_MESSAGE_LIMIT, 1),
MAX_MESSAGE_LIMIT
);
}
/**
* Collect recent room messages from the local live timeline (newest last).
* Skips reactions, edits, and redacted events.
*/
function collectRecentMessages(room: any, limit: number): ApiMessage[] {
const liveEvents = room.getLiveTimeline?.()?.getEvents?.() ?? [];
const isDirect = isDirectRoom(room);
const roomId = room.roomId;
const roomName = room.name || 'Unnamed Room';
const messages: ApiMessage[] = [];
for (let i = liveEvents.length - 1; i >= 0 && messages.length < limit; i -= 1) {
const evt = liveEvents[i];
if (!evt) continue;
if (typeof evt.isRedacted === 'function' && evt.isRedacted()) continue;
if (reactionOrEditEvent(evt)) continue;
const type = evt.getType?.();
if (
type !== MessageEvent.RoomMessage &&
type !== MessageEvent.RoomMessageEncrypted &&
type !== MessageEvent.Sticker
) {
continue;
}
const content = evt.getContent?.() ?? {};
const sender = evt.getSender?.() ?? '';
const member = sender ? room.getMember?.(sender) : null;
if (type === MessageEvent.RoomMessageEncrypted && !content.msgtype) {
messages.push({
eventId: evt.getId?.() ?? '',
roomId,
roomName,
isDirect,
sender,
senderName: member?.name ?? sender,
timestamp: evt.getTs?.() ?? 0,
msgtype: 'm.encrypted',
body: '[encrypted]',
});
continue;
}
messages.push({
eventId: evt.getId?.() ?? '',
roomId,
roomName,
isDirect,
sender,
senderName: member?.name ?? sender,
timestamp: evt.getTs?.() ?? 0,
msgtype: content.msgtype || (type === MessageEvent.Sticker ? 'm.sticker' : 'm.text'),
body: content.body || '',
});
}
return messages.reverse();
}
function getRoomsForScope(matrixClient: any, scope: MessageScope) {
const rooms = (matrixClient?.getRooms?.() || []).filter(
(room: any) => !isSpaceLikeRoom(room) && room.getMyMembership?.() === 'join'
);
if (scope === 'groups') {
return rooms.filter((room: any) => !isDirectRoom(room));
}
if (scope === 'dms') {
return rooms.filter((room: any) => isDirectRoom(room));
}
return rooms;
}
/**
* Get recent messages across groups, DMs, or both (combined).
*/
async function getMessagesByScope(
matrixClient: any,
scope: MessageScope,
limit?: number | string
) {
const limitNum = parseMessageLimit(limit);
const rooms = getRoomsForScope(matrixClient, scope);
const candidates: ApiMessage[] = [];
for (const room of rooms) {
candidates.push(...collectRecentMessages(room, limitNum));
}
candidates.sort((a, b) => a.timestamp - b.timestamp);
const messages = candidates.slice(-limitNum);
return {
scope,
limit: limitNum,
count: messages.length,
messages,
};
}
/**
* List unread conversations across groups, DMs, or both (combined).
* Uses the same unread rules as the app UI (notification counts + read receipts, muted excluded).
*/
async function getUnreadsByScope(matrixClient: any, scope: MessageScope) {
const rooms = getRoomsForScope(matrixClient, scope);
const unreads: Array<{
roomId: string;
name: string;
isDirect: boolean;
avatar: string | null;
total: number;
highlight: number;
latest?: {
eventId: string;
sender: string;
senderName: string;
timestamp: number;
msgtype: string;
body: string;
};
}> = [];
for (const room of rooms) {
if (getNotificationType(matrixClient, room.roomId) === NotificationType.Mute) {
continue;
}
if (!roomHaveNotification(room) && !roomHaveUnread(matrixClient, room)) {
continue;
}
const unreadInfo = getUnreadInfo(room);
const latestMessages = collectRecentMessages(room, 1);
const latest = latestMessages[0];
unreads.push({
roomId: room.roomId,
name: room.name || 'Unnamed Room',
isDirect: isDirectRoom(room),
avatar: room.getMxcAvatarUrl?.() || null,
total: unreadInfo.total,
highlight: unreadInfo.highlight,
latest: latest
? {
eventId: latest.eventId,
sender: latest.sender,
senderName: latest.senderName,
timestamp: latest.timestamp,
msgtype: latest.msgtype,
body: latest.body,
}
: undefined,
});
}
unreads.sort((a, b) => {
const aTs = a.latest?.timestamp ?? 0;
const bTs = b.latest?.timestamp ?? 0;
if (bTs !== aTs) return bTs - aTs;
if (b.highlight !== a.highlight) return b.highlight - a.highlight;
return b.total - a.total;
});
return {
scope,
count: unreads.length,
unreads,
};
}
/**
* Get the currently active room ID
* Extracts room ID from the current URL pathname

View File

@@ -5,7 +5,7 @@ import {
LightTheme,
ThemeContextProvider,
ThemeKind,
useActiveTheme,
useDisplayTheme,
useSystemThemeKind,
} from '../hooks/useTheme';
import { useSetting } from '../state/hooks/settings';
@@ -29,21 +29,21 @@ export function UnAuthRouteThemeManager() {
}
export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
const activeTheme = useActiveTheme();
const displayTheme = useDisplayTheme();
const [monochromeMode] = useSetting(settingsAtom, 'monochromeMode');
useEffect(() => {
document.body.className = '';
document.body.classList.add(configClass, varsClass);
document.body.classList.add(...activeTheme.classNames);
document.body.classList.add(...displayTheme.classNames);
if (monochromeMode) {
document.body.style.filter = 'grayscale(1)';
} else {
document.body.style.filter = '';
}
}, [activeTheme, monochromeMode]);
}, [displayTheme, monochromeMode]);
return <ThemeContextProvider value={activeTheme}>{children}</ThemeContextProvider>;
return <ThemeContextProvider value={displayTheme}>{children}</ThemeContextProvider>;
}

View File

@@ -58,6 +58,7 @@ import {
listenForAndroidShares,
materializeSharedFile,
} from '../../utils/androidShare';
import { UpdatesDialogHost } from '../../components/updates-dialog';
/**
* Applies the selected emoji style font to the document.
@@ -701,6 +702,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<TaskbarFlashStopper />
<AndroidShareIntentHandler />
{children}
<UpdatesDialogHost />
</>
);
}

View File

@@ -1,15 +1,19 @@
import React, { ReactNode } from 'react';
import { useParams } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { decodeRouteParam } from '../../pathUtils';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
import { useDirectRooms } from './useDirectRooms';
import { mDirectAtom } from '../../../state/mDirectList';
import { Membership } from '../../../../types/matrix/room';
export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
const mx = useMatrixClient();
const rooms = useDirectRooms();
const mDirects = useAtomValue(mDirectAtom);
const { roomIdOrAlias: rawRoomIdOrAlias, eventId: rawEventId } = useParams();
const roomIdOrAlias = decodeRouteParam(rawRoomIdOrAlias);
@@ -17,7 +21,13 @@ export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
const roomId = useSelectedRoom();
const room = mx.getRoom(roomId);
if (!room || !rooms.includes(room.roomId)) {
// useDirectRooms can lag m.direct / allRooms during catch-up; allow joined m.direct rooms.
const isJoinedDirect =
Boolean(room) &&
room!.getMyMembership() === Membership.Join &&
(rooms.includes(room!.roomId) || mDirects.has(room!.roomId));
if (!room || !isJoinedDirect) {
return (
<JoinBeforeNavigate
roomIdOrAlias={roomIdOrAlias ?? rawRoomIdOrAlias!}

View File

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

View File

@@ -11,6 +11,9 @@ import { nameInitials } from '../../../utils/common';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { Settings } from '../../../features/settings';
import { useUserProfile } from '../../../hooks/useUserProfile';
import { useOtherUserCollectibles } from '../../../hooks/useUserCollectibles';
import { pickAvatarDecorationUrl } from '../../../utils/collectibleAssets';
import * as avatarDecorationCss from '../../../styles/AvatarDecoration.css';
import { Modal500 } from '../../../components/Modal500';
import { AccountSwitcher } from '../../../components/account-switcher';
import { stopPropagation } from '../../../utils/keyboard';
@@ -34,6 +37,8 @@ export function SettingsTab() {
const avatarUrl = profile.avatarUrl && userId
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const { assetUrls } = useOtherUserCollectibles(userId ?? '');
const avatarDecorationUrl = pickAvatarDecorationUrl(assetUrls);
const openSettings = () => setSettings(true);
const closeSettings = () => setSettings(false);
@@ -50,12 +55,23 @@ export function SettingsTab() {
as="button"
onClick={openSettings}
onContextMenu={handleContextMenu}
style={{ overflow: 'visible' }}
>
<UserAvatar
userId={userId ?? ''}
src={avatarUrl}
renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}
/>
<div className={avatarDecorationCss.SidebarAvatarStack}>
<UserAvatar
userId={userId ?? ''}
src={avatarUrl}
renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}
/>
{avatarDecorationUrl && (
<img
className={avatarDecorationCss.AvatarDecorationOverlay}
src={avatarDecorationUrl}
alt=""
draggable={false}
/>
)}
</div>
</SidebarAvatar>
<PopOut

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