17 Commits

Author SHA1 Message Date
47979718eb Fix responsive message images
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Constrain image attachments to their parent while preserving their aspect ratio.
2026-08-25 00:02:45 +10:00
862307ee7d Add TikTok player iframe
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Use TikTok's supported player endpoint rather than injecting its oEmbed script.
2026-08-24 23:58:31 +10:00
b16263b481 Enable native HTTP for Capacitor.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
Route mobile fetch requests through Capacitor's native network bridge to support redirected collectible catalog downloads.
2026-08-24 20:06:27 +10:00
c34cfaf33f Enable collectibles on mobile clients.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
Fetch and cache the published catalog in the renderer when Electron is unavailable, then download selected CDN assets directly for Matrix upload.
2026-08-24 19:52:50 +10:00
87c9f59826 Use public Discord collectibles catalog.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Remove local token setup in favor of the cached published catalog and document the collectible profile experience in the latest update notes.
2026-08-24 19:29:48 +10:00
d59945eb4c Add configurable sidebar nameplate background.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Render the equipped nameplate behind the larger settings avatar with blur-aware sidebar controls, adjustable opacity, and immediate collectible cache invalidation.
2026-08-24 18:48:48 +10:00
0cbc5d9a1c Add Discord collectibles to profiles, messages, and settings.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
Integrate profile effects, nameplates, and avatar decorations with live catalog browsing, Matrix profile storage, and rendering across user heroes, DMs, messages, and the sidebar avatar.
2026-08-24 00:54:48 +10:00
6cb1e14632 Fix What's New dialog layout and improve dev styling/debugging.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
Portal the updates dialog to document.body with stable data attributes,
fix max-width (invalid S800 token), bundle update images for Capacitor,
and add readable vanilla-extract class names plus portalContainer tap fixes.
2026-08-23 16:54:59 +10:00
7d866404b3 Fix Android updates dialog, mobile About page, and user color crash guard.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Use stripBase for update static copy paths, show Settings detail on mobile
when nav is hidden, pause Settings focus trap during release notes, and guard
extractMemberColorPreference when room is not a Matrix Room instance.
2026-08-23 16:18:35 +10:00
c264de53b5 Fix View updates on Android: bundled notes, portal stack, tap handling.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Bundle update markdown at build time for Capacitor, portal into
portalContainer above Settings, defer open to avoid FocusTrap closing
on the same tap, and disable click-outside dismiss.
2026-08-23 08:38:23 +10:00
b6f3f5c0aa Move update manifest generator into cinny for standalone Android builds.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
2026-08-23 08:26:27 +10:00
25be638c5e Fix auto-show of What's New on first launch after an app update.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
2026-08-23 08:23:15 +10:00
a20c871726 Fix updates dialog on Android by portaling above Settings and resolving Capacitor asset URLs.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
2026-08-23 08:19:51 +10:00
e65a516350 Add MSC4522 username colors, release notes dialog, and profile layout fixes.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
Replace legacy profile style metadata with MSC4133 profile fields, add in-app update notes, tighten account profile name spacing, and wire desktop media save helpers through the client.
2026-08-23 08:06:48 +10:00
9887903f49 Add centralized saveMedia helpers for image, video, and file downloads.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Platforms can register a custom saver; viewers share one download/save path instead of FileSaver call sites.
2026-08-09 20:49:00 +10:00
898d217451 Make sick emoji confetti a fullscreen downward spew with morph.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
🤢 now fills the viewport, morphs to 🤮 while particles spawn, hides the jumbo stand-in cleanly, and keeps green droplets out of the face morph.
2026-08-09 18:35:12 +10:00
e08b4ec22e Replace image lightbox with slate-style shadowbox.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Full-viewport blur backdrop, floating chrome, wheel/click/pinch zoom and pan; drop the ModalWide card shell around timeline and profile image viewers.
2026-08-09 15:58:47 +10:00
83 changed files with 12837 additions and 1695 deletions

View File

@@ -7,6 +7,9 @@
"LocalNotifications": { "LocalNotifications": {
"smallIcon": "ic_stat_paarrot", "smallIcon": "ic_stat_paarrot",
"iconColor": "#FF8A00" "iconColor": "#FF8A00"
},
"CapacitorHttp": {
"enabled": true
} }
} }
} }

1464
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@
}, },
"scripts": { "scripts": {
"start": "vite", "start": "vite",
"build": "vite build", "build": "node scripts/generate-update-manifest.mjs && vite build",
"lint": "yarn check:eslint && yarn check:prettier", "lint": "yarn check:eslint && yarn check:prettier",
"check:eslint": "eslint src/*", "check:eslint": "eslint src/*",
"check:prettier": "prettier --check .", "check:prettier": "prettier --check .",
@@ -84,8 +84,10 @@
"react-error-boundary": "6.1.2", "react-error-boundary": "6.1.2",
"react-google-recaptcha": "3.1.0", "react-google-recaptcha": "3.1.0",
"react-i18next": "17.0.8", "react-i18next": "17.0.8",
"react-markdown": "10.1.0",
"react-range": "1.10.0", "react-range": "1.10.0",
"react-router-dom": "7.18.1", "react-router-dom": "7.18.1",
"remark-gfm": "4.0.1",
"sanitize-html": "2.17.5", "sanitize-html": "2.17.5",
"slate": "0.124.1", "slate": "0.124.1",
"slate-dom": "0.124.1", "slate-dom": "0.124.1",

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.

22
public/update/4.11.177.md Normal file
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.

View File

@@ -0,0 +1,22 @@
---
version: 4.11.185
title: Discord collectibles
date: 2026-08-24
summary: Add Discord-style profile effects, nameplates, and avatar decorations to Paarrot profiles.
---
## Discord collectibles
- Browse and apply profile effects, animated nameplates, and avatar decorations in Settings → Account → Profile.
- Collectible assets are uploaded to your Matrix profile, so other Paarrot users can see them without Discord.
- The catalog now comes from a public git, and no Discord token is required in Paarrot for the cosmetics.
- Profile-effect previews play their intro and loop animations, with a mini profile preview for each effect.
![Discord Cosmetics 1](./images/discordcosmetics1.png) ![Discord Cosmetics 2](./images/discordcosmetics2.png)
## Profile presentation
- Avatar decorations render in messages, profile cards, and the bottom sidebar avatar.
- Nameplates to the right of messages, beneath timestamps and can be displayed behind the bottom sidebar avatar with opacity settings.
- Customize the sidebar nameplate opacity in Settings → General → Collectibles.

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -0,0 +1,11 @@
{
"current": "currentupdate.md",
"older": [
{
"file": "4.11.175.md",
"version": "4.11.175",
"title": "Stability & polish",
"date": "2026-08-10"
}
]
}

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env node
/**
* Regenerates public/update/manifest.json from markdown files.
*
* - currentupdate.md is always the "current" entry
* - Other *.md files matching X.Y.Z.md become "older" (sorted newest first)
* - title, date, version come from YAML frontmatter when present
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const UPDATE_DIR = path.join(ROOT, 'public', 'update');
const MANIFEST_PATH = path.join(UPDATE_DIR, 'manifest.json');
const CURRENT_FILE = 'currentupdate.md';
const VERSION_FILE_RE = /^(\d+\.\d+\.\d+)\.md$/;
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
function parseFrontmatter(source) {
const match = FRONTMATTER_RE.exec(source.trimStart());
if (!match) return {};
const frontmatter = {};
match[1].split('\n').forEach((line) => {
const colon = line.indexOf(':');
if (colon <= 0) return;
const key = line.slice(0, colon).trim();
const raw = line.slice(colon + 1).trim();
frontmatter[key] = raw.replace(/^['"]|['"]$/g, '');
});
return frontmatter;
}
function compareVersions(a, b) {
const pa = a.split('.').map((part) => Number.parseInt(part, 10));
const pb = b.split('.').map((part) => Number.parseInt(part, 10));
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i += 1) {
const na = pa[i] ?? 0;
const nb = pb[i] ?? 0;
if (na !== nb) return nb - na;
}
return 0;
}
function readMarkdownMeta(fileName) {
const filePath = path.join(UPDATE_DIR, fileName);
const source = fs.readFileSync(filePath, 'utf8');
const frontmatter = parseFrontmatter(source);
const versionFromName = VERSION_FILE_RE.exec(fileName)?.[1];
return {
file: fileName,
version: frontmatter.version ?? versionFromName,
title: frontmatter.title,
date: frontmatter.date,
};
}
function buildManifest() {
if (!fs.existsSync(UPDATE_DIR)) {
throw new Error(`Update directory not found: ${UPDATE_DIR}`);
}
const currentPath = path.join(UPDATE_DIR, CURRENT_FILE);
if (!fs.existsSync(currentPath)) {
throw new Error(`Missing ${CURRENT_FILE} in ${UPDATE_DIR}`);
}
const older = fs
.readdirSync(UPDATE_DIR, { withFileTypes: true })
.filter((entry) => entry.isFile() && VERSION_FILE_RE.test(entry.name))
.map((entry) => readMarkdownMeta(entry.name))
.filter((entry) => entry.version)
.sort((a, b) => compareVersions(a.version, b.version));
return {
current: CURRENT_FILE,
older: older.map(({ file, version, title, date }) => {
const item = { file };
if (version) item.version = version;
if (title) item.title = title;
if (date) item.date = date;
return item;
}),
};
}
function main() {
const manifest = buildManifest();
const json = `${JSON.stringify(manifest, null, 2)}\n`;
let previous = null;
if (fs.existsSync(MANIFEST_PATH)) {
previous = fs.readFileSync(MANIFEST_PATH, 'utf8');
}
if (previous === json) {
console.log('[update-manifest] manifest.json is already up to date');
return;
}
fs.writeFileSync(MANIFEST_PATH, json, 'utf8');
console.log(
`[update-manifest] wrote manifest.json (${manifest.older.length} older version(s))`
);
}
main();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -262,7 +262,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
<AttachmentBox <AttachmentBox
style={{ style={{
width: toRem(width), width: toRem(width),
height: toRem(height), aspectRatio: `${width} / ${height}`,
['--media-h' as string]: `${height}px`, ['--media-h' as string]: `${height}px`,
}} }}
data-paarrot-media-height={height} data-paarrot-media-height={height}

View File

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

View File

@@ -8,7 +8,7 @@ export const Attachment = recipe({
color: color.SurfaceVariant.OnContainer, color: color.SurfaceVariant.OnContainer,
borderRadius: config.radii.R400, borderRadius: config.radii.R400,
overflow: 'hidden', overflow: 'hidden',
maxWidth: toRem(400), maxWidth: `min(100%, ${toRem(400)})`,
}, },
variants: { variants: {
outlined: { outlined: {
@@ -33,7 +33,7 @@ export const AttachmentHeader = style({
export const AttachmentBox = style([ export const AttachmentBox = style([
DefaultReset, DefaultReset,
{ {
maxWidth: toRem(400), maxWidth: `min(100%, ${toRem(400)})`,
maxHeight: toRem(400), maxHeight: toRem(400),
overflow: 'hidden', overflow: 'hidden',
}, },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,8 @@ export const Sidebar = style([
width: toRem(66), width: toRem(66),
backgroundColor: color.Background.Container, backgroundColor: color.Background.Container,
borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`, borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
position: 'relative',
isolation: 'isolate',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@@ -185,6 +187,46 @@ export const SidebarAvatar = recipe({
}); });
export type SidebarAvatarVariants = RecipeVariants<typeof SidebarAvatar>; export type SidebarAvatarVariants = RecipeVariants<typeof SidebarAvatar>;
/** A vertical rendition of the user's nameplate behind the bottom settings avatar. */
export const SidebarAvatarNameplate = style({
position: 'absolute',
// The 50px avatar is centered in the 66px sidebar. Pin the rotated plate's
// bottom-right corner 8px left of it so the 66px cross-axis
// spans the whole sidebar and grows upward from its bottom edge.
right: toRem(58),
bottom: toRem(-16),
width: toRem(220),
height: toRem(66),
transformOrigin: 'right bottom',
transform: 'rotate(90deg)',
objectFit: 'cover',
objectPosition: 'right center',
pointerEvents: 'none',
zIndex: -1,
});
export const SidebarAvatarLarge = style({
width: toRem(50),
height: toRem(50),
borderRadius: '50%',
backgroundColor: `color-mix(in srgb, ${color.Background.Container} 68%, transparent)`,
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
});
export const SidebarSearchAvatar = style({
position: 'relative',
zIndex: 1,
backgroundColor: `color-mix(in srgb, ${color.Background.Container} 68%, transparent)`,
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
});
export const SidebarAvatarForeground = style({
position: 'relative',
zIndex: 1,
});
export const SidebarFolder = recipe({ export const SidebarFolder = recipe({
base: [ base: [
ContainerColor({ variant: 'Background' }), ContainerColor({ variant: 'Background' }),

View File

@@ -0,0 +1,35 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { resolveUpdateAssetUrl } from '../../data/updateNotes';
import * as css from './UpdatesDialog.css';
type UpdateMarkdownViewerProps = {
markdown: string;
};
export function UpdateMarkdownViewer({ markdown }: UpdateMarkdownViewerProps) {
if (!markdown.trim()) {
return null;
}
return (
<div className={css.Markdown}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noreferrer noopener">
{children}
</a>
),
img: ({ src, alt }) => (
<img src={resolveUpdateAssetUrl(src) ?? src} alt={alt ?? ''} loading="lazy" />
),
}}
>
{markdown}
</ReactMarkdown>
</div>
);
}

View File

@@ -0,0 +1,329 @@
import { globalStyle, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
const MOBILE_BREAKPOINT = '480px';
/** Above folds overlays (9999) and Settings modals. */
const UPDATES_DIALOG_Z = 10001;
const DIALOG_MAX_HEIGHT =
'calc(85vh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))';
/** [data-updates-dialog] — full-screen host portaled to document.body */
export const Root = style({
position: 'fixed',
inset: 0,
zIndex: UPDATES_DIALOG_Z,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'auto',
});
/** [data-updates-dialog-backdrop] */
export const Backdrop = style({
position: 'absolute',
inset: 0,
zIndex: 0,
backgroundColor: 'rgba(0, 0, 0, 0.55)',
});
/** [data-updates-dialog-frame] */
export const Frame = style({
position: 'relative',
zIndex: 1,
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
maxHeight: '100vh',
maxWidth: '100vw',
padding:
'env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px)',
pointerEvents: 'none',
});
/** [data-updates-dialog-panel] */
export const Panel = style({
position: 'relative',
display: 'flex',
flexDirection: 'column',
width: '100%',
maxWidth: toRem(560),
maxHeight: DIALOG_MAX_HEIGHT,
overflow: 'hidden',
pointerEvents: 'auto',
borderRadius: config.radii.R400,
backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer,
boxShadow: config.shadow.E400,
'@media': {
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
},
},
});
export const Hero = style({
display: 'flex',
flexDirection: 'column',
gap: config.space.S200,
padding: config.space.S500,
paddingBottom: config.space.S400,
backgroundColor: color.Primary.Container,
color: color.Primary.OnContainer,
borderBottom: `${config.borderWidth.B300} solid ${color.Primary.ContainerLine}`,
'@media': {
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
padding: config.space.S400,
paddingBottom: config.space.S300,
},
},
});
export const HeroRow = style({
display: 'flex',
alignItems: 'center',
gap: config.space.S300,
minWidth: 0,
});
export const HeroLogo = style({
width: toRem(48),
height: toRem(48),
flexShrink: 0,
'@media': {
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
width: toRem(40),
height: toRem(40),
},
},
});
export const HeroTitleWrap = style({
display: 'flex',
flexDirection: 'column',
gap: config.space.S100,
minWidth: 0,
});
export const BodyScroll = style({
flex: '1 1 auto',
minHeight: 0,
backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer,
});
export const BodyContent = style({
display: 'flex',
flexDirection: 'column',
padding: config.space.S500,
paddingTop: config.space.S400,
'@media': {
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
padding: config.space.S400,
paddingTop: config.space.S300,
},
},
});
export const BodyLoading = style({
padding: config.space.S600,
});
export const Markdown = style({
width: '100%',
color: color.Surface.OnContainer,
fontSize: toRem(14),
lineHeight: 1.55,
});
globalStyle(`${Markdown} h1, ${Markdown} h2, ${Markdown} h3, ${Markdown} h4`, {
margin: 0,
marginTop: config.space.S400,
marginBottom: config.space.S200,
lineHeight: 1.3,
color: color.Surface.OnContainer,
});
globalStyle(`${Markdown} h1:first-child, ${Markdown} h2:first-child, ${Markdown} h3:first-child, ${Markdown} h4:first-child`, {
marginTop: 0,
});
globalStyle(`${Markdown} h2`, { fontSize: toRem(18) });
globalStyle(`${Markdown} h3`, { fontSize: toRem(16) });
globalStyle(`${Markdown} p`, {
margin: 0,
marginBottom: config.space.S300,
});
globalStyle(`${Markdown} p:last-child`, { marginBottom: 0 });
globalStyle(`${Markdown} ul, ${Markdown} ol`, {
margin: 0,
marginBottom: config.space.S300,
paddingLeft: config.space.S500,
});
globalStyle(`${Markdown} li`, { marginBottom: config.space.S100 });
globalStyle(`${Markdown} li:last-child`, { marginBottom: 0 });
globalStyle(`${Markdown} a`, {
color: color.Primary.Main,
textDecoration: 'underline',
textUnderlineOffset: toRem(2),
});
globalStyle(`${Markdown} a:hover`, { color: color.Primary.MainHover });
globalStyle(`${Markdown} img`, {
display: 'block',
maxWidth: '100%',
height: 'auto',
marginTop: config.space.S200,
marginBottom: config.space.S300,
borderRadius: config.radii.R300,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
});
globalStyle(`${Markdown} code`, {
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: toRem(13),
padding: `0 ${config.space.S100}`,
borderRadius: config.radii.R300,
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
});
globalStyle(`${Markdown} pre`, {
margin: 0,
marginBottom: config.space.S300,
padding: config.space.S300,
borderRadius: config.radii.R300,
backgroundColor: color.SurfaceVariant.Container,
overflow: 'auto',
});
globalStyle(`${Markdown} pre code`, {
padding: 0,
backgroundColor: 'transparent',
});
globalStyle(`${Markdown} blockquote`, {
margin: 0,
marginBottom: config.space.S300,
paddingLeft: config.space.S300,
borderLeft: `${toRem(3)} solid ${color.Primary.Main}`,
color: color.Surface.OnContainer,
opacity: 0.9,
});
globalStyle(`${Markdown} hr`, {
border: 'none',
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
margin: `${config.space.S400} 0`,
});
globalStyle(`${Markdown} table`, {
width: '100%',
borderCollapse: 'collapse',
marginBottom: config.space.S300,
fontSize: toRem(13),
});
globalStyle(`${Markdown} th, ${Markdown} td`, {
padding: config.space.S200,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
textAlign: 'left',
});
globalStyle(`${Markdown} th`, {
backgroundColor: color.SurfaceVariant.Container,
});
export const OlderSection = style({
width: '100%',
marginTop: config.space.S500,
});
export const OlderToggle = style({
width: '100%',
});
export const OlderList = style({
listStyle: 'none',
margin: 0,
padding: 0,
display: 'flex',
flexDirection: 'column',
gap: config.space.S100,
width: '100%',
});
export const OlderListItem = style({
width: '100%',
});
export const OlderItem = style({
all: 'unset',
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: config.space.S100,
width: '100%',
minHeight: toRem(48),
padding: config.space.S300,
borderRadius: config.radii.R300,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
cursor: 'pointer',
textAlign: 'left',
selectors: {
'&:hover': {
backgroundColor: color.SurfaceVariant.ContainerHover,
},
'&[data-active="true"]': {
borderColor: color.Primary.Main,
backgroundColor: color.Primary.Container,
color: color.Primary.OnContainer,
},
},
});
export const Footer = style({
display: 'flex',
flexDirection: 'column',
gap: config.space.S200,
padding: config.space.S400,
paddingTop: config.space.S300,
paddingBottom: `calc(${config.space.S400} + env(safe-area-inset-bottom, 0px))`,
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
backgroundColor: color.Surface.Container,
'@media': {
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
padding: config.space.S300,
paddingTop: config.space.S300,
paddingBottom: `calc(${config.space.S300} + env(safe-area-inset-bottom, 0px))`,
},
},
});
export const FooterButton = style({
width: '100%',
});
export const CloseButton = style({
position: 'absolute',
top: config.space.S300,
right: config.space.S300,
'@media': {
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
top: config.space.S200,
right: config.space.S200,
},
},
});

View File

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

View File

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

View File

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

View File

@@ -112,5 +112,7 @@ export const TikTokEmbedContainer = style([
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: '#000', backgroundColor: '#000',
border: 'none',
display: 'block',
}, },
]); ]);

View File

@@ -42,6 +42,12 @@ export function isTikTokUrl(url: string): boolean {
return TIKTOK_URL_PATTERNS.some((pattern) => pattern.test(url)); return TIKTOK_URL_PATTERNS.some((pattern) => pattern.test(url));
} }
function extractTikTokVideoId(url: string, embedHtml = ''): string | null {
const urlMatch = url.match(/\/video\/(\d+)/i) ?? url.match(/\/v\/(\d+)/i);
const embedMatch = embedHtml.match(/data-video-id=["'](\d+)["']/i);
return urlMatch?.[1] ?? embedMatch?.[1] ?? null;
}
/** /**
* Fetches TikTok video metadata using oEmbed API * Fetches TikTok video metadata using oEmbed API
* @param url The TikTok video URL * @param url The TikTok video URL
@@ -53,6 +59,7 @@ async function fetchTikTokOEmbed(url: string): Promise<{
authorUrl: string; authorUrl: string;
thumbnailUrl: string; thumbnailUrl: string;
embedHtml: string; embedHtml: string;
videoId: string | null;
} | null> { } | null> {
try { try {
const oembedUrl = `${TIKTOK_OEMBED_API}?url=${encodeURIComponent(url)}`; const oembedUrl = `${TIKTOK_OEMBED_API}?url=${encodeURIComponent(url)}`;
@@ -67,6 +74,7 @@ async function fetchTikTokOEmbed(url: string): Promise<{
authorUrl: data.author_url || url, authorUrl: data.author_url || url,
thumbnailUrl: data.thumbnail_url || '', thumbnailUrl: data.thumbnail_url || '',
embedHtml: data.html || '', embedHtml: data.html || '',
videoId: extractTikTokVideoId(url, data.html || ''),
}; };
} catch { } catch {
return null; return null;
@@ -75,7 +83,15 @@ async function fetchTikTokOEmbed(url: string): Promise<{
type TikTokEmbedState = type TikTokEmbedState =
| { status: 'loading' } | { status: 'loading' }
| { status: 'loaded'; title: string; authorName: string; authorUrl: string; thumbnailUrl: string; embedHtml: string } | {
status: 'loaded';
title: string;
authorName: string;
authorUrl: string;
thumbnailUrl: string;
embedHtml: string;
videoId: string | null;
}
| { status: 'error' }; | { status: 'error' };
type TikTokEmbedProps = { type TikTokEmbedProps = {
@@ -88,7 +104,7 @@ type TikTokEmbedProps = {
*/ */
export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref) => { export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref) => {
const [state, setState] = useState<TikTokEmbedState>({ status: 'loading' }); const [state, setState] = useState<TikTokEmbedState>({ status: 'loading' });
const [showEmbed, setShowEmbed] = useState(false); const [showPlayer, setShowPlayer] = useState(false);
// Fetch video info on mount // Fetch video info on mount
useEffect(() => { useEffect(() => {
@@ -105,6 +121,7 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
authorUrl: info.authorUrl, authorUrl: info.authorUrl,
thumbnailUrl: info.thumbnailUrl, thumbnailUrl: info.thumbnailUrl,
embedHtml: info.embedHtml, embedHtml: info.embedHtml,
videoId: info.videoId,
}); });
} else { } else {
setState({ status: 'error' }); setState({ status: 'error' });
@@ -169,7 +186,7 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
} }
// Loaded state - show thumbnail or embed // Loaded state - show thumbnail or embed
if (!showEmbed) { if (!showPlayer) {
return ( return (
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}> <Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
<div className={css.TikTokEmbedHeader}> <div className={css.TikTokEmbedHeader}>
@@ -203,23 +220,27 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
<button <button
type="button" type="button"
className={css.TikTokThumbnailButton} className={css.TikTokThumbnailButton}
onClick={() => setShowEmbed(true)} onClick={() => setShowPlayer(true)}
aria-label="Load TikTok video" aria-label="Play TikTok video"
> >
{state.thumbnailUrl && ( {state.thumbnailUrl ? (
<img <img
src={state.thumbnailUrl} src={state.thumbnailUrl}
alt={state.title} alt={state.title}
className={css.TikTokThumbnail} className={css.TikTokThumbnail}
crossOrigin="anonymous" crossOrigin="anonymous"
/> />
)} ) : null}
</button> </button>
</Box> </Box>
); );
} }
// Show embed (using dangerouslySetInnerHTML for TikTok's oEmbed HTML) const playerUrl = state.videoId
? `https://www.tiktok.com/player/v1/${state.videoId}?autoplay=1`
: null;
// Use TikTok's documented player instead of injecting the oEmbed script.
return ( return (
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}> <Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
<div className={css.TikTokEmbedHeader}> <div className={css.TikTokEmbedHeader}>
@@ -236,10 +257,20 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
{state.title} {state.title}
</Text> </Text>
</div> </div>
<div {playerUrl ? (
className={css.TikTokEmbedContainer} <iframe
dangerouslySetInnerHTML={{ __html: state.embedHtml }} className={css.TikTokEmbedContainer}
/> src={playerUrl}
title="TikTok video player"
allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
allowFullScreen
/>
) : (
<div
className={css.TikTokEmbedContainer}
dangerouslySetInnerHTML={{ __html: state.embedHtml }}
/>
)}
</Box> </Box>
); );
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -117,6 +117,8 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { useImagePackRooms } from '../../hooks/useImagePackRooms'; import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { useIsDirectRoom } from '../../hooks/useRoom'; import { useIsDirectRoom } from '../../hooks/useRoom';
import { useOtherUserCollectibles } from '../../hooks/useUserCollectibles';
import { pickNameplateUrl } from '../../utils/collectibleAssets';
import { setupCopyHandler } from '../../utils/copyHandler'; import { setupCopyHandler } from '../../utils/copyHandler';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace'; import { useSpaceOptionally } from '../../hooks/useSpace';
@@ -570,6 +572,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing'); const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const direct = useIsDirectRoom(); const direct = useIsDirectRoom();
const dmUserId = direct ? room.guessDMUserId() ?? undefined : undefined;
const [timelineHovered, setTimelineHovered] = useState(false);
const { assetUrls: dmAssetUrls } = useOtherUserCollectibles(dmUserId ?? '');
const dmNameplateUrl = pickNameplateUrl(dmAssetUrls);
const dmNameplateIsVideo = Boolean(
dmAssetUrls['nameplate:animated'] || dmAssetUrls['nameplate:asset.webm']
);
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents'); const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents'); const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
@@ -2530,7 +2539,33 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const atLiveBottom = atBottom && liveTimelineLinked && rangeAtEnd; const atLiveBottom = atBottom && liveTimelineLinked && rangeAtEnd;
return ( return (
<Box grow="Yes" style={{ position: 'relative' }}> <Box
grow="Yes"
style={{ position: 'relative' }}
onMouseEnter={() => setTimelineHovered(true)}
onMouseLeave={() => setTimelineHovered(false)}
>
{direct && timelineHovered && dmNameplateUrl && (
<div className={css.DmNameplateBackground} aria-hidden="true">
{dmNameplateIsVideo ? (
<video
className={css.DmNameplateBackgroundMedia}
src={dmNameplateUrl}
autoPlay
loop
muted
playsInline
/>
) : (
<img
className={css.DmNameplateBackgroundMedia}
src={dmNameplateUrl}
alt=""
draggable={false}
/>
)}
</div>
)}
<Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll=""> <Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll="">
<Box <Box
ref={timelineContentRef} ref={timelineContentRef}

View File

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

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useRef } from 'react'; import React, { useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { EmojiConfettiBurst } from './types'; import { EmojiConfettiBurst } from './types';
import { findJumboEmojiElement, getLocalBurstCanvasSize } from './findJumboMount'; import { findJumboEmojiElement, getLocalBurstCanvasSize, measureJumboGlyph, setJumboEmojiHidden } from './findJumboMount';
import { import {
BurstParticle, BurstParticle,
drawBurstParticles, drawBurstParticles,
@@ -15,6 +15,7 @@ import {
FireworkSim, FireworkSim,
getFireworkDpr, getFireworkDpr,
stepFireworkSim, stepFireworkSim,
syncFireworkHero,
} from './fireworkParticleEngine'; } from './fireworkParticleEngine';
const MAX_DPR = 2; const MAX_DPR = 2;
@@ -81,36 +82,85 @@ function FireworkBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasPro
}; };
const { width, height } = resize(); const { width, height } = resize();
const jumbo = findJumboEmojiElement(burst.targetEventId); const profile = getEmojiBurstProfile(primaryEmoji);
const jumboRect = jumbo?.getBoundingClientRect(); const glyph = measureJumboGlyph(burst.targetEventId);
const pinToJumbo = Boolean(profile.morphTo && glyph);
const origin = { const origin = {
x: jumboRect ? jumboRect.left + jumboRect.width / 2 : burst.origin.x, x: glyph ? glyph.x : burst.origin.x,
y: jumboRect ? jumboRect.top + jumboRect.height / 2 : burst.origin.y, y: glyph ? glyph.y : burst.origin.y,
maskRadius: burst.origin.maskRadius ?? 36, maskRadius: glyph ? glyph.size * 0.5 : burst.origin.maskRadius ?? 36,
}; };
// Clamp origin into the viewport so off-screen messages still blast on-screen. // Keep morph heroes glued to the message emoji; only clamp undirected fireworks.
origin.x = Math.min(width - 24, Math.max(24, origin.x)); if (!pinToJumbo) {
origin.y = Math.min(height - 24, Math.max(24, origin.y)); origin.x = Math.min(width - 24, Math.max(24, origin.x));
origin.y = Math.min(height - 24, Math.max(24, origin.y));
}
const profile = getEmojiBurstProfile(primaryEmoji); simRef.current = createFireworkSim(
simRef.current = createFireworkSim(width, height, origin, primaryEmoji, profile); 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 = () => { const onResize = () => {
// Extra Things: match CSS size; sim keeps its launch-time dimensions. // Extra Things: match CSS size; sim keeps its launch-time world size.
resize(); resize();
syncHero();
}; };
window.addEventListener('resize', onResize); 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 tick = (now: number) => {
const sim = simRef.current; const sim = simRef.current;
if (!sim) return; if (!sim) return;
syncHero();
const last = lastFrameTimeRef.current ?? now; const last = lastFrameTimeRef.current ?? now;
const dtSeconds = Math.min((now - last) / 1000, 0.05); const dtSeconds = Math.min((now - last) / 1000, 0.05);
lastFrameTimeRef.current = now; lastFrameTimeRef.current = now;
const alive = stepFireworkSim(sim, now, dtSeconds); 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.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, canvas.width, canvas.height); context.clearRect(0, 0, canvas.width, canvas.height);
@@ -120,9 +170,7 @@ function FireworkBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasPro
return; return;
} }
onCompleteRef.current(burst.id); finish();
frameRef.current = null;
lastFrameTimeRef.current = null;
}; };
lastFrameTimeRef.current = performance.now(); lastFrameTimeRef.current = performance.now();
@@ -130,11 +178,15 @@ function FireworkBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasPro
return () => { return () => {
window.removeEventListener('resize', onResize); window.removeEventListener('resize', onResize);
window.removeEventListener('scroll', syncHero, true);
revealJumbo();
if (frameRef.current !== null) { if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current); cancelAnimationFrame(frameRef.current);
frameRef.current = null; frameRef.current = null;
} }
simRef.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]); }, [burst.id, burst.origin.maskRadius, burst.origin.x, burst.origin.y, burst.targetEventId, primaryEmoji]);

View File

@@ -35,6 +35,17 @@ export type EmojiBurstProfile = {
wobble?: boolean; wobble?: boolean;
/** Full-viewport overlay (Box2D pile for fireworks). */ /** Full-viewport overlay (Box2D pile for fireworks). */
fullscreen?: boolean; 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 = { const DEFAULT_PROFILE: EmojiBurstProfile = {
@@ -382,6 +393,58 @@ const PROFILE_OVERRIDES: Record<string, Partial<EmojiBurstProfile>> = {
companionChance: 0.5, companionChance: 0.5,
twinkle: true, 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', style: 'bounce',
particleCount: 20, particleCount: 20,

View File

@@ -6,6 +6,60 @@ export function findJumboEmojiElement(targetEventId: string): HTMLElement | null
return jumbo instanceof HTMLElement ? jumbo : 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 { export function getLocalBurstCanvasSize(maskRadius: number): number {
return Math.max(300, Math.round(maskRadius * 6.5)); return Math.max(300, Math.round(maskRadius * 6.5));
} }

View File

@@ -4,7 +4,11 @@
* Same vibe as late-90s/MSN page effects, just with emoji. * Same vibe as late-90s/MSN page effects, just with emoji.
*/ */
import { BurstPoint } from './burstOrigin'; import { BurstPoint } from './burstOrigin';
import { pickParticleEmoji, type EmojiBurstProfile } from './emojiParticleProfiles'; import {
pickParticleEmoji,
sampleBurstAngle,
type EmojiBurstProfile,
} from './emojiParticleProfiles';
const EMOJI_CACHE_PX = 40; const EMOJI_CACHE_PX = 40;
const EMOJI_CACHE_SCALE = 2; const EMOJI_CACHE_SCALE = 2;
@@ -43,9 +47,25 @@ export type FireworkParticle = {
settled: boolean; 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 = { export type FireworkSim = {
particles: FireworkParticle[]; particles: FireworkParticle[];
profile: EmojiBurstProfile; 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; width: number;
height: number; height: number;
origin: BurstPoint; origin: BurstPoint;
@@ -66,11 +86,35 @@ function lerp(min: number, max: number): number {
return min + Math.random() * (max - min); return min + Math.random() * (max - min);
} }
function getEmojiCanvas(emoji: string): HTMLCanvasElement { const HERO_PAD_RATIO = 0.24;
const cached = emojiCanvasCache.get(emoji);
if (cached) return cached; 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 size = EMOJI_CACHE_PX * EMOJI_CACHE_SCALE;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = size; canvas.width = size;
canvas.height = size; canvas.height = size;
@@ -79,11 +123,52 @@ function getEmojiCanvas(emoji: string): HTMLCanvasElement {
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.imageSmoothingEnabled = false; ctx.imageSmoothingEnabled = false;
ctx.font = `${EMOJI_CACHE_PX}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`; ctx.font = `${fontPx}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
ctx.fillText(emoji, size / 2, size / 2); // 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(emoji, canvas); emojiCanvasCache.set(cacheKey, canvas);
return 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( function buildSpawnPlan(
@@ -91,13 +176,16 @@ function buildSpawnPlan(
primaryEmoji: string, primaryEmoji: string,
startMs: number startMs: number
): SpawnItem[] { ): SpawnItem[] {
const plan: 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), {
}, atMs: startMs,
]; emoji: primaryEmoji,
fontSize: lerp(profile.heroFontSizeMin, profile.heroFontSizeMax),
},
];
const count = profile.particleCount; const count = profile.particleCount;
for (let i = 0; i < count; i += 1) { for (let i = 0; i < count; i += 1) {
@@ -109,7 +197,7 @@ function buildSpawnPlan(
80 + 80 +
eased * SPAWN_WINDOW_MS + eased * SPAWN_WINDOW_MS +
(Math.random() - 0.5) * 70, (Math.random() - 0.5) * 70,
emoji: pickParticleEmoji(profile, primaryEmoji), emoji: pickSpewEmoji(profile, primaryEmoji),
fontSize: lerp(profile.fontSizeMin, profile.fontSizeMax), fontSize: lerp(profile.fontSizeMin, profile.fontSizeMax),
}); });
} }
@@ -118,6 +206,18 @@ function buildSpawnPlan(
return plan; 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 { function ensurePile(sim: FireworkSim): CanvasRenderingContext2D {
if (sim.pileCanvas && sim.pileCtx) return sim.pileCtx; if (sim.pileCanvas && sim.pileCtx) return sim.pileCtx;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
@@ -145,20 +245,36 @@ function stampSettled(sim: FireworkSim, p: FireworkParticle) {
function spawnOne(sim: FireworkSim, item: SpawnItem, now: number) { function spawnOne(sim: FireworkSim, item: SpawnItem, now: number) {
const { profile, origin } = sim; const { profile, origin } = sim;
const jitter = Math.max(4, (origin.maskRadius ?? 28) * 0.25); const jitter = Math.max(4, (origin.maskRadius ?? 28) * 0.25);
const angle = Math.random() * Math.PI * 2; const directed = profile.angleSpread !== undefined;
const angle = directed ? sampleBurstAngle(profile) : Math.random() * Math.PI * 2;
const speed = lerp(profile.speedMin, profile.speedMax); const speed = lerp(profile.speedMin, profile.speedMax);
const launchUp = lerp(profile.launchUpMin, profile.launchUpMax); const launchUp = lerp(profile.launchUpMin, profile.launchUpMax);
const drawSize = item.fontSize * EMOJI_CACHE_SCALE * 0.85; 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({ sim.particles.push({
x: origin.x + (Math.random() - 0.5) * jitter, x: origin.x + (Math.random() - 0.5) * jitter,
y: origin.y + (Math.random() - 0.5) * jitter, y: origin.y + (Math.random() - 0.5) * jitter,
vx: Math.cos(angle) * speed + (Math.random() - 0.5) * 40, vx,
vy: Math.sin(angle) * speed - launchUp, vy,
spin: lerp(profile.spinMin, profile.spinMax) * 0.35, spin: lerp(profile.spinMin, profile.spinMax) * 0.35,
angle: (Math.random() - 0.5) * 40, angle: (Math.random() - 0.5) * 40,
emoji: item.emoji, emoji,
emojiCanvas: getEmojiCanvas(item.emoji), emojiCanvas: getEmojiCanvas(emoji, tint),
drawSize, drawSize,
halfSize: drawSize / 2, halfSize: drawSize / 2,
bornAt: now, bornAt: now,
@@ -203,17 +319,49 @@ export function createFireworkSim(
originPx: BurstPoint, originPx: BurstPoint,
primaryEmoji: string, primaryEmoji: string,
profile: EmojiBurstProfile, profile: EmojiBurstProfile,
startMs = performance.now() startMs = performance.now(),
/** Pixel size of the source jumbo emoji — hero face matches this when set. */
heroSizePx?: number
): FireworkSim { ): FireworkSim {
const colCount = Math.max(8, Math.ceil(widthPx / PILE_CELL)); 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 { return {
particles: [], particles: [],
profile, profile,
primaryEmoji,
morphAtMs: profile.morphTo ? startMs + (profile.morphAfterMs ?? 220) : null,
heroUntilMs,
hero,
width: widthPx, width: widthPx,
height: heightPx, height: heightPx,
origin: originPx, origin: originPx,
startMs, startMs,
spawnPlan: buildSpawnPlan(profile, primaryEmoji, startMs), spawnPlan,
spawnCursor: 0, spawnCursor: 0,
floorY: heightPx - FLOOR_PAD, floorY: heightPx - FLOOR_PAD,
pileCols: new Uint16Array(colCount), pileCols: new Uint16Array(colCount),
@@ -223,10 +371,27 @@ export function createFireworkSim(
}; };
} }
/** 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. */ /** @returns false when the burst should be removed. */
export function stepFireworkSim(sim: FireworkSim, now: number, dtSeconds: number): boolean { export function stepFireworkSim(sim: FireworkSim, now: number, dtSeconds: number): boolean {
if (now - sim.startMs > FIREWORK_TOTAL_MS) return false; 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 dt = Math.min(dtSeconds, 0.05);
const g = sim.profile.gravity; const g = sim.profile.gravity;
@@ -299,6 +464,20 @@ export function drawFireworkSim(
context.drawImage(sim.pileCanvas, 0, 0); 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) { for (let i = 0; i < sim.particles.length; i += 1) {
const p = sim.particles[i]; const p = sim.particles[i];
if (p.settled) continue; if (p.settled) continue;

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { getVersion } from '@tauri-apps/api/app'; import { getAppVersion } from '../../../utils/appVersion';
import { useOpenReleaseNotesDialog } from '../../../components/updates-dialog/UpdatesDialogHost';
import { getCurrentUpdatePreview } from '../../../data/updateNotes';
import { Box, Text, IconButton, Scroll, Button, config, toRem } from 'folds'; import { Box, Text, IconButton, Scroll, Button, config, toRem } from 'folds';
import { Icon, Icons } from '../../../components/icons'; import { Icon, Icons } from '../../../components/icons';
import { Page, PageContent, PageHeader } from '../../../components/page'; import { Page, PageContent, PageHeader } from '../../../components/page';
@@ -15,9 +17,13 @@ type AboutProps = {
}; };
export function About({ requestClose }: AboutProps) { export function About({ requestClose }: AboutProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const openReleaseNotes = useOpenReleaseNotesDialog();
const [version, setVersion] = useState<string>(''); const [version, setVersion] = useState<string>('');
const [protocolStatus, setProtocolStatus] = useState<string>('Checking desktop protocol integration...'); const [protocolStatus, setProtocolStatus] = useState<string>('Checking desktop protocol integration...');
const [protocolBusy, setProtocolBusy] = useState<boolean>(false); const [protocolBusy, setProtocolBusy] = useState<boolean>(false);
const [updatePreview, setUpdatePreview] = useState<{ title: string; description: string } | null>(
null
);
const formatProtocolStatus = useCallback((data: { const formatProtocolStatus = useCallback((data: {
scheme: string; scheme: string;
@@ -93,8 +99,9 @@ export function About({ requestClose }: AboutProps) {
}; };
useEffect(() => { useEffect(() => {
getVersion().then(setVersion).catch(() => setVersion('unknown')); getAppVersion().then(setVersion).catch(() => setVersion('unknown'));
refreshProtocolStatus(); refreshProtocolStatus();
getCurrentUpdatePreview().then(setUpdatePreview).catch(() => setUpdatePreview(null));
}, [refreshProtocolStatus]); }, [refreshProtocolStatus]);
return ( return (
@@ -151,6 +158,36 @@ export function About({ requestClose }: AboutProps) {
</Box> </Box>
</Box> </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"> <Box direction="Column" gap="100">
<Text size="L400">Options</Text> <Text size="L400">Options</Text>
<SequenceCard <SequenceCard

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import React, {
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { as, Box, Button, Chip, color, config, Header, IconButton, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Switch, Text, toRem } from 'folds'; import { as, Box, Button, Chip, color, config, Header, IconButton, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Switch, Text, toRem } from 'folds';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { Range } from 'react-range';
import { Icon, Icons } from '../../../components/icons'; import { Icon, Icons } from '../../../components/icons';
import { HexColorPicker } from 'react-colorful'; import { HexColorPicker } from 'react-colorful';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
@@ -334,6 +335,66 @@ const emojiStyleNames: Record<EmojiStyle, string> = {
[EmojiStyle.Twemoji]: 'Twemoji', [EmojiStyle.Twemoji]: 'Twemoji',
}; };
function SidebarNameplate() {
const [sidebarNameplateOpacity, setSidebarNameplateOpacity] = useSetting(
settingsAtom,
'sidebarNameplateOpacity'
);
return (
<Box direction="Column" gap="100">
<Text size="L400">Collectibles</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Sidebar Nameplate Opacity"
description="Set to 0% to hide your equipped nameplate behind the bottom sidebar avatar."
after={
<Box gap="200" alignItems="Center" style={{ width: toRem(180) }}>
<Range
step={1}
min={0}
max={100}
values={[sidebarNameplateOpacity]}
onChange={(values) => setSidebarNameplateOpacity(values[0])}
renderTrack={(params) => (
<div
{...params.props}
style={{
...params.props.style,
width: '100%',
height: toRem(8),
borderRadius: toRem(4),
background: `linear-gradient(to right, ${color.Primary.Main} ${sidebarNameplateOpacity}%, ${color.Surface.ContainerLine} ${sidebarNameplateOpacity}%)`,
}}
>
{params.children}
</div>
)}
renderThumb={(params) => (
<div
{...params.props}
style={{
...params.props.style,
width: toRem(16),
height: toRem(16),
borderRadius: '50%',
backgroundColor: color.Primary.Main,
border: `${toRem(2)} solid ${color.Surface.Container}`,
}}
/>
)}
/>
<Text size="T300" style={{ minWidth: toRem(32), textAlign: 'right' }}>
{sidebarNameplateOpacity}%
</Text>
</Box>
}
/>
</SequenceCard>
</Box>
);
}
type EmojiStyleSelectorProps = { type EmojiStyleSelectorProps = {
selected: EmojiStyle; selected: EmojiStyle;
onSelect: (style: EmojiStyle) => void; onSelect: (style: EmojiStyle) => void;
@@ -1222,6 +1283,7 @@ export function General({ requestClose }: GeneralProps) {
<PageContent> <PageContent>
<Box direction="Column" gap="700"> <Box direction="Column" gap="700">
<Appearance /> <Appearance />
<SidebarNameplate />
<DateAndTime /> <DateAndTime />
<Editor /> <Editor />
<Spaces /> <Spaces />

View File

@@ -1,56 +1,17 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { MatrixClient, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { useMediaAuthentication } from './useMediaAuthentication'; import { useMediaAuthentication } from './useMediaAuthentication';
import { mxcUrlToHttp } from '../utils/matrix'; import { mxcUrlToHttp } from '../utils/matrix';
import { getCurrentAccessToken } from '../utils/auth'; import { getCurrentAccessToken } from '../utils/auth';
import { import {
fetchAndExtractMetadata, deleteBannerUrl,
extractMetadataFromImage, loadBannerUrl,
embedMetadataInImage, registerBannerCacheClear,
detectImageFormat, saveBannerUrl,
getMimeType, } from '../utils/profileFields';
getExtension,
ImageMetadata,
convertImageDataToPng,
uint8ArrayToBlob,
} from '../utils/imageMetadata';
/** /**
* Fetches the user's current avatar as raw image data * Hook to manage the user's profile banner (MSC4427 via MSC4133).
*/
async function fetchAvatarData(
mx: MatrixClient,
avatarMxc: string,
useAuthentication: boolean
): Promise<ArrayBuffer | null> {
const url = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
if (!url) return null;
try {
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
let response = await fetch(url, {
method: 'GET',
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
// If we got a 401 and we tried with auth, fallback to unauthenticated request
if (!response.ok && response.status === 401 && accessToken && useAuthentication) {
response = await fetch(url, { method: 'GET' });
}
if (!response.ok) return null;
return await response.arrayBuffer();
} catch {
return null;
}
}
/**
* Hook to manage the user's chosen profile banner, stored in avatar image metadata
* The banner URL is embedded in avatar metadata and syncs via the avatar
* @returns The current banner URL, a setter function, and loading state
*/ */
export function useUserBanner(): [ export function useUserBanner(): [
string | undefined, string | undefined,
@@ -58,280 +19,124 @@ export function useUserBanner(): [
boolean boolean
] { ] {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [banner, setBanner] = useState<string | undefined>(); const [banner, setBanner] = useState<string | undefined>();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Extract banner from current avatar on mount and when profile changes
useEffect(() => { useEffect(() => {
const loadBanner = async () => {
setLoading(true);
try {
const userId = mx.getUserId();
if (!userId) {
setLoading(false);
return;
}
const profile = await mx.getProfileInfo(userId);
const avatarUrl = profile.avatar_url;
console.log('[useUserBanner.loadBanner] Loading banner from avatar:', avatarUrl);
if (!avatarUrl) {
setBanner(undefined);
setLoading(false);
return;
}
const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication);
if (!httpUrl) {
setBanner(undefined);
setLoading(false);
return;
}
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
console.log('[useUserBanner.loadBanner] Extracted banner:', metadata.banner);
setBanner(metadata.banner);
} catch {
setBanner(undefined);
}
setLoading(false);
};
loadBanner();
// Listen for avatar changes and reload banner
const userId = mx.getUserId();
if (!userId) return undefined;
const user = mx.getUser(userId);
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = (newAvatarUrl) => {
console.log('[useUserBanner] Avatar changed event fired, new URL:', newAvatarUrl);
loadBanner();
};
user?.on(UserEvent.AvatarUrl, onAvatarChange);
return () => {
user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
};
}, [mx, useAuthentication]);
const updateBanner = useCallback(async (newBanner: string | undefined) => {
const userId = mx.getUserId(); const userId = mx.getUserId();
if (!userId) { if (!userId) {
throw new Error('No user ID'); setLoading(false);
return undefined;
} }
const profile = await mx.getProfileInfo(userId); let cancelled = false;
const avatarUrl = profile.avatar_url;
if (!avatarUrl) { const load = async () => {
throw new Error('No avatar set. Please upload an avatar first.'); setLoading(true);
} try {
const bannerMxc = await loadBannerUrl(mx, userId);
// Fetch current avatar if (!cancelled) setBanner(bannerMxc);
const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication); } catch {
if (!avatarData) { if (!cancelled) setBanner(undefined);
throw new Error('Failed to fetch current avatar');
}
// Detect image format — 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; if (!cancelled) setLoading(false);
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); load();
return () => {
cancelled = true;
};
}, [mx]);
if (!newAvatarData) { const updateBanner = useCallback(
throw new Error('Failed to embed banner in avatar metadata'); async (newBanner: string | undefined) => {
} if (!newBanner) {
await deleteBannerUrl(mx);
// Verify the banner was embedded correctly before uploading setBanner(undefined);
const verifyMetadata = extractMetadataFromImage(newAvatarData); return;
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 await saveBannerUrl(mx, newBanner);
// setBanner(newBanner); setBanner(newBanner);
}, [mx, useAuthentication]); },
[mx]
);
return [banner, updateBanner, loading]; return [banner, updateBanner, loading];
} }
// Cache for user banners to avoid refetching for each message
const userBannerCache = new Map<string, { bannerMxc: string | undefined; timestamp: number }>(); const userBannerCache = new Map<string, { bannerMxc: string | undefined; timestamp: number }>();
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes const CACHE_TTL_MS = 5 * 60 * 1000;
registerBannerCacheClear((userId: string) => {
userBannerCache.delete(userId);
});
async function fetchBannerBlobUrl(
mx: ReturnType<typeof useMatrixClient>,
bannerMxc: string,
useAuthentication: boolean
): Promise<string | undefined> {
const bannerHttpUrl = mxcUrlToHttp(mx, bannerMxc, useAuthentication);
if (!bannerHttpUrl) return undefined;
const accessToken = getCurrentAccessToken();
const headers: HeadersInit = {};
if (useAuthentication && accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
const response = await fetch(bannerHttpUrl, { headers });
if (!response.ok) return undefined;
const blob = await response.blob();
return URL.createObjectURL(blob);
}
/** /**
* Hook to get another user's chosen profile banner from their avatar metadata * Hook to get another user's profile banner as a blob URL.
* @param userId - The user ID to get banner for
* @param avatarMxc - The user's avatar MXC URL
* @returns The user's chosen banner as a blob URL or undefined
*/ */
export function useOtherUserBanner(userId: string, avatarMxc: string | undefined): string | undefined { export function useOtherUserBanner(userId: string): string | undefined {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [bannerBlobUrl, setBannerBlobUrl] = useState<string | undefined>(); const [bannerBlobUrl, setBannerBlobUrl] = useState<string | undefined>();
useEffect(() => { useEffect(() => {
let blobUrl: string | undefined; if (!userId) {
if (!avatarMxc) {
console.log('[useOtherUserBanner] No avatarMxc provided for', userId);
setBannerBlobUrl(undefined); setBannerBlobUrl(undefined);
return undefined; return undefined;
} }
const loadBanner = async () => { let blobUrl: string | undefined;
console.log('[useOtherUserBanner] Loading banner for', userId, 'avatarMxc:', avatarMxc); let cancelled = false;
// Check cache first
const cacheKey = `${userId}:${avatarMxc}`;
const cached = userBannerCache.get(cacheKey);
const load = async () => {
const cached = userBannerCache.get(userId);
let bannerMxc: string | undefined; let bannerMxc: string | undefined;
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
// Use cached banner MXC
console.log('[useOtherUserBanner] Using cached bannerMxc:', cached.bannerMxc);
bannerMxc = cached.bannerMxc; bannerMxc = cached.bannerMxc;
} else { } else {
// Fetch banner MXC from avatar metadata bannerMxc = await loadBannerUrl(mx, userId);
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication); userBannerCache.set(userId, { bannerMxc, timestamp: Date.now() });
console.log('[useOtherUserBanner] Fetching metadata from:', httpUrl);
if (!httpUrl) {
console.log('[useOtherUserBanner] Failed to get HTTP URL');
setBannerBlobUrl(undefined);
return;
}
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
console.log('[useOtherUserBanner] Extracted metadata:', metadata);
bannerMxc = metadata.banner;
// Cache the banner MXC (not the blob URL)
userBannerCache.set(cacheKey, { bannerMxc, timestamp: Date.now() });
} }
if (!bannerMxc) { if (!bannerMxc) {
console.log('[useOtherUserBanner] No banner MXC found'); if (!cancelled) setBannerBlobUrl(undefined);
setBannerBlobUrl(undefined);
return; return;
} }
console.log('[useOtherUserBanner] Fetching banner image from MXC:', bannerMxc); blobUrl = await fetchBannerBlobUrl(mx, bannerMxc, useAuthentication);
if (!cancelled) setBannerBlobUrl(blobUrl);
// Fetch the banner image data with authentication
const bannerHttpUrl = mxcUrlToHttp(mx, bannerMxc, useAuthentication);
if (!bannerHttpUrl) {
console.log('[useOtherUserBanner] Failed to convert banner MXC to HTTP URL');
setBannerBlobUrl(undefined);
return;
}
console.log('[useOtherUserBanner] Banner HTTP URL:', bannerHttpUrl);
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken();
const headers: HeadersInit = {};
if (useAuthentication && accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
const response = await fetch(bannerHttpUrl, { headers });
if (!response.ok) {
setBannerBlobUrl(undefined);
return;
}
const blob = await response.blob();
blobUrl = URL.createObjectURL(blob);
setBannerBlobUrl(blobUrl);
}; };
loadBanner(); load();
// Cleanup blob URL when component unmounts or deps change
return () => { return () => {
if (blobUrl) { cancelled = true;
URL.revokeObjectURL(blobUrl); if (blobUrl) URL.revokeObjectURL(blobUrl);
}
}; };
}, [mx, useAuthentication, avatarMxc, userId]); }, [mx, useAuthentication, userId]);
return bannerBlobUrl; return bannerBlobUrl;
} }

View File

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

View File

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

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

View File

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

View File

@@ -4,6 +4,7 @@ import React from 'react';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
import { searchModalAtom } from '../../../state/searchModal'; import { searchModalAtom } from '../../../state/searchModal';
import * as sidebarCss from '../../../components/sidebar/Sidebar.css';
export function SearchTab() { export function SearchTab() {
const [opened, setOpen] = useAtom(searchModalAtom); const [opened, setOpen] = useAtom(searchModalAtom);
@@ -14,7 +15,13 @@ export function SearchTab() {
<SidebarItem active={opened}> <SidebarItem active={opened}>
<SidebarItemTooltip tooltip="Search"> <SidebarItemTooltip tooltip="Search">
{(triggerRef) => ( {(triggerRef) => (
<SidebarAvatar as="button" ref={triggerRef} outlined onClick={open}> <SidebarAvatar
as="button"
ref={triggerRef}
className={sidebarCss.SidebarSearchAvatar}
outlined
onClick={open}
>
<Icon src={Icons.Search} filled={opened} /> <Icon src={Icons.Search} filled={opened} />
</SidebarAvatar> </SidebarAvatar>
)} )}

View File

@@ -11,6 +11,12 @@ import { nameInitials } from '../../../utils/common';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { Settings } from '../../../features/settings'; import { Settings } from '../../../features/settings';
import { useUserProfile } from '../../../hooks/useUserProfile'; import { useUserProfile } from '../../../hooks/useUserProfile';
import { useOtherUserCollectibles } from '../../../hooks/useUserCollectibles';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { pickAvatarDecorationUrl, pickNameplateUrl } from '../../../utils/collectibleAssets';
import * as avatarDecorationCss from '../../../styles/AvatarDecoration.css';
import * as sidebarCss from '../../../components/sidebar/Sidebar.css';
import { Modal500 } from '../../../components/Modal500'; import { Modal500 } from '../../../components/Modal500';
import { AccountSwitcher } from '../../../components/account-switcher'; import { AccountSwitcher } from '../../../components/account-switcher';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
@@ -34,6 +40,13 @@ export function SettingsTab() {
const avatarUrl = profile.avatarUrl && userId const avatarUrl = profile.avatarUrl && userId
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined; : undefined;
const { assetUrls } = useOtherUserCollectibles(userId ?? '');
const avatarDecorationUrl = pickAvatarDecorationUrl(assetUrls);
const nameplateUrl = pickNameplateUrl(assetUrls);
const [sidebarNameplateOpacity] = useSetting(settingsAtom, 'sidebarNameplateOpacity');
const nameplateIsVideo = Boolean(
assetUrls['nameplate:animated'] || assetUrls['nameplate:asset.webm']
);
const openSettings = () => setSettings(true); const openSettings = () => setSettings(true);
const closeSettings = () => setSettings(false); const closeSettings = () => setSettings(false);
@@ -48,14 +61,49 @@ export function SettingsTab() {
<SidebarItem active={settings}> <SidebarItem active={settings}>
<SidebarAvatar <SidebarAvatar
as="button" as="button"
className={sidebarCss.SidebarAvatarLarge}
onClick={openSettings} onClick={openSettings}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
style={{ overflow: 'visible' }}
> >
<UserAvatar <div className={avatarDecorationCss.SidebarAvatarStack}>
userId={userId ?? ''} {sidebarNameplateOpacity > 0 &&
src={avatarUrl} nameplateUrl &&
renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>} (nameplateIsVideo ? (
/> <video
className={sidebarCss.SidebarAvatarNameplate}
src={nameplateUrl}
style={{ opacity: sidebarNameplateOpacity / 100 }}
autoPlay
loop
muted
playsInline
aria-hidden="true"
/>
) : (
<img
className={sidebarCss.SidebarAvatarNameplate}
src={nameplateUrl}
style={{ opacity: sidebarNameplateOpacity / 100 }}
alt=""
draggable={false}
/>
))}
<UserAvatar
className={sidebarCss.SidebarAvatarForeground}
userId={userId ?? ''}
src={avatarUrl}
renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}
/>
{avatarDecorationUrl && (
<img
className={avatarDecorationCss.AvatarDecorationOverlay}
src={avatarDecorationUrl}
alt=""
draggable={false}
/>
)}
</div>
</SidebarAvatar> </SidebarAvatar>
<PopOut <PopOut

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

@@ -50,6 +50,7 @@ export interface Settings {
encUrlPreview: boolean; encUrlPreview: boolean;
showHiddenEvents: boolean; showHiddenEvents: boolean;
legacyUsernameColor: boolean; legacyUsernameColor: boolean;
sidebarNameplateOpacity: number;
showNotifications: boolean; showNotifications: boolean;
isNotificationSounds: boolean; isNotificationSounds: boolean;
@@ -93,6 +94,7 @@ const defaultSettings: Settings = {
encUrlPreview: false, encUrlPreview: false,
showHiddenEvents: false, showHiddenEvents: false,
legacyUsernameColor: false, legacyUsernameColor: false,
sidebarNameplateOpacity: 60,
showNotifications: true, showNotifications: true,
isNotificationSounds: true, isNotificationSounds: true,

View File

@@ -0,0 +1,29 @@
import { style } from '@vanilla-extract/css';
/** Discord presets are 128px canvases aligned to an ~80px avatar circle. */
export const AVATAR_DECORATION_SCALE_PERCENT = 119;
/** Inner avatar diameter as a fraction of the decoration canvas (80 / 128). */
export const AVATAR_DECORATION_INNER_PERCENT = 62.5;
export const AvatarDecorationOverlay = style({
position: 'absolute',
top: '50%',
left: '50%',
width: `${AVATAR_DECORATION_SCALE_PERCENT}%`,
height: `${AVATAR_DECORATION_SCALE_PERCENT}%`,
transform: 'translate(-50%, -50%)',
objectFit: 'contain',
pointerEvents: 'none',
zIndex: 2,
});
export const SidebarAvatarStack = style({
position: 'relative',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'visible',
});

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,102 @@
import { DiscordCollectibleItem } from './discordCollectibles';
function introDurationFromEffectMeta(effect: Record<string, unknown> | undefined): number | undefined {
if (!effect) return undefined;
const raw = effect.duration ?? effect.durationMs;
if (typeof raw !== 'number' || raw <= 0) return undefined;
return raw > 100 ? raw : raw * 1000;
}
function catalogAssetUrl(item: DiscordCollectibleItem, ...roles: string[]): string | undefined {
for (const role of roles) {
const asset = item.assets.find((entry) => entry.role === role);
if (asset?.url) return asset.url;
}
return undefined;
}
export function pickCatalogStaticPreviewUrl(item: DiscordCollectibleItem): string | undefined {
switch (item.type) {
case 'nameplate':
return catalogAssetUrl(item, 'static') ?? item.thumbnailUrl;
case 'avatar_decoration':
return catalogAssetUrl(item, 'static') ?? item.thumbnailUrl;
case 'profile_effect':
return catalogAssetUrl(item, 'thumbnail', 'reduced_motion') ?? item.thumbnailUrl;
default:
return item.thumbnailUrl;
}
}
export function pickCatalogAnimatedPreviewUrl(item: DiscordCollectibleItem): string | undefined {
switch (item.type) {
case 'nameplate':
return catalogAssetUrl(item, 'animated');
case 'avatar_decoration':
return catalogAssetUrl(item, 'animated');
case 'profile_effect':
return pickCatalogProfileEffectLoopUrl(item);
default:
return undefined;
}
}
export function pickCatalogProfileEffectIntroUrl(item: DiscordCollectibleItem): string | undefined {
return catalogAssetUrl(item, 'effect_0');
}
export function pickCatalogProfileEffectLoopUrl(item: DiscordCollectibleItem): string | undefined {
return catalogAssetUrl(item, 'effect_1', 'effect_0', 'effect_2', 'effect_3');
}
export function catalogProfileEffectIntroDurationMs(item: DiscordCollectibleItem): number | undefined {
const effect = item.effect?.effects?.[0] as Record<string, unknown> | undefined;
return introDurationFromEffectMeta(effect);
}
export function pickStoredProfileEffectIntroUrl(
assetUrls: Record<string, string | undefined>
): string | undefined {
return assetUrls['profile_effect:effect_0'];
}
export function pickStoredProfileEffectLoopUrl(
assetUrls: Record<string, string | undefined>
): string | undefined {
return (
assetUrls['profile_effect:effect_1'] ||
assetUrls['profile_effect:effect_0'] ||
assetUrls['profile_effect:effect_2'] ||
assetUrls['profile_effect:effect_3']
);
}
export function isCatalogVideoPreview(url?: string): boolean {
return Boolean(url && (url.includes('.webm') || url.includes('asset.webm')));
}
export function pickNameplateUrl(
assetUrls: Record<string, string | undefined>
): string | undefined {
return (
assetUrls['nameplate:animated'] ||
assetUrls['nameplate:asset.webm'] ||
assetUrls['nameplate:static'] ||
assetUrls['nameplate:static.png']
);
}
export function pickAvatarDecorationUrl(
assetUrls: Record<string, string | undefined>
): string | undefined {
return assetUrls['avatar_decoration:animated'] || assetUrls['avatar_decoration:animated.png'];
}
export function pickProfileEffectUrl(
assetUrls: Record<string, string | undefined>
): string | undefined {
return (
pickStoredProfileEffectLoopUrl(assetUrls) ||
assetUrls['profile_effect:reduced_motion']
);
}

View File

@@ -0,0 +1,323 @@
import { MatrixClient } from 'matrix-js-sdk';
import { StoredCollectible } from './profileFields';
export type CollectibleKind = 'profile_effect' | 'nameplate' | 'avatar_decoration';
export type DiscordCollectibleAsset = {
role: string;
url: string;
filename: string;
mimeType: string;
};
export type DiscordCollectibleItem = {
id: string;
skuId: string;
name: string;
type: CollectibleKind;
category: string;
label: string;
thumbnailUrl?: string;
previewAspectRatio?: number;
previewColors?: number[];
previewGradient?: string;
palette?: string;
paletteLabel?: string;
assets: DiscordCollectibleAsset[];
effect?: {
animationType?: number;
thumbnailPreviewSrc?: string;
reducedMotionSrc?: string;
effects?: Array<Record<string, unknown>>;
};
};
export type DownloadedCollectibleAsset = DiscordCollectibleAsset & {
data: Uint8Array;
};
const CDN_HOST_RE = /(^|\.)(discordapp\.com|discordapp\.net|discord\.com)$/i;
const PUBLISHED_CATALOG_URLS = [
'https://github.com/litruv/discord-collectibles/releases/download/latest/profileeffects.json',
'https://github.com/litruv/discord-collectibles/releases/download/latest/nameplate.json',
'https://github.com/litruv/discord-collectibles/releases/download/latest/avatardecorations.json',
];
const CATALOG_CACHE_KEY = 'paarrot.discordCollectiblesCatalog';
const CATALOG_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
type PublishedCatalog = {
schema_version: number;
fetchedAt: string;
items: DiscordCollectibleItem[];
};
function isPublishedCatalog(value: unknown): value is { schema_version: number; items: DiscordCollectibleItem[] } {
if (!value || typeof value !== 'object') return false;
const catalog = value as Record<string, unknown>;
return (
catalog.schema_version === 1 &&
Array.isArray(catalog.items) &&
catalog.items.every(
(item) =>
item &&
typeof item === 'object' &&
typeof (item as DiscordCollectibleItem).id === 'string' &&
Array.isArray((item as DiscordCollectibleItem).assets)
)
);
}
function loadCachedCatalog(): PublishedCatalog | undefined {
try {
const raw = localStorage.getItem(CATALOG_CACHE_KEY);
if (!raw) return undefined;
const cached = JSON.parse(raw) as PublishedCatalog;
if (
!isPublishedCatalog(cached) ||
typeof cached.fetchedAt !== 'string' ||
Date.now() - Date.parse(cached.fetchedAt) >= CATALOG_CACHE_TTL_MS
) {
return undefined;
}
return cached;
} catch {
return undefined;
}
}
function saveCachedCatalog(catalog: PublishedCatalog): void {
try {
localStorage.setItem(CATALOG_CACHE_KEY, JSON.stringify(catalog));
} catch {
// A full or unavailable storage backend should not prevent catalog use.
}
}
export async function fetchPublishedCollectiblesCatalog(
force = false
): Promise<DiscordCollectibleItem[]> {
const cached = loadCachedCatalog();
if (!force && cached) return cached.items;
try {
const documents = await Promise.all(
PUBLISHED_CATALOG_URLS.map(async (url) => {
const response = await fetch(url);
if (!response.ok) throw new Error(`Catalog request failed with HTTP ${response.status}.`);
const catalog = await response.json();
if (!isPublishedCatalog(catalog)) throw new Error('Catalog has an unsupported schema.');
return catalog;
})
);
const items = documents.flatMap((catalog) => catalog.items);
if (items.length === 0) throw new Error('Catalog is empty.');
saveCachedCatalog({ schema_version: 1, fetchedAt: new Date().toISOString(), items });
return items;
} catch (error) {
if (cached) return cached.items;
throw error;
}
}
function isCdnUrl(value: unknown): value is string {
if (typeof value !== 'string' || !value.startsWith('http')) return false;
try {
return CDN_HOST_RE.test(new URL(value).hostname);
} catch {
return false;
}
}
function replaceCdnUrls<T>(value: T, urlToMxc: Map<string, string>): T {
if (value == null) return value;
if (typeof value === 'string') {
return (isCdnUrl(value) && urlToMxc.has(value) ? urlToMxc.get(value) : value) as T;
}
if (Array.isArray(value)) {
return value.map((entry) => replaceCdnUrls(entry, urlToMxc)) as T;
}
if (typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
out[key] = replaceCdnUrls(entry, urlToMxc);
}
return out as T;
}
return value;
}
export async function downloadCollectibleAssets(
assets: DiscordCollectibleAsset[]
): Promise<DownloadedCollectibleAsset[]> {
const api = window.electron?.discordCollectibles;
if (api) {
const result = await api.downloadAssets(assets);
if (!result?.success || !result.data) {
throw new Error(result?.error || 'Failed to download collectible assets from Discord CDN.');
}
return result.data.map((asset) => ({
role: asset.role,
url: asset.url,
filename: asset.filename,
mimeType: asset.mimeType,
data: asset.data instanceof Uint8Array ? asset.data : new Uint8Array(asset.data),
}));
}
return Promise.all(
assets.map(async (asset) => {
if (!isCdnUrl(asset.url)) throw new Error('Collectible asset URL is not a Discord CDN URL.');
const response = await fetch(asset.url);
if (!response.ok) throw new Error(`Failed to download ${asset.filename}: HTTP ${response.status}.`);
return {
...asset,
data: new Uint8Array(await response.arrayBuffer()),
};
})
);
}
export async function uploadCollectibleToMatrix(
mx: MatrixClient,
item: DiscordCollectibleItem,
onProgress?: (message: string) => void
): Promise<StoredCollectible> {
onProgress?.('Downloading from Discord…');
const downloaded = await downloadCollectibleAssets(item.assets);
const urlToMxc = new Map<string, string>();
const assets: Record<string, string> = {};
for (const asset of downloaded) {
onProgress?.(`Uploading ${asset.filename}`);
const blob = new Blob([asset.data], { type: asset.mimeType });
const file = new File([blob], asset.filename, { type: asset.mimeType });
const response = await mx.uploadContent(file, {
name: asset.filename,
type: asset.mimeType,
includeFilename: true,
});
if (!response.content_uri) {
throw new Error(`Failed to upload ${asset.filename} to Matrix.`);
}
urlToMxc.set(asset.url, response.content_uri);
assets[asset.role] = response.content_uri;
}
const stored: StoredCollectible = {
sku_id: item.skuId,
name: item.name,
assets,
};
if (item.effect) {
stored.effect = replaceCdnUrls(item.effect, urlToMxc);
}
return stored;
}
export function collectibleKindLabel(kind: CollectibleKind): string {
switch (kind) {
case 'profile_effect':
return 'Profile effect';
case 'nameplate':
return 'Nameplate';
case 'avatar_decoration':
return 'Avatar decoration';
default:
return kind;
}
}
export type CollectibleVariantGroup = {
id: string;
name: string;
category: string;
items: DiscordCollectibleItem[];
};
function stripBundleSuffix(name: string): string {
return name.replace(/\s+Bundle$/i, '').trim();
}
function stripVariantSuffix(name: string): string {
return name.replace(/\s*\([^)]+\)\s*$/, '').trim();
}
export function variantGroupKey(item: DiscordCollectibleItem): string {
const baseName = stripVariantSuffix(stripBundleSuffix(item.name));
return `${item.type}:${baseName}`;
}
export function variantGroupDisplayName(item: DiscordCollectibleItem): string {
return stripVariantSuffix(stripBundleSuffix(item.name));
}
export function variantItemLabel(item: DiscordCollectibleItem): string {
if (item.paletteLabel) return item.paletteLabel;
const nameMatch = item.name.match(/\(([^)]+)\)\s*$/);
if (nameMatch) return nameMatch[1];
const labelMatch = item.label.match(/\(([^)]+)\)\s*$/);
if (labelMatch) return labelMatch[1];
if (item.label && item.label !== item.name) return item.label;
return 'Default';
}
function dedupeVariantItems(items: DiscordCollectibleItem[]): DiscordCollectibleItem[] {
const seen = new Set<string>();
return items.filter((item) => {
if (seen.has(item.skuId)) return false;
seen.add(item.skuId);
return true;
});
}
export function groupCollectibleItems(items: DiscordCollectibleItem[]): CollectibleVariantGroup[] {
const map = new Map<string, DiscordCollectibleItem[]>();
for (const item of items) {
const key = variantGroupKey(item);
const list = map.get(key);
if (list) list.push(item);
else map.set(key, [item]);
}
return [...map.entries()]
.map(([id, groupItems]) => {
const items = dedupeVariantItems(groupItems).sort((a, b) =>
variantItemLabel(a).localeCompare(variantItemLabel(b))
);
return {
id,
name: variantGroupDisplayName(items[0]),
category: items[0].category,
items,
};
})
.sort((a, b) => a.name.localeCompare(b.name));
}
export function defaultPreviewAspectRatio(kind: CollectibleKind): number {
switch (kind) {
case 'profile_effect':
return 450 / 880;
case 'nameplate':
return 448 / 84;
case 'avatar_decoration':
return 1;
default:
return 1;
}
}
export function gradientFromPreviewColors(colors?: number[]): string | undefined {
if (!colors?.length) return undefined;
const toRgb = (value: number) => {
const n = value >>> 0;
return `rgb(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255})`;
};
if (colors.length === 1) return toRgb(colors[0]);
return `linear-gradient(135deg, ${toRgb(colors[0])}, ${toRgb(colors[1])})`;
}

View File

@@ -0,0 +1,28 @@
/** Discord nameplate palette IDs from the API — mapped to representative gradient colors. */
const NAMEPLATE_PALETTE_GRADIENTS: Record<string, string> = {
crimson: 'linear-gradient(135deg, #5c0a1c, #dc143c)',
berry: 'linear-gradient(135deg, #4a1030, #c42d78)',
sky: 'linear-gradient(135deg, #0a2a5c, #3b8eed)',
teal: 'linear-gradient(135deg, #0a3d3d, #2dd4bf)',
forest: 'linear-gradient(135deg, #0a2e1a, #22c55e)',
bubble_gum: 'linear-gradient(135deg, #4a1038, #f472b6)',
violet: 'linear-gradient(135deg, #2d1050, #8b5cf6)',
cobalt: 'linear-gradient(135deg, #0a1448, #3b5bdb)',
clover: 'linear-gradient(135deg, #0a3d20, #4ade80)',
lemon: 'linear-gradient(135deg, #4a3d0a, #fbbf24)',
white: 'linear-gradient(135deg, #888888, #f0f0f0)',
black: 'linear-gradient(135deg, #1a1a1a, #404040)',
};
export function nameplatePaletteGradient(palette?: string): string | undefined {
if (!palette) return undefined;
return NAMEPLATE_PALETTE_GRADIENTS[palette];
}
export function formatNameplatePalette(palette?: string): string | undefined {
if (!palette) return undefined;
return palette
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}

View File

@@ -0,0 +1,297 @@
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';
/** Paarrot profile effect (animated profile background) */
export const PROFILE_KEY_PROFILE_EFFECT = 'im.paarrot.profile_effect';
/** Paarrot nameplate (username bar background) */
export const PROFILE_KEY_NAMEPLATE = 'im.paarrot.nameplate';
/** Paarrot avatar decoration (avatar frame) */
export const PROFILE_KEY_AVATAR_DECORATION = 'im.paarrot.avatar_decoration';
export type StoredCollectible = {
sku_id: string;
name: string;
assets: Record<string, string>;
effect?: unknown;
};
export type CollectibleProfileFields = {
profile_effect?: StoredCollectible;
nameplate?: StoredCollectible;
avatar_decoration?: StoredCollectible;
};
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;
}
function parseStoredCollectible(value: unknown): StoredCollectible | undefined {
if (!value || typeof value !== 'object') return undefined;
const obj = value as Record<string, unknown>;
const sku_id = typeof obj.sku_id === 'string' ? obj.sku_id : undefined;
const name = typeof obj.name === 'string' ? obj.name : undefined;
const assets = obj.assets;
if (!sku_id || !name || !assets || typeof assets !== 'object') return undefined;
const parsedAssets: Record<string, string> = {};
for (const [key, mxc] of Object.entries(assets as Record<string, unknown>)) {
if (typeof mxc === 'string' && mxc.startsWith('mxc://')) {
parsedAssets[key] = mxc;
}
}
if (Object.keys(parsedAssets).length === 0) return undefined;
return {
sku_id,
name,
assets: parsedAssets,
effect: obj.effect,
};
}
export function extractCollectiblesFromProfile(profile: Record<string, unknown>): CollectibleProfileFields {
return {
profile_effect: parseStoredCollectible(profile[PROFILE_KEY_PROFILE_EFFECT]),
nameplate: parseStoredCollectible(profile[PROFILE_KEY_NAMEPLATE]),
avatar_decoration: parseStoredCollectible(profile[PROFILE_KEY_AVATAR_DECORATION]),
};
}
async function loadCollectiblesFromProfile(mx: MatrixClient, userId: string): Promise<CollectibleProfileFields> {
if (await mx.doesServerSupportExtendedProfiles()) {
try {
const profile = await mx.getExtendedProfile(userId);
return extractCollectiblesFromProfile(profile);
} catch {
// fall through
}
}
try {
const profile = (await mx.getProfileInfo(userId)) as Record<string, unknown>;
return extractCollectiblesFromProfile(profile);
} catch {
return {};
}
}
export async function loadUserCollectibles(mx: MatrixClient, userId: string): Promise<CollectibleProfileFields> {
return loadCollectiblesFromProfile(mx, userId);
}
export async function saveCollectible(
mx: MatrixClient,
key: string,
collectible: StoredCollectible | undefined
): Promise<void> {
if (!(await mx.doesServerSupportExtendedProfiles())) {
throw new Error('Server does not support extended profile fields (MSC4133)');
}
if (!collectible) {
await mx.deleteExtendedProfileProperty(key);
return;
}
await mx.setExtendedProfileProperty(key, collectible);
}
export async function saveProfileEffect(mx: MatrixClient, collectible: StoredCollectible | undefined): Promise<void> {
await saveCollectible(mx, PROFILE_KEY_PROFILE_EFFECT, collectible);
}
export async function saveNameplate(mx: MatrixClient, collectible: StoredCollectible | undefined): Promise<void> {
await saveCollectible(mx, PROFILE_KEY_NAMEPLATE, collectible);
}
export async function saveAvatarDecoration(mx: MatrixClient, collectible: StoredCollectible | undefined): Promise<void> {
await saveCollectible(mx, PROFILE_KEY_AVATAR_DECORATION, collectible);
}
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 || typeof (room as Room).getMember !== 'function' || !userId) 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

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

36
src/ext.d.ts vendored
View File

@@ -56,6 +56,18 @@ interface ElectronAPI {
clipboard?: { clipboard?: {
writeText: (text: string) => void; writeText: (text: string) => void;
}; };
media?: {
saveFile: (payload: {
filename: string;
mimeType?: string;
data: Uint8Array;
}) => Promise<{
success?: boolean;
canceled?: boolean;
path?: string;
error?: string;
}>;
};
plugins?: PluginAPI; plugins?: PluginAPI;
protocol?: { protocol?: {
getStatus: () => Promise<{ getStatus: () => Promise<{
@@ -95,6 +107,30 @@ interface ElectronAPI {
error?: string; error?: string;
}>; }>;
}; };
updater?: {
onUpdateDownloaded: (callback: (info: {
version: string;
releaseNotes?: unknown;
}) => void) => void;
};
discordCollectibles?: {
fetchCatalog: (force?: boolean) => Promise<{
success: boolean;
data?: { items: unknown[]; fetchedAt: string };
error?: string;
}>;
downloadAssets: (assets: Array<{ role: string; url: string; filename: string; mimeType: string }>) => Promise<{
success: boolean;
data?: Array<{
role: string;
url: string;
filename: string;
mimeType: string;
data: Uint8Array;
}>;
error?: string;
}>;
};
} }
declare global { declare global {

View File

@@ -147,6 +147,27 @@ body.stationery-dark-theme {
background-color: #262626; background-color: #262626;
} }
/*
* Folds Overlay / PopOut portals mount here. The container must not steal taps;
* only its children should. Empty portal shells (e.g. closed overlays) must not
* block dialogs portaled elsewhere.
*/
#portalContainer {
position: fixed;
inset: 0;
z-index: 9998;
pointer-events: none;
overflow: hidden;
}
#portalContainer > * {
pointer-events: auto;
}
#portalContainer > *:empty {
pointer-events: none !important;
}
.twilight-theme #root { .twilight-theme #root {
background: linear-gradient(180deg, rgba(28, 26, 46, 0.5) 0%, rgba(36, 34, 61, 0.3) 100%); background: linear-gradient(180deg, rgba(28, 26, 46, 0.5) 0%, rgba(36, 34, 61, 0.3) 100%);
} }

View File

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

View File

@@ -0,0 +1,101 @@
import path from 'path';
import { transformAsync } from '@babel/core';
import vanillaBabelPlugin from '@vanilla-extract/babel-plugin-debug-ids';
import typescriptSyntax from '@babel/plugin-syntax-typescript';
const CSS_TS_FILTER = /\.css\.(js|cjs|mjs|jsx|ts|tsx)(\?.*)?$/;
/** Slug for vanilla-extract class names (letters, digits, _, -). */
function sanitizeIdentifierPart(value) {
return String(value)
.replace(/\s/g, '_')
.replace(/[^a-zA-Z0-9_-]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
}
function fileScopeSlug(filePath, projectRoot, packageName) {
const absolute = path.isAbsolute(filePath) ? filePath : path.join(projectRoot, filePath);
const normalized = absolute.replace(/\\/g, '/');
const srcRoot = path.join(projectRoot, 'src').replace(/\\/g, '/');
let rel;
if (normalized.startsWith(srcRoot)) {
rel = path.relative(path.join(projectRoot, 'src'), absolute);
} else if (normalized.includes('/src/')) {
rel = normalized.split('/src/').pop();
} else if (packageName) {
rel = path.join(packageName, path.basename(absolute));
} else {
rel = path.relative(projectRoot, absolute);
}
return sanitizeIdentifierPart(
String(rel)
.replace(/\\/g, '/')
.replace(/\.css\.(ts|tsx|js|cjs|mjs|jsx)$/i, '')
.replace(/\//g, '_')
);
}
function finalizeIdentifier(parts) {
let name = parts.filter(Boolean).join('_');
if (!name) {
name = 've_style';
}
if (/^[0-9]/.test(name)) {
name = `_${name}`;
}
if (!/^[A-Z_][0-9A-Z_-]+$/i.test(name)) {
name = `ve_${name}`;
}
return name;
}
/**
* Human-readable vanilla-extract class names without hash suffixes.
* Example: app_components_updates_dialog_UpdatesDialog_Root
*/
export function createReadableVanillaExtractIdentifiers(projectRoot) {
return function readableVanillaExtractIdentifier({ debugId, filePath, packageName, hash }) {
const scope = fileScopeSlug(filePath, projectRoot, packageName);
const exportName = debugId ? sanitizeIdentifierPart(debugId) : '';
if (exportName) {
return finalizeIdentifier([scope, exportName]);
}
// Unnamed styles (rare): keep a short disambiguator from the scoped hash.
const suffix = sanitizeIdentifierPart(String(hash).replace(/^_/, ''));
return finalizeIdentifier([scope, suffix]);
};
}
/**
* Injects export names into style() calls so readable identifiers can use them.
* Required when identifiers is a function — vanilla-extract only runs this for identOption === 'debug'.
*/
export function vanillaExtractDebugIdsPlugin() {
return {
name: 'vanilla-extract-debug-ids',
enforce: 'pre',
async transform(code, id) {
if (!CSS_TS_FILTER.test(id)) {
return null;
}
const result = await transformAsync(code, {
filename: id,
plugins: [vanillaBabelPlugin, typescriptSyntax],
configFile: false,
babelrc: false,
});
if (!result?.code) {
return null;
}
return { code: result.code, map: result.map };
},
};
}

View File

@@ -11,6 +11,10 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import buildConfig from './build.config'; import buildConfig from './build.config';
import { liveTsxPlugin, readDefaultLiveSource } from './playground-liveTsxPlugin'; import { liveTsxPlugin, readDefaultLiveSource } from './playground-liveTsxPlugin';
import {
createReadableVanillaExtractIdentifiers,
vanillaExtractDebugIdsPlugin,
} from './vanillaExtractIdentifiers.js';
const projectRoot = path.resolve(); const projectRoot = path.resolve();
@@ -53,6 +57,12 @@ const copyFiles = {
src: 'public/font', src: 'public/font',
dest: 'public/', dest: 'public/',
}, },
{
src: 'public/update/**/*',
dest: 'update',
// stripBase removes public/update from the matched path; rename alone only changes the filename.
rename: { stripBase: 2 },
},
], ],
}; };
@@ -124,6 +134,37 @@ function servePublicAssets() {
res.setHeader('Content-Type', contentType); res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'no-cache'); 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); const fileStream = fs.createReadStream(resolvedPath);
fileStream.pipe(res); fileStream.pipe(res);
} else { } else {
@@ -215,7 +256,7 @@ function corsProxyMiddleware() {
}; };
} }
export default defineConfig({ export default defineConfig(() => ({
appType: 'spa', appType: 'spa',
publicDir: false, publicDir: false,
base: buildConfig.base, base: buildConfig.base,
@@ -267,7 +308,15 @@ export default defineConfig({
promiseImportName: (i) => `__tla_${i}`, promiseImportName: (i) => `__tla_${i}`,
}), }),
viteStaticCopy(copyFiles), viteStaticCopy(copyFiles),
vanillaExtractPlugin(), ...(process.env.VITE_VE_IDENTIFIERS === 'short' ? [] : [vanillaExtractDebugIdsPlugin()]),
vanillaExtractPlugin({
unstable_pluginFilter: ({ name }) =>
name === 'vite-tsconfig-paths' || name === 'vanilla-extract-debug-ids',
identifiers:
process.env.VITE_VE_IDENTIFIERS === 'short'
? 'short'
: createReadableVanillaExtractIdentifiers(projectRoot),
}),
wasm(), wasm(),
react(), react(),
VitePWA({ VitePWA({
@@ -322,4 +371,4 @@ export default defineConfig({
plugins: [inject({ Buffer: ['buffer', 'Buffer'] })], plugins: [inject({ Buffer: ['buffer', 'Buffer'] })],
}, },
}, },
}); }));