Compare commits

6 Commits

Author SHA1 Message Date
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
64 changed files with 11174 additions and 1521 deletions

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

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

1464
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -84,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",

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

@@ -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,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"
<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
className={css.ImageViewerImg}
style={{
cursor,
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
}}
ref={imgRef}
className={css.Image}
src={src}
alt={alt}
draggable={false}
onMouseDown={onMouseDown}
onLoad={fitImage}
/>
</Box>
</Box>
</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

@@ -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,8 +191,7 @@ export const ImageContent = as<'div', ImageContentProps>(
<div className={css.MediaSkeleton} />
))}
{srcState.status === AsyncStatus.Success && (
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<Overlay open={viewer} backdrop={null}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
@@ -201,19 +199,13 @@ export const ImageContent = as<'div', ImageContentProps>(
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal
className={ModalWide}
size="500"
>
{renderViewer({
src: srcState.data,
alt: body,
requestClose: () => setViewer(false),
})}
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
@@ -126,8 +103,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
padding: config.space.S400,
paddingTop: `calc(${config.space.S200} + ${toRem(36)})`,
marginTop: toRem(-36),
background: gradientStyle,
textAlign: 'center'
textAlign: 'center',
}}
>
{/* Display Name */}
@@ -152,20 +128,14 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
{presence?.status && (
<Box
style={{
backgroundColor: pillBgColor || color.Surface.Container,
border: pillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
backgroundColor: color.Surface.Container,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
padding: `${toRem(6)} ${toRem(10)}`,
borderRadius: toRem(16),
maxWidth: toRem(250),
}}
>
<Text
size="T300"
className={BreakWord}
style={{
color: pillBgColor ? getContrastingTextColor(pillBgColor) : undefined
}}
>
<Text size="T300" className={BreakWord}>
{presence.status}
</Text>
</Box>
@@ -173,9 +143,9 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
{/* Chips Row */}
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
{server && <ServerChip server={server} borderColor={pillBgColor} />}
<ShareChip userId={userId} borderColor={pillBgColor} />
{creator ? <CreatorChip borderColor={pillBgColor} /> : <PowerChip userId={userId} borderColor={pillBgColor} />}
{server && <ServerChip server={server} />}
<ShareChip userId={userId} />
{creator ? <CreatorChip /> : <PowerChip userId={userId} />}
</Box>
{/* Message Button */}
@@ -195,8 +165,8 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
{/* Additional Chips if needed */}
{userId !== myUserId && (
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
<MutualRoomsChip userId={userId} borderColor={pillBgColor} />
<OptionsChip userId={userId} borderColor={pillBgColor} />
<MutualRoomsChip userId={userId} />
<OptionsChip userId={userId} />
</Box>
)}
</Box>

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

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

@@ -0,0 +1,125 @@
import { trimTrailingSlash } from '../utils/common';
const UPDATE_BASE_PATH = `${trimTrailingSlash(import.meta.env.BASE_URL)}/update`;
export type UpdateManifestEntry = {
file: string;
version?: string;
title?: string;
date?: string;
};
export type UpdateManifest = {
current: string;
older: UpdateManifestEntry[];
};
export type ParsedUpdateDoc = {
file: string;
version?: string;
title?: string;
date?: string;
summary?: string;
markdown: string;
};
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
function parseFrontmatter(source: string): { frontmatter: Record<string, string>; body: string } {
const match = FRONTMATTER_RE.exec(source.trimStart());
if (!match) {
return { frontmatter: {}, body: source };
}
const frontmatter: Record<string, string> = {};
match[1].split('\n').forEach((line) => {
const colon = line.indexOf(':');
if (colon <= 0) return;
const key = line.slice(0, colon).trim();
const raw = line.slice(colon + 1).trim();
frontmatter[key] = raw.replace(/^['"]|['"]$/g, '');
});
return { frontmatter, body: match[2] };
}
function parseUpdateMarkdown(file: string, source: string): ParsedUpdateDoc {
const { frontmatter, body } = parseFrontmatter(source);
return {
file,
version: frontmatter.version,
title: frontmatter.title,
date: frontmatter.date,
summary: frontmatter.summary,
markdown: body.trim(),
};
}
export function resolveUpdateAssetUrl(src: string | undefined): string | undefined {
if (!src) return undefined;
if (/^(https?:|data:|blob:)/i.test(src)) return src;
if (src.startsWith('/')) return src;
const normalized = src.replace(/^\.\//, '');
return `${UPDATE_BASE_PATH}/${normalized}`;
}
export async function loadUpdateManifest(): Promise<UpdateManifest | null> {
try {
const response = await fetch(`${UPDATE_BASE_PATH}/manifest.json`, { cache: 'no-cache' });
if (!response.ok) return null;
const data = (await response.json()) as UpdateManifest;
if (!data?.current || !Array.isArray(data.older)) return null;
return {
current: data.current,
older: data.older.filter((entry) => entry?.file),
};
} catch {
return null;
}
}
export async function loadUpdateDocument(file: string): Promise<ParsedUpdateDoc | null> {
const safeFile = file.replace(/^\/+/, '');
if (!safeFile || safeFile.includes('..')) return null;
try {
const response = await fetch(`${UPDATE_BASE_PATH}/${safeFile}`, { cache: 'no-cache' });
if (!response.ok) return null;
const source = await response.text();
return parseUpdateMarkdown(safeFile, source);
} catch {
return null;
}
}
export async function loadCurrentUpdateDocument(): Promise<ParsedUpdateDoc | null> {
const manifest = await loadUpdateManifest();
if (!manifest) return null;
return loadUpdateDocument(manifest.current);
}
export async function getCurrentUpdatePreview(): Promise<{
title: string;
description: string;
} | null> {
const doc = await loadCurrentUpdateDocument();
if (!doc) return null;
const title = doc.title ?? (doc.version ? `Paarrot ${doc.version}` : 'Release notes');
const description =
doc.summary ??
doc.markdown
.replace(/^#+\s+/gm, '')
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.split('\n')
.map((line) => line.replace(/^[-*•]\s*/, '').trim())
.find(Boolean) ??
'See what changed in this version.';
return { title, description };
}

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} />
) : (
<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

@@ -2011,6 +2011,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
return 'CALL_EVENT_PENDING' as unknown as React.ReactNode;
},
[MessageEvent.RelayEmojiConfetti]: () => null,
[MessageEvent.PaarrotUiAction]: () => null,
},
(mEventId, mEvent, item) => {
if (!showHiddenEvents) return null;

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

@@ -1,13 +1,22 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { EmojiConfettiBurst } from './types';
import { findJumboEmojiElement, getLocalBurstCanvasSize } from './findJumboMount';
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;
@@ -17,6 +26,198 @@ type EmojiConfettiBurstCanvasProps = {
};
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[]>([]);
@@ -89,13 +290,7 @@ export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBur
canvas.style.width = `${canvasSize}px`;
canvas.style.height = `${canvasSize}px`;
spawnEmojiBurst(
particlesRef.current,
burst.id,
localOrigin,
burst.emojis[0] ?? '🎉',
performance.now()
);
spawnEmojiBurst(particlesRef.current, burst.id, localOrigin, primaryEmoji, performance.now());
const tick = (now: number) => {
updateLayerPosition();
@@ -152,7 +347,7 @@ export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBur
window.clearInterval(retry);
frameCleanup?.();
};
}, [burst.emojis, burst.id, burst.targetEventId, canvasSize, localOrigin]);
}, [burst.id, burst.targetEventId, canvasSize, localOrigin, primaryEmoji]);
return createPortal(
<div

View File

@@ -7,7 +7,8 @@ export type BurstMotionStyle =
| 'spiral'
| 'punch'
| 'scatter'
| 'shower';
| 'shower'
| 'firework';
export type EmojiBurstProfile = {
style: BurstMotionStyle;
@@ -32,6 +33,19 @@ export type EmojiBurstProfile = {
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 = {
@@ -340,6 +354,97 @@ const PROFILE_OVERRIDES: Record<string, Partial<EmojiBurstProfile>> = {
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,
@@ -369,6 +474,10 @@ export function getEmojiBurstProfile(emoji: string): EmojiBurstProfile {
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;

View File

@@ -6,6 +6,60 @@ export function findJumboEmojiElement(targetEventId: string): HTMLElement | null
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

@@ -785,7 +785,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)

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,6 +17,7 @@ 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);
@@ -93,10 +96,13 @@ 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]);
const [updatePreview, setUpdatePreview] = useState<{ title: string; description: string } | null>(null);
return (
<Page>
<PageHeader outlined={false}>
@@ -151,6 +157,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

@@ -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,30 +29,22 @@ 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 {
embedMetadataInImage,
detectImageFormat,
getMimeType,
getExtension,
ImageMetadata,
needsPngForMetadata,
convertImageDataToPng,
uint8ArrayToBlob,
} from '../../../utils/imageMetadata';
import { getCurrentAccessToken } from '../../../utils/auth';
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();
@@ -98,26 +90,25 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
: 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);
};
@@ -126,141 +117,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);
@@ -475,22 +340,6 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
setSaving(true);
setError(undefined);
try {
// Re-embed existing banner/color/style into the new avatar when present
const metadata: ImageMetadata = {
banner: userBanner,
color: userColor,
avatarBorderColor: profileStyle.avatarBorderColor,
gradient: profileStyle.gradient,
};
const hasMetadata = Boolean(
metadata.color ||
metadata.banner ||
metadata.avatarBorderColor ||
metadata.gradient
);
// Nothing to re-embed — use the already-uploaded MXC directly
if (!hasMetadata) {
await mx.setAvatarUrl(upload.mxc);
const userId = mx.getUserId();
if (userId) {
@@ -501,64 +350,12 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
}
setAvatarFile(undefined);
await syncUserAvatar();
return;
}
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');
let avatarData: ArrayBuffer | Uint8Array = await response.arrayBuffer();
let format = detectImageFormat(avatarData);
if (format === 'unknown') throw new Error('Unsupported avatar image format');
// Banner / border / gradient only live in PNG tEXt — convert JPEG/WebP/GIF first
if (format !== 'png' && needsPngForMetadata(metadata)) {
const pngData = await convertImageDataToPng(avatarData);
if (!pngData) throw new Error('Failed to convert avatar to PNG for metadata');
avatarData = pngData;
format = 'png';
}
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 = uint8ArrayToBlob(modifiedData, mimeType);
const uploadResponse = await mx.uploadContent(blob, { name: `avatar.${extension}`, type: mimeType });
await mx.setAvatarUrl(uploadResponse.content_uri);
const userId = mx.getUserId();
if (userId) {
const user = mx.getUser(userId);
if (user && user.avatarUrl !== uploadResponse.content_uri) {
user.setAvatarUrl(uploadResponse.content_uri);
}
}
setAvatarFile(undefined);
await syncUserAvatar();
} catch (e: any) {
setError(`Failed to save avatar: ${e?.message || 'Unknown error'}`);
}
setSaving(false);
},
[mx, useAuthentication, userBanner, userColor, profileStyle, syncUserAvatar]
[mx, syncUserAvatar]
);
const handleRemoveBanner = async () => {
@@ -574,24 +371,11 @@ 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);
return (
<Box direction="Column" gap="300">
@@ -627,7 +411,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',
@@ -740,7 +524,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
as="button"
onClick={handleAvatarClick}
style={{
backgroundColor: previewBorderColor || color.Surface.Container,
backgroundColor: color.Surface.Container,
border: 'none',
padding: 0,
cursor: 'pointer',
@@ -748,7 +532,6 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
}}
>
<AvatarPresence
badgeBackgroundColor={previewBorderColor}
badge={
presence && (
<PresenceBadge presence={presence.presence} status={presence.status} />
@@ -760,9 +543,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
style={{
width: toRem(72),
height: toRem(72),
outline: previewBorderColor
? `${toRem(4)} solid ${previewBorderColor}`
: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
}}
>
<UserAvatar
@@ -832,73 +613,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
@@ -910,6 +638,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)}`,
@@ -926,19 +655,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>
@@ -946,8 +684,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),
@@ -980,8 +719,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),
@@ -1032,14 +771,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) => {
@@ -1070,63 +808,42 @@ 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,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
@@ -1134,111 +851,91 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
width: '100%',
}}
>
<Text size="H6">Profile Style</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)}
<Text size="H6">Username colors</Text>
<Text size="T200" style={{ opacity: 0.8 }}>
Set how your name appears on dark and light themes (MSC4522). Other clients that support this
spec will see your chosen colors.
</Text>
<Box gap="400" wrap="Wrap">
<Box direction="Column" gap="200">
<Text size="T300">On dark themes</Text>
<Text size="T200" style={{ opacity: 0.7 }}>Bright colors work best</Text>
<HexColorPicker color={localOnDark} onChange={(c) => { setLocalOnDark(c); setColorError(undefined); }} />
<Input
size="300"
variant="Secondary"
style={{ width: toRem(120) }}
value={localOnDark}
onChange={(e) => {
setLocalOnDark(e.target.value);
setColorError(undefined);
}}
/>
<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>
</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)})`,
backgroundColor: localOnDark,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
}}
/>
<Box gap="100">
</Box>
<Box direction="Column" gap="200">
<Text size="T300">On light themes</Text>
<Text size="T200" style={{ opacity: 0.7 }}>Darker colors work best</Text>
<HexColorPicker color={localOnLight} onChange={(c) => { setLocalOnLight(c); setColorError(undefined); }} />
<Input
size="300"
variant="Secondary"
style={{ width: toRem(120) }}
value={localOnLight}
onChange={(e) => {
setLocalOnLight(e.target.value);
setColorError(undefined);
}}
/>
<Box
style={{
width: toRem(48),
height: toRem(48),
borderRadius: toRem(8),
backgroundColor: localOnLight,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
}}
/>
</Box>
</Box>
<Box gap="200" alignItems="Center" wrap="Wrap">
<Button
size="300"
variant="Primary"
fill="Solid"
radii="300"
onClick={handleGradientSave}
disabled={savingStyle}
onClick={handleColorSave}
disabled={savingColor || colorLoading}
>
<Text size="B300">Save</Text>
<Text size="B300">{savingColor ? 'Saving...' : 'Save colors'}</Text>
</Button>
{profileStyle.gradient && (
{hasSavedColors && (
<Button
size="300"
variant="Critical"
fill="Soft"
radii="300"
onClick={handleGradientRemove}
disabled={savingStyle}
onClick={handleColorRemove}
disabled={savingColor}
>
<Text size="B300">Remove</Text>
<Text size="B300">Remove colors</Text>
</Button>
)}
</Box>
</Box>
</Box>
</Box>
{styleError && (
<Text size="T200" style={{ color: color.Critical.Main }}>{styleError}</Text>
{colorError && (
<Text size="T200" style={{ color: color.Critical.Main }}>{colorError}</Text>
)}
</Box>

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

@@ -1,56 +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,
convertImageDataToPng,
uint8ArrayToBlob,
} 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,
@@ -58,254 +19,67 @@ 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 () => {
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;
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);
const bannerMxc = await loadBannerUrl(mx, userId);
if (!cancelled) setBanner(bannerMxc);
} catch {
setBanner(undefined);
if (!cancelled) setBanner(undefined);
}
setLoading(false);
if (!cancelled) 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);
load();
return () => {
user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
cancelled = true;
};
}, [mx, useAuthentication]);
}, [mx]);
const updateBanner = useCallback(async (newBanner: string | undefined) => {
const userId = mx.getUserId();
if (!userId) {
throw new Error('No user ID');
const updateBanner = useCallback(
async (newBanner: string | undefined) => {
if (!newBanner) {
await deleteBannerUrl(mx);
setBanner(undefined);
return;
}
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 — banner lives in PNG tEXt, so convert other formats first
let workingData: ArrayBuffer | Uint8Array = avatarData;
let format = detectImageFormat(workingData);
if (format === 'unknown') {
throw new Error('Unsupported avatar image format');
}
if (format !== 'png') {
console.log('[updateBanner] Converting', format, 'avatar to PNG for banner metadata');
const pngData = await convertImageDataToPng(workingData);
if (!pngData) {
throw new Error('Failed to convert avatar to PNG for banner metadata');
}
workingData = pngData;
format = 'png';
}
// Get existing metadata to preserve
const existingMetadata = extractMetadataFromImage(workingData);
console.log('[updateBanner] Existing metadata:', existingMetadata);
// Modify image metadata with new banner, preserving color.
// Empty string clears banner (PNG embed drops falsy fields after stripping old chunks).
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(workingData, 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 || undefined) !== verifyMetadata.banner) {
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 = uint8ArrayToBlob(newAvatarData, mimeType);
const uploadResponse = await mx.uploadContent(blob, {
name: `avatar.${extension}`,
type: mimeType,
});
console.log('[updateBanner] Avatar uploaded successfully, MXC:', uploadResponse.content_uri);
// Update profile with new avatar
try {
console.log('[updateBanner] Setting avatar URL to:', uploadResponse.content_uri);
await Promise.race([
mx.setAvatarUrl(uploadResponse.content_uri),
new Promise((_, reject) => {
setTimeout(() => reject(new Error('setAvatarUrl timeout')), 30000);
})
]);
console.log('[updateBanner] setAvatarUrl completed');
// Manually sync user object to ensure event listeners are triggered
const user = mx.getUser(userId);
if (user && user.avatarUrl !== uploadResponse.content_uri) {
console.log('[updateBanner] Manually syncing user avatar URL');
user.setAvatarUrl(uploadResponse.content_uri);
}
console.log('[updateBanner] Banner update complete');
} catch (error) {
throw new Error(`Failed to update avatar URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// Note: Don't set banner here - let the avatar change event handle it
// setBanner(newBanner);
}, [mx, useAuthentication]);
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;
/**
* 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
*/
export function useOtherUserBanner(userId: string, avatarMxc: string | undefined): string | undefined {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [bannerBlobUrl, setBannerBlobUrl] = useState<string | undefined>();
registerBannerCacheClear((userId: string) => {
userBannerCache.delete(userId);
});
useEffect(() => {
let blobUrl: string | undefined;
if (!avatarMxc) {
console.log('[useOtherUserBanner] No avatarMxc provided for', 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 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() });
}
if (!bannerMxc) {
console.log('[useOtherUserBanner] No banner MXC found');
setBannerBlobUrl(undefined);
return;
}
console.log('[useOtherUserBanner] Fetching banner image from MXC:', bannerMxc);
// Fetch the banner image data with authentication
async function fetchBannerBlobUrl(
mx: ReturnType<typeof useMatrixClient>,
bannerMxc: string,
useAuthentication: boolean
): Promise<string | undefined> {
const bannerHttpUrl = mxcUrlToHttp(mx, bannerMxc, useAuthentication);
if (!bannerHttpUrl) {
console.log('[useOtherUserBanner] Failed to convert banner MXC to HTTP URL');
setBannerBlobUrl(undefined);
return;
}
if (!bannerHttpUrl) return undefined;
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) {
@@ -313,25 +87,56 @@ export function useOtherUserBanner(userId: string, avatarMxc: string | undefined
}
const response = await fetch(bannerHttpUrl, { headers });
if (!response.ok) {
if (!response.ok) return undefined;
const blob = await response.blob();
return URL.createObjectURL(blob);
}
/**
* Hook to get another user's profile banner as a blob URL.
*/
export function useOtherUserBanner(userId: string): string | undefined {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [bannerBlobUrl, setBannerBlobUrl] = useState<string | undefined>();
useEffect(() => {
if (!userId) {
setBannerBlobUrl(undefined);
return undefined;
}
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) {
bannerMxc = cached.bannerMxc;
} else {
bannerMxc = await loadBannerUrl(mx, userId);
userBannerCache.set(userId, { bannerMxc, timestamp: Date.now() });
}
if (!bannerMxc) {
if (!cancelled) 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

@@ -1,227 +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,
uint8ArrayToBlob,
} 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 () => {
setLoading(true);
try {
const userId = mx.getUserId();
if (!userId) {
setLoading(false);
return;
return undefined;
}
const profile = await mx.getProfileInfo(userId);
const avatarUrl = profile.avatar_url;
if (!avatarUrl) {
setColor(undefined);
setLoading(false);
return;
}
const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication);
if (!httpUrl) {
setColor(undefined);
setLoading(false);
return;
}
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null);
setColor(extractedColor);
} catch (e) {
console.error('Failed to load user color from avatar:', e);
setColor(undefined);
}
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);
return () => {
user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
};
}, [mx, useAuthentication]);
const updateColor = useCallback(async (newColor: string | undefined) => {
const userId = mx.getUserId();
if (!userId) return;
let cancelled = false;
const load = async () => {
setLoading(true);
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');
}
// 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');
}
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 = uint8ArrayToBlob(newAvatarData, 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);
const loaded = await loadColorPreference(mx, userId);
if (!cancelled) setPreference(loaded);
} catch (e) {
console.error('Failed to update user color in avatar:', e);
throw e;
console.error('Failed to load color preference:', e);
if (!cancelled) setPreference(undefined);
}
}, [mx, useAuthentication]);
if (!cancelled) setLoading(false);
};
return [color, updateColor, loading];
load();
return () => {
cancelled = true;
};
}, [mx]);
const updatePreference = useCallback(
async (newPreference: ColorPreference | undefined) => {
if (!hasColorPreference(newPreference)) {
await deleteColorPreference(mx);
setPreference(undefined);
return;
}
await saveColorPreference(mx, newPreference!);
setPreference(newPreference);
},
[mx]
);
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);
}
@@ -229,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,276 +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,
convertImageDataToPng,
uint8ArrayToBlob,
} 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');
}
// Border/gradient live in PNG tEXt — convert other formats first
let workingData: ArrayBuffer | Uint8Array = avatarData;
let format = detectImageFormat(workingData);
if (format === 'unknown') {
throw new Error('Unsupported avatar image format');
}
if (format !== 'png') {
const pngData = await convertImageDataToPng(workingData);
if (!pngData) {
throw new Error('Failed to convert avatar to PNG for style metadata');
}
workingData = pngData;
format = 'png';
}
// Get existing metadata to preserve
const existingMetadata = extractMetadataFromImage(workingData);
// 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(workingData, 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 = uint8ArrayToBlob(newAvatarData, 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

@@ -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.
@@ -698,6 +699,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<MessageNotifications />
<BackgroundSyncSetup />
<PaarrotAPIInitializer />
<UpdatesDialogHost />
<TaskbarFlashStopper />
<AndroidShareIntentHandler />
{children}

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

@@ -0,0 +1,55 @@
import { atom } from 'jotai';
const LAST_SEEN_VERSION_KEY = 'paarrot:lastSeenVersion';
const PENDING_RELEASE_NOTES_KEY = 'paarrot:pendingReleaseNotes';
export type PendingReleaseNotes = {
version: string;
releaseNotes: unknown;
};
export type ReleaseNotesDialogState = {
open: boolean;
/** Opened from About instead of an automatic post-update prompt. */
manual: boolean;
};
export const releaseNotesDialogAtom = atom<ReleaseNotesDialogState>({
open: false,
manual: false,
});
export function getLastSeenAppVersion(): string | null {
return localStorage.getItem(LAST_SEEN_VERSION_KEY);
}
export function setLastSeenAppVersion(version: string): void {
localStorage.setItem(LAST_SEEN_VERSION_KEY, version);
}
export function storePendingReleaseNotes(version: string, releaseNotes: unknown): void {
try {
localStorage.setItem(
PENDING_RELEASE_NOTES_KEY,
JSON.stringify({ version, releaseNotes } satisfies PendingReleaseNotes)
);
} catch {
// ignore quota errors
}
}
export function getPendingReleaseNotes(): PendingReleaseNotes | null {
try {
const raw = localStorage.getItem(PENDING_RELEASE_NOTES_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as PendingReleaseNotes;
if (typeof parsed?.version !== 'string') return null;
return parsed;
} catch {
return null;
}
}
export function clearPendingReleaseNotes(): void {
localStorage.removeItem(PENDING_RELEASE_NOTES_KEY);
}

View File

@@ -0,0 +1,10 @@
import { getVersion } from '@tauri-apps/api/app';
/** Returns the running app version (Electron shim or Tauri). */
export async function getAppVersion(): Promise<string> {
try {
return await getVersion();
} catch {
return 'unknown';
}
}

View File

@@ -0,0 +1,192 @@
import { MatrixClient, Room } from 'matrix-js-sdk';
import { ThemeKind } from '../hooks/useTheme';
/** MSC4522 stable profile field for username colors */
export const PROFILE_KEY_COLOR_PREFERENCE_STABLE = 'm.color_preference';
/** MSC4522 unstable profile field for username colors */
export const PROFILE_KEY_COLOR_PREFERENCE_UNSTABLE = 'eu.she-a.color';
/** MSC4427 unstable profile field for banner */
export const PROFILE_KEY_BANNER_URL_UNSTABLE = 'chat.commet.profile_banner';
/** MSC4427 stable profile field for banner */
export const PROFILE_KEY_BANNER_URL_STABLE = 'm.banner_url';
export type ColorPreference = {
on_dark?: string;
on_light?: string;
};
const HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
export function isValidHexColor(color: string): boolean {
return HEX_COLOR_RE.test(color);
}
export function parseColorPreference(value: unknown): ColorPreference | undefined {
if (!value || typeof value !== 'object') return undefined;
const obj = value as Record<string, unknown>;
const on_dark = typeof obj.on_dark === 'string' && isValidHexColor(obj.on_dark) ? obj.on_dark : undefined;
const on_light = typeof obj.on_light === 'string' && isValidHexColor(obj.on_light) ? obj.on_light : undefined;
if (!on_dark && !on_light) return undefined;
return { on_dark, on_light };
}
export function extractColorPreferenceFromProfile(profile: Record<string, unknown>): ColorPreference | undefined {
const stable = parseColorPreference(profile[PROFILE_KEY_COLOR_PREFERENCE_STABLE]);
if (stable) return stable;
return parseColorPreference(profile[PROFILE_KEY_COLOR_PREFERENCE_UNSTABLE]);
}
export function resolveColorForTheme(
preference: ColorPreference | undefined,
themeKind: ThemeKind
): string | undefined {
if (!preference) return undefined;
if (themeKind === ThemeKind.Dark) {
return preference.on_dark ?? preference.on_light;
}
return preference.on_light ?? preference.on_dark;
}
export function hasColorPreference(preference: ColorPreference | undefined): boolean {
return Boolean(preference?.on_dark || preference?.on_light);
}
export function extractBannerUrlFromProfile(profile: Record<string, unknown>): string | undefined {
const stable = profile[PROFILE_KEY_BANNER_URL_STABLE];
if (typeof stable === 'string' && stable.startsWith('mxc://')) return stable;
const unstable = profile[PROFILE_KEY_BANNER_URL_UNSTABLE];
if (typeof unstable === 'string' && unstable.startsWith('mxc://')) return unstable;
return undefined;
}
export async function getBannerUrlProfileKey(mx: MatrixClient): Promise<string> {
if (await mx.isVersionSupported('v1.16')) {
return PROFILE_KEY_BANNER_URL_STABLE;
}
return PROFILE_KEY_BANNER_URL_UNSTABLE;
}
export async function loadBannerUrl(mx: MatrixClient, userId: string): Promise<string | undefined> {
if (await mx.doesServerSupportExtendedProfiles()) {
try {
const profile = await mx.getExtendedProfile(userId);
return extractBannerUrlFromProfile(profile);
} catch {
// fall through
}
}
try {
const profile = (await mx.getProfileInfo(userId)) as Record<string, unknown>;
return extractBannerUrlFromProfile(profile);
} catch {
return undefined;
}
}
export async function saveBannerUrl(mx: MatrixClient, bannerMxc: string): Promise<void> {
if (!(await mx.doesServerSupportExtendedProfiles())) {
throw new Error('Server does not support extended profile fields (MSC4133)');
}
const key = await getBannerUrlProfileKey(mx);
await mx.setExtendedProfileProperty(key, bannerMxc);
const userId = mx.getUserId();
if (userId) clearBannerCache(userId);
}
export async function deleteBannerUrl(mx: MatrixClient): Promise<void> {
if (!(await mx.doesServerSupportExtendedProfiles())) {
throw new Error('Server does not support extended profile fields (MSC4133)');
}
const key = await getBannerUrlProfileKey(mx);
await mx.deleteExtendedProfileProperty(key);
const userId = mx.getUserId();
if (userId) clearBannerCache(userId);
}
let bannerCacheClearImpl: (userId: string) => void = () => {};
export function registerBannerCacheClear(fn: (userId: string) => void): void {
bannerCacheClearImpl = fn;
}
function clearBannerCache(userId: string): void {
bannerCacheClearImpl(userId);
}
export async function getColorPreferenceProfileKey(mx: MatrixClient): Promise<string> {
if (await mx.isVersionSupported('v1.16')) {
return PROFILE_KEY_COLOR_PREFERENCE_STABLE;
}
return PROFILE_KEY_COLOR_PREFERENCE_UNSTABLE;
}
export async function loadColorPreference(
mx: MatrixClient,
userId: string
): Promise<ColorPreference | undefined> {
if (await mx.doesServerSupportExtendedProfiles()) {
try {
const profile = await mx.getExtendedProfile(userId);
return extractColorPreferenceFromProfile(profile);
} catch {
// fall through to standard profile endpoint
}
}
try {
const profile = (await mx.getProfileInfo(userId)) as Record<string, unknown>;
return extractColorPreferenceFromProfile(profile);
} catch {
return undefined;
}
}
export function extractMemberColorPreference(room: Room | undefined, userId: string): ColorPreference | undefined {
if (!room) return undefined;
const member = room.getMember(userId);
const content = member?.events.member?.getContent();
if (!content) return undefined;
return extractColorPreferenceFromProfile(content as Record<string, unknown>);
}
export async function saveColorPreference(mx: MatrixClient, preference: ColorPreference): Promise<void> {
if (!(await mx.doesServerSupportExtendedProfiles())) {
throw new Error('Server does not support extended profile fields (MSC4133)');
}
const key = await getColorPreferenceProfileKey(mx);
await mx.setExtendedProfileProperty(key, preference);
const userId = mx.getUserId();
if (userId) clearPreferenceCache(userId);
}
let preferenceCacheClearImpl: (userId: string) => void = () => {};
export function registerPreferenceCacheClear(fn: (userId: string) => void): void {
preferenceCacheClearImpl = fn;
}
function clearPreferenceCache(userId: string): void {
preferenceCacheClearImpl(userId);
}
export async function deleteColorPreference(mx: MatrixClient): Promise<void> {
if (!(await mx.doesServerSupportExtendedProfiles())) {
throw new Error('Server does not support extended profile fields (MSC4133)');
}
const key = await getColorPreferenceProfileKey(mx);
await mx.deleteExtendedProfileProperty(key);
const userId = mx.getUserId();
if (userId) clearPreferenceCache(userId);
}

View File

@@ -0,0 +1,111 @@
import { registerMediaSaver } from './saveMedia';
import { isElectron, isTauri } from './tauri';
type ElectronSaveResult = {
success?: boolean;
canceled?: boolean;
path?: string;
error?: string;
};
type ElectronMediaApi = {
saveFile?: (payload: {
filename: string;
mimeType?: string;
data: Uint8Array;
}) => Promise<ElectronSaveResult>;
};
async function blobToUint8Array(blob: Blob): Promise<Uint8Array> {
const buffer = await blob.arrayBuffer();
return new Uint8Array(buffer);
}
function guessFilters(filename: string, mimeType?: string): Array<{ name: string; extensions: string[] }> {
const ext = filename.includes('.') ? filename.split('.').pop()!.toLowerCase() : '';
const mime = (mimeType || '').toLowerCase();
if (mime.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) {
return [
{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] },
{ name: 'All Files', extensions: ['*'] },
];
}
if (mime.startsWith('video/') || ['mp4', 'webm', 'mkv', 'mov'].includes(ext)) {
return [
{ name: 'Videos', extensions: ['mp4', 'webm', 'mkv', 'mov'] },
{ name: 'All Files', extensions: ['*'] },
];
}
if (mime.startsWith('audio/') || ['mp3', 'ogg', 'wav', 'm4a', 'flac'].includes(ext)) {
return [
{ name: 'Audio', extensions: ['mp3', 'ogg', 'wav', 'm4a', 'flac'] },
{ name: 'All Files', extensions: ['*'] },
];
}
if (mime === 'application/pdf' || ext === 'pdf') {
return [
{ name: 'PDF', extensions: ['pdf'] },
{ name: 'All Files', extensions: ['*'] },
];
}
if (ext) {
return [
{ name: ext.toUpperCase(), extensions: [ext] },
{ name: 'All Files', extensions: ['*'] },
];
}
return [{ name: 'All Files', extensions: ['*'] }];
}
async function saveWithElectron(blob: Blob, filename: string): Promise<void> {
const media = (window.electron as { media?: ElectronMediaApi } | undefined)?.media;
if (!media?.saveFile) {
throw new Error('Electron media.saveFile is unavailable');
}
const data = await blobToUint8Array(blob);
const result = await media.saveFile({
filename,
mimeType: blob.type || 'application/octet-stream',
data,
});
if (result?.canceled) return;
if (result?.success === false) {
throw new Error(result.error || 'Failed to save file');
}
}
async function saveWithTauri(blob: Blob, filename: string): Promise<void> {
const { save } = await import('@tauri-apps/plugin-dialog');
const { writeFile } = await import('@tauri-apps/plugin-fs');
const path = await save({
defaultPath: filename,
filters: guessFilters(filename, blob.type),
});
if (!path) return;
const data = await blobToUint8Array(blob);
await writeFile(path, data);
}
/**
* Wire Electron / Tauri native save dialogs into core saveMedia helpers.
* Call once at desktop app startup. No-ops in plain browser.
*/
export function registerDesktopMediaSaver(): void {
if (isElectron()) {
registerMediaSaver(async (blob, filename) => {
await saveWithElectron(blob, filename);
});
return;
}
if (isTauri()) {
registerMediaSaver(async (blob, filename) => {
await saveWithTauri(blob, filename);
});
}
}

View File

@@ -24,6 +24,7 @@ import {
Membership,
MessageEvent,
NotificationType,
PaarrotUiContent,
RoomToParents,
RoomType,
StateEvent,
@@ -125,6 +126,33 @@ export const getPaarrotRoomKind = (room: Room | null): string | undefined => {
return typeof kind === 'string' ? kind : undefined;
};
/**
* Valid bot-driven room UI state (im.paarrot.ui with version + root node).
*/
export const parsePaarrotUiContent = (raw: unknown): PaarrotUiContent | undefined => {
if (!raw || typeof raw !== 'object') return undefined;
const content = raw as PaarrotUiContent;
if (
typeof content.version !== 'number' ||
!content.root ||
typeof content.root !== 'object' ||
typeof content.root.type !== 'string' ||
typeof content.root.id !== 'string'
) {
return undefined;
}
return content;
};
export const getPaarrotUiContent = (room: Room | null): PaarrotUiContent | undefined => {
if (!room) return undefined;
const event = room.currentState?.getStateEvents(StateEvent.PaarrotUi, '');
if (!event) return undefined;
return parsePaarrotUiContent(event.getContent());
};
export const hasRoomAppUi = (room: Room | null): boolean => getPaarrotUiContent(room) !== undefined;
/**
* m.forum space root (forum board lobby), not a plain m.space or a lone m.forum topic channel.
* Matrix sets create type m.forum on forum spaces but SDK isSpaceRoom() is often still false;

View File

@@ -0,0 +1,55 @@
import FileSaver from 'file-saver';
import { downloadMedia } from './matrix';
import { getCurrentAccessToken } from './auth';
export type MediaSaver = (blob: Blob, filename: string) => void | Promise<void>;
let customMediaSaver: MediaSaver | null = null;
const defaultMediaSaver: MediaSaver = (blob, filename) => {
FileSaver.saveAs(blob, filename);
};
/**
* Register a platform-specific media saver (Android MediaStore, Electron dialog, etc.).
* Pass null to restore the default FileSaver path.
*
* Platforms should call this once at startup (mobile overlay / desktop shell entry).
*/
export const registerMediaSaver = (saver: MediaSaver | null): void => {
customMediaSaver = saver;
};
/**
* Persist a Blob to disk via the registered platform saver, or FileSaver by default.
*/
export const saveMediaBlob = async (blob: Blob, filename: string): Promise<void> => {
const saver = customMediaSaver ?? defaultMediaSaver;
await saver(blob, filename);
};
/**
* Fetch media (auth download or blob:/data:/http URL) and save it via [saveMediaBlob].
*/
export const downloadAndSaveMedia = async (
src: string,
filename: string,
accessToken?: string | null
): Promise<void> => {
let blob: Blob;
try {
if (src.startsWith('blob:') || src.startsWith('data:')) {
const res = await fetch(src);
if (!res.ok) throw new Error(`Failed to fetch ${src}`);
blob = await res.blob();
} else {
blob = await downloadMedia(src, accessToken ?? getCurrentAccessToken());
}
} catch (error) {
console.warn('[saveMedia] downloadMedia failed, trying fetch fallback:', error);
const res = await fetch(src);
if (!res.ok) throw error;
blob = await res.blob();
}
await saveMediaBlob(blob, filename);
};

View File

@@ -534,12 +534,20 @@ export const readClipboardImage = async (): Promise<File | null> => {
try {
const electron = (window as any).electron;
if (electron?.clipboard?.readImage) {
const dataUrl = await electron.clipboard.readImage();
const result = await electron.clipboard.readImage();
// IPC may return a data URL string or { success, data } depending on path.
const dataUrl =
typeof result === 'string'
? result
: result && typeof result === 'object' && typeof result.data === 'string'
? result.data
: null;
if (!dataUrl) return null;
// Convert data URL to File
const response = await fetch(dataUrl);
const blob = await response.blob();
if (blob.size < 32) return null;
return new File([blob], 'clipboard-image.png', { type: 'image/png' });
}
} catch (err) {
@@ -559,6 +567,7 @@ export const readClipboardImage = async (): Promise<File | null> => {
// Convert data URL to File
const response = await fetch(dataUrl);
const blob = await response.blob();
if (blob.size < 32) return null;
return new File([blob], 'clipboard-image.png', { type: 'image/png' });
} catch (err) {
console.warn('Failed to read Tauri clipboard image:', err);

18
src/ext.d.ts vendored
View File

@@ -56,6 +56,18 @@ interface ElectronAPI {
clipboard?: {
writeText: (text: string) => void;
};
media?: {
saveFile: (payload: {
filename: string;
mimeType?: string;
data: Uint8Array;
}) => Promise<{
success?: boolean;
canceled?: boolean;
path?: string;
error?: string;
}>;
};
plugins?: PluginAPI;
protocol?: {
getStatus: () => Promise<{
@@ -95,6 +107,12 @@ interface ElectronAPI {
error?: string;
}>;
};
updater?: {
onUpdateDownloaded: (callback: (info: {
version: string;
releaseNotes?: unknown;
}) => void) => void;
};
}
declare global {

View File

@@ -17,6 +17,7 @@ import { trimTrailingSlash } from './app/utils/common';
import App from './app/pages/App';
import { applySafeAreaInsets, isTauri } from './app/utils/tauri';
import { enableViewTransitionsForNavigation } from './app/utils/viewTransitions';
import { registerDesktopMediaSaver } from './app/utils/registerDesktopMediaSaver';
// import i18n (needs to be bundled ;))
import './app/i18n';
@@ -29,6 +30,9 @@ applySafeAreaInsets();
// Enable View Transitions API for smooth navigation
enableViewTransitionsForNavigation();
// Desktop shells: native Save dialog instead of browser FileSaver downloads.
registerDesktopMediaSaver();
// Register Service Worker
if ('serviceWorker' in navigator) {
const swUrl =

View File

@@ -48,6 +48,9 @@ export enum StateEvent {
/** Paarrot room classification marker for custom room behaviors */
PaarrotRoomKind = 'im.paarrot.room.kind',
/** Bot-driven declarative room UI (takes over room view) */
PaarrotUi = 'im.paarrot.ui',
/** Matrix RTC call membership (MSC3401) */
CallMember = 'org.matrix.msc3401.call.member',
}
@@ -61,8 +64,114 @@ export enum MessageEvent {
/** Relay emoji confetti burst tied to a target message */
RelayEmojiConfetti = 'app.relay.emoji_confetti',
/** User interaction with bot-driven room UI (im.paarrot.ui) */
PaarrotUiAction = 'im.paarrot.ui.action',
}
/** Declarative UI node types for im.paarrot.ui */
export type PaarrotUiNodeType =
| 'panel'
| 'row'
| 'text'
| 'button'
| 'input'
| 'select'
| 'image'
| 'spacer';
export type PaarrotUiNodeBase = {
type: PaarrotUiNodeType;
id: string;
};
export type PaarrotUiPanelNode = PaarrotUiNodeBase & {
type: 'panel';
children?: PaarrotUiNode[];
};
export type PaarrotUiRowNode = PaarrotUiNodeBase & {
type: 'row';
children?: PaarrotUiNode[];
};
export type PaarrotUiTextNode = PaarrotUiNodeBase & {
type: 'text';
text?: string;
className?: string;
};
export type PaarrotUiButtonNode = PaarrotUiNodeBase & {
type: 'button';
label: string;
action: string;
value?: string | number | boolean;
disabled?: boolean;
variant?: 'Primary' | 'Secondary' | 'Success' | 'Warning' | 'Critical';
};
export type PaarrotUiInputNode = PaarrotUiNodeBase & {
type: 'input';
name?: string;
label?: string;
placeholder?: string;
value?: string;
inputType?: 'text' | 'number' | 'password';
};
export type PaarrotUiSelectOption = {
label: string;
value: string;
};
export type PaarrotUiSelectNode = PaarrotUiNodeBase & {
type: 'select';
name?: string;
label?: string;
options?: PaarrotUiSelectOption[];
value?: string;
};
export type PaarrotUiImageNode = PaarrotUiNodeBase & {
type: 'image';
src: string;
alt?: string;
width?: number;
height?: number;
};
export type PaarrotUiSpacerNode = PaarrotUiNodeBase & {
type: 'spacer';
size?: number;
};
export type PaarrotUiNode =
| PaarrotUiPanelNode
| PaarrotUiRowNode
| PaarrotUiTextNode
| PaarrotUiButtonNode
| PaarrotUiInputNode
| PaarrotUiSelectNode
| PaarrotUiImageNode
| PaarrotUiSpacerNode;
/** Content of state event im.paarrot.ui */
export type PaarrotUiContent = {
version: number;
title?: string;
css?: string;
root: PaarrotUiNode;
};
/** Content of timeline event im.paarrot.ui.action */
export type PaarrotUiActionContent = {
action: string;
component_id: string;
value?: string | number | boolean;
values?: Record<string, string>;
ui_event_id?: string;
};
export enum RoomType {
Space = 'm.space',
Forum = 'm.forum',

View File

@@ -53,6 +53,11 @@ const copyFiles = {
src: 'public/font',
dest: 'public/',
},
{
src: 'public/update/**/*',
dest: 'update',
rename: (_name, _ext, fullPath) => fullPath.replace(/^public[/\\]update[/\\]/, ''),
},
],
};
@@ -124,6 +129,37 @@ function servePublicAssets() {
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'no-cache');
const fileStream = fs.createReadStream(resolvedPath);
fileStream.pipe(res);
} else {
res.writeHead(404);
res.end('File not found');
}
} else if (req.url?.startsWith('/update/')) {
const fileName = req.url.replace('/update/', '');
const resolvedPath = path.join(path.resolve(), 'public', 'update', fileName);
if (fs.existsSync(resolvedPath) && !resolvedPath.includes('..')) {
const ext = path.extname(fileName).toLowerCase();
const contentType =
ext === '.md'
? 'text/markdown; charset=utf-8'
: ext === '.json'
? 'application/json'
: ext === '.svg'
? 'image/svg+xml'
: ext === '.png'
? 'image/png'
: ext === '.jpg' || ext === '.jpeg'
? 'image/jpeg'
: ext === '.webp'
? 'image/webp'
: ext === '.gif'
? 'image/gif'
: 'application/octet-stream';
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'no-cache');
const fileStream = fs.createReadStream(resolvedPath);
fileStream.pipe(res);
} else {