Compare commits
11 Commits
25be638c5e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 47979718eb | |||
| 862307ee7d | |||
| b16263b481 | |||
| c34cfaf33f | |||
| 87c9f59826 | |||
| d59945eb4c | |||
| 0cbc5d9a1c | |||
| 6cb1e14632 | |||
| 7d866404b3 | |||
| c264de53b5 | |||
| b6f3f5c0aa |
@@ -7,6 +7,9 @@
|
||||
"LocalNotifications": {
|
||||
"smallIcon": "ic_stat_paarrot",
|
||||
"iconColor": "#FF8A00"
|
||||
},
|
||||
"CapacitorHttp": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "node ../scripts/generate-update-manifest.mjs && vite build",
|
||||
"build": "node scripts/generate-update-manifest.mjs && vite build",
|
||||
"lint": "yarn check:eslint && yarn check:prettier",
|
||||
"check:eslint": "eslint src/*",
|
||||
"check:prettier": "prettier --check .",
|
||||
|
||||
22
public/update/4.11.177.md
Normal file
22
public/update/4.11.177.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
version: 4.11.177
|
||||
title: Profile colors & banners
|
||||
date: 2026-08-23
|
||||
summary: Username colors now follow MSC4522 with separate dark and light theme pickers.
|
||||
---
|
||||
|
||||
## Profile colors (MSC4522)
|
||||
|
||||
Username colors are now stored on your Matrix profile instead of inside avatar images. In turn **will need to be updated in your Account settings**, this is now cross compatible with Element + Cinny
|
||||
|
||||
- Set **separate colors for dark and light themes** in Settings → Account → Profile.
|
||||
- Per-room username overrides are read from member profile data when available.
|
||||
|
||||

|
||||
|
||||
## Profile banners (MSC4133)
|
||||
|
||||
Profile banners now live on your Matrix profile via `m.banner_url` (MSC4133), not embedded in avatar metadata.
|
||||
|
||||
- Upload or remove a banner independently from your avatar.
|
||||
- Other clients that support MSC4133 can read your banner directly.
|
||||
@@ -1,22 +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.
|
||||
version: 4.11.185
|
||||
title: Discord collectibles
|
||||
date: 2026-08-24
|
||||
summary: Add Discord-style profile effects, nameplates, and avatar decorations to Paarrot profiles.
|
||||
---
|
||||
|
||||
## Profile colors (MSC4522)
|
||||
## Discord collectibles
|
||||
|
||||
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
|
||||
- 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.
|
||||
|
||||
- Set **separate colors for dark and light themes** in Settings → Account → Profile.
|
||||
- Per-room username overrides are read from member profile data when available.
|
||||
|
||||

|
||||
 
|
||||
|
||||
## Profile banners (MSC4133)
|
||||
## Profile presentation
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
BIN
public/update/images/discordcosmetics1.png
Normal file
BIN
public/update/images/discordcosmetics1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
public/update/images/discordcosmetics2.png
Normal file
BIN
public/update/images/discordcosmetics2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
114
scripts/generate-update-manifest.mjs
Normal file
114
scripts/generate-update-manifest.mjs
Normal file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Regenerates public/update/manifest.json from markdown files.
|
||||
*
|
||||
* - currentupdate.md is always the "current" entry
|
||||
* - Other *.md files matching X.Y.Z.md become "older" (sorted newest first)
|
||||
* - title, date, version come from YAML frontmatter when present
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const UPDATE_DIR = path.join(ROOT, 'public', 'update');
|
||||
const MANIFEST_PATH = path.join(UPDATE_DIR, 'manifest.json');
|
||||
const CURRENT_FILE = 'currentupdate.md';
|
||||
const VERSION_FILE_RE = /^(\d+\.\d+\.\d+)\.md$/;
|
||||
|
||||
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
||||
|
||||
function parseFrontmatter(source) {
|
||||
const match = FRONTMATTER_RE.exec(source.trimStart());
|
||||
if (!match) return {};
|
||||
|
||||
const frontmatter = {};
|
||||
match[1].split('\n').forEach((line) => {
|
||||
const colon = line.indexOf(':');
|
||||
if (colon <= 0) return;
|
||||
const key = line.slice(0, colon).trim();
|
||||
const raw = line.slice(colon + 1).trim();
|
||||
frontmatter[key] = raw.replace(/^['"]|['"]$/g, '');
|
||||
});
|
||||
return frontmatter;
|
||||
}
|
||||
|
||||
function compareVersions(a, b) {
|
||||
const pa = a.split('.').map((part) => Number.parseInt(part, 10));
|
||||
const pb = b.split('.').map((part) => Number.parseInt(part, 10));
|
||||
const len = Math.max(pa.length, pb.length);
|
||||
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
const na = pa[i] ?? 0;
|
||||
const nb = pb[i] ?? 0;
|
||||
if (na !== nb) return nb - na;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function readMarkdownMeta(fileName) {
|
||||
const filePath = path.join(UPDATE_DIR, fileName);
|
||||
const source = fs.readFileSync(filePath, 'utf8');
|
||||
const frontmatter = parseFrontmatter(source);
|
||||
const versionFromName = VERSION_FILE_RE.exec(fileName)?.[1];
|
||||
|
||||
return {
|
||||
file: fileName,
|
||||
version: frontmatter.version ?? versionFromName,
|
||||
title: frontmatter.title,
|
||||
date: frontmatter.date,
|
||||
};
|
||||
}
|
||||
|
||||
function buildManifest() {
|
||||
if (!fs.existsSync(UPDATE_DIR)) {
|
||||
throw new Error(`Update directory not found: ${UPDATE_DIR}`);
|
||||
}
|
||||
|
||||
const currentPath = path.join(UPDATE_DIR, CURRENT_FILE);
|
||||
if (!fs.existsSync(currentPath)) {
|
||||
throw new Error(`Missing ${CURRENT_FILE} in ${UPDATE_DIR}`);
|
||||
}
|
||||
|
||||
const older = fs
|
||||
.readdirSync(UPDATE_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && VERSION_FILE_RE.test(entry.name))
|
||||
.map((entry) => readMarkdownMeta(entry.name))
|
||||
.filter((entry) => entry.version)
|
||||
.sort((a, b) => compareVersions(a.version, b.version));
|
||||
|
||||
return {
|
||||
current: CURRENT_FILE,
|
||||
older: older.map(({ file, version, title, date }) => {
|
||||
const item = { file };
|
||||
if (version) item.version = version;
|
||||
if (title) item.title = title;
|
||||
if (date) item.date = date;
|
||||
return item;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const manifest = buildManifest();
|
||||
const json = `${JSON.stringify(manifest, null, 2)}\n`;
|
||||
|
||||
let previous = null;
|
||||
if (fs.existsSync(MANIFEST_PATH)) {
|
||||
previous = fs.readFileSync(MANIFEST_PATH, 'utf8');
|
||||
}
|
||||
|
||||
if (previous === json) {
|
||||
console.log('[update-manifest] manifest.json is already up to date');
|
||||
return;
|
||||
}
|
||||
|
||||
fs.writeFileSync(MANIFEST_PATH, json, 'utf8');
|
||||
console.log(
|
||||
`[update-manifest] wrote manifest.json (${manifest.older.length} older version(s))`
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { ReactNode, useCallback, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Modal, Overlay, OverlayBackdrop, OverlayCenter, PopOutContainerProvider } from 'folds';
|
||||
import { stopPropagation } from '../utils/keyboard';
|
||||
import { releaseNotesDialogAtom } from '../state/releaseNotes';
|
||||
|
||||
type Modal500Props = {
|
||||
requestClose: () => void;
|
||||
@@ -10,11 +12,13 @@ type Modal500Props = {
|
||||
export function Modal500({ requestClose, children }: Modal500Props) {
|
||||
const [modalEl, setModalEl] = useState<HTMLDivElement | null>(null);
|
||||
const modalRef = useCallback((el: HTMLDivElement | null) => setModalEl(el), []);
|
||||
const releaseNotesOpen = useAtomValue(releaseNotesDialogAtom).open;
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
active={!releaseNotesOpen}
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
clickOutsideDeactivates: true,
|
||||
|
||||
@@ -34,7 +34,7 @@ function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState })
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu style={{ width: toRem(340) }}>
|
||||
<Menu style={{ width: toRem(260) }}>
|
||||
<SpaceProvider value={space ?? null}>
|
||||
<RoomProvider value={room}>
|
||||
<UserRoomProfile userId={userId} />
|
||||
|
||||
@@ -262,7 +262,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
||||
<AttachmentBox
|
||||
style={{
|
||||
width: toRem(width),
|
||||
height: toRem(height),
|
||||
aspectRatio: `${width} / ${height}`,
|
||||
['--media-h' as string]: `${height}px`,
|
||||
}}
|
||||
data-paarrot-media-height={height}
|
||||
|
||||
@@ -8,7 +8,7 @@ export const Attachment = recipe({
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
borderRadius: config.radii.R400,
|
||||
overflow: 'hidden',
|
||||
maxWidth: toRem(400),
|
||||
maxWidth: `min(100%, ${toRem(400)})`,
|
||||
},
|
||||
variants: {
|
||||
outlined: {
|
||||
@@ -33,7 +33,7 @@ export const AttachmentHeader = style({
|
||||
export const AttachmentBox = style([
|
||||
DefaultReset,
|
||||
{
|
||||
maxWidth: toRem(400),
|
||||
maxWidth: `min(100%, ${toRem(400)})`,
|
||||
maxHeight: toRem(400),
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ type PageRootProps = {
|
||||
export function PageRoot({ nav, children }: PageRootProps) {
|
||||
const screenSize = useScreenSizeContext();
|
||||
const showCompactMaster = useShowCompactMasterView();
|
||||
const showDetail = !showCompactMaster || nav == null;
|
||||
|
||||
return (
|
||||
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
|
||||
@@ -23,7 +24,7 @@ export function PageRoot({ nav, children }: PageRootProps) {
|
||||
{screenSize !== ScreenSize.Mobile && (
|
||||
<Line variant="Background" size="300" direction="Vertical" />
|
||||
)}
|
||||
{!showCompactMaster && children}
|
||||
{showDetail && children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ type AvatarPresenceProps = {
|
||||
export const AvatarPresence = as<'div', AvatarPresenceProps>(
|
||||
({ as: AsAvatarPresence, badge, variant = 'Surface', badgeBackgroundColor, children, ...props }, ref) => (
|
||||
<Box as={AsAvatarPresence} className={css.AvatarPresence} {...props} ref={ref}>
|
||||
{children}
|
||||
{badge && (
|
||||
<div
|
||||
className={css.AvatarPresenceBadge}
|
||||
@@ -75,7 +76,6 @@ export const AvatarPresence = as<'div', AvatarPresenceProps>(
|
||||
{badge}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ export const AvatarPresenceBadge = style({
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
transform: 'translate(25%, 25%)',
|
||||
zIndex: 1,
|
||||
zIndex: 10,
|
||||
|
||||
display: 'flex',
|
||||
padding: config.borderWidth.B600,
|
||||
|
||||
@@ -9,6 +9,8 @@ export const Sidebar = style([
|
||||
width: toRem(66),
|
||||
backgroundColor: color.Background.Container,
|
||||
borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
|
||||
position: 'relative',
|
||||
isolation: 'isolate',
|
||||
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -185,6 +187,46 @@ export const SidebarAvatar = recipe({
|
||||
});
|
||||
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({
|
||||
base: [
|
||||
ContainerColor({ variant: 'Background' }),
|
||||
|
||||
@@ -3,37 +3,64 @@ import { color, config, toRem } from 'folds';
|
||||
|
||||
const MOBILE_BREAKPOINT = '480px';
|
||||
|
||||
export const PortalLayer = style({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: config.zIndex.Max,
|
||||
});
|
||||
/** Above folds overlays (9999) and Settings modals. */
|
||||
const UPDATES_DIALOG_Z = 10001;
|
||||
|
||||
/** Dialog + overlay padding must never exceed the viewport height. */
|
||||
const DIALOG_MAX_HEIGHT =
|
||||
'calc(85vh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))';
|
||||
|
||||
export const OverlayFrame = style({
|
||||
/** [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',
|
||||
});
|
||||
|
||||
export const DialogShell = style({
|
||||
/** [data-updates-dialog-panel] */
|
||||
export const Panel = style({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
maxWidth: `min(${toRem(560)}, calc(100vw - ${config.space.S800}))`,
|
||||
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 - ${config.space.S400})`,
|
||||
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -102,7 +129,7 @@ export const BodyContent = style({
|
||||
});
|
||||
|
||||
export const BodyLoading = style({
|
||||
padding: config.space.S800,
|
||||
padding: config.space.S600,
|
||||
});
|
||||
|
||||
export const Markdown = style({
|
||||
@@ -165,7 +192,7 @@ globalStyle(`${Markdown} code`, {
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: toRem(13),
|
||||
padding: `0 ${config.space.S100}`,
|
||||
borderRadius: config.radii.R200,
|
||||
borderRadius: config.radii.R300,
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
});
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
config,
|
||||
Dialog,
|
||||
IconButton,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Scroll,
|
||||
Spinner,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import { 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';
|
||||
@@ -49,24 +37,34 @@ export function UpdatesDialog({
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className={css.PortalLayer}>
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter className={css.OverlayFrame}>
|
||||
<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,
|
||||
onDeactivate: onClose,
|
||||
clickOutsideDeactivates: true,
|
||||
clickOutsideDeactivates: false,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Dialog variant="Surface" className={css.DialogShell}>
|
||||
<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's new</Text>
|
||||
<Text size="H4" truncate>
|
||||
<Text id="updates-dialog-title" size="H4" truncate>
|
||||
{displayVersion ? `Paarrot ${displayVersion}` : 'Paarrot updates'}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -176,10 +174,9 @@ export function UpdatesDialog({
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
|
||||
@@ -26,7 +26,10 @@ type UpdaterInfo = {
|
||||
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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -112,5 +112,7 @@ export const TikTokEmbedContainer = style([
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#000',
|
||||
border: 'none',
|
||||
display: 'block',
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -42,6 +42,12 @@ export function isTikTokUrl(url: string): boolean {
|
||||
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
|
||||
* @param url The TikTok video URL
|
||||
@@ -53,6 +59,7 @@ async function fetchTikTokOEmbed(url: string): Promise<{
|
||||
authorUrl: string;
|
||||
thumbnailUrl: string;
|
||||
embedHtml: string;
|
||||
videoId: string | null;
|
||||
} | null> {
|
||||
try {
|
||||
const oembedUrl = `${TIKTOK_OEMBED_API}?url=${encodeURIComponent(url)}`;
|
||||
@@ -67,6 +74,7 @@ async function fetchTikTokOEmbed(url: string): Promise<{
|
||||
authorUrl: data.author_url || url,
|
||||
thumbnailUrl: data.thumbnail_url || '',
|
||||
embedHtml: data.html || '',
|
||||
videoId: extractTikTokVideoId(url, data.html || ''),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -75,7 +83,15 @@ async function fetchTikTokOEmbed(url: string): Promise<{
|
||||
|
||||
type TikTokEmbedState =
|
||||
| { 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' };
|
||||
|
||||
type TikTokEmbedProps = {
|
||||
@@ -88,7 +104,7 @@ type TikTokEmbedProps = {
|
||||
*/
|
||||
export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref) => {
|
||||
const [state, setState] = useState<TikTokEmbedState>({ status: 'loading' });
|
||||
const [showEmbed, setShowEmbed] = useState(false);
|
||||
const [showPlayer, setShowPlayer] = useState(false);
|
||||
|
||||
// Fetch video info on mount
|
||||
useEffect(() => {
|
||||
@@ -105,6 +121,7 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
|
||||
authorUrl: info.authorUrl,
|
||||
thumbnailUrl: info.thumbnailUrl,
|
||||
embedHtml: info.embedHtml,
|
||||
videoId: info.videoId,
|
||||
});
|
||||
} else {
|
||||
setState({ status: 'error' });
|
||||
@@ -169,7 +186,7 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
|
||||
}
|
||||
|
||||
// Loaded state - show thumbnail or embed
|
||||
if (!showEmbed) {
|
||||
if (!showPlayer) {
|
||||
return (
|
||||
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
|
||||
<div className={css.TikTokEmbedHeader}>
|
||||
@@ -203,23 +220,27 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
|
||||
<button
|
||||
type="button"
|
||||
className={css.TikTokThumbnailButton}
|
||||
onClick={() => setShowEmbed(true)}
|
||||
aria-label="Load TikTok video"
|
||||
onClick={() => setShowPlayer(true)}
|
||||
aria-label="Play TikTok video"
|
||||
>
|
||||
{state.thumbnailUrl && (
|
||||
{state.thumbnailUrl ? (
|
||||
<img
|
||||
src={state.thumbnailUrl}
|
||||
alt={state.title}
|
||||
className={css.TikTokThumbnail}
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</button>
|
||||
</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 (
|
||||
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
|
||||
<div className={css.TikTokEmbedHeader}>
|
||||
@@ -236,10 +257,20 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
|
||||
{state.title}
|
||||
</Text>
|
||||
</div>
|
||||
{playerUrl ? (
|
||||
<iframe
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
77
src/app/components/user-profile/ProfileEffectMedia.tsx
Normal file
77
src/app/components/user-profile/ProfileEffectMedia.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { ReactNode, useState } from 'react';
|
||||
import { Avatar, Box, Overlay, Text, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../icons';
|
||||
import classNames from 'classnames';
|
||||
@@ -14,22 +14,36 @@ import { ImageViewer } from '../image-viewer';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { useOtherUserColor } from '../../hooks/useUserColor';
|
||||
import { useOtherUserBanner } from '../../hooks/useUserBanner';
|
||||
import { useOtherUserCollectibles } from '../../hooks/useUserCollectibles';
|
||||
import { pickAvatarDecorationUrl } from '../../utils/collectibleAssets';
|
||||
import * as avatarDecorationCss from '../../styles/AvatarDecoration.css';
|
||||
import { ProfileCollectibleOverlays } from './ProfileCollectibleOverlays';
|
||||
|
||||
type UserHeroProps = {
|
||||
userId: string;
|
||||
avatarUrl?: string;
|
||||
avatarMxc?: string;
|
||||
presence?: UserPresence;
|
||||
children?: ReactNode;
|
||||
};
|
||||
export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroProps) {
|
||||
export function UserHero({ userId, avatarUrl, avatarMxc, presence, children }: UserHeroProps) {
|
||||
const [viewAvatar, setViewAvatar] = useState<string>();
|
||||
const bannerUrl = useOtherUserBanner(userId);
|
||||
const { assetUrls } = useOtherUserCollectibles(userId);
|
||||
const avatarDecorationUrl = pickAvatarDecorationUrl(assetUrls);
|
||||
|
||||
return (
|
||||
<Box
|
||||
direction="Column"
|
||||
className={css.UserHero}
|
||||
>
|
||||
<Box direction="Column" className={css.UserHeroZone}>
|
||||
{bannerUrl && (
|
||||
<div className={css.UserHeroBannerReflection} aria-hidden="true">
|
||||
<img
|
||||
className={css.UserHeroBannerReflectionImg}
|
||||
src={bannerUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={css.UserHeroCoverContainer}
|
||||
style={{
|
||||
@@ -47,15 +61,22 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
|
||||
</div>
|
||||
<div className={css.UserHeroAvatarContainer}>
|
||||
<AvatarPresence
|
||||
className={css.UserAvatarContainer}
|
||||
className={classNames(
|
||||
css.UserAvatarContainer,
|
||||
!avatarUrl && css.UserAvatarContainerFallback
|
||||
)}
|
||||
badge={
|
||||
presence && <PresenceBadge presence={presence.presence} status={presence.status} />
|
||||
}
|
||||
>
|
||||
<div className={css.UserHeroAvatarStack}>
|
||||
<Avatar
|
||||
as={avatarUrl ? 'button' : 'div'}
|
||||
onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined}
|
||||
className={css.UserHeroAvatar}
|
||||
className={classNames(
|
||||
css.UserHeroAvatar,
|
||||
avatarUrl ? css.UserHeroAvatarWithImage : css.UserHeroAvatarBorder
|
||||
)}
|
||||
size="500"
|
||||
style={{
|
||||
width: toRem(72),
|
||||
@@ -70,6 +91,15 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
|
||||
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
{avatarDecorationUrl && (
|
||||
<img
|
||||
className={avatarDecorationCss.AvatarDecorationOverlay}
|
||||
src={avatarDecorationUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AvatarPresence>
|
||||
{viewAvatar && (
|
||||
<Overlay open backdrop={null}>
|
||||
@@ -90,6 +120,12 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
|
||||
</Overlay>
|
||||
)}
|
||||
</div>
|
||||
{children && (
|
||||
<Box direction="Column" className={css.UserHeroInfo}>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
<ProfileCollectibleOverlays assetUrls={assetUrls} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Box, Button, color, config, Text, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../icons';
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { UserHero, UserHeroName } from './UserHero';
|
||||
import { UserHero } from './UserHero';
|
||||
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
@@ -94,7 +94,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
||||
avatarUrl={avatarUrl}
|
||||
avatarMxc={avatarMxc}
|
||||
presence={presence && presence.lastActiveTs !== 0 ? presence : undefined}
|
||||
/>
|
||||
>
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="200"
|
||||
@@ -103,19 +103,19 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
||||
padding: config.space.S400,
|
||||
paddingTop: `calc(${config.space.S200} + ${toRem(36)})`,
|
||||
marginTop: toRem(-36),
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Display Name */}
|
||||
<Text
|
||||
size="H4"
|
||||
className={classNames(BreakWord, LineClamp3)}
|
||||
style={{ color: getMemberDisplayName(room, userId) !== userId ? profileColor : undefined, textShadow: profileTextShadow }}
|
||||
style={{
|
||||
color: getMemberDisplayName(room, userId) !== userId ? profileColor : undefined,
|
||||
textShadow: profileTextShadow,
|
||||
}}
|
||||
>
|
||||
{getMemberDisplayName(room, userId)}
|
||||
</Text>
|
||||
|
||||
{/* Username */}
|
||||
<Text
|
||||
size="T200"
|
||||
className={BreakWord}
|
||||
@@ -124,7 +124,6 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
||||
{userId}
|
||||
</Text>
|
||||
|
||||
{/* Status Pill */}
|
||||
{presence?.status && (
|
||||
<Box
|
||||
style={{
|
||||
@@ -140,7 +139,10 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</UserHero>
|
||||
|
||||
<Box direction="Column" gap="200" alignItems="Center" style={{ padding: config.space.S400, textAlign: 'center' }}>
|
||||
{/* Chips Row */}
|
||||
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
|
||||
{server && <ServerChip server={server} />}
|
||||
@@ -170,7 +172,8 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{hasBottomContent && <Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
|
||||
{hasBottomContent && (
|
||||
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
|
||||
{ignored && <IgnoredUserAlert />}
|
||||
{member && membership === Membership.Ban && (
|
||||
<UserBanAlert
|
||||
@@ -206,7 +209,8 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
||||
canKick={canKickUser && membership === Membership.Join}
|
||||
canBan={canBanUser && membership !== Membership.Ban}
|
||||
/>
|
||||
</Box>}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
export const UserHeader = style({
|
||||
@@ -10,14 +10,26 @@ export const UserHeader = style({
|
||||
padding: config.space.S200,
|
||||
});
|
||||
|
||||
export const UserHero = style({
|
||||
export const UserHeroZone = style({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
isolation: 'isolate',
|
||||
});
|
||||
|
||||
export const UserHeroCollectibleLayer = style({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none',
|
||||
overflow: 'visible',
|
||||
});
|
||||
|
||||
export const UserHeroCoverContainer = style({
|
||||
position: 'relative',
|
||||
height: toRem(140),
|
||||
overflow: 'hidden',
|
||||
zIndex: 1,
|
||||
});
|
||||
export const UserHeroCover = style({
|
||||
height: '100%',
|
||||
@@ -36,12 +48,68 @@ export const UserHeroBanner = style({
|
||||
export const UserHeroAvatarContainer = style({
|
||||
position: 'relative',
|
||||
height: toRem(29),
|
||||
zIndex: 4,
|
||||
overflow: 'visible',
|
||||
});
|
||||
|
||||
export const UserHeroBannerReflection = style({
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: toRem(140),
|
||||
bottom: 0,
|
||||
overflow: 'hidden',
|
||||
zIndex: 0,
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
|
||||
export const UserHeroBannerReflectionImg = style({
|
||||
position: 'absolute',
|
||||
top: toRem(12),
|
||||
left: '-10%',
|
||||
width: '120%',
|
||||
height: toRem(160),
|
||||
objectFit: 'cover',
|
||||
transformOrigin: 'top center',
|
||||
transform: 'scaleY(-1) scale(1.2)',
|
||||
filter: 'blur(48px) saturate(1.15)',
|
||||
});
|
||||
|
||||
export const UserHeroInfo = style({
|
||||
position: 'relative',
|
||||
zIndex: 4,
|
||||
textAlign: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
});
|
||||
|
||||
globalStyle(`${UserHeroBannerReflection}::after`, {
|
||||
content: '',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: toRem(72),
|
||||
background: `linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, ${color.Surface.Container} 55%, transparent) 55%,
|
||||
${color.Surface.Container} 100%
|
||||
)`,
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
|
||||
export const UserAvatarContainer = style({
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: 0,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
|
||||
export const UserAvatarContainerFallback = style({
|
||||
backgroundColor: color.Surface.Container,
|
||||
});
|
||||
|
||||
@@ -53,14 +121,31 @@ export const UserStatusBubble = style({
|
||||
zIndex: 2,
|
||||
});
|
||||
|
||||
export const UserHeroAvatarStack = style({
|
||||
position: 'relative',
|
||||
width: toRem(72),
|
||||
height: toRem(72),
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const UserHeroAvatar = style({
|
||||
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
|
||||
selectors: {
|
||||
'button&': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const UserHeroAvatarBorder = style({
|
||||
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
|
||||
border: 'none',
|
||||
});
|
||||
|
||||
export const UserHeroAvatarWithImage = style({
|
||||
outline: 'none',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
});
|
||||
export const UserHeroAvatarImg = style({
|
||||
selectors: {
|
||||
[`button${UserHeroAvatar}:hover &`]: {
|
||||
@@ -68,3 +153,15 @@ export const UserHeroAvatarImg = style({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ProfileEffectOverlay = style({
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: '100%',
|
||||
aspectRatio: '450 / 880',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'top center',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
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 =
|
||||
@@ -64,27 +76,66 @@ function parseUpdateMarkdown(file: string, source: string): ParsedUpdateDoc {
|
||||
};
|
||||
}
|
||||
|
||||
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:)/i.test(src)) return src;
|
||||
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;
|
||||
|
||||
const normalized = src.replace(/^\.\//, '');
|
||||
return new URL(normalized, `${getUpdateBaseUrl()}/`).href;
|
||||
}
|
||||
|
||||
export async function loadUpdateManifest(): Promise<UpdateManifest | null> {
|
||||
try {
|
||||
const response = await fetch(getUpdateFileUrl('manifest.json'), { cache: 'no-cache' });
|
||||
if (!response.ok) return null;
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as UpdateManifest;
|
||||
if (!data?.current || !Array.isArray(data.older)) return null;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -96,13 +147,15 @@ export async function loadUpdateDocument(file: string): Promise<ParsedUpdateDoc
|
||||
|
||||
try {
|
||||
const response = await fetch(getUpdateFileUrl(safeFile), { cache: 'no-cache' });
|
||||
if (!response.ok) return null;
|
||||
|
||||
if (response.ok) {
|
||||
const source = await response.text();
|
||||
return parseUpdateMarkdown(safeFile, source);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
// fall through to bundled copy
|
||||
}
|
||||
|
||||
return loadBundledDocument(safeFile);
|
||||
}
|
||||
|
||||
export async function loadCurrentUpdateDocument(): Promise<ParsedUpdateDoc | null> {
|
||||
|
||||
@@ -29,7 +29,26 @@ export const TimelineFloat = recipe({
|
||||
},
|
||||
});
|
||||
|
||||
export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>;
|
||||
export const DmNameplateBackground = style({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const DmNameplateBackgroundMedia = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 'auto',
|
||||
width: toRem(280),
|
||||
maxWidth: '70%',
|
||||
height: toRem(64),
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'right top',
|
||||
opacity: 0.12,
|
||||
});
|
||||
|
||||
export const CarouselScroller = style([
|
||||
DefaultReset,
|
||||
|
||||
@@ -117,6 +117,8 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||
import { useOtherUserCollectibles } from '../../hooks/useUserCollectibles';
|
||||
import { pickNameplateUrl } from '../../utils/collectibleAssets';
|
||||
import { setupCopyHandler } from '../../utils/copyHandler';
|
||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
@@ -570,6 +572,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||
const direct = useIsDirectRoom();
|
||||
const dmUserId = direct ? room.guessDMUserId() ?? undefined : undefined;
|
||||
const [timelineHovered, setTimelineHovered] = useState(false);
|
||||
const { assetUrls: dmAssetUrls } = useOtherUserCollectibles(dmUserId ?? '');
|
||||
const dmNameplateUrl = pickNameplateUrl(dmAssetUrls);
|
||||
const dmNameplateIsVideo = Boolean(
|
||||
dmAssetUrls['nameplate:animated'] || dmAssetUrls['nameplate:asset.webm']
|
||||
);
|
||||
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
||||
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||
@@ -2530,7 +2539,33 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const atLiveBottom = atBottom && liveTimelineLinked && rangeAtEnd;
|
||||
|
||||
return (
|
||||
<Box grow="Yes" style={{ position: 'relative' }}>
|
||||
<Box
|
||||
grow="Yes"
|
||||
style={{ position: 'relative' }}
|
||||
onMouseEnter={() => setTimelineHovered(true)}
|
||||
onMouseLeave={() => setTimelineHovered(false)}
|
||||
>
|
||||
{direct && timelineHovered && dmNameplateUrl && (
|
||||
<div className={css.DmNameplateBackground} aria-hidden="true">
|
||||
{dmNameplateIsVideo ? (
|
||||
<video
|
||||
className={css.DmNameplateBackgroundMedia}
|
||||
src={dmNameplateUrl}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
className={css.DmNameplateBackgroundMedia}
|
||||
src={dmNameplateUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll="">
|
||||
<Box
|
||||
ref={timelineContentRef}
|
||||
|
||||
@@ -62,6 +62,10 @@ import colorMXID from '../../../../util/colorMXID';
|
||||
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
||||
import { Presence, useUserPresence } from '../../../hooks/useUserPresence';
|
||||
import { useOtherUserColor } from '../../../hooks/useUserColor';
|
||||
import { useIsDirectRoom } from '../../../hooks/useRoom';
|
||||
import { useOtherUserCollectibles } from '../../../hooks/useUserCollectibles';
|
||||
import { pickAvatarDecorationUrl, pickNameplateUrl } from '../../../utils/collectibleAssets';
|
||||
import * as avatarDecorationCss from '../../../styles/AvatarDecoration.css';
|
||||
|
||||
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
|
||||
|
||||
@@ -754,8 +758,15 @@ export const Message = as<'div', MessageProps>(
|
||||
) => {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const direct = useIsDirectRoom();
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
const senderPresence = useUserPresence(senderId);
|
||||
const { assetUrls } = useOtherUserCollectibles(senderId);
|
||||
const nameplateUrl = pickNameplateUrl(assetUrls);
|
||||
const nameplateIsVideo = Boolean(
|
||||
assetUrls['nameplate:animated'] || assetUrls['nameplate:asset.webm']
|
||||
);
|
||||
const avatarDecorationUrl = pickAvatarDecorationUrl(assetUrls);
|
||||
|
||||
const [hover, setHover] = useState(false);
|
||||
const { hoverProps } = useHover({ onHoverChange: setHover });
|
||||
@@ -796,6 +807,7 @@ export const Message = as<'div', MessageProps>(
|
||||
|
||||
// Priority: custom user color > tag color (non-legacy) > colorMXID (legacy)
|
||||
const usernameColor = customUserColor ?? (legacyUsernameColor ? colorMXID(senderId) : tagColor);
|
||||
const showNameplate = direct && hover && Boolean(nameplateUrl);
|
||||
|
||||
const headerJSX = !collapse && (
|
||||
<Box
|
||||
@@ -824,7 +836,8 @@ export const Message = as<'div', MessageProps>(
|
||||
</Username>
|
||||
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
||||
</Box>
|
||||
<Box shrink="No" gap="100">
|
||||
<Box shrink="No" className={css.MessageNameplateAnchor}>
|
||||
<Box shrink="No" gap="100" alignItems="Center">
|
||||
{messageLayout === MessageLayout.Modern && hover && (
|
||||
<>
|
||||
<Text as="span" size="T200" priority="300">
|
||||
@@ -842,6 +855,23 @@ export const Message = as<'div', MessageProps>(
|
||||
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>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -857,8 +887,13 @@ export const Message = as<'div', MessageProps>(
|
||||
<AvatarBase
|
||||
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
|
||||
>
|
||||
<div className={css.MessageAvatarStack}>
|
||||
<Avatar
|
||||
className={classNames(css.MessageAvatar, presenceClass)}
|
||||
className={classNames(
|
||||
avatarDecorationUrl ? css.MessageAvatarCircular : css.MessageAvatar,
|
||||
!avatarDecorationUrl && presenceClass
|
||||
)}
|
||||
radii={avatarDecorationUrl ? 'Pill' : undefined}
|
||||
as="button"
|
||||
size="300"
|
||||
data-user-id={senderId}
|
||||
@@ -875,6 +910,15 @@ export const Message = as<'div', MessageProps>(
|
||||
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
{avatarDecorationUrl && (
|
||||
<img
|
||||
className={avatarDecorationCss.AvatarDecorationOverlay}
|
||||
src={avatarDecorationUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AvatarBase>
|
||||
);
|
||||
|
||||
@@ -953,6 +997,10 @@ export const Message = as<'div', MessageProps>(
|
||||
{...hoverProps}
|
||||
{...focusWithinProps}
|
||||
ref={ref}
|
||||
>
|
||||
<div
|
||||
className={css.MessageContentLayer}
|
||||
data-message-nameplate-readable={showNameplate ? '' : undefined}
|
||||
>
|
||||
{!edit && (hover || !!menuAnchor || !!emojiBoardAnchor) && (
|
||||
<div className={css.MessageOptionsBase}>
|
||||
@@ -1242,6 +1290,7 @@ export const Message = as<'div', MessageProps>(
|
||||
{msgContentJSX}
|
||||
</ModernLayout>
|
||||
)}
|
||||
</div>
|
||||
</MessageBase>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, config, toRem, color } from 'folds';
|
||||
import * as layoutCss from '../../../components/message/layout/layout.css';
|
||||
|
||||
const NAMEPLATE_READABLE_TEXT_OUTLINE = [
|
||||
`0 0 2px ${color.Surface.ContainerHover}`,
|
||||
`0 0 4px ${color.Surface.ContainerHover}`,
|
||||
`-1px -1px 0 ${color.Surface.ContainerHover}`,
|
||||
`1px -1px 0 ${color.Surface.ContainerHover}`,
|
||||
`-1px 1px 0 ${color.Surface.ContainerHover}`,
|
||||
`1px 1px 0 ${color.Surface.ContainerHover}`,
|
||||
`0 -1px 0 ${color.Surface.ContainerHover}`,
|
||||
`0 1px 0 ${color.Surface.ContainerHover}`,
|
||||
`-1px 0 0 ${color.Surface.ContainerHover}`,
|
||||
`1px 0 0 ${color.Surface.ContainerHover}`,
|
||||
].join(', ');
|
||||
|
||||
globalStyle(`[data-message-nameplate-readable] [data-message-header] button`, {
|
||||
textShadow: NAMEPLATE_READABLE_TEXT_OUTLINE,
|
||||
});
|
||||
|
||||
globalStyle(`[data-message-nameplate-readable] [data-message-header] time`, {
|
||||
textShadow: NAMEPLATE_READABLE_TEXT_OUTLINE,
|
||||
});
|
||||
|
||||
globalStyle(`[data-message-nameplate-readable] [data-message-header] span`, {
|
||||
textShadow: NAMEPLATE_READABLE_TEXT_OUTLINE,
|
||||
});
|
||||
|
||||
globalStyle(`[data-message-nameplate-readable] .${layoutCss.MessageTextBody.classNames.base}`, {
|
||||
textShadow: NAMEPLATE_READABLE_TEXT_OUTLINE,
|
||||
});
|
||||
|
||||
export const MessageBase = style({
|
||||
position: 'relative',
|
||||
@@ -51,6 +81,12 @@ export const MessageAvatar = style({
|
||||
},
|
||||
});
|
||||
|
||||
export const MessageAvatarCircular = style({
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
});
|
||||
|
||||
export const MessageAvatarOnline = style({
|
||||
'::before': {
|
||||
backgroundColor: '#38842b',
|
||||
@@ -69,8 +105,41 @@ export const MessageAvatarOffline = style({
|
||||
},
|
||||
});
|
||||
|
||||
export const MessageQuickReaction = style({
|
||||
minWidth: toRem(32),
|
||||
export const MessageAvatarStack = style({
|
||||
position: 'relative',
|
||||
display: 'inline-flex',
|
||||
lineHeight: 0,
|
||||
overflow: 'visible',
|
||||
});
|
||||
|
||||
export const MessageNameplateAnchor = style({
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const MessageNameplateWrap = style({
|
||||
position: 'absolute',
|
||||
top: `calc(100% + ${toRem(6)})`,
|
||||
right: 0,
|
||||
width: toRem(220),
|
||||
maxWidth: toRem(280),
|
||||
height: toRem(42),
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
});
|
||||
|
||||
export const MessageNameplateOverlay = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'right center',
|
||||
pointerEvents: 'none',
|
||||
borderRadius: toRem(6),
|
||||
});
|
||||
|
||||
export const MessageContentLayer = style({
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
});
|
||||
|
||||
export const MessageMenuGroup = style({
|
||||
|
||||
@@ -21,6 +21,9 @@ export function About({ requestClose }: AboutProps) {
|
||||
const [version, setVersion] = useState<string>('');
|
||||
const [protocolStatus, setProtocolStatus] = useState<string>('Checking desktop protocol integration...');
|
||||
const [protocolBusy, setProtocolBusy] = useState<boolean>(false);
|
||||
const [updatePreview, setUpdatePreview] = useState<{ title: string; description: string } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const formatProtocolStatus = useCallback((data: {
|
||||
scheme: string;
|
||||
@@ -101,8 +104,6 @@ export function About({ requestClose }: AboutProps) {
|
||||
getCurrentUpdatePreview().then(setUpdatePreview).catch(() => setUpdatePreview(null));
|
||||
}, [refreshProtocolStatus]);
|
||||
|
||||
const [updatePreview, setUpdatePreview] = useState<{ title: string; description: string } | null>(null);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader outlined={false}>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
332
src/app/features/settings/account/CollectiblesSection.css.ts
Normal file
332
src/app/features/settings/account/CollectiblesSection.css.ts
Normal 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',
|
||||
});
|
||||
543
src/app/features/settings/account/CollectiblesSection.tsx
Normal file
543
src/app/features/settings/account/CollectiblesSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,10 @@ import { AvatarPresence, PresenceBadge } from '../../../components/presence';
|
||||
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
|
||||
import colorMXID, { getColorMXIDValue } from '../../../../util/colorMXID';
|
||||
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,
|
||||
@@ -53,6 +57,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
const profile = useUserProfile(userId);
|
||||
const presence = useUserPresence(userId);
|
||||
const [userBanner, setUserBanner, loading] = useUserBanner();
|
||||
const [collectibles, updateCollectible] = useUserCollectibles();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const [hoveredArea, setHoveredArea] = useState<string | null>(null);
|
||||
@@ -84,6 +89,17 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||
: undefined;
|
||||
|
||||
const avatarDecorationUrl = useMemo(() => {
|
||||
const decoration = collectibles.avatar_decoration;
|
||||
if (!decoration) return undefined;
|
||||
const assetUrls: Record<string, string | undefined> = {};
|
||||
for (const [role, mxc] of Object.entries(decoration.assets)) {
|
||||
assetUrls[`avatar_decoration:${role}`] =
|
||||
mxcUrlToHttp(mx, mxc, useAuthentication) ?? undefined;
|
||||
}
|
||||
return pickAvatarDecorationUrl(assetUrls);
|
||||
}, [collectibles.avatar_decoration, mx, useAuthentication]);
|
||||
|
||||
// Larger avatar URL for the blurred cover fallback
|
||||
const avatarCoverUrl = profile.avatarUrl
|
||||
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication) ?? undefined
|
||||
@@ -376,15 +392,28 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
resolveColorForTheme(previewPreference, theme.kind) || getColorMXIDValue(userId, theme.kind === ThemeKind.Dark);
|
||||
const previewTextShadow = `0 1px 4px ${getTextShadowColor(previewProfileColor)}`;
|
||||
const hasSavedColors = hasColorPreference(colorPreference);
|
||||
const colorPickerSize = toRem(200);
|
||||
const colorPreviewPlateStyle = {
|
||||
width: '100%',
|
||||
boxSizing: 'border-box' as const,
|
||||
padding: `${toRem(4)} ${toRem(8)}`,
|
||||
borderRadius: toRem(8),
|
||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
lineHeight: 1.2,
|
||||
};
|
||||
const displayName = profile.displayName || getMxIdLocalPart(userId) || userId;
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="300">
|
||||
<Box direction="Column" gap="300" style={{ width: '100%', alignItems: 'center' }}>
|
||||
<Box
|
||||
direction="Column"
|
||||
style={{
|
||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
borderRadius: toRem(8),
|
||||
width: toRem(340),
|
||||
width: '60%',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
@@ -521,14 +550,10 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
onMouseLeave={() => setHoveredArea(null)}
|
||||
>
|
||||
<Box
|
||||
as="button"
|
||||
onClick={handleAvatarClick}
|
||||
style={{
|
||||
backgroundColor: color.Surface.Container,
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
borderRadius: '50%',
|
||||
position: 'relative',
|
||||
width: toRem(76),
|
||||
height: toRem(76),
|
||||
}}
|
||||
>
|
||||
<AvatarPresence
|
||||
@@ -537,13 +562,31 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
<PresenceBadge presence={presence.presence} status={presence.status} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Box
|
||||
as="button"
|
||||
onClick={handleAvatarClick}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: avatarUrl ? 'transparent' : color.Surface.Container,
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
size="500"
|
||||
style={{
|
||||
width: toRem(72),
|
||||
height: toRem(72),
|
||||
width: toRem(76),
|
||||
height: toRem(76),
|
||||
...(avatarUrl
|
||||
? { outline: 'none', border: 'none', boxShadow: 'none' }
|
||||
: {
|
||||
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<UserAvatar
|
||||
@@ -553,6 +596,15 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
{avatarDecorationUrl && (
|
||||
<img
|
||||
className={avatarDecorationCss.AvatarDecorationOverlay}
|
||||
src={avatarDecorationUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</AvatarPresence>
|
||||
</Box>
|
||||
{/* Avatar action icons - shown on hover */}
|
||||
@@ -845,67 +897,100 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
direction="Column"
|
||||
gap="300"
|
||||
style={{
|
||||
padding: config.space.S300,
|
||||
padding: config.space.S400,
|
||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
borderRadius: toRem(8),
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<Text size="H6">Username colors</Text>
|
||||
<Text size="T200" style={{ opacity: 0.8 }}>
|
||||
Set how your name appears on dark and light themes (MSC4522). Other clients that support this
|
||||
spec will see your chosen colors.
|
||||
</Text>
|
||||
|
||||
<Box gap="400" wrap="Wrap">
|
||||
<Box direction="Column" gap="200">
|
||||
<Box direction="Row" gap="400" wrap="Wrap" alignItems="Start">
|
||||
<Box direction="Column" gap="200" style={{ flex: '1 1 0', minWidth: toRem(140) }}>
|
||||
<Text size="T300">On dark themes</Text>
|
||||
<Text size="T200" style={{ opacity: 0.7 }}>Bright colors work best</Text>
|
||||
<HexColorPicker color={localOnDark} onChange={(c) => { setLocalOnDark(c); setColorError(undefined); }} />
|
||||
<Box
|
||||
style={{
|
||||
...colorPreviewPlateStyle,
|
||||
backgroundColor: '#262626',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="H4"
|
||||
className={classNames(BreakWord, LineClamp3)}
|
||||
title={displayName}
|
||||
style={{
|
||||
color: localOnDark,
|
||||
textShadow: `0 1px 4px ${getTextShadowColor(localOnDark)}`,
|
||||
textAlign: 'center',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
</Box>
|
||||
<HexColorPicker
|
||||
color={localOnDark}
|
||||
onChange={(c) => {
|
||||
setLocalOnDark(c);
|
||||
setColorError(undefined);
|
||||
}}
|
||||
style={{ width: '100%', height: colorPickerSize }}
|
||||
/>
|
||||
<Input
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
style={{ width: toRem(120) }}
|
||||
style={{ width: '100%' }}
|
||||
value={localOnDark}
|
||||
onChange={(e) => {
|
||||
setLocalOnDark(e.target.value);
|
||||
setColorError(undefined);
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
width: toRem(48),
|
||||
height: toRem(48),
|
||||
borderRadius: toRem(8),
|
||||
backgroundColor: localOnDark,
|
||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box direction="Column" gap="200">
|
||||
<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>
|
||||
<HexColorPicker color={localOnLight} onChange={(c) => { setLocalOnLight(c); setColorError(undefined); }} />
|
||||
<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: toRem(120) }}
|
||||
style={{ width: '100%' }}
|
||||
value={localOnLight}
|
||||
onChange={(e) => {
|
||||
setLocalOnLight(e.target.value);
|
||||
setColorError(undefined);
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
width: toRem(48),
|
||||
height: toRem(48),
|
||||
borderRadius: toRem(8),
|
||||
backgroundColor: localOnLight,
|
||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -939,6 +1024,21 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="300"
|
||||
style={{
|
||||
padding: config.space.S400,
|
||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||
borderRadius: toRem(8),
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<Text size="H6">Discord profile overlays</Text>
|
||||
<CollectiblesSection collectibles={collectibles} onApply={updateCollectible} />
|
||||
</Box>
|
||||
|
||||
{uploadAtom && (
|
||||
<Box gap="200" direction="Column" style={{ width: '100%' }}>
|
||||
<CompactUploadCardRenderer
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import React, {
|
||||
import dayjs from 'dayjs';
|
||||
import { as, Box, Button, Chip, color, config, Header, IconButton, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Switch, Text, toRem } from 'folds';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { Range } from 'react-range';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
@@ -334,6 +335,66 @@ const emojiStyleNames: Record<EmojiStyle, string> = {
|
||||
[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 = {
|
||||
selected: EmojiStyle;
|
||||
onSelect: (style: EmojiStyle) => void;
|
||||
@@ -1222,6 +1283,7 @@ export function General({ requestClose }: GeneralProps) {
|
||||
<PageContent>
|
||||
<Box direction="Column" gap="700">
|
||||
<Appearance />
|
||||
<SidebarNameplate />
|
||||
<DateAndTime />
|
||||
<Editor />
|
||||
<Spaces />
|
||||
|
||||
179
src/app/hooks/useUserCollectibles.ts
Normal file
179
src/app/hooks/useUserCollectibles.ts
Normal 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 };
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import React from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
|
||||
import { searchModalAtom } from '../../../state/searchModal';
|
||||
import * as sidebarCss from '../../../components/sidebar/Sidebar.css';
|
||||
|
||||
export function SearchTab() {
|
||||
const [opened, setOpen] = useAtom(searchModalAtom);
|
||||
@@ -14,7 +15,13 @@ export function SearchTab() {
|
||||
<SidebarItem active={opened}>
|
||||
<SidebarItemTooltip tooltip="Search">
|
||||
{(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} />
|
||||
</SidebarAvatar>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,12 @@ import { nameInitials } from '../../../utils/common';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { Settings } from '../../../features/settings';
|
||||
import { useUserProfile } from '../../../hooks/useUserProfile';
|
||||
import { useOtherUserCollectibles } from '../../../hooks/useUserCollectibles';
|
||||
import { 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 { AccountSwitcher } from '../../../components/account-switcher';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
@@ -34,6 +40,13 @@ export function SettingsTab() {
|
||||
const avatarUrl = profile.avatarUrl && userId
|
||||
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||
: undefined;
|
||||
const { assetUrls } = useOtherUserCollectibles(userId ?? '');
|
||||
const avatarDecorationUrl = pickAvatarDecorationUrl(assetUrls);
|
||||
const nameplateUrl = pickNameplateUrl(assetUrls);
|
||||
const [sidebarNameplateOpacity] = useSetting(settingsAtom, 'sidebarNameplateOpacity');
|
||||
const nameplateIsVideo = Boolean(
|
||||
assetUrls['nameplate:animated'] || assetUrls['nameplate:asset.webm']
|
||||
);
|
||||
|
||||
const openSettings = () => setSettings(true);
|
||||
const closeSettings = () => setSettings(false);
|
||||
@@ -48,14 +61,49 @@ export function SettingsTab() {
|
||||
<SidebarItem active={settings}>
|
||||
<SidebarAvatar
|
||||
as="button"
|
||||
className={sidebarCss.SidebarAvatarLarge}
|
||||
onClick={openSettings}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ overflow: 'visible' }}
|
||||
>
|
||||
<div className={avatarDecorationCss.SidebarAvatarStack}>
|
||||
{sidebarNameplateOpacity > 0 &&
|
||||
nameplateUrl &&
|
||||
(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>
|
||||
|
||||
<PopOut
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface Settings {
|
||||
encUrlPreview: boolean;
|
||||
showHiddenEvents: boolean;
|
||||
legacyUsernameColor: boolean;
|
||||
sidebarNameplateOpacity: number;
|
||||
|
||||
showNotifications: boolean;
|
||||
isNotificationSounds: boolean;
|
||||
@@ -93,6 +94,7 @@ const defaultSettings: Settings = {
|
||||
encUrlPreview: false,
|
||||
showHiddenEvents: false,
|
||||
legacyUsernameColor: false,
|
||||
sidebarNameplateOpacity: 60,
|
||||
|
||||
showNotifications: true,
|
||||
isNotificationSounds: true,
|
||||
|
||||
29
src/app/styles/AvatarDecoration.css.ts
Normal file
29
src/app/styles/AvatarDecoration.css.ts
Normal 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',
|
||||
});
|
||||
102
src/app/utils/collectibleAssets.ts
Normal file
102
src/app/utils/collectibleAssets.ts
Normal 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']
|
||||
);
|
||||
}
|
||||
323
src/app/utils/discordCollectibles.ts
Normal file
323
src/app/utils/discordCollectibles.ts
Normal 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])})`;
|
||||
}
|
||||
28
src/app/utils/discordNameplatePalettes.ts
Normal file
28
src/app/utils/discordNameplatePalettes.ts
Normal 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(' ');
|
||||
}
|
||||
@@ -13,6 +13,28 @@ 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;
|
||||
@@ -66,6 +88,89 @@ export function extractBannerUrlFromProfile(profile: Record<string, unknown>): s
|
||||
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;
|
||||
@@ -152,7 +257,7 @@ export async function loadColorPreference(
|
||||
}
|
||||
|
||||
export function extractMemberColorPreference(room: Room | undefined, userId: string): ColorPreference | undefined {
|
||||
if (!room) return 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;
|
||||
|
||||
18
src/ext.d.ts
vendored
18
src/ext.d.ts
vendored
@@ -113,6 +113,24 @@ interface ElectronAPI {
|
||||
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 {
|
||||
|
||||
@@ -147,6 +147,27 @@ body.stationery-dark-theme {
|
||||
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 {
|
||||
background: linear-gradient(180deg, rgba(28, 26, 46, 0.5) 0%, rgba(36, 34, 61, 0.3) 100%);
|
||||
}
|
||||
|
||||
101
vanillaExtractIdentifiers.js
Normal file
101
vanillaExtractIdentifiers.js
Normal 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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,10 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import buildConfig from './build.config';
|
||||
import { liveTsxPlugin, readDefaultLiveSource } from './playground-liveTsxPlugin';
|
||||
import {
|
||||
createReadableVanillaExtractIdentifiers,
|
||||
vanillaExtractDebugIdsPlugin,
|
||||
} from './vanillaExtractIdentifiers.js';
|
||||
|
||||
const projectRoot = path.resolve();
|
||||
|
||||
@@ -56,7 +60,8 @@ const copyFiles = {
|
||||
{
|
||||
src: 'public/update/**/*',
|
||||
dest: 'update',
|
||||
rename: (_name, _ext, fullPath) => fullPath.replace(/^public[/\\]update[/\\]/, ''),
|
||||
// stripBase removes public/update from the matched path; rename alone only changes the filename.
|
||||
rename: { stripBase: 2 },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -251,7 +256,7 @@ function corsProxyMiddleware() {
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
export default defineConfig(() => ({
|
||||
appType: 'spa',
|
||||
publicDir: false,
|
||||
base: buildConfig.base,
|
||||
@@ -303,7 +308,15 @@ export default defineConfig({
|
||||
promiseImportName: (i) => `__tla_${i}`,
|
||||
}),
|
||||
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(),
|
||||
react(),
|
||||
VitePWA({
|
||||
@@ -358,4 +371,4 @@ export default defineConfig({
|
||||
plugins: [inject({ Buffer: ['buffer', 'Buffer'] })],
|
||||
},
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user