Compare commits
14 Commits
9887903f49
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 47979718eb | |||
| 862307ee7d | |||
| b16263b481 | |||
| c34cfaf33f | |||
| 87c9f59826 | |||
| d59945eb4c | |||
| 0cbc5d9a1c | |||
| 6cb1e14632 | |||
| 7d866404b3 | |||
| c264de53b5 | |||
| b6f3f5c0aa | |||
| 25be638c5e | |||
| a20c871726 | |||
| e65a516350 |
@@ -7,6 +7,9 @@
|
|||||||
"LocalNotifications": {
|
"LocalNotifications": {
|
||||||
"smallIcon": "ic_stat_paarrot",
|
"smallIcon": "ic_stat_paarrot",
|
||||||
"iconColor": "#FF8A00"
|
"iconColor": "#FF8A00"
|
||||||
|
},
|
||||||
|
"CapacitorHttp": {
|
||||||
|
"enabled": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1464
package-lock.json
generated
1464
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
"build": "vite build",
|
"build": "node scripts/generate-update-manifest.mjs && vite build",
|
||||||
"lint": "yarn check:eslint && yarn check:prettier",
|
"lint": "yarn check:eslint && yarn check:prettier",
|
||||||
"check:eslint": "eslint src/*",
|
"check:eslint": "eslint src/*",
|
||||||
"check:prettier": "prettier --check .",
|
"check:prettier": "prettier --check .",
|
||||||
@@ -84,8 +84,10 @@
|
|||||||
"react-error-boundary": "6.1.2",
|
"react-error-boundary": "6.1.2",
|
||||||
"react-google-recaptcha": "3.1.0",
|
"react-google-recaptcha": "3.1.0",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
|
"react-markdown": "10.1.0",
|
||||||
"react-range": "1.10.0",
|
"react-range": "1.10.0",
|
||||||
"react-router-dom": "7.18.1",
|
"react-router-dom": "7.18.1",
|
||||||
|
"remark-gfm": "4.0.1",
|
||||||
"sanitize-html": "2.17.5",
|
"sanitize-html": "2.17.5",
|
||||||
"slate": "0.124.1",
|
"slate": "0.124.1",
|
||||||
"slate-dom": "0.124.1",
|
"slate-dom": "0.124.1",
|
||||||
|
|||||||
16
public/update/4.11.175.md
Normal file
16
public/update/4.11.175.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
version: 4.11.175
|
||||||
|
title: Stability & polish
|
||||||
|
date: 2026-08-10
|
||||||
|
summary: Desktop update flow improvements and UI refinements.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Desktop updates
|
||||||
|
|
||||||
|
- Smoother download and install flow in the title bar updater.
|
||||||
|
- Better handling when an update is already downloading.
|
||||||
|
|
||||||
|
## UI polish
|
||||||
|
|
||||||
|
- Refined spacing and contrast in settings panels.
|
||||||
|
- Improved mobile layout for room headers and notifications.
|
||||||
22
public/update/4.11.177.md
Normal file
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.
|
||||||
22
public/update/currentupdate.md
Normal file
22
public/update/currentupdate.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
version: 4.11.185
|
||||||
|
title: Discord collectibles
|
||||||
|
date: 2026-08-24
|
||||||
|
summary: Add Discord-style profile effects, nameplates, and avatar decorations to Paarrot profiles.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Discord collectibles
|
||||||
|
|
||||||
|
- Browse and apply profile effects, animated nameplates, and avatar decorations in Settings → Account → Profile.
|
||||||
|
- Collectible assets are uploaded to your Matrix profile, so other Paarrot users can see them without Discord.
|
||||||
|
- The catalog now comes from a public git, and no Discord token is required in Paarrot for the cosmetics.
|
||||||
|
- Profile-effect previews play their intro and loop animations, with a mini profile preview for each effect.
|
||||||
|
|
||||||
|
|
||||||
|
 
|
||||||
|
|
||||||
|
## Profile presentation
|
||||||
|
|
||||||
|
- Avatar decorations render in messages, profile cards, and the bottom sidebar avatar.
|
||||||
|
- Nameplates to the right of messages, beneath timestamps and can be displayed behind the bottom sidebar avatar with opacity settings.
|
||||||
|
- Customize the sidebar nameplate opacity in Settings → General → Collectibles.
|
||||||
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 |
5634
public/update/images/paarrot.svg
Normal file
5634
public/update/images/paarrot.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 889 KiB |
BIN
public/update/images/usernamecolors.png
Normal file
BIN
public/update/images/usernamecolors.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
11
public/update/manifest.json
Normal file
11
public/update/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"current": "currentupdate.md",
|
||||||
|
"older": [
|
||||||
|
{
|
||||||
|
"file": "4.11.175.md",
|
||||||
|
"version": "4.11.175",
|
||||||
|
"title": "Stability & polish",
|
||||||
|
"date": "2026-08-10"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
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 React, { ReactNode, useCallback, useState } from 'react';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
import { Modal, Overlay, OverlayBackdrop, OverlayCenter, PopOutContainerProvider } from 'folds';
|
import { Modal, Overlay, OverlayBackdrop, OverlayCenter, PopOutContainerProvider } from 'folds';
|
||||||
import { stopPropagation } from '../utils/keyboard';
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
import { releaseNotesDialogAtom } from '../state/releaseNotes';
|
||||||
|
|
||||||
type Modal500Props = {
|
type Modal500Props = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
@@ -10,11 +12,13 @@ type Modal500Props = {
|
|||||||
export function Modal500({ requestClose, children }: Modal500Props) {
|
export function Modal500({ requestClose, children }: Modal500Props) {
|
||||||
const [modalEl, setModalEl] = useState<HTMLDivElement | null>(null);
|
const [modalEl, setModalEl] = useState<HTMLDivElement | null>(null);
|
||||||
const modalRef = useCallback((el: HTMLDivElement | null) => setModalEl(el), []);
|
const modalRef = useCallback((el: HTMLDivElement | null) => setModalEl(el), []);
|
||||||
|
const releaseNotesOpen = useAtomValue(releaseNotesDialogAtom).open;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
|
active={!releaseNotesOpen}
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState })
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu style={{ width: toRem(340) }}>
|
<Menu style={{ width: toRem(260) }}>
|
||||||
<SpaceProvider value={space ?? null}>
|
<SpaceProvider value={space ?? null}>
|
||||||
<RoomProvider value={room}>
|
<RoomProvider value={room}>
|
||||||
<UserRoomProfile userId={userId} />
|
<UserRoomProfile userId={userId} />
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
|||||||
<AttachmentBox
|
<AttachmentBox
|
||||||
style={{
|
style={{
|
||||||
width: toRem(width),
|
width: toRem(width),
|
||||||
height: toRem(height),
|
aspectRatio: `${width} / ${height}`,
|
||||||
['--media-h' as string]: `${height}px`,
|
['--media-h' as string]: `${height}px`,
|
||||||
}}
|
}}
|
||||||
data-paarrot-media-height={height}
|
data-paarrot-media-height={height}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export const Reply = as<'div', ReplyProps>(
|
|||||||
const { body } = replyEvent?.getContent() ?? {};
|
const { body } = replyEvent?.getContent() ?? {};
|
||||||
const sender = replyEvent?.getSender();
|
const sender = replyEvent?.getSender();
|
||||||
const senderAvatarMxc = sender ? getMemberAvatarMxc(room, sender) : undefined;
|
const senderAvatarMxc = sender ? getMemberAvatarMxc(room, sender) : undefined;
|
||||||
const customUserColor = useOtherUserColor(sender ?? '', senderAvatarMxc);
|
const customUserColor = useOtherUserColor(sender ?? '', room);
|
||||||
const powerTag = sender ? getMemberPowerTag?.(sender) : undefined;
|
const powerTag = sender ? getMemberPowerTag?.(sender) : undefined;
|
||||||
const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined;
|
const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const Attachment = recipe({
|
|||||||
color: color.SurfaceVariant.OnContainer,
|
color: color.SurfaceVariant.OnContainer,
|
||||||
borderRadius: config.radii.R400,
|
borderRadius: config.radii.R400,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
maxWidth: toRem(400),
|
maxWidth: `min(100%, ${toRem(400)})`,
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
outlined: {
|
outlined: {
|
||||||
@@ -33,7 +33,7 @@ export const AttachmentHeader = style({
|
|||||||
export const AttachmentBox = style([
|
export const AttachmentBox = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
maxWidth: toRem(400),
|
maxWidth: `min(100%, ${toRem(400)})`,
|
||||||
maxHeight: toRem(400),
|
maxHeight: toRem(400),
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type PageRootProps = {
|
|||||||
export function PageRoot({ nav, children }: PageRootProps) {
|
export function PageRoot({ nav, children }: PageRootProps) {
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const showCompactMaster = useShowCompactMasterView();
|
const showCompactMaster = useShowCompactMasterView();
|
||||||
|
const showDetail = !showCompactMaster || nav == null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
|
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
|
||||||
@@ -23,7 +24,7 @@ export function PageRoot({ nav, children }: PageRootProps) {
|
|||||||
{screenSize !== ScreenSize.Mobile && (
|
{screenSize !== ScreenSize.Mobile && (
|
||||||
<Line variant="Background" size="300" direction="Vertical" />
|
<Line variant="Background" size="300" direction="Vertical" />
|
||||||
)}
|
)}
|
||||||
{!showCompactMaster && children}
|
{showDetail && children}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ type AvatarPresenceProps = {
|
|||||||
export const AvatarPresence = as<'div', AvatarPresenceProps>(
|
export const AvatarPresence = as<'div', AvatarPresenceProps>(
|
||||||
({ as: AsAvatarPresence, badge, variant = 'Surface', badgeBackgroundColor, children, ...props }, ref) => (
|
({ as: AsAvatarPresence, badge, variant = 'Surface', badgeBackgroundColor, children, ...props }, ref) => (
|
||||||
<Box as={AsAvatarPresence} className={css.AvatarPresence} {...props} ref={ref}>
|
<Box as={AsAvatarPresence} className={css.AvatarPresence} {...props} ref={ref}>
|
||||||
|
{children}
|
||||||
{badge && (
|
{badge && (
|
||||||
<div
|
<div
|
||||||
className={css.AvatarPresenceBadge}
|
className={css.AvatarPresenceBadge}
|
||||||
@@ -75,7 +76,6 @@ export const AvatarPresence = as<'div', AvatarPresenceProps>(
|
|||||||
{badge}
|
{badge}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{children}
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const AvatarPresenceBadge = style({
|
|||||||
bottom: 0,
|
bottom: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
transform: 'translate(25%, 25%)',
|
transform: 'translate(25%, 25%)',
|
||||||
zIndex: 1,
|
zIndex: 10,
|
||||||
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
padding: config.borderWidth.B600,
|
padding: config.borderWidth.B600,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export const Sidebar = style([
|
|||||||
width: toRem(66),
|
width: toRem(66),
|
||||||
backgroundColor: color.Background.Container,
|
backgroundColor: color.Background.Container,
|
||||||
borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
|
borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
|
||||||
|
position: 'relative',
|
||||||
|
isolation: 'isolate',
|
||||||
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -185,6 +187,46 @@ export const SidebarAvatar = recipe({
|
|||||||
});
|
});
|
||||||
export type SidebarAvatarVariants = RecipeVariants<typeof SidebarAvatar>;
|
export type SidebarAvatarVariants = RecipeVariants<typeof SidebarAvatar>;
|
||||||
|
|
||||||
|
/** A vertical rendition of the user's nameplate behind the bottom settings avatar. */
|
||||||
|
export const SidebarAvatarNameplate = style({
|
||||||
|
position: 'absolute',
|
||||||
|
// The 50px avatar is centered in the 66px sidebar. Pin the rotated plate's
|
||||||
|
// bottom-right corner 8px left of it so the 66px cross-axis
|
||||||
|
// spans the whole sidebar and grows upward from its bottom edge.
|
||||||
|
right: toRem(58),
|
||||||
|
bottom: toRem(-16),
|
||||||
|
width: toRem(220),
|
||||||
|
height: toRem(66),
|
||||||
|
transformOrigin: 'right bottom',
|
||||||
|
transform: 'rotate(90deg)',
|
||||||
|
objectFit: 'cover',
|
||||||
|
objectPosition: 'right center',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: -1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SidebarAvatarLarge = style({
|
||||||
|
width: toRem(50),
|
||||||
|
height: toRem(50),
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: `color-mix(in srgb, ${color.Background.Container} 68%, transparent)`,
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
WebkitBackdropFilter: 'blur(10px)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SidebarSearchAvatar = style({
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
backgroundColor: `color-mix(in srgb, ${color.Background.Container} 68%, transparent)`,
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
WebkitBackdropFilter: 'blur(10px)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SidebarAvatarForeground = style({
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
});
|
||||||
|
|
||||||
export const SidebarFolder = recipe({
|
export const SidebarFolder = recipe({
|
||||||
base: [
|
base: [
|
||||||
ContainerColor({ variant: 'Background' }),
|
ContainerColor({ variant: 'Background' }),
|
||||||
|
|||||||
35
src/app/components/updates-dialog/UpdateMarkdownViewer.tsx
Normal file
35
src/app/components/updates-dialog/UpdateMarkdownViewer.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { resolveUpdateAssetUrl } from '../../data/updateNotes';
|
||||||
|
import * as css from './UpdatesDialog.css';
|
||||||
|
|
||||||
|
type UpdateMarkdownViewerProps = {
|
||||||
|
markdown: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UpdateMarkdownViewer({ markdown }: UpdateMarkdownViewerProps) {
|
||||||
|
if (!markdown.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.Markdown}>
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
a: ({ href, children }) => (
|
||||||
|
<a href={href} target="_blank" rel="noreferrer noopener">
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
img: ({ src, alt }) => (
|
||||||
|
<img src={resolveUpdateAssetUrl(src) ?? src} alt={alt ?? ''} loading="lazy" />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{markdown}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
329
src/app/components/updates-dialog/UpdatesDialog.css.ts
Normal file
329
src/app/components/updates-dialog/UpdatesDialog.css.ts
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = '480px';
|
||||||
|
|
||||||
|
/** Above folds overlays (9999) and Settings modals. */
|
||||||
|
const UPDATES_DIALOG_Z = 10001;
|
||||||
|
|
||||||
|
const DIALOG_MAX_HEIGHT =
|
||||||
|
'calc(85vh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))';
|
||||||
|
|
||||||
|
/** [data-updates-dialog] — full-screen host portaled to document.body */
|
||||||
|
export const Root = style({
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: UPDATES_DIALOG_Z,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** [data-updates-dialog-backdrop] */
|
||||||
|
export const Backdrop = style({
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** [data-updates-dialog-frame] */
|
||||||
|
export const Frame = style({
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
maxHeight: '100vh',
|
||||||
|
maxWidth: '100vw',
|
||||||
|
padding:
|
||||||
|
'env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** [data-updates-dialog-panel] */
|
||||||
|
export const Panel = style({
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: toRem(560),
|
||||||
|
maxHeight: DIALOG_MAX_HEIGHT,
|
||||||
|
overflow: 'hidden',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
boxShadow: config.shadow.E400,
|
||||||
|
'@media': {
|
||||||
|
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||||
|
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Hero = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: config.space.S200,
|
||||||
|
padding: config.space.S500,
|
||||||
|
paddingBottom: config.space.S400,
|
||||||
|
backgroundColor: color.Primary.Container,
|
||||||
|
color: color.Primary.OnContainer,
|
||||||
|
borderBottom: `${config.borderWidth.B300} solid ${color.Primary.ContainerLine}`,
|
||||||
|
'@media': {
|
||||||
|
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||||
|
padding: config.space.S400,
|
||||||
|
paddingBottom: config.space.S300,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const HeroRow = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S300,
|
||||||
|
minWidth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const HeroLogo = style({
|
||||||
|
width: toRem(48),
|
||||||
|
height: toRem(48),
|
||||||
|
flexShrink: 0,
|
||||||
|
'@media': {
|
||||||
|
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||||
|
width: toRem(40),
|
||||||
|
height: toRem(40),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const HeroTitleWrap = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: config.space.S100,
|
||||||
|
minWidth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BodyScroll = style({
|
||||||
|
flex: '1 1 auto',
|
||||||
|
minHeight: 0,
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BodyContent = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
padding: config.space.S500,
|
||||||
|
paddingTop: config.space.S400,
|
||||||
|
'@media': {
|
||||||
|
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||||
|
padding: config.space.S400,
|
||||||
|
paddingTop: config.space.S300,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BodyLoading = style({
|
||||||
|
padding: config.space.S600,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Markdown = style({
|
||||||
|
width: '100%',
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
fontSize: toRem(14),
|
||||||
|
lineHeight: 1.55,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} h1, ${Markdown} h2, ${Markdown} h3, ${Markdown} h4`, {
|
||||||
|
margin: 0,
|
||||||
|
marginTop: config.space.S400,
|
||||||
|
marginBottom: config.space.S200,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} h1:first-child, ${Markdown} h2:first-child, ${Markdown} h3:first-child, ${Markdown} h4:first-child`, {
|
||||||
|
marginTop: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} h2`, { fontSize: toRem(18) });
|
||||||
|
globalStyle(`${Markdown} h3`, { fontSize: toRem(16) });
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} p`, {
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} p:last-child`, { marginBottom: 0 });
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} ul, ${Markdown} ol`, {
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
paddingLeft: config.space.S500,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} li`, { marginBottom: config.space.S100 });
|
||||||
|
globalStyle(`${Markdown} li:last-child`, { marginBottom: 0 });
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} a`, {
|
||||||
|
color: color.Primary.Main,
|
||||||
|
textDecoration: 'underline',
|
||||||
|
textUnderlineOffset: toRem(2),
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} a:hover`, { color: color.Primary.MainHover });
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} img`, {
|
||||||
|
display: 'block',
|
||||||
|
maxWidth: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
marginTop: config.space.S200,
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} code`, {
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||||
|
fontSize: toRem(13),
|
||||||
|
padding: `0 ${config.space.S100}`,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} pre`, {
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
padding: config.space.S300,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
overflow: 'auto',
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} pre code`, {
|
||||||
|
padding: 0,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} blockquote`, {
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
paddingLeft: config.space.S300,
|
||||||
|
borderLeft: `${toRem(3)} solid ${color.Primary.Main}`,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
opacity: 0.9,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} hr`, {
|
||||||
|
border: 'none',
|
||||||
|
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
margin: `${config.space.S400} 0`,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} table`, {
|
||||||
|
width: '100%',
|
||||||
|
borderCollapse: 'collapse',
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
fontSize: toRem(13),
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} th, ${Markdown} td`, {
|
||||||
|
padding: config.space.S200,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
textAlign: 'left',
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} th`, {
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OlderSection = style({
|
||||||
|
width: '100%',
|
||||||
|
marginTop: config.space.S500,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OlderToggle = style({
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OlderList = style({
|
||||||
|
listStyle: 'none',
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: config.space.S100,
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OlderListItem = style({
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OlderItem = style({
|
||||||
|
all: 'unset',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: config.space.S100,
|
||||||
|
width: '100%',
|
||||||
|
minHeight: toRem(48),
|
||||||
|
padding: config.space.S300,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: color.SurfaceVariant.ContainerHover,
|
||||||
|
},
|
||||||
|
'&[data-active="true"]': {
|
||||||
|
borderColor: color.Primary.Main,
|
||||||
|
backgroundColor: color.Primary.Container,
|
||||||
|
color: color.Primary.OnContainer,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Footer = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: config.space.S200,
|
||||||
|
padding: config.space.S400,
|
||||||
|
paddingTop: config.space.S300,
|
||||||
|
paddingBottom: `calc(${config.space.S400} + env(safe-area-inset-bottom, 0px))`,
|
||||||
|
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
'@media': {
|
||||||
|
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||||
|
padding: config.space.S300,
|
||||||
|
paddingTop: config.space.S300,
|
||||||
|
paddingBottom: `calc(${config.space.S300} + env(safe-area-inset-bottom, 0px))`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FooterButton = style({
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CloseButton = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: config.space.S300,
|
||||||
|
right: config.space.S300,
|
||||||
|
'@media': {
|
||||||
|
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||||
|
top: config.space.S200,
|
||||||
|
right: config.space.S200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
183
src/app/components/updates-dialog/UpdatesDialog.tsx
Normal file
183
src/app/components/updates-dialog/UpdatesDialog.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { Box, Button, IconButton, Scroll, Spinner, Text } from 'folds';
|
||||||
|
import { Icon, Icons } from '../icons';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import type { ParsedUpdateDoc, UpdateManifest } from '../../data/updateNotes';
|
||||||
|
import PaarrotSVG from '../../../../public/res/svg/paarrot.svg';
|
||||||
|
import { UpdateMarkdownViewer } from './UpdateMarkdownViewer';
|
||||||
|
import * as css from './UpdatesDialog.css';
|
||||||
|
|
||||||
|
type UpdatesDialogProps = {
|
||||||
|
manifest: UpdateManifest | null;
|
||||||
|
activeDoc: ParsedUpdateDoc | null;
|
||||||
|
loading: boolean;
|
||||||
|
onSelectFile: (file: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UpdatesDialog({
|
||||||
|
manifest,
|
||||||
|
activeDoc,
|
||||||
|
loading,
|
||||||
|
onSelectFile,
|
||||||
|
onClose,
|
||||||
|
}: UpdatesDialogProps) {
|
||||||
|
const [showOlderList, setShowOlderList] = useState(false);
|
||||||
|
|
||||||
|
const displayVersion = activeDoc?.version;
|
||||||
|
const displayTitle = activeDoc?.title ?? (displayVersion ? `Version ${displayVersion}` : 'Release notes');
|
||||||
|
const isCurrent = manifest && activeDoc?.file === manifest.current;
|
||||||
|
const olderEntries = manifest?.older ?? [];
|
||||||
|
|
||||||
|
const handleSelectOlder = (file: string) => {
|
||||||
|
onSelectFile(file);
|
||||||
|
setShowOlderList(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
data-updates-dialog=""
|
||||||
|
className={css.Root}
|
||||||
|
data-disable-swipe-back="true"
|
||||||
|
data-disable-swipe-reply="true"
|
||||||
|
>
|
||||||
|
<div data-updates-dialog-backdrop="" className={css.Backdrop} aria-hidden="true" />
|
||||||
|
<div data-updates-dialog-frame="" className={css.Frame}>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
clickOutsideDeactivates: false,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="updates-dialog-title"
|
||||||
|
data-updates-dialog-panel=""
|
||||||
|
className={css.Panel}
|
||||||
|
>
|
||||||
|
<Box className={css.Hero}>
|
||||||
|
<Box className={css.HeroRow}>
|
||||||
|
<img className={css.HeroLogo} src={PaarrotSVG} alt="" draggable={false} />
|
||||||
|
<Box className={css.HeroTitleWrap}>
|
||||||
|
<Text size="L400" priority="300">What's new</Text>
|
||||||
|
<Text id="updates-dialog-title" size="H4" truncate>
|
||||||
|
{displayVersion ? `Paarrot ${displayVersion}` : 'Paarrot updates'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Text size="H6">{displayTitle}</Text>
|
||||||
|
{activeDoc?.date && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
{activeDoc.date}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!loading && !isCurrent && manifest?.current && (
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => onSelectFile(manifest.current)}
|
||||||
|
>
|
||||||
|
<Text size="B300">Back to current update</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Scroll className={css.BodyScroll} visibility="Hover">
|
||||||
|
<Box className={css.BodyContent} direction="Column">
|
||||||
|
{loading && (
|
||||||
|
<Box justifyContent="Center" alignItems="Center" className={css.BodyLoading}>
|
||||||
|
<Spinner size="400" variant="Secondary" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && activeDoc?.markdown && (
|
||||||
|
<UpdateMarkdownViewer markdown={activeDoc.markdown} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !activeDoc?.markdown && (
|
||||||
|
<Text size="T300" priority="400">
|
||||||
|
No update notes are available right now.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && olderEntries.length > 0 && (
|
||||||
|
<Box className={css.OlderSection} direction="Column" gap="200">
|
||||||
|
<Button
|
||||||
|
className={css.OlderToggle}
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
aria-expanded={showOlderList}
|
||||||
|
onClick={() => setShowOlderList((open) => !open)}
|
||||||
|
>
|
||||||
|
<Text size="B300">
|
||||||
|
{showOlderList
|
||||||
|
? 'Hide older versions'
|
||||||
|
: `Older versions (${olderEntries.length})`}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{showOlderList && (
|
||||||
|
<ul className={css.OlderList}>
|
||||||
|
{olderEntries.map((entry) => {
|
||||||
|
const isActive = activeDoc?.file === entry.file;
|
||||||
|
const label = entry.version ?? entry.file.replace(/\.md$/i, '');
|
||||||
|
return (
|
||||||
|
<li key={entry.file} className={css.OlderListItem}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={css.OlderItem}
|
||||||
|
data-active={isActive ? 'true' : 'false'}
|
||||||
|
onClick={() => handleSelectOlder(entry.file)}
|
||||||
|
>
|
||||||
|
<Text size="B300">{label}</Text>
|
||||||
|
{entry.title && (
|
||||||
|
<Text size="T200" priority="400">
|
||||||
|
{entry.title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{entry.date && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
{entry.date}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
|
||||||
|
<Box className={css.Footer}>
|
||||||
|
<Button
|
||||||
|
className={css.FooterButton}
|
||||||
|
variant="Primary"
|
||||||
|
fill="Solid"
|
||||||
|
radii="300"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<Text size="B400">Got it</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box className={css.CloseButton}>
|
||||||
|
<IconButton variant="Surface" onClick={onClose} aria-label="Close">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</FocusTrap>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
153
src/app/components/updates-dialog/UpdatesDialogHost.tsx
Normal file
153
src/app/components/updates-dialog/UpdatesDialogHost.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useAtom, useSetAtom } from 'jotai';
|
||||||
|
import {
|
||||||
|
loadCurrentUpdateDocument,
|
||||||
|
loadUpdateDocument,
|
||||||
|
loadUpdateManifest,
|
||||||
|
type ParsedUpdateDoc,
|
||||||
|
type UpdateManifest,
|
||||||
|
} from '../../data/updateNotes';
|
||||||
|
import {
|
||||||
|
clearPendingReleaseNotes,
|
||||||
|
getLastSeenAppVersion,
|
||||||
|
releaseNotesDialogAtom,
|
||||||
|
setLastSeenAppVersion,
|
||||||
|
storePendingReleaseNotes,
|
||||||
|
} from '../../state/releaseNotes';
|
||||||
|
import { getAppVersion } from '../../utils/appVersion';
|
||||||
|
import { UpdatesDialog } from './UpdatesDialog';
|
||||||
|
|
||||||
|
type UpdaterInfo = {
|
||||||
|
version: string;
|
||||||
|
releaseNotes?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Open the What's New dialog from Settings → About (or anywhere else). */
|
||||||
|
export function useOpenReleaseNotesDialog(): () => void {
|
||||||
|
const setDialogState = useSetAtom(releaseNotesDialogAtom);
|
||||||
|
return useCallback(() => {
|
||||||
|
// Defer so the opening tap does not trip FocusTrap click-outside on Android.
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
setDialogState({ open: true, manual: true });
|
||||||
|
});
|
||||||
|
}, [setDialogState]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpdatesDialogHost() {
|
||||||
|
const [dialogState, setDialogState] = useAtom(releaseNotesDialogAtom);
|
||||||
|
const [manifest, setManifest] = useState<UpdateManifest | null>(null);
|
||||||
|
const [activeDoc, setActiveDoc] = useState<ParsedUpdateDoc | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const closeDialog = useCallback(() => {
|
||||||
|
setDialogState({ open: false, manual: false });
|
||||||
|
setManifest(null);
|
||||||
|
setActiveDoc(null);
|
||||||
|
setLoading(false);
|
||||||
|
getAppVersion().then((version) => {
|
||||||
|
if (version !== 'unknown') {
|
||||||
|
setLastSeenAppVersion(version);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [setDialogState]);
|
||||||
|
|
||||||
|
const selectFile = useCallback(async (file: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
const doc = await loadUpdateDocument(file);
|
||||||
|
setActiveDoc(doc);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Persist updater metadata when a download finishes (version tracking after restart).
|
||||||
|
useEffect(() => {
|
||||||
|
const electron = window.electron;
|
||||||
|
if (!electron?.updater?.onUpdateDownloaded) return undefined;
|
||||||
|
|
||||||
|
const onDownloaded = (info: UpdaterInfo) => {
|
||||||
|
if (!info?.version) return;
|
||||||
|
storePendingReleaseNotes(info.version, info.releaseNotes);
|
||||||
|
};
|
||||||
|
|
||||||
|
electron.updater.onUpdateDownloaded(onDownloaded);
|
||||||
|
return undefined;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Show What's New after the app version changes (post-update restart).
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const checkVersion = async () => {
|
||||||
|
const currentVersion = await getAppVersion();
|
||||||
|
if (cancelled || currentVersion === 'unknown') return;
|
||||||
|
|
||||||
|
const lastSeen = getLastSeenAppVersion();
|
||||||
|
const shouldAutoShow = lastSeen !== currentVersion;
|
||||||
|
|
||||||
|
if (shouldAutoShow) {
|
||||||
|
const loadedManifest = await loadUpdateManifest();
|
||||||
|
if (cancelled || !loadedManifest) return;
|
||||||
|
|
||||||
|
const doc = await loadUpdateDocument(loadedManifest.current);
|
||||||
|
if (cancelled || !doc) return;
|
||||||
|
|
||||||
|
setManifest(loadedManifest);
|
||||||
|
setActiveDoc(doc);
|
||||||
|
setDialogState({ open: true, manual: false });
|
||||||
|
clearPendingReleaseNotes();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = window.setTimeout(checkVersion, 1500);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [setDialogState]);
|
||||||
|
|
||||||
|
// Load markdown when the dialog opens (manual open from About, etc.).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dialogState.open) return undefined;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const loadedManifest = await loadUpdateManifest();
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (!loadedManifest) {
|
||||||
|
setManifest(null);
|
||||||
|
setActiveDoc(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = await loadUpdateDocument(loadedManifest.current);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
setManifest(loadedManifest);
|
||||||
|
setActiveDoc(doc);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [dialogState.open]);
|
||||||
|
|
||||||
|
if (!dialogState.open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UpdatesDialog
|
||||||
|
manifest={manifest}
|
||||||
|
activeDoc={activeDoc}
|
||||||
|
loading={loading}
|
||||||
|
onSelectFile={selectFile}
|
||||||
|
onClose={closeDialog}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/app/components/updates-dialog/index.ts
Normal file
3
src/app/components/updates-dialog/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './UpdatesDialog';
|
||||||
|
export * from './UpdatesDialogHost';
|
||||||
|
export * from './UpdateMarkdownViewer';
|
||||||
@@ -112,5 +112,7 @@ export const TikTokEmbedContainer = style([
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: '#000',
|
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));
|
return TIKTOK_URL_PATTERNS.some((pattern) => pattern.test(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractTikTokVideoId(url: string, embedHtml = ''): string | null {
|
||||||
|
const urlMatch = url.match(/\/video\/(\d+)/i) ?? url.match(/\/v\/(\d+)/i);
|
||||||
|
const embedMatch = embedHtml.match(/data-video-id=["'](\d+)["']/i);
|
||||||
|
return urlMatch?.[1] ?? embedMatch?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches TikTok video metadata using oEmbed API
|
* Fetches TikTok video metadata using oEmbed API
|
||||||
* @param url The TikTok video URL
|
* @param url The TikTok video URL
|
||||||
@@ -53,29 +59,39 @@ async function fetchTikTokOEmbed(url: string): Promise<{
|
|||||||
authorUrl: string;
|
authorUrl: string;
|
||||||
thumbnailUrl: string;
|
thumbnailUrl: string;
|
||||||
embedHtml: string;
|
embedHtml: string;
|
||||||
|
videoId: string | null;
|
||||||
} | null> {
|
} | null> {
|
||||||
try {
|
try {
|
||||||
const oembedUrl = `${TIKTOK_OEMBED_API}?url=${encodeURIComponent(url)}`;
|
const oembedUrl = `${TIKTOK_OEMBED_API}?url=${encodeURIComponent(url)}`;
|
||||||
const response = await fetch(oembedUrl);
|
const response = await fetch(oembedUrl);
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: data.title || 'TikTok Video',
|
title: data.title || 'TikTok Video',
|
||||||
authorName: data.author_name || 'Unknown',
|
authorName: data.author_name || 'Unknown',
|
||||||
authorUrl: data.author_url || url,
|
authorUrl: data.author_url || url,
|
||||||
thumbnailUrl: data.thumbnail_url || '',
|
thumbnailUrl: data.thumbnail_url || '',
|
||||||
embedHtml: data.html || '',
|
embedHtml: data.html || '',
|
||||||
|
videoId: extractTikTokVideoId(url, data.html || ''),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type TikTokEmbedState =
|
type TikTokEmbedState =
|
||||||
| { status: 'loading' }
|
| { status: 'loading' }
|
||||||
| { status: 'loaded'; title: string; authorName: string; authorUrl: string; thumbnailUrl: string; embedHtml: string }
|
| {
|
||||||
|
status: 'loaded';
|
||||||
|
title: string;
|
||||||
|
authorName: string;
|
||||||
|
authorUrl: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
embedHtml: string;
|
||||||
|
videoId: string | null;
|
||||||
|
}
|
||||||
| { status: 'error' };
|
| { status: 'error' };
|
||||||
|
|
||||||
type TikTokEmbedProps = {
|
type TikTokEmbedProps = {
|
||||||
@@ -88,7 +104,7 @@ type TikTokEmbedProps = {
|
|||||||
*/
|
*/
|
||||||
export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref) => {
|
export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref) => {
|
||||||
const [state, setState] = useState<TikTokEmbedState>({ status: 'loading' });
|
const [state, setState] = useState<TikTokEmbedState>({ status: 'loading' });
|
||||||
const [showEmbed, setShowEmbed] = useState(false);
|
const [showPlayer, setShowPlayer] = useState(false);
|
||||||
|
|
||||||
// Fetch video info on mount
|
// Fetch video info on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -96,7 +112,7 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
|
|||||||
|
|
||||||
fetchTikTokOEmbed(url).then((info) => {
|
fetchTikTokOEmbed(url).then((info) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
if (info) {
|
if (info) {
|
||||||
setState({
|
setState({
|
||||||
status: 'loaded',
|
status: 'loaded',
|
||||||
@@ -105,6 +121,7 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
|
|||||||
authorUrl: info.authorUrl,
|
authorUrl: info.authorUrl,
|
||||||
thumbnailUrl: info.thumbnailUrl,
|
thumbnailUrl: info.thumbnailUrl,
|
||||||
embedHtml: info.embedHtml,
|
embedHtml: info.embedHtml,
|
||||||
|
videoId: info.videoId,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setState({ status: 'error' });
|
setState({ status: 'error' });
|
||||||
@@ -134,9 +151,9 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
|
|||||||
TikTok - Loading...
|
TikTok - Loading...
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<Box
|
<Box
|
||||||
className={css.TikTokThumbnailContainer}
|
className={css.TikTokThumbnailContainer}
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
justifyContent="Center"
|
justifyContent="Center"
|
||||||
style={{ background: '#000', display: 'flex' }}
|
style={{ background: '#000', display: 'flex' }}
|
||||||
>
|
>
|
||||||
@@ -169,7 +186,7 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Loaded state - show thumbnail or embed
|
// Loaded state - show thumbnail or embed
|
||||||
if (!showEmbed) {
|
if (!showPlayer) {
|
||||||
return (
|
return (
|
||||||
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
|
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
|
||||||
<div className={css.TikTokEmbedHeader}>
|
<div className={css.TikTokEmbedHeader}>
|
||||||
@@ -203,23 +220,27 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={css.TikTokThumbnailButton}
|
className={css.TikTokThumbnailButton}
|
||||||
onClick={() => setShowEmbed(true)}
|
onClick={() => setShowPlayer(true)}
|
||||||
aria-label="Load TikTok video"
|
aria-label="Play TikTok video"
|
||||||
>
|
>
|
||||||
{state.thumbnailUrl && (
|
{state.thumbnailUrl ? (
|
||||||
<img
|
<img
|
||||||
src={state.thumbnailUrl}
|
src={state.thumbnailUrl}
|
||||||
alt={state.title}
|
alt={state.title}
|
||||||
className={css.TikTokThumbnail}
|
className={css.TikTokThumbnail}
|
||||||
crossOrigin="anonymous"
|
crossOrigin="anonymous"
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show embed (using dangerouslySetInnerHTML for TikTok's oEmbed HTML)
|
const playerUrl = state.videoId
|
||||||
|
? `https://www.tiktok.com/player/v1/${state.videoId}?autoplay=1`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Use TikTok's documented player instead of injecting the oEmbed script.
|
||||||
return (
|
return (
|
||||||
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
|
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
|
||||||
<div className={css.TikTokEmbedHeader}>
|
<div className={css.TikTokEmbedHeader}>
|
||||||
@@ -236,10 +257,20 @@ export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref)
|
|||||||
{state.title}
|
{state.title}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<div
|
{playerUrl ? (
|
||||||
className={css.TikTokEmbedContainer}
|
<iframe
|
||||||
dangerouslySetInnerHTML={{ __html: state.embedHtml }}
|
className={css.TikTokEmbedContainer}
|
||||||
/>
|
src={playerUrl}
|
||||||
|
title="TikTok video player"
|
||||||
|
allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={css.TikTokEmbedContainer}
|
||||||
|
dangerouslySetInnerHTML={{ __html: state.embedHtml }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { Avatar, Box, Overlay, Text, toRem } from 'folds';
|
||||||
import { Icon, Icons } from '../icons';
|
import { Icon, Icons } from '../icons';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
@@ -14,31 +14,36 @@ import { ImageViewer } from '../image-viewer';
|
|||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { useOtherUserColor } from '../../hooks/useUserColor';
|
import { useOtherUserColor } from '../../hooks/useUserColor';
|
||||||
import { useOtherUserBanner } from '../../hooks/useUserBanner';
|
import { useOtherUserBanner } from '../../hooks/useUserBanner';
|
||||||
import { useOtherUserProfileStyle } from '../../hooks/useUserProfileStyle';
|
import { useOtherUserCollectibles } from '../../hooks/useUserCollectibles';
|
||||||
|
import { pickAvatarDecorationUrl } from '../../utils/collectibleAssets';
|
||||||
|
import * as avatarDecorationCss from '../../styles/AvatarDecoration.css';
|
||||||
|
import { ProfileCollectibleOverlays } from './ProfileCollectibleOverlays';
|
||||||
|
|
||||||
type UserHeroProps = {
|
type UserHeroProps = {
|
||||||
userId: string;
|
userId: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
avatarMxc?: string;
|
avatarMxc?: string;
|
||||||
presence?: UserPresence;
|
presence?: UserPresence;
|
||||||
|
children?: ReactNode;
|
||||||
};
|
};
|
||||||
export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroProps) {
|
export function UserHero({ userId, avatarUrl, avatarMxc, presence, children }: UserHeroProps) {
|
||||||
const [viewAvatar, setViewAvatar] = useState<string>();
|
const [viewAvatar, setViewAvatar] = useState<string>();
|
||||||
const bannerUrl = useOtherUserBanner(userId, avatarMxc); // Now returns blob URL directly
|
const bannerUrl = useOtherUserBanner(userId);
|
||||||
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
|
const { assetUrls } = useOtherUserCollectibles(userId);
|
||||||
|
const avatarDecorationUrl = pickAvatarDecorationUrl(assetUrls);
|
||||||
console.log('[UserHero]', {
|
|
||||||
userId,
|
|
||||||
avatarMxc,
|
|
||||||
bannerUrl,
|
|
||||||
profileStyle,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box direction="Column" className={css.UserHeroZone}>
|
||||||
direction="Column"
|
{bannerUrl && (
|
||||||
className={css.UserHero}
|
<div className={css.UserHeroBannerReflection} aria-hidden="true">
|
||||||
>
|
<img
|
||||||
|
className={css.UserHeroBannerReflectionImg}
|
||||||
|
src={bannerUrl}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={css.UserHeroCoverContainer}
|
className={css.UserHeroCoverContainer}
|
||||||
style={{
|
style={{
|
||||||
@@ -56,35 +61,45 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
|
|||||||
</div>
|
</div>
|
||||||
<div className={css.UserHeroAvatarContainer}>
|
<div className={css.UserHeroAvatarContainer}>
|
||||||
<AvatarPresence
|
<AvatarPresence
|
||||||
className={css.UserAvatarContainer}
|
className={classNames(
|
||||||
style={profileStyle.avatarBorderColor ? { backgroundColor: profileStyle.avatarBorderColor } : undefined}
|
css.UserAvatarContainer,
|
||||||
badgeBackgroundColor={profileStyle.avatarBorderColor}
|
!avatarUrl && css.UserAvatarContainerFallback
|
||||||
|
)}
|
||||||
badge={
|
badge={
|
||||||
presence && <PresenceBadge presence={presence.presence} status={presence.status} />
|
presence && <PresenceBadge presence={presence.presence} status={presence.status} />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Avatar
|
<div className={css.UserHeroAvatarStack}>
|
||||||
as={avatarUrl ? 'button' : 'div'}
|
<Avatar
|
||||||
onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined}
|
as={avatarUrl ? 'button' : 'div'}
|
||||||
className={css.UserHeroAvatar}
|
onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined}
|
||||||
size="500"
|
className={classNames(
|
||||||
style={{
|
css.UserHeroAvatar,
|
||||||
width: toRem(72),
|
avatarUrl ? css.UserHeroAvatarWithImage : css.UserHeroAvatarBorder
|
||||||
height: toRem(72),
|
)}
|
||||||
...(profileStyle.avatarBorderColor ? {
|
size="500"
|
||||||
outline: `${toRem(4)} solid ${profileStyle.avatarBorderColor}`,
|
style={{
|
||||||
outlineOffset: toRem(-1),
|
width: toRem(72),
|
||||||
} : {}),
|
height: toRem(72),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
className={css.UserHeroAvatarImg}
|
className={css.UserHeroAvatarImg}
|
||||||
userId={userId}
|
userId={userId}
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
alt={userId}
|
alt={userId}
|
||||||
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
|
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
|
||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
{avatarDecorationUrl && (
|
||||||
|
<img
|
||||||
|
className={avatarDecorationCss.AvatarDecorationOverlay}
|
||||||
|
src={avatarDecorationUrl}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</AvatarPresence>
|
</AvatarPresence>
|
||||||
{viewAvatar && (
|
{viewAvatar && (
|
||||||
<Overlay open backdrop={null}>
|
<Overlay open backdrop={null}>
|
||||||
@@ -105,6 +120,12 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
|
|||||||
</Overlay>
|
</Overlay>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{children && (
|
||||||
|
<Box direction="Column" className={css.UserHeroInfo}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<ProfileCollectibleOverlays assetUrls={assetUrls} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -114,16 +135,9 @@ type UserHeroNameProps = {
|
|||||||
userId: string;
|
userId: string;
|
||||||
avatarMxc?: string;
|
avatarMxc?: string;
|
||||||
};
|
};
|
||||||
export function UserHeroName({ displayName, userId, avatarMxc }: UserHeroNameProps) {
|
export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
|
||||||
const username = getMxIdLocalPart(userId);
|
const username = getMxIdLocalPart(userId);
|
||||||
const userColor = useOtherUserColor(userId, avatarMxc);
|
const userColor = useOtherUserColor(userId);
|
||||||
|
|
||||||
console.log('[UserHeroName]', {
|
|
||||||
userId,
|
|
||||||
avatarMxc,
|
|
||||||
userColor,
|
|
||||||
fallbackColor: colorMXID(userId),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" direction="Column" gap="100">
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Box, Button, color, config, Text, toRem } from 'folds';
|
|||||||
import { Icon, Icons } from '../icons';
|
import { Icon, Icons } from '../icons';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { UserHero, UserHeroName } from './UserHero';
|
import { UserHero } from './UserHero';
|
||||||
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
|
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
@@ -10,7 +10,6 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|||||||
import { usePowerLevels } from '../../hooks/usePowerLevels';
|
import { usePowerLevels } from '../../hooks/usePowerLevels';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
import { useUserPresence } from '../../hooks/useUserPresence';
|
import { useUserPresence } from '../../hooks/useUserPresence';
|
||||||
import { useOtherUserProfileStyle } from '../../hooks/useUserProfileStyle';
|
|
||||||
import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips';
|
import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips';
|
||||||
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
import { PowerChip } from './PowerChip';
|
import { PowerChip } from './PowerChip';
|
||||||
@@ -24,7 +23,7 @@ import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
|
|||||||
import { CreatorChip } from './CreatorChip';
|
import { CreatorChip } from './CreatorChip';
|
||||||
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
|
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
|
||||||
import { DirectCreateSearchParams } from '../../pages/paths';
|
import { DirectCreateSearchParams } from '../../pages/paths';
|
||||||
import { getContrastingTextColor, stripAlphaFromColor, getTextShadowColor } from '../../utils/common';
|
import { getTextShadowColor } from '../../utils/common';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { BreakWord, LineClamp3 } from '../../styles/Text.css';
|
import { BreakWord, LineClamp3 } from '../../styles/Text.css';
|
||||||
import colorMXID, { getColorMXIDValue } from '../../../util/colorMXID';
|
import colorMXID, { getColorMXIDValue } from '../../../util/colorMXID';
|
||||||
@@ -65,13 +64,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
|
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
|
||||||
|
|
||||||
const presence = useUserPresence(userId);
|
const presence = useUserPresence(userId);
|
||||||
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
|
const userColor = useOtherUserColor(userId);
|
||||||
const userColor = useOtherUserColor(userId, avatarMxc);
|
|
||||||
|
|
||||||
// Build gradient CSS if configured
|
|
||||||
const gradientStyle = profileStyle.gradient
|
|
||||||
? `linear-gradient(${profileStyle.gradient.direction}, ${profileStyle.gradient.startColor}, ${profileStyle.gradient.stopColor})`
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const handleMessage = () => {
|
const handleMessage = () => {
|
||||||
closeUserRoomProfile();
|
closeUserRoomProfile();
|
||||||
@@ -93,22 +86,6 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
// Use userColor for text, or fall back to colorMXID
|
// Use userColor for text, or fall back to colorMXID
|
||||||
const profileColor = userColor ? userColor : getColorMXIDValue(userId);
|
const profileColor = userColor ? userColor : getColorMXIDValue(userId);
|
||||||
const profileTextShadow = `0 1px 4px ${getTextShadowColor(profileColor)}`;
|
const profileTextShadow = `0 1px 4px ${getTextShadowColor(profileColor)}`;
|
||||||
|
|
||||||
// Use avatarBorderColor for pill background only if it has visible alpha (not fully transparent)
|
|
||||||
const hasVisibleBorder = profileStyle.avatarBorderColor && !profileStyle.avatarBorderColor.endsWith('00');
|
|
||||||
const pillBgColor = hasVisibleBorder ? stripAlphaFromColor(profileStyle.avatarBorderColor!) : undefined;
|
|
||||||
|
|
||||||
console.log('[UserRoomProfile]', {
|
|
||||||
userId,
|
|
||||||
avatarMxc,
|
|
||||||
userColor,
|
|
||||||
profileStyle,
|
|
||||||
profileColor,
|
|
||||||
profileTextShadow,
|
|
||||||
hasVisibleBorder,
|
|
||||||
pillBgColor,
|
|
||||||
fallbackColorMXID: getColorMXIDValue(userId),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column">
|
<Box direction="Column">
|
||||||
@@ -117,65 +94,60 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
avatarUrl={avatarUrl}
|
avatarUrl={avatarUrl}
|
||||||
avatarMxc={avatarMxc}
|
avatarMxc={avatarMxc}
|
||||||
presence={presence && presence.lastActiveTs !== 0 ? presence : undefined}
|
presence={presence && presence.lastActiveTs !== 0 ? presence : undefined}
|
||||||
/>
|
|
||||||
<Box
|
|
||||||
direction="Column"
|
|
||||||
gap="200"
|
|
||||||
alignItems="Center"
|
|
||||||
style={{
|
|
||||||
padding: config.space.S400,
|
|
||||||
paddingTop: `calc(${config.space.S200} + ${toRem(36)})`,
|
|
||||||
marginTop: toRem(-36),
|
|
||||||
background: gradientStyle,
|
|
||||||
textAlign: 'center'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Display Name */}
|
<Box
|
||||||
<Text
|
direction="Column"
|
||||||
size="H4"
|
gap="200"
|
||||||
className={classNames(BreakWord, LineClamp3)}
|
alignItems="Center"
|
||||||
style={{ color: getMemberDisplayName(room, userId) !== userId ? profileColor : undefined, textShadow: profileTextShadow }}
|
style={{
|
||||||
|
padding: config.space.S400,
|
||||||
|
paddingTop: `calc(${config.space.S200} + ${toRem(36)})`,
|
||||||
|
marginTop: toRem(-36),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{getMemberDisplayName(room, userId)}
|
<Text
|
||||||
</Text>
|
size="H4"
|
||||||
|
className={classNames(BreakWord, LineClamp3)}
|
||||||
{/* Username */}
|
|
||||||
<Text
|
|
||||||
size="T200"
|
|
||||||
className={BreakWord}
|
|
||||||
style={{ color: profileColor, textShadow: profileTextShadow }}
|
|
||||||
>
|
|
||||||
{userId}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Status Pill */}
|
|
||||||
{presence?.status && (
|
|
||||||
<Box
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: pillBgColor || color.Surface.Container,
|
color: getMemberDisplayName(room, userId) !== userId ? profileColor : undefined,
|
||||||
border: pillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
textShadow: profileTextShadow,
|
||||||
padding: `${toRem(6)} ${toRem(10)}`,
|
|
||||||
borderRadius: toRem(16),
|
|
||||||
maxWidth: toRem(250),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
{getMemberDisplayName(room, userId)}
|
||||||
size="T300"
|
</Text>
|
||||||
className={BreakWord}
|
|
||||||
|
<Text
|
||||||
|
size="T200"
|
||||||
|
className={BreakWord}
|
||||||
|
style={{ color: profileColor, textShadow: profileTextShadow }}
|
||||||
|
>
|
||||||
|
{userId}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{presence?.status && (
|
||||||
|
<Box
|
||||||
style={{
|
style={{
|
||||||
color: pillBgColor ? getContrastingTextColor(pillBgColor) : undefined
|
backgroundColor: color.Surface.Container,
|
||||||
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
|
padding: `${toRem(6)} ${toRem(10)}`,
|
||||||
|
borderRadius: toRem(16),
|
||||||
|
maxWidth: toRem(250),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{presence.status}
|
<Text size="T300" className={BreakWord}>
|
||||||
</Text>
|
{presence.status}
|
||||||
</Box>
|
</Text>
|
||||||
)}
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</UserHero>
|
||||||
|
|
||||||
|
<Box direction="Column" gap="200" alignItems="Center" style={{ padding: config.space.S400, textAlign: 'center' }}>
|
||||||
{/* Chips Row */}
|
{/* Chips Row */}
|
||||||
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
|
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
|
||||||
{server && <ServerChip server={server} borderColor={pillBgColor} />}
|
{server && <ServerChip server={server} />}
|
||||||
<ShareChip userId={userId} borderColor={pillBgColor} />
|
<ShareChip userId={userId} />
|
||||||
{creator ? <CreatorChip borderColor={pillBgColor} /> : <PowerChip userId={userId} borderColor={pillBgColor} />}
|
{creator ? <CreatorChip /> : <PowerChip userId={userId} />}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Message Button */}
|
{/* Message Button */}
|
||||||
@@ -195,12 +167,13 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
{/* Additional Chips if needed */}
|
{/* Additional Chips if needed */}
|
||||||
{userId !== myUserId && (
|
{userId !== myUserId && (
|
||||||
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
|
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
|
||||||
<MutualRoomsChip userId={userId} borderColor={pillBgColor} />
|
<MutualRoomsChip userId={userId} />
|
||||||
<OptionsChip userId={userId} borderColor={pillBgColor} />
|
<OptionsChip userId={userId} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{hasBottomContent && <Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
|
{hasBottomContent && (
|
||||||
|
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
|
||||||
{ignored && <IgnoredUserAlert />}
|
{ignored && <IgnoredUserAlert />}
|
||||||
{member && membership === Membership.Ban && (
|
{member && membership === Membership.Ban && (
|
||||||
<UserBanAlert
|
<UserBanAlert
|
||||||
@@ -236,7 +209,8 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
canKick={canKickUser && membership === Membership.Join}
|
canKick={canKickUser && membership === Membership.Join}
|
||||||
canBan={canBanUser && membership !== Membership.Ban}
|
canBan={canBanUser && membership !== Membership.Ban}
|
||||||
/>
|
/>
|
||||||
</Box>}
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
import { color, config, toRem } from 'folds';
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
export const UserHeader = style({
|
export const UserHeader = style({
|
||||||
@@ -10,14 +10,26 @@ export const UserHeader = style({
|
|||||||
padding: config.space.S200,
|
padding: config.space.S200,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UserHero = style({
|
export const UserHeroZone = style({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
isolation: 'isolate',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UserHeroCollectibleLayer = style({
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 10,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
overflow: 'visible',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UserHeroCoverContainer = style({
|
export const UserHeroCoverContainer = style({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
height: toRem(140),
|
height: toRem(140),
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
zIndex: 1,
|
||||||
});
|
});
|
||||||
export const UserHeroCover = style({
|
export const UserHeroCover = style({
|
||||||
height: '100%',
|
height: '100%',
|
||||||
@@ -36,12 +48,68 @@ export const UserHeroBanner = style({
|
|||||||
export const UserHeroAvatarContainer = style({
|
export const UserHeroAvatarContainer = style({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
height: toRem(29),
|
height: toRem(29),
|
||||||
|
zIndex: 4,
|
||||||
|
overflow: 'visible',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const UserHeroBannerReflection = style({
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: toRem(140),
|
||||||
|
bottom: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
zIndex: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UserHeroBannerReflectionImg = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: toRem(12),
|
||||||
|
left: '-10%',
|
||||||
|
width: '120%',
|
||||||
|
height: toRem(160),
|
||||||
|
objectFit: 'cover',
|
||||||
|
transformOrigin: 'top center',
|
||||||
|
transform: 'scaleY(-1) scale(1.2)',
|
||||||
|
filter: 'blur(48px) saturate(1.15)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UserHeroInfo = style({
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 4,
|
||||||
|
textAlign: 'center',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${UserHeroBannerReflection}::after`, {
|
||||||
|
content: '',
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: toRem(72),
|
||||||
|
background: `linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent 0%,
|
||||||
|
color-mix(in srgb, ${color.Surface.Container} 55%, transparent) 55%,
|
||||||
|
${color.Surface.Container} 100%
|
||||||
|
)`,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
export const UserAvatarContainer = style({
|
export const UserAvatarContainer = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
top: 0,
|
top: 0,
|
||||||
transform: 'translate(-50%, -50%)',
|
transform: 'translate(-50%, -50%)',
|
||||||
|
zIndex: 4,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UserAvatarContainerFallback = style({
|
||||||
backgroundColor: color.Surface.Container,
|
backgroundColor: color.Surface.Container,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,14 +121,31 @@ export const UserStatusBubble = style({
|
|||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const UserHeroAvatarStack = style({
|
||||||
|
position: 'relative',
|
||||||
|
width: toRem(72),
|
||||||
|
height: toRem(72),
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
export const UserHeroAvatar = style({
|
export const UserHeroAvatar = style({
|
||||||
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'button&': {
|
'button&': {
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const UserHeroAvatarBorder = style({
|
||||||
|
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
|
||||||
|
border: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UserHeroAvatarWithImage = style({
|
||||||
|
outline: 'none',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
});
|
||||||
export const UserHeroAvatarImg = style({
|
export const UserHeroAvatarImg = style({
|
||||||
selectors: {
|
selectors: {
|
||||||
[`button${UserHeroAvatar}:hover &`]: {
|
[`button${UserHeroAvatar}:hover &`]: {
|
||||||
@@ -68,3 +153,15 @@ export const UserHeroAvatarImg = style({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ProfileEffectOverlay = style({
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: '450 / 880',
|
||||||
|
objectFit: 'contain',
|
||||||
|
objectPosition: 'top center',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
|||||||
187
src/app/data/updateNotes.ts
Normal file
187
src/app/data/updateNotes.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import bundledManifest from '../../../public/update/manifest.json';
|
||||||
|
import { trimTrailingSlash } from '../utils/common';
|
||||||
|
|
||||||
|
const bundledMarkdownByFile = import.meta.glob('../../../public/update/*.md', {
|
||||||
|
query: '?raw',
|
||||||
|
import: 'default',
|
||||||
|
eager: true,
|
||||||
|
}) as Record<string, string>;
|
||||||
|
|
||||||
|
const bundledImageByFile = import.meta.glob('../../../public/update/images/*', {
|
||||||
|
import: 'default',
|
||||||
|
eager: true,
|
||||||
|
}) as Record<string, string>;
|
||||||
|
|
||||||
|
function getUpdateBaseUrl(): string {
|
||||||
|
const basePath = trimTrailingSlash(import.meta.env.BASE_URL || './');
|
||||||
|
const relative =
|
||||||
|
basePath === '.' || basePath === '' ? 'update/' : `${basePath}/update/`;
|
||||||
|
return new URL(relative, window.location.href).href.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUpdateFileUrl(file: string): string {
|
||||||
|
return new URL(file, `${getUpdateBaseUrl()}/`).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateManifestEntry = {
|
||||||
|
file: string;
|
||||||
|
version?: string;
|
||||||
|
title?: string;
|
||||||
|
date?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateManifest = {
|
||||||
|
current: string;
|
||||||
|
older: UpdateManifestEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParsedUpdateDoc = {
|
||||||
|
file: string;
|
||||||
|
version?: string;
|
||||||
|
title?: string;
|
||||||
|
date?: string;
|
||||||
|
summary?: string;
|
||||||
|
markdown: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
||||||
|
|
||||||
|
function parseFrontmatter(source: string): { frontmatter: Record<string, string>; body: string } {
|
||||||
|
const match = FRONTMATTER_RE.exec(source.trimStart());
|
||||||
|
if (!match) {
|
||||||
|
return { frontmatter: {}, body: source };
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontmatter: Record<string, string> = {};
|
||||||
|
match[1].split('\n').forEach((line) => {
|
||||||
|
const colon = line.indexOf(':');
|
||||||
|
if (colon <= 0) return;
|
||||||
|
const key = line.slice(0, colon).trim();
|
||||||
|
const raw = line.slice(colon + 1).trim();
|
||||||
|
frontmatter[key] = raw.replace(/^['"]|['"]$/g, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
return { frontmatter, body: match[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUpdateMarkdown(file: string, source: string): ParsedUpdateDoc {
|
||||||
|
const { frontmatter, body } = parseFrontmatter(source);
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
version: frontmatter.version,
|
||||||
|
title: frontmatter.title,
|
||||||
|
date: frontmatter.date,
|
||||||
|
summary: frontmatter.summary,
|
||||||
|
markdown: body.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBundledManifest(): UpdateManifest {
|
||||||
|
const manifest = bundledManifest as UpdateManifest;
|
||||||
|
return {
|
||||||
|
current: manifest.current,
|
||||||
|
older: (manifest.older ?? []).filter((entry) => entry?.file),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBundledDocument(file: string): ParsedUpdateDoc | null {
|
||||||
|
const safeFile = file.replace(/^\/+/, '');
|
||||||
|
const entry = Object.entries(bundledMarkdownByFile).find(([path]) =>
|
||||||
|
path.endsWith(`/${safeFile}`)
|
||||||
|
);
|
||||||
|
if (!entry) return null;
|
||||||
|
return parseUpdateMarkdown(safeFile, entry[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBundledImageUrl(normalizedPath: string): string | undefined {
|
||||||
|
const needle = normalizedPath.replace(/^\/+/, '');
|
||||||
|
if (!needle) return undefined;
|
||||||
|
|
||||||
|
const entry = Object.entries(bundledImageByFile).find(([path]) => {
|
||||||
|
const normalized = path.replace(/\\/g, '/');
|
||||||
|
return normalized.endsWith(`/${needle}`) || normalized.endsWith(`/${needle.split('/').pop() ?? ''}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return entry?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveUpdateAssetUrl(src: string | undefined): string | undefined {
|
||||||
|
if (!src) return undefined;
|
||||||
|
if (/^(https?:|data:|blob:|capacitor:)/i.test(src)) return src;
|
||||||
|
|
||||||
|
const normalized = src.replace(/^\.\//, '').replace(/^\/+/, '');
|
||||||
|
const bundled = resolveBundledImageUrl(normalized);
|
||||||
|
if (bundled) return bundled;
|
||||||
|
|
||||||
|
if (src.startsWith('/')) return src;
|
||||||
|
|
||||||
|
return new URL(normalized, `${getUpdateBaseUrl()}/`).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadUpdateManifest(): Promise<UpdateManifest | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(getUpdateFileUrl('manifest.json'), { cache: 'no-cache' });
|
||||||
|
if (response.ok) {
|
||||||
|
const data = (await response.json()) as UpdateManifest;
|
||||||
|
if (data?.current && Array.isArray(data.older)) {
|
||||||
|
return {
|
||||||
|
current: data.current,
|
||||||
|
older: data.older.filter((entry) => entry?.file),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to bundled copy (Capacitor / offline)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return loadBundledManifest();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadUpdateDocument(file: string): Promise<ParsedUpdateDoc | null> {
|
||||||
|
const safeFile = file.replace(/^\/+/, '');
|
||||||
|
if (!safeFile || safeFile.includes('..')) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(getUpdateFileUrl(safeFile), { cache: 'no-cache' });
|
||||||
|
if (response.ok) {
|
||||||
|
const source = await response.text();
|
||||||
|
return parseUpdateMarkdown(safeFile, source);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to bundled copy
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadBundledDocument(safeFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCurrentUpdateDocument(): Promise<ParsedUpdateDoc | null> {
|
||||||
|
const manifest = await loadUpdateManifest();
|
||||||
|
if (!manifest) return null;
|
||||||
|
return loadUpdateDocument(manifest.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentUpdatePreview(): Promise<{
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
} | null> {
|
||||||
|
const doc = await loadCurrentUpdateDocument();
|
||||||
|
if (!doc) return null;
|
||||||
|
|
||||||
|
const title = doc.title ?? (doc.version ? `Paarrot ${doc.version}` : 'Release notes');
|
||||||
|
const description =
|
||||||
|
doc.summary ??
|
||||||
|
doc.markdown
|
||||||
|
.replace(/^#+\s+/gm, '')
|
||||||
|
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.replace(/^[-*•]\s*/, '').trim())
|
||||||
|
.find(Boolean) ??
|
||||||
|
'See what changed in this version.';
|
||||||
|
|
||||||
|
return { title, description };
|
||||||
|
}
|
||||||
@@ -105,7 +105,7 @@ function SearchResultItem({
|
|||||||
relation?.rel_type === RelationType.Thread ? relation.event_id : undefined;
|
relation?.rel_type === RelationType.Thread ? relation.event_id : undefined;
|
||||||
|
|
||||||
// Get custom user color from avatar metadata
|
// Get custom user color from avatar metadata
|
||||||
const customUserColor = useOtherUserColor(event.sender, senderAvatarMxc);
|
const customUserColor = useOtherUserColor(event.sender, room);
|
||||||
|
|
||||||
const memberPowerTag = getMemberPowerTag(event.sender);
|
const memberPowerTag = getMemberPowerTag(event.sender);
|
||||||
const tagColor = memberPowerTag?.color
|
const tagColor = memberPowerTag?.color
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Get custom user color from avatar metadata
|
// Get custom user color from avatar metadata
|
||||||
const replyCustomColor = useOtherUserColor(replyUserID ?? '', replyAvatarMxc);
|
const replyCustomColor = useOtherUserColor(replyUserID ?? '', room);
|
||||||
|
|
||||||
const replyPowerTag = replyUserID ? getMemberPowerTag(replyUserID) : undefined;
|
const replyPowerTag = replyUserID ? getMemberPowerTag(replyUserID) : undefined;
|
||||||
const replyPowerColor = replyPowerTag?.color
|
const replyPowerColor = replyPowerTag?.color
|
||||||
|
|||||||
@@ -29,7 +29,26 @@ export const TimelineFloat = recipe({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>;
|
export const DmNameplateBackground = style({
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DmNameplateBackgroundMedia = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
left: 'auto',
|
||||||
|
width: toRem(280),
|
||||||
|
maxWidth: '70%',
|
||||||
|
height: toRem(64),
|
||||||
|
objectFit: 'cover',
|
||||||
|
objectPosition: 'right top',
|
||||||
|
opacity: 0.12,
|
||||||
|
});
|
||||||
|
|
||||||
export const CarouselScroller = style([
|
export const CarouselScroller = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|||||||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||||
|
import { useOtherUserCollectibles } from '../../hooks/useUserCollectibles';
|
||||||
|
import { pickNameplateUrl } from '../../utils/collectibleAssets';
|
||||||
import { setupCopyHandler } from '../../utils/copyHandler';
|
import { setupCopyHandler } from '../../utils/copyHandler';
|
||||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
@@ -570,6 +572,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
||||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||||
const direct = useIsDirectRoom();
|
const direct = useIsDirectRoom();
|
||||||
|
const dmUserId = direct ? room.guessDMUserId() ?? undefined : undefined;
|
||||||
|
const [timelineHovered, setTimelineHovered] = useState(false);
|
||||||
|
const { assetUrls: dmAssetUrls } = useOtherUserCollectibles(dmUserId ?? '');
|
||||||
|
const dmNameplateUrl = pickNameplateUrl(dmAssetUrls);
|
||||||
|
const dmNameplateIsVideo = Boolean(
|
||||||
|
dmAssetUrls['nameplate:animated'] || dmAssetUrls['nameplate:asset.webm']
|
||||||
|
);
|
||||||
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
||||||
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
||||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||||
@@ -2530,7 +2539,33 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const atLiveBottom = atBottom && liveTimelineLinked && rangeAtEnd;
|
const atLiveBottom = atBottom && liveTimelineLinked && rangeAtEnd;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" style={{ position: 'relative' }}>
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
style={{ position: 'relative' }}
|
||||||
|
onMouseEnter={() => setTimelineHovered(true)}
|
||||||
|
onMouseLeave={() => setTimelineHovered(false)}
|
||||||
|
>
|
||||||
|
{direct && timelineHovered && dmNameplateUrl && (
|
||||||
|
<div className={css.DmNameplateBackground} aria-hidden="true">
|
||||||
|
{dmNameplateIsVideo ? (
|
||||||
|
<video
|
||||||
|
className={css.DmNameplateBackgroundMedia}
|
||||||
|
src={dmNameplateUrl}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
className={css.DmNameplateBackgroundMedia}
|
||||||
|
src={dmNameplateUrl}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll="">
|
<Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll="">
|
||||||
<Box
|
<Box
|
||||||
ref={timelineContentRef}
|
ref={timelineContentRef}
|
||||||
|
|||||||
@@ -489,7 +489,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
const dmAvatarMxc =
|
const dmAvatarMxc =
|
||||||
(dmUserId ? room.getMember(dmUserId)?.getMxcAvatarUrl() : undefined) ||
|
(dmUserId ? room.getMember(dmUserId)?.getMxcAvatarUrl() : undefined) ||
|
||||||
(isDirect ? avatarMxc : undefined);
|
(isDirect ? avatarMxc : undefined);
|
||||||
const dmCustomColor = useOtherUserColor(dmUserId ?? '', dmAvatarMxc);
|
const dmCustomColor = useOtherUserColor(dmUserId ?? '');
|
||||||
const dmFolderTabColor =
|
const dmFolderTabColor =
|
||||||
stationery && isDirect
|
stationery && isDirect
|
||||||
? `color-mix(in srgb, ${dmCustomColor ?? colorMXID(dmUserId ?? room.roomId)} 50%, var(--folder-tab-room, #f3e6c8))`
|
? `color-mix(in srgb, ${dmCustomColor ?? colorMXID(dmUserId ?? room.roomId)} 50%, var(--folder-tab-room, #f3e6c8))`
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ import colorMXID from '../../../../util/colorMXID';
|
|||||||
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
||||||
import { Presence, useUserPresence } from '../../../hooks/useUserPresence';
|
import { Presence, useUserPresence } from '../../../hooks/useUserPresence';
|
||||||
import { useOtherUserColor } from '../../../hooks/useUserColor';
|
import { useOtherUserColor } from '../../../hooks/useUserColor';
|
||||||
|
import { useIsDirectRoom } from '../../../hooks/useRoom';
|
||||||
|
import { useOtherUserCollectibles } from '../../../hooks/useUserCollectibles';
|
||||||
|
import { pickAvatarDecorationUrl, pickNameplateUrl } from '../../../utils/collectibleAssets';
|
||||||
|
import * as avatarDecorationCss from '../../../styles/AvatarDecoration.css';
|
||||||
|
|
||||||
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
|
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
|
||||||
|
|
||||||
@@ -754,8 +758,15 @@ export const Message = as<'div', MessageProps>(
|
|||||||
) => {
|
) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const direct = useIsDirectRoom();
|
||||||
const senderId = mEvent.getSender() ?? '';
|
const senderId = mEvent.getSender() ?? '';
|
||||||
const senderPresence = useUserPresence(senderId);
|
const senderPresence = useUserPresence(senderId);
|
||||||
|
const { assetUrls } = useOtherUserCollectibles(senderId);
|
||||||
|
const nameplateUrl = pickNameplateUrl(assetUrls);
|
||||||
|
const nameplateIsVideo = Boolean(
|
||||||
|
assetUrls['nameplate:animated'] || assetUrls['nameplate:asset.webm']
|
||||||
|
);
|
||||||
|
const avatarDecorationUrl = pickAvatarDecorationUrl(assetUrls);
|
||||||
|
|
||||||
const [hover, setHover] = useState(false);
|
const [hover, setHover] = useState(false);
|
||||||
const { hoverProps } = useHover({ onHoverChange: setHover });
|
const { hoverProps } = useHover({ onHoverChange: setHover });
|
||||||
@@ -785,7 +796,7 @@ export const Message = as<'div', MessageProps>(
|
|||||||
const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
|
const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
|
||||||
|
|
||||||
// Get custom user color from avatar metadata (takes priority)
|
// Get custom user color from avatar metadata (takes priority)
|
||||||
const customUserColor = useOtherUserColor(senderId, senderAvatarMxc);
|
const customUserColor = useOtherUserColor(senderId, room);
|
||||||
|
|
||||||
const tagColor = memberPowerTag?.color
|
const tagColor = memberPowerTag?.color
|
||||||
? accessibleTagColors?.get(memberPowerTag.color)
|
? accessibleTagColors?.get(memberPowerTag.color)
|
||||||
@@ -796,6 +807,7 @@ export const Message = as<'div', MessageProps>(
|
|||||||
|
|
||||||
// Priority: custom user color > tag color (non-legacy) > colorMXID (legacy)
|
// Priority: custom user color > tag color (non-legacy) > colorMXID (legacy)
|
||||||
const usernameColor = customUserColor ?? (legacyUsernameColor ? colorMXID(senderId) : tagColor);
|
const usernameColor = customUserColor ?? (legacyUsernameColor ? colorMXID(senderId) : tagColor);
|
||||||
|
const showNameplate = direct && hover && Boolean(nameplateUrl);
|
||||||
|
|
||||||
const headerJSX = !collapse && (
|
const headerJSX = !collapse && (
|
||||||
<Box
|
<Box
|
||||||
@@ -824,23 +836,41 @@ export const Message = as<'div', MessageProps>(
|
|||||||
</Username>
|
</Username>
|
||||||
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" gap="100">
|
<Box shrink="No" className={css.MessageNameplateAnchor}>
|
||||||
{messageLayout === MessageLayout.Modern && hover && (
|
<Box shrink="No" gap="100" alignItems="Center">
|
||||||
<>
|
{messageLayout === MessageLayout.Modern && hover && (
|
||||||
<Text as="span" size="T200" priority="300">
|
<>
|
||||||
{senderId}
|
<Text as="span" size="T200" priority="300">
|
||||||
</Text>
|
{senderId}
|
||||||
<Text as="span" size="T200" priority="300">
|
</Text>
|
||||||
|
|
<Text as="span" size="T200" priority="300">
|
||||||
</Text>
|
|
|
||||||
</>
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{showNameplate && (
|
||||||
|
<div className={css.MessageNameplateWrap}>
|
||||||
|
{nameplateIsVideo ? (
|
||||||
|
<video
|
||||||
|
className={css.MessageNameplateOverlay}
|
||||||
|
src={nameplateUrl}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img className={css.MessageNameplateOverlay} src={nameplateUrl} alt="" draggable={false} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<Time
|
|
||||||
ts={mEvent.getTs()}
|
|
||||||
compact={messageLayout === MessageLayout.Compact}
|
|
||||||
hour24Clock={hour24Clock}
|
|
||||||
dateFormatString={dateFormatString}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -857,24 +887,38 @@ export const Message = as<'div', MessageProps>(
|
|||||||
<AvatarBase
|
<AvatarBase
|
||||||
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
|
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
|
||||||
>
|
>
|
||||||
<Avatar
|
<div className={css.MessageAvatarStack}>
|
||||||
className={classNames(css.MessageAvatar, presenceClass)}
|
<Avatar
|
||||||
as="button"
|
className={classNames(
|
||||||
size="300"
|
avatarDecorationUrl ? css.MessageAvatarCircular : css.MessageAvatar,
|
||||||
data-user-id={senderId}
|
!avatarDecorationUrl && presenceClass
|
||||||
onClick={onUserClick}
|
)}
|
||||||
>
|
radii={avatarDecorationUrl ? 'Pill' : undefined}
|
||||||
<UserAvatar
|
as="button"
|
||||||
userId={senderId}
|
size="300"
|
||||||
src={
|
data-user-id={senderId}
|
||||||
senderAvatarMxc
|
onClick={onUserClick}
|
||||||
? mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined
|
>
|
||||||
: undefined
|
<UserAvatar
|
||||||
}
|
userId={senderId}
|
||||||
alt={senderDisplayName}
|
src={
|
||||||
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
senderAvatarMxc
|
||||||
/>
|
? mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined
|
||||||
</Avatar>
|
: undefined
|
||||||
|
}
|
||||||
|
alt={senderDisplayName}
|
||||||
|
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
{avatarDecorationUrl && (
|
||||||
|
<img
|
||||||
|
className={avatarDecorationCss.AvatarDecorationOverlay}
|
||||||
|
src={avatarDecorationUrl}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</AvatarBase>
|
</AvatarBase>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -954,6 +998,10 @@ export const Message = as<'div', MessageProps>(
|
|||||||
{...focusWithinProps}
|
{...focusWithinProps}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
className={css.MessageContentLayer}
|
||||||
|
data-message-nameplate-readable={showNameplate ? '' : undefined}
|
||||||
|
>
|
||||||
{!edit && (hover || !!menuAnchor || !!emojiBoardAnchor) && (
|
{!edit && (hover || !!menuAnchor || !!emojiBoardAnchor) && (
|
||||||
<div className={css.MessageOptionsBase}>
|
<div className={css.MessageOptionsBase}>
|
||||||
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
|
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
|
||||||
@@ -1242,6 +1290,7 @@ export const Message = as<'div', MessageProps>(
|
|||||||
{msgContentJSX}
|
{msgContentJSX}
|
||||||
</ModernLayout>
|
</ModernLayout>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</MessageBase>
|
</MessageBase>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,35 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
import { DefaultReset, config, toRem, color } from 'folds';
|
import { DefaultReset, config, toRem, color } from 'folds';
|
||||||
|
import * as layoutCss from '../../../components/message/layout/layout.css';
|
||||||
|
|
||||||
|
const NAMEPLATE_READABLE_TEXT_OUTLINE = [
|
||||||
|
`0 0 2px ${color.Surface.ContainerHover}`,
|
||||||
|
`0 0 4px ${color.Surface.ContainerHover}`,
|
||||||
|
`-1px -1px 0 ${color.Surface.ContainerHover}`,
|
||||||
|
`1px -1px 0 ${color.Surface.ContainerHover}`,
|
||||||
|
`-1px 1px 0 ${color.Surface.ContainerHover}`,
|
||||||
|
`1px 1px 0 ${color.Surface.ContainerHover}`,
|
||||||
|
`0 -1px 0 ${color.Surface.ContainerHover}`,
|
||||||
|
`0 1px 0 ${color.Surface.ContainerHover}`,
|
||||||
|
`-1px 0 0 ${color.Surface.ContainerHover}`,
|
||||||
|
`1px 0 0 ${color.Surface.ContainerHover}`,
|
||||||
|
].join(', ');
|
||||||
|
|
||||||
|
globalStyle(`[data-message-nameplate-readable] [data-message-header] button`, {
|
||||||
|
textShadow: NAMEPLATE_READABLE_TEXT_OUTLINE,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`[data-message-nameplate-readable] [data-message-header] time`, {
|
||||||
|
textShadow: NAMEPLATE_READABLE_TEXT_OUTLINE,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`[data-message-nameplate-readable] [data-message-header] span`, {
|
||||||
|
textShadow: NAMEPLATE_READABLE_TEXT_OUTLINE,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`[data-message-nameplate-readable] .${layoutCss.MessageTextBody.classNames.base}`, {
|
||||||
|
textShadow: NAMEPLATE_READABLE_TEXT_OUTLINE,
|
||||||
|
});
|
||||||
|
|
||||||
export const MessageBase = style({
|
export const MessageBase = style({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -51,6 +81,12 @@ export const MessageAvatar = style({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const MessageAvatarCircular = style({
|
||||||
|
cursor: 'pointer',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'visible',
|
||||||
|
});
|
||||||
|
|
||||||
export const MessageAvatarOnline = style({
|
export const MessageAvatarOnline = style({
|
||||||
'::before': {
|
'::before': {
|
||||||
backgroundColor: '#38842b',
|
backgroundColor: '#38842b',
|
||||||
@@ -69,8 +105,41 @@ export const MessageAvatarOffline = style({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const MessageQuickReaction = style({
|
export const MessageAvatarStack = style({
|
||||||
minWidth: toRem(32),
|
position: 'relative',
|
||||||
|
display: 'inline-flex',
|
||||||
|
lineHeight: 0,
|
||||||
|
overflow: 'visible',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MessageNameplateAnchor = style({
|
||||||
|
position: 'relative',
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MessageNameplateWrap = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: `calc(100% + ${toRem(6)})`,
|
||||||
|
right: 0,
|
||||||
|
width: toRem(220),
|
||||||
|
maxWidth: toRem(280),
|
||||||
|
height: toRem(42),
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MessageNameplateOverlay = style({
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
objectPosition: 'right center',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
borderRadius: toRem(6),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MessageContentLayer = style({
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const MessageMenuGroup = style({
|
export const MessageMenuGroup = style({
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ function PinnedMessage({
|
|||||||
|
|
||||||
const sender = pinnedEvent?.getSender();
|
const sender = pinnedEvent?.getSender();
|
||||||
const senderAvatarMxc = sender ? getMemberAvatarMxc(room, sender) : undefined;
|
const senderAvatarMxc = sender ? getMemberAvatarMxc(room, sender) : undefined;
|
||||||
const customUserColor = useOtherUserColor(sender ?? '', senderAvatarMxc);
|
const customUserColor = useOtherUserColor(sender ?? '', room);
|
||||||
|
|
||||||
const handleOpenClick: MouseEventHandler = (evt) => {
|
const handleOpenClick: MouseEventHandler = (evt) => {
|
||||||
evt.stopPropagation();
|
evt.stopPropagation();
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { getVersion } from '@tauri-apps/api/app';
|
import { getAppVersion } from '../../../utils/appVersion';
|
||||||
|
import { useOpenReleaseNotesDialog } from '../../../components/updates-dialog/UpdatesDialogHost';
|
||||||
|
import { getCurrentUpdatePreview } from '../../../data/updateNotes';
|
||||||
import { Box, Text, IconButton, Scroll, Button, config, toRem } from 'folds';
|
import { Box, Text, IconButton, Scroll, Button, config, toRem } from 'folds';
|
||||||
import { Icon, Icons } from '../../../components/icons';
|
import { Icon, Icons } from '../../../components/icons';
|
||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
@@ -15,9 +17,13 @@ type AboutProps = {
|
|||||||
};
|
};
|
||||||
export function About({ requestClose }: AboutProps) {
|
export function About({ requestClose }: AboutProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const openReleaseNotes = useOpenReleaseNotesDialog();
|
||||||
const [version, setVersion] = useState<string>('');
|
const [version, setVersion] = useState<string>('');
|
||||||
const [protocolStatus, setProtocolStatus] = useState<string>('Checking desktop protocol integration...');
|
const [protocolStatus, setProtocolStatus] = useState<string>('Checking desktop protocol integration...');
|
||||||
const [protocolBusy, setProtocolBusy] = useState<boolean>(false);
|
const [protocolBusy, setProtocolBusy] = useState<boolean>(false);
|
||||||
|
const [updatePreview, setUpdatePreview] = useState<{ title: string; description: string } | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
const formatProtocolStatus = useCallback((data: {
|
const formatProtocolStatus = useCallback((data: {
|
||||||
scheme: string;
|
scheme: string;
|
||||||
@@ -93,8 +99,9 @@ export function About({ requestClose }: AboutProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getVersion().then(setVersion).catch(() => setVersion('unknown'));
|
getAppVersion().then(setVersion).catch(() => setVersion('unknown'));
|
||||||
refreshProtocolStatus();
|
refreshProtocolStatus();
|
||||||
|
getCurrentUpdatePreview().then(setUpdatePreview).catch(() => setUpdatePreview(null));
|
||||||
}, [refreshProtocolStatus]);
|
}, [refreshProtocolStatus]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -151,6 +158,36 @@ export function About({ requestClose }: AboutProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">What's new</Text>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="300"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title={updatePreview?.title ?? 'Release notes'}
|
||||||
|
description={
|
||||||
|
updatePreview
|
||||||
|
? `Paarrot ${version} — ${updatePreview.description}`
|
||||||
|
: 'View highlights from the latest Paarrot update.'
|
||||||
|
}
|
||||||
|
after={
|
||||||
|
<Button
|
||||||
|
onClick={openReleaseNotes}
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
outlined
|
||||||
|
>
|
||||||
|
<Text size="B300">View updates</Text>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Options</Text>
|
<Text size="L400">Options</Text>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import React, {
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Box, Text, IconButton, Input, Avatar, Button, Overlay, OverlayBackdrop, OverlayCenter, Modal, Dialog, Header, config, Spinner, color, toRem } from 'folds';
|
import { Box, Text, IconButton, Input, Avatar, Button, Overlay, OverlayBackdrop, OverlayCenter, Modal, Dialog, Header, config, Spinner, color, toRem } from 'folds';
|
||||||
import { Icon, Icons } from '../../../components/icons';
|
import { Icon, Icons } from '../../../components/icons';
|
||||||
import { HexColorPicker, RgbaColorPicker, RgbaColor } from 'react-colorful';
|
import { HexColorPicker } from 'react-colorful';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
||||||
@@ -19,7 +19,7 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
|||||||
import { UserAvatar } from '../../../components/user-avatar';
|
import { UserAvatar } from '../../../components/user-avatar';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { useAuthenticatedMediaUrl } from '../../../hooks/useAuthenticatedMediaUrl';
|
import { useAuthenticatedMediaUrl } from '../../../hooks/useAuthenticatedMediaUrl';
|
||||||
import { nameInitials, getContrastingTextColor, stripAlphaFromColor, getTextShadowColor } from '../../../utils/common';
|
import { getTextShadowColor } from '../../../utils/common';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useFilePicker } from '../../../hooks/useFilePicker';
|
import { useFilePicker } from '../../../hooks/useFilePicker';
|
||||||
import { useObjectURL } from '../../../hooks/useObjectURL';
|
import { useObjectURL } from '../../../hooks/useObjectURL';
|
||||||
@@ -29,30 +29,26 @@ import { ModalWide } from '../../../styles/Modal.css';
|
|||||||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
||||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||||
import { useCapabilities } from '../../../hooks/useCapabilities';
|
import { useCapabilities } from '../../../hooks/useCapabilities';
|
||||||
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
|
import { useUserColorPreference } from '../../../hooks/useUserColor';
|
||||||
import { AngleSelector } from '../../../components/AngleSelector';
|
import { useUserBanner } from '../../../hooks/useUserBanner';
|
||||||
import { useUserColor, useOtherUserColor } from '../../../hooks/useUserColor';
|
|
||||||
import { useUserBanner, useOtherUserBanner } from '../../../hooks/useUserBanner';
|
|
||||||
import { useUserProfileStyle } from '../../../hooks/useUserProfileStyle';
|
|
||||||
import { useUserPresence } from '../../../hooks/useUserPresence';
|
import { useUserPresence } from '../../../hooks/useUserPresence';
|
||||||
|
import { useTheme, ThemeKind } from '../../../hooks/useTheme';
|
||||||
import { AvatarPresence, PresenceBadge } from '../../../components/presence';
|
import { AvatarPresence, PresenceBadge } from '../../../components/presence';
|
||||||
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
|
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
|
||||||
import colorMXID, { getColorMXIDValue } from '../../../../util/colorMXID';
|
import colorMXID, { getColorMXIDValue } from '../../../../util/colorMXID';
|
||||||
import {
|
|
||||||
embedMetadataInImage,
|
|
||||||
detectImageFormat,
|
|
||||||
getMimeType,
|
|
||||||
getExtension,
|
|
||||||
ImageMetadata,
|
|
||||||
needsPngForMetadata,
|
|
||||||
convertImageDataToPng,
|
|
||||||
uint8ArrayToBlob,
|
|
||||||
} from '../../../utils/imageMetadata';
|
|
||||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||||
|
import { CollectiblesSection } from './CollectiblesSection';
|
||||||
|
import { useUserCollectibles } from '../../../hooks/useUserCollectibles';
|
||||||
|
import { pickAvatarDecorationUrl } from '../../../utils/collectibleAssets';
|
||||||
|
import * as avatarDecorationCss from '../../../styles/AvatarDecoration.css';
|
||||||
|
import {
|
||||||
|
ColorPreference,
|
||||||
|
hasColorPreference,
|
||||||
|
resolveColorForTheme,
|
||||||
|
} from '../../../utils/profileFields';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Banner upload component for user's profile banner
|
* Banner upload component for user's profile banner (MSC4427 via MSC4133).
|
||||||
* Stored in avatar image metadata, visible to other Paarrot users
|
|
||||||
*/
|
*/
|
||||||
function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTMLDivElement>; nameRef: React.RefObject<HTMLDivElement> }) {
|
function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTMLDivElement>; nameRef: React.RefObject<HTMLDivElement> }) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
@@ -61,6 +57,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
const profile = useUserProfile(userId);
|
const profile = useUserProfile(userId);
|
||||||
const presence = useUserPresence(userId);
|
const presence = useUserPresence(userId);
|
||||||
const [userBanner, setUserBanner, loading] = useUserBanner();
|
const [userBanner, setUserBanner, loading] = useUserBanner();
|
||||||
|
const [collectibles, updateCollectible] = useUserCollectibles();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [hoveredArea, setHoveredArea] = useState<string | null>(null);
|
const [hoveredArea, setHoveredArea] = useState<string | null>(null);
|
||||||
@@ -92,32 +89,42 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
|
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const avatarDecorationUrl = useMemo(() => {
|
||||||
|
const decoration = collectibles.avatar_decoration;
|
||||||
|
if (!decoration) return undefined;
|
||||||
|
const assetUrls: Record<string, string | undefined> = {};
|
||||||
|
for (const [role, mxc] of Object.entries(decoration.assets)) {
|
||||||
|
assetUrls[`avatar_decoration:${role}`] =
|
||||||
|
mxcUrlToHttp(mx, mxc, useAuthentication) ?? undefined;
|
||||||
|
}
|
||||||
|
return pickAvatarDecorationUrl(assetUrls);
|
||||||
|
}, [collectibles.avatar_decoration, mx, useAuthentication]);
|
||||||
|
|
||||||
// Larger avatar URL for the blurred cover fallback
|
// Larger avatar URL for the blurred cover fallback
|
||||||
const avatarCoverUrl = profile.avatarUrl
|
const avatarCoverUrl = profile.avatarUrl
|
||||||
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication) ?? undefined
|
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication) ?? undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
const authenticatedCoverUrl = useAuthenticatedMediaUrl(avatarCoverUrl, useAuthentication);
|
const authenticatedCoverUrl = useAuthenticatedMediaUrl(avatarCoverUrl, useAuthentication);
|
||||||
|
|
||||||
const [userColor, setUserColor] = useUserColor();
|
const theme = useTheme();
|
||||||
const [localColor, setLocalColor] = useState(userColor ?? '#3b82f6');
|
const [colorPreference, setColorPreference, colorLoading] = useUserColorPreference();
|
||||||
|
const [localOnDark, setLocalOnDark] = useState('#ffd9f5');
|
||||||
|
const [localOnLight, setLocalOnLight] = useState('#440000');
|
||||||
const [savingColor, setSavingColor] = useState(false);
|
const [savingColor, setSavingColor] = useState(false);
|
||||||
const [colorError, setColorError] = useState<string>();
|
const [colorError, setColorError] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userColor) {
|
if (colorPreference?.on_dark) setLocalOnDark(colorPreference.on_dark);
|
||||||
setLocalColor(userColor);
|
if (colorPreference?.on_light) setLocalOnLight(colorPreference.on_light);
|
||||||
}
|
}, [colorPreference]);
|
||||||
}, [userColor]);
|
|
||||||
|
|
||||||
const handleColorSave = async () => {
|
const handleColorSave = async () => {
|
||||||
setSavingColor(true);
|
setSavingColor(true);
|
||||||
setColorError(undefined);
|
setColorError(undefined);
|
||||||
try {
|
try {
|
||||||
await setUserColor(localColor);
|
await setColorPreference({ on_dark: localOnDark, on_light: localOnLight });
|
||||||
// Sync user object to propagate changes throughout app
|
|
||||||
await syncUserAvatar();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setColorError('Failed to save color');
|
setColorError('Failed to save colors. Your server may not support profile fields (MSC4133).');
|
||||||
}
|
}
|
||||||
setSavingColor(false);
|
setSavingColor(false);
|
||||||
};
|
};
|
||||||
@@ -126,141 +133,15 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
setSavingColor(true);
|
setSavingColor(true);
|
||||||
setColorError(undefined);
|
setColorError(undefined);
|
||||||
try {
|
try {
|
||||||
await setUserColor(undefined);
|
await setColorPreference(undefined);
|
||||||
|
setLocalOnDark('#ffd9f5');
|
||||||
|
setLocalOnLight('#440000');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setColorError('Failed to remove color');
|
setColorError('Failed to remove colors');
|
||||||
}
|
}
|
||||||
setSavingColor(false);
|
setSavingColor(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Profile style settings (border color and gradient)
|
|
||||||
const [profileStyle, setProfileStyle, styleLoading] = useUserProfileStyle();
|
|
||||||
// Initialize with transparent alpha so saved values show in preview until user edits
|
|
||||||
const [localBorderColor, setLocalBorderColor] = useState<RgbaColor>({ r: 59, g: 130, b: 246, a: 0 });
|
|
||||||
const [localGradientStart, setLocalGradientStart] = useState<RgbaColor>({ r: 0, g: 0, b: 0, a: 0 });
|
|
||||||
const [localGradientStop, setLocalGradientStop] = useState<RgbaColor>({ r: 0, g: 0, b: 0, a: 0 });
|
|
||||||
const [localGradientDirection, setLocalGradientDirection] = useState(180); // degrees (180 = top to bottom)
|
|
||||||
const [savingStyle, setSavingStyle] = useState(false);
|
|
||||||
const [styleError, setStyleError] = useState<string>();
|
|
||||||
// Track if user has started editing (to show local values in preview)
|
|
||||||
const [editingBorder, setEditingBorder] = useState(false);
|
|
||||||
const [editingGradient, setEditingGradient] = useState(false);
|
|
||||||
|
|
||||||
// Helper to convert RGBA to hex with alpha (#RRGGBBAA)
|
|
||||||
const rgbaToHex = (rgba: RgbaColor): string => {
|
|
||||||
const r = rgba.r.toString(16).padStart(2, '0');
|
|
||||||
const g = rgba.g.toString(16).padStart(2, '0');
|
|
||||||
const b = rgba.b.toString(16).padStart(2, '0');
|
|
||||||
const a = Math.round(rgba.a * 255).toString(16).padStart(2, '0');
|
|
||||||
return `#${r}${g}${b}${a}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to convert hex with alpha to RGBA
|
|
||||||
const hexToRgba = (hex: string): RgbaColor => {
|
|
||||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i.exec(hex);
|
|
||||||
if (result) {
|
|
||||||
return {
|
|
||||||
r: parseInt(result[1], 16),
|
|
||||||
g: parseInt(result[2], 16),
|
|
||||||
b: parseInt(result[3], 16),
|
|
||||||
a: result[4] ? parseInt(result[4], 16) / 255 : 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { r: 59, g: 130, b: 246, a: 1 };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to parse degrees from direction string (e.g., "180deg" -> 180)
|
|
||||||
const parseDirectionDegrees = (direction: string): number => {
|
|
||||||
const degMatch = direction.match(/^(\d+)deg$/i);
|
|
||||||
if (degMatch) return parseInt(degMatch[1], 10);
|
|
||||||
// Fallback for legacy "to X" format
|
|
||||||
const keywordMap: Record<string, number> = {
|
|
||||||
'to top': 0,
|
|
||||||
'to top right': 45,
|
|
||||||
'to right': 90,
|
|
||||||
'to bottom right': 135,
|
|
||||||
'to bottom': 180,
|
|
||||||
'to bottom left': 225,
|
|
||||||
'to left': 270,
|
|
||||||
'to top left': 315,
|
|
||||||
};
|
|
||||||
return keywordMap[direction.toLowerCase()] ?? 180;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sync local style state with loaded profile style
|
|
||||||
useEffect(() => {
|
|
||||||
if (profileStyle.avatarBorderColor) {
|
|
||||||
setLocalBorderColor(hexToRgba(profileStyle.avatarBorderColor));
|
|
||||||
}
|
|
||||||
if (profileStyle.gradient) {
|
|
||||||
setLocalGradientStart(hexToRgba(profileStyle.gradient.startColor));
|
|
||||||
setLocalGradientStop(hexToRgba(profileStyle.gradient.stopColor));
|
|
||||||
setLocalGradientDirection(parseDirectionDegrees(profileStyle.gradient.direction));
|
|
||||||
}
|
|
||||||
}, [profileStyle]);
|
|
||||||
|
|
||||||
const handleBorderColorSave = async () => {
|
|
||||||
setSavingStyle(true);
|
|
||||||
setStyleError(undefined);
|
|
||||||
try {
|
|
||||||
await setProfileStyle({ avatarBorderColor: rgbaToHex(localBorderColor) });
|
|
||||||
await syncUserAvatar();
|
|
||||||
setEditingBorder(false);
|
|
||||||
} catch (e) {
|
|
||||||
setStyleError('Failed to save border color');
|
|
||||||
}
|
|
||||||
setSavingStyle(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBorderColorRemove = async () => {
|
|
||||||
setSavingStyle(true);
|
|
||||||
setStyleError(undefined);
|
|
||||||
try {
|
|
||||||
await setProfileStyle({ avatarBorderColor: undefined });
|
|
||||||
await syncUserAvatar();
|
|
||||||
setEditingBorder(false);
|
|
||||||
setLocalBorderColor({ r: 59, g: 130, b: 246, a: 0 });
|
|
||||||
} catch (e) {
|
|
||||||
setStyleError('Failed to remove border color');
|
|
||||||
}
|
|
||||||
setSavingStyle(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGradientSave = async () => {
|
|
||||||
setSavingStyle(true);
|
|
||||||
setStyleError(undefined);
|
|
||||||
try {
|
|
||||||
await setProfileStyle({
|
|
||||||
gradient: {
|
|
||||||
direction: `${localGradientDirection}deg`,
|
|
||||||
startColor: rgbaToHex(localGradientStart),
|
|
||||||
stopColor: rgbaToHex(localGradientStop),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await syncUserAvatar();
|
|
||||||
setEditingGradient(false);
|
|
||||||
} catch (e) {
|
|
||||||
setStyleError('Failed to save gradient');
|
|
||||||
}
|
|
||||||
setSavingStyle(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGradientRemove = async () => {
|
|
||||||
setSavingStyle(true);
|
|
||||||
setStyleError(undefined);
|
|
||||||
try {
|
|
||||||
await setProfileStyle({ gradient: undefined });
|
|
||||||
await syncUserAvatar();
|
|
||||||
setEditingGradient(false);
|
|
||||||
setLocalGradientStart({ r: 0, g: 0, b: 0, a: 0 });
|
|
||||||
setLocalGradientStop({ r: 0, g: 0, b: 0, a: 0 });
|
|
||||||
setLocalGradientDirection(180);
|
|
||||||
} catch (e) {
|
|
||||||
setStyleError('Failed to remove gradient');
|
|
||||||
}
|
|
||||||
setSavingStyle(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [isEditingName, setIsEditingName] = useState(false);
|
const [isEditingName, setIsEditingName] = useState(false);
|
||||||
const [editedName, setEditedName] = useState(profile.displayName || '');
|
const [editedName, setEditedName] = useState(profile.displayName || '');
|
||||||
const [savingName, setSavingName] = useState(false);
|
const [savingName, setSavingName] = useState(false);
|
||||||
@@ -475,82 +356,14 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
try {
|
try {
|
||||||
// Re-embed existing banner/color/style into the new avatar when present
|
await mx.setAvatarUrl(upload.mxc);
|
||||||
const metadata: ImageMetadata = {
|
|
||||||
banner: userBanner,
|
|
||||||
color: userColor,
|
|
||||||
avatarBorderColor: profileStyle.avatarBorderColor,
|
|
||||||
gradient: profileStyle.gradient,
|
|
||||||
};
|
|
||||||
const hasMetadata = Boolean(
|
|
||||||
metadata.color ||
|
|
||||||
metadata.banner ||
|
|
||||||
metadata.avatarBorderColor ||
|
|
||||||
metadata.gradient
|
|
||||||
);
|
|
||||||
|
|
||||||
// Nothing to re-embed — use the already-uploaded MXC directly
|
|
||||||
if (!hasMetadata) {
|
|
||||||
await mx.setAvatarUrl(upload.mxc);
|
|
||||||
const userId = mx.getUserId();
|
|
||||||
if (userId) {
|
|
||||||
const user = mx.getUser(userId);
|
|
||||||
if (user && user.avatarUrl !== upload.mxc) {
|
|
||||||
user.setAvatarUrl(upload.mxc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setAvatarFile(undefined);
|
|
||||||
await syncUserAvatar();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const httpUrl = mxcUrlToHttp(mx, upload.mxc, useAuthentication);
|
|
||||||
if (!httpUrl) throw new Error('Could not resolve uploaded avatar URL');
|
|
||||||
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
let response = await fetch(httpUrl, {
|
|
||||||
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// If we got a 401 and we tried with auth, fallback to unauthenticated request
|
|
||||||
if (!response.ok && response.status === 401 && accessToken && useAuthentication) {
|
|
||||||
console.warn('[Profile] Auth failed (401), attempting unauthenticated fallback for avatar fetch');
|
|
||||||
response = await fetch(httpUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch uploaded avatar');
|
|
||||||
let avatarData: ArrayBuffer | Uint8Array = await response.arrayBuffer();
|
|
||||||
|
|
||||||
let format = detectImageFormat(avatarData);
|
|
||||||
if (format === 'unknown') throw new Error('Unsupported avatar image format');
|
|
||||||
|
|
||||||
// Banner / border / gradient only live in PNG tEXt — convert JPEG/WebP/GIF first
|
|
||||||
if (format !== 'png' && needsPngForMetadata(metadata)) {
|
|
||||||
const pngData = await convertImageDataToPng(avatarData);
|
|
||||||
if (!pngData) throw new Error('Failed to convert avatar to PNG for metadata');
|
|
||||||
avatarData = pngData;
|
|
||||||
format = 'png';
|
|
||||||
}
|
|
||||||
|
|
||||||
const modifiedData = embedMetadataInImage(avatarData, metadata);
|
|
||||||
if (!modifiedData) throw new Error('Failed to embed metadata in avatar');
|
|
||||||
|
|
||||||
const mimeType = getMimeType(format);
|
|
||||||
const extension = getExtension(format);
|
|
||||||
const blob = uint8ArrayToBlob(modifiedData, mimeType);
|
|
||||||
const uploadResponse = await mx.uploadContent(blob, { name: `avatar.${extension}`, type: mimeType });
|
|
||||||
|
|
||||||
await mx.setAvatarUrl(uploadResponse.content_uri);
|
|
||||||
|
|
||||||
const userId = mx.getUserId();
|
const userId = mx.getUserId();
|
||||||
if (userId) {
|
if (userId) {
|
||||||
const user = mx.getUser(userId);
|
const user = mx.getUser(userId);
|
||||||
if (user && user.avatarUrl !== uploadResponse.content_uri) {
|
if (user && user.avatarUrl !== upload.mxc) {
|
||||||
user.setAvatarUrl(uploadResponse.content_uri);
|
user.setAvatarUrl(upload.mxc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setAvatarFile(undefined);
|
setAvatarFile(undefined);
|
||||||
await syncUserAvatar();
|
await syncUserAvatar();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -558,7 +371,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
}
|
}
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
},
|
},
|
||||||
[mx, useAuthentication, userBanner, userColor, profileStyle, syncUserAvatar]
|
[mx, syncUserAvatar]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRemoveBanner = async () => {
|
const handleRemoveBanner = async () => {
|
||||||
@@ -574,33 +387,33 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build gradient CSS for preview (shows local editing values when user is editing,
|
const previewPreference: ColorPreference = { on_dark: localOnDark, on_light: localOnLight };
|
||||||
// otherwise falls back to saved profile style)
|
const previewProfileColor =
|
||||||
const previewGradient = editingGradient
|
resolveColorForTheme(previewPreference, theme.kind) || getColorMXIDValue(userId, theme.kind === ThemeKind.Dark);
|
||||||
? `linear-gradient(${localGradientDirection}deg, ${rgbaToHex(localGradientStart)}, ${rgbaToHex(localGradientStop)})`
|
|
||||||
: profileStyle.gradient
|
|
||||||
? `linear-gradient(${profileStyle.gradient.direction}, ${profileStyle.gradient.startColor}, ${profileStyle.gradient.stopColor})`
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Build border color for preview (shows local editing value when user is editing,
|
|
||||||
// otherwise falls back to saved profile style)
|
|
||||||
const previewBorderColor = editingBorder
|
|
||||||
? rgbaToHex(localBorderColor)
|
|
||||||
: profileStyle.avatarBorderColor;
|
|
||||||
|
|
||||||
const previewPillBgColor = previewBorderColor ? stripAlphaFromColor(previewBorderColor) : undefined;
|
|
||||||
const previewProfileColor = userColor || getColorMXIDValue(userId);
|
|
||||||
|
|
||||||
const previewTextShadow = `0 1px 4px ${getTextShadowColor(previewProfileColor)}`;
|
const previewTextShadow = `0 1px 4px ${getTextShadowColor(previewProfileColor)}`;
|
||||||
|
const hasSavedColors = hasColorPreference(colorPreference);
|
||||||
|
const colorPickerSize = toRem(200);
|
||||||
|
const colorPreviewPlateStyle = {
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box' as const,
|
||||||
|
padding: `${toRem(4)} ${toRem(8)}`,
|
||||||
|
borderRadius: toRem(8),
|
||||||
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
};
|
||||||
|
const displayName = profile.displayName || getMxIdLocalPart(userId) || userId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="300">
|
<Box direction="Column" gap="300" style={{ width: '100%', alignItems: 'center' }}>
|
||||||
<Box
|
<Box
|
||||||
direction="Column"
|
direction="Column"
|
||||||
style={{
|
style={{
|
||||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
borderRadius: toRem(8),
|
borderRadius: toRem(8),
|
||||||
width: toRem(340),
|
width: '60%',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
@@ -627,7 +440,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
backgroundColor: bannerBlobUrl ? 'transparent' : (userColor || colorMXID(userId)),
|
backgroundColor: bannerBlobUrl ? 'transparent' : colorMXID(userId),
|
||||||
filter: bannerBlobUrl || authenticatedCoverUrl ? 'none' : 'brightness(50%)',
|
filter: bannerBlobUrl || authenticatedCoverUrl ? 'none' : 'brightness(50%)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -737,41 +550,61 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
onMouseLeave={() => setHoveredArea(null)}
|
onMouseLeave={() => setHoveredArea(null)}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
as="button"
|
|
||||||
onClick={handleAvatarClick}
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: previewBorderColor || color.Surface.Container,
|
position: 'relative',
|
||||||
border: 'none',
|
width: toRem(76),
|
||||||
padding: 0,
|
height: toRem(76),
|
||||||
cursor: 'pointer',
|
|
||||||
borderRadius: '50%',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AvatarPresence
|
<AvatarPresence
|
||||||
badgeBackgroundColor={previewBorderColor}
|
|
||||||
badge={
|
badge={
|
||||||
presence && (
|
presence && (
|
||||||
<PresenceBadge presence={presence.presence} status={presence.status} />
|
<PresenceBadge presence={presence.presence} status={presence.status} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Box
|
||||||
size="500"
|
as="button"
|
||||||
|
onClick={handleAvatarClick}
|
||||||
style={{
|
style={{
|
||||||
width: toRem(72),
|
position: 'relative',
|
||||||
height: toRem(72),
|
width: '100%',
|
||||||
outline: previewBorderColor
|
height: '100%',
|
||||||
? `${toRem(4)} solid ${previewBorderColor}`
|
backgroundColor: avatarUrl ? 'transparent' : color.Surface.Container,
|
||||||
: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '50%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<UserAvatar
|
<Avatar
|
||||||
userId={userId}
|
size="500"
|
||||||
src={avatarUrl}
|
style={{
|
||||||
alt={userId}
|
width: toRem(76),
|
||||||
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
|
height: toRem(76),
|
||||||
/>
|
...(avatarUrl
|
||||||
</Avatar>
|
? { outline: 'none', border: 'none', boxShadow: 'none' }
|
||||||
|
: {
|
||||||
|
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
userId={userId}
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={userId}
|
||||||
|
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
{avatarDecorationUrl && (
|
||||||
|
<img
|
||||||
|
className={avatarDecorationCss.AvatarDecorationOverlay}
|
||||||
|
src={avatarDecorationUrl}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</AvatarPresence>
|
</AvatarPresence>
|
||||||
</Box>
|
</Box>
|
||||||
{/* Avatar action icons - shown on hover */}
|
{/* Avatar action icons - shown on hover */}
|
||||||
@@ -832,73 +665,20 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Profile Info Section - matches UserRoomProfile gradient section */}
|
{/* Profile Info Section */}
|
||||||
<Box
|
<Box
|
||||||
direction="Column"
|
direction="Column"
|
||||||
gap="400"
|
gap="200"
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
style={{
|
style={{
|
||||||
padding: config.space.S400,
|
padding: config.space.S400,
|
||||||
paddingTop: `calc(${config.space.S400} + ${toRem(36)})`,
|
paddingTop: `calc(${config.space.S400} + ${toRem(36)})`,
|
||||||
marginTop: toRem(-36),
|
marginTop: toRem(-36),
|
||||||
background: previewGradient,
|
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Display Name with Color Picker */}
|
{/* Display Name */}
|
||||||
<Box alignItems="Center" gap="200" justifyContent="Center">
|
<Box style={{ width: '100%' }}>
|
||||||
<HexColorPickerPopOut
|
|
||||||
picker={
|
|
||||||
<Box direction="Column" gap="200">
|
|
||||||
<HexColorPicker color={localColor} onChange={setLocalColor} />
|
|
||||||
<Box gap="100" alignItems="Center">
|
|
||||||
<Input
|
|
||||||
size="300"
|
|
||||||
variant="Secondary"
|
|
||||||
style={{ width: toRem(100) }}
|
|
||||||
value={localColor}
|
|
||||||
onChange={(e) => {
|
|
||||||
setLocalColor(e.target.value);
|
|
||||||
setColorError(undefined);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="300"
|
|
||||||
variant="Primary"
|
|
||||||
fill="Solid"
|
|
||||||
radii="300"
|
|
||||||
onClick={handleColorSave}
|
|
||||||
disabled={savingColor}
|
|
||||||
>
|
|
||||||
<Text size="B300">{savingColor ? 'Saving...' : 'Save'}</Text>
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
{colorError && (
|
|
||||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
|
||||||
{colorError}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
onRemove={userColor ? handleColorRemove : undefined}
|
|
||||||
>
|
|
||||||
{(onOpen) => (
|
|
||||||
<Box
|
|
||||||
as="button"
|
|
||||||
onClick={onOpen}
|
|
||||||
disabled={savingColor}
|
|
||||||
style={{
|
|
||||||
width: toRem(24),
|
|
||||||
height: toRem(24),
|
|
||||||
borderRadius: toRem(6),
|
|
||||||
backgroundColor: userColor ?? localColor,
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</HexColorPickerPopOut>
|
|
||||||
{isEditingName ? (
|
{isEditingName ? (
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -910,6 +690,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
size="400"
|
size="400"
|
||||||
disabled={savingName}
|
disabled={savingName}
|
||||||
style={{
|
style={{
|
||||||
|
width: '100%',
|
||||||
fontSize: 'var(--token.font-size.H400)',
|
fontSize: 'var(--token.font-size.H400)',
|
||||||
fontWeight: 'var(--token.font-weight.H400)',
|
fontWeight: 'var(--token.font-weight.H400)',
|
||||||
padding: `${toRem(4)} ${toRem(8)}`,
|
padding: `${toRem(4)} ${toRem(8)}`,
|
||||||
@@ -926,19 +707,28 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
onMouseEnter={() => setHoveredArea('name')}
|
onMouseEnter={() => setHoveredArea('name')}
|
||||||
onMouseLeave={() => setHoveredArea(null)}
|
onMouseLeave={() => setHoveredArea(null)}
|
||||||
style={{
|
style={{
|
||||||
|
width: '100%',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
background: 'none',
|
background: 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
padding: `${toRem(4)} ${toRem(8)}`,
|
padding: `${toRem(4)} ${toRem(8)}`,
|
||||||
margin: `${toRem(-4)} ${toRem(-8)}`,
|
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
lineHeight: 1.2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
size="H4"
|
size="H4"
|
||||||
className={classNames(BreakWord, LineClamp3)}
|
className={classNames(BreakWord, LineClamp3)}
|
||||||
title={profile.displayName || getMxIdLocalPart(userId)}
|
title={profile.displayName || getMxIdLocalPart(userId)}
|
||||||
style={{ color: previewProfileColor, textShadow: previewTextShadow }}
|
style={{
|
||||||
|
color: previewProfileColor,
|
||||||
|
textShadow: previewTextShadow,
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: '100%',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{profile.displayName || getMxIdLocalPart(userId) || userId}
|
{profile.displayName || getMxIdLocalPart(userId) || userId}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -946,8 +736,9 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: '50%',
|
||||||
right: toRem(-32),
|
right: 0,
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
backgroundColor: color.Surface.Container,
|
backgroundColor: color.Surface.Container,
|
||||||
borderRadius: toRem(20),
|
borderRadius: toRem(20),
|
||||||
padding: toRem(6),
|
padding: toRem(6),
|
||||||
@@ -980,8 +771,8 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
onMouseEnter={() => setHoveredArea('status')}
|
onMouseEnter={() => setHoveredArea('status')}
|
||||||
onMouseLeave={() => setHoveredArea(null)}
|
onMouseLeave={() => setHoveredArea(null)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: previewPillBgColor || color.Surface.Container,
|
backgroundColor: color.Surface.Container,
|
||||||
border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
padding: `${toRem(6)} ${toRem(10)}`,
|
padding: `${toRem(6)} ${toRem(10)}`,
|
||||||
borderRadius: toRem(16),
|
borderRadius: toRem(16),
|
||||||
maxWidth: toRem(250),
|
maxWidth: toRem(250),
|
||||||
@@ -1032,14 +823,13 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
className={BreakWord}
|
className={BreakWord}
|
||||||
style={{
|
style={{
|
||||||
fontStyle: presence?.status ? 'normal' : 'italic',
|
fontStyle: presence?.status ? 'normal' : 'italic',
|
||||||
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{presence?.status || 'Click to set a custom status...'}</Text>
|
{presence?.status || 'Click to set a custom status...'}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{presence?.status && hoveredArea === 'status' && (
|
{presence?.status && hoveredArea === 'status' && (
|
||||||
<>
|
<>
|
||||||
<Icon size="50" src={Icons.Pencil} style={{ color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined }} />
|
<Icon size="50" src={Icons.Pencil} />
|
||||||
<Box
|
<Box
|
||||||
as="button"
|
as="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -1070,178 +860,185 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
|
<Box alignItems="Center" gap="200" wrap="Wrap" justifyContent="Center">
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: previewPillBgColor || color.Surface.Container,
|
backgroundColor: color.Surface.Container,
|
||||||
border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
padding: `${toRem(4)} ${toRem(8)}`,
|
padding: `${toRem(4)} ${toRem(8)}`,
|
||||||
borderRadius: toRem(16),
|
borderRadius: toRem(16),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text size="B300">ruv.wtf</Text>
|
||||||
size="B300"
|
|
||||||
style={{
|
|
||||||
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
ruv.wtf
|
|
||||||
</Text>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: previewPillBgColor || color.Surface.Container,
|
backgroundColor: color.Surface.Container,
|
||||||
border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
padding: `${toRem(4)} ${toRem(8)}`,
|
padding: `${toRem(4)} ${toRem(8)}`,
|
||||||
borderRadius: toRem(16),
|
borderRadius: toRem(16),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text size="B300">Share</Text>
|
||||||
size="B300"
|
|
||||||
style={{
|
|
||||||
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Share
|
|
||||||
</Text>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: previewPillBgColor || color.Surface.Container,
|
backgroundColor: color.Surface.Container,
|
||||||
border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
padding: `${toRem(4)} ${toRem(8)}`,
|
padding: `${toRem(4)} ${toRem(8)}`,
|
||||||
borderRadius: toRem(16),
|
borderRadius: toRem(16),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text size="B300">Admin</Text>
|
||||||
size="B300"
|
|
||||||
style={{
|
|
||||||
color: previewPillBgColor ? getContrastingTextColor(previewPillBgColor) : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Admin
|
|
||||||
</Text>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Profile Style Settings */}
|
{/* MSC4522 username colors */}
|
||||||
<Box
|
<Box
|
||||||
direction="Column"
|
direction="Column"
|
||||||
gap="200"
|
gap="300"
|
||||||
style={{
|
style={{
|
||||||
padding: config.space.S300,
|
padding: config.space.S400,
|
||||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
borderRadius: toRem(8),
|
borderRadius: toRem(8),
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="H6">Profile Style</Text>
|
<Text size="H6">Username colors</Text>
|
||||||
|
|
||||||
{/* Avatar Border Color */}
|
<Box direction="Row" gap="400" wrap="Wrap" alignItems="Start">
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="200" style={{ flex: '1 1 0', minWidth: toRem(140) }}>
|
||||||
<Text size="T300">Avatar Border Color</Text>
|
<Text size="T300">On dark themes</Text>
|
||||||
<Box gap="200" alignItems="Center" wrap="Wrap">
|
<Text size="T200" style={{ opacity: 0.7 }}>Bright colors work best</Text>
|
||||||
<RgbaColorPicker color={localBorderColor} onChange={(c) => { setLocalBorderColor(c); setEditingBorder(true); }} />
|
<Box
|
||||||
<Box direction="Column" gap="100">
|
style={{
|
||||||
<Text size="T200" style={{ opacity: 0.7 }}>
|
...colorPreviewPlateStyle,
|
||||||
Preview: {rgbaToHex(localBorderColor)}
|
backgroundColor: '#262626',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size="H4"
|
||||||
|
className={classNames(BreakWord, LineClamp3)}
|
||||||
|
title={displayName}
|
||||||
|
style={{
|
||||||
|
color: localOnDark,
|
||||||
|
textShadow: `0 1px 4px ${getTextShadowColor(localOnDark)}`,
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
</Text>
|
</Text>
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
width: toRem(48),
|
|
||||||
height: toRem(48),
|
|
||||||
borderRadius: '50%',
|
|
||||||
border: `${toRem(4)} solid ${rgbaToHex(localBorderColor)}`,
|
|
||||||
backgroundColor: color.Surface.Container,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box gap="100">
|
|
||||||
<Button
|
|
||||||
size="300"
|
|
||||||
variant="Primary"
|
|
||||||
fill="Solid"
|
|
||||||
radii="300"
|
|
||||||
onClick={handleBorderColorSave}
|
|
||||||
disabled={savingStyle}
|
|
||||||
>
|
|
||||||
<Text size="B300">Save</Text>
|
|
||||||
</Button>
|
|
||||||
{profileStyle.avatarBorderColor && (
|
|
||||||
<Button
|
|
||||||
size="300"
|
|
||||||
variant="Critical"
|
|
||||||
fill="Soft"
|
|
||||||
radii="300"
|
|
||||||
onClick={handleBorderColorRemove}
|
|
||||||
disabled={savingStyle}
|
|
||||||
>
|
|
||||||
<Text size="B300">Remove</Text>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
<HexColorPicker
|
||||||
|
color={localOnDark}
|
||||||
|
onChange={(c) => {
|
||||||
|
setLocalOnDark(c);
|
||||||
|
setColorError(undefined);
|
||||||
|
}}
|
||||||
|
style={{ width: '100%', height: colorPickerSize }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={localOnDark}
|
||||||
|
onChange={(e) => {
|
||||||
|
setLocalOnDark(e.target.value);
|
||||||
|
setColorError(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box direction="Column" gap="200" style={{ flex: '1 1 0', minWidth: toRem(140) }}>
|
||||||
|
<Text size="T300">On light themes</Text>
|
||||||
|
<Text size="T200" style={{ opacity: 0.7 }}>Darker colors work best</Text>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
...colorPreviewPlateStyle,
|
||||||
|
backgroundColor: '#F0F0F0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size="H4"
|
||||||
|
className={classNames(BreakWord, LineClamp3)}
|
||||||
|
title={displayName}
|
||||||
|
style={{
|
||||||
|
color: localOnLight,
|
||||||
|
textShadow: `0 1px 4px ${getTextShadowColor(localOnLight)}`,
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<HexColorPicker
|
||||||
|
color={localOnLight}
|
||||||
|
onChange={(c) => {
|
||||||
|
setLocalOnLight(c);
|
||||||
|
setColorError(undefined);
|
||||||
|
}}
|
||||||
|
style={{ width: '100%', height: colorPickerSize }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={localOnLight}
|
||||||
|
onChange={(e) => {
|
||||||
|
setLocalOnLight(e.target.value);
|
||||||
|
setColorError(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Profile Gradient */}
|
<Box gap="200" alignItems="Center" wrap="Wrap">
|
||||||
<Box direction="Column" gap="100">
|
<Button
|
||||||
<Text size="T300">Profile Card Gradient</Text>
|
size="300"
|
||||||
<Box gap="200" alignItems="Start" wrap="Wrap">
|
variant="Primary"
|
||||||
<Box direction="Column" gap="100">
|
fill="Solid"
|
||||||
<Text size="T200">Start Color</Text>
|
radii="300"
|
||||||
<RgbaColorPicker color={localGradientStart} onChange={(c) => { setLocalGradientStart(c); setEditingGradient(true); }} />
|
onClick={handleColorSave}
|
||||||
</Box>
|
disabled={savingColor || colorLoading}
|
||||||
<Box direction="Column" gap="100">
|
>
|
||||||
<Text size="T200">End Color</Text>
|
<Text size="B300">{savingColor ? 'Saving...' : 'Save colors'}</Text>
|
||||||
<RgbaColorPicker color={localGradientStop} onChange={(c) => { setLocalGradientStop(c); setEditingGradient(true); }} />
|
</Button>
|
||||||
</Box>
|
{hasSavedColors && (
|
||||||
<Box direction="Column" gap="100">
|
<Button
|
||||||
<Text size="T200">Direction</Text>
|
size="300"
|
||||||
<AngleSelector
|
variant="Critical"
|
||||||
value={localGradientDirection}
|
fill="Soft"
|
||||||
onChange={(deg) => { setLocalGradientDirection(deg); setEditingGradient(true); }}
|
radii="300"
|
||||||
/>
|
onClick={handleColorRemove}
|
||||||
<Box
|
disabled={savingColor}
|
||||||
style={{
|
>
|
||||||
width: toRem(100),
|
<Text size="B300">Remove colors</Text>
|
||||||
height: toRem(60),
|
</Button>
|
||||||
borderRadius: toRem(8),
|
)}
|
||||||
background: `linear-gradient(${localGradientDirection}deg, ${rgbaToHex(localGradientStart)}, ${rgbaToHex(localGradientStop)})`,
|
|
||||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box gap="100">
|
|
||||||
<Button
|
|
||||||
size="300"
|
|
||||||
variant="Primary"
|
|
||||||
fill="Solid"
|
|
||||||
radii="300"
|
|
||||||
onClick={handleGradientSave}
|
|
||||||
disabled={savingStyle}
|
|
||||||
>
|
|
||||||
<Text size="B300">Save</Text>
|
|
||||||
</Button>
|
|
||||||
{profileStyle.gradient && (
|
|
||||||
<Button
|
|
||||||
size="300"
|
|
||||||
variant="Critical"
|
|
||||||
fill="Soft"
|
|
||||||
radii="300"
|
|
||||||
onClick={handleGradientRemove}
|
|
||||||
disabled={savingStyle}
|
|
||||||
>
|
|
||||||
<Text size="B300">Remove</Text>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{styleError && (
|
{colorError && (
|
||||||
<Text size="T200" style={{ color: color.Critical.Main }}>{styleError}</Text>
|
<Text size="T200" style={{ color: color.Critical.Main }}>{colorError}</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
gap="300"
|
||||||
|
style={{
|
||||||
|
padding: config.space.S400,
|
||||||
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
|
borderRadius: toRem(8),
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="H6">Discord profile overlays</Text>
|
||||||
|
<CollectiblesSection collectibles={collectibles} onApply={updateCollectible} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
{uploadAtom && (
|
{uploadAtom && (
|
||||||
<Box gap="200" direction="Column" style={{ width: '100%' }}>
|
<Box gap="200" direction="Column" style={{ width: '100%' }}>
|
||||||
<CompactUploadCardRenderer
|
<CompactUploadCardRenderer
|
||||||
|
|||||||
@@ -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 dayjs from 'dayjs';
|
||||||
import { as, Box, Button, Chip, color, config, Header, IconButton, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Switch, Text, toRem } from 'folds';
|
import { as, Box, Button, Chip, color, config, Header, IconButton, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Switch, Text, toRem } from 'folds';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { Range } from 'react-range';
|
||||||
import { Icon, Icons } from '../../../components/icons';
|
import { Icon, Icons } from '../../../components/icons';
|
||||||
import { HexColorPicker } from 'react-colorful';
|
import { HexColorPicker } from 'react-colorful';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
@@ -334,6 +335,66 @@ const emojiStyleNames: Record<EmojiStyle, string> = {
|
|||||||
[EmojiStyle.Twemoji]: 'Twemoji',
|
[EmojiStyle.Twemoji]: 'Twemoji',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function SidebarNameplate() {
|
||||||
|
const [sidebarNameplateOpacity, setSidebarNameplateOpacity] = useSetting(
|
||||||
|
settingsAtom,
|
||||||
|
'sidebarNameplateOpacity'
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Collectibles</Text>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Sidebar Nameplate Opacity"
|
||||||
|
description="Set to 0% to hide your equipped nameplate behind the bottom sidebar avatar."
|
||||||
|
after={
|
||||||
|
<Box gap="200" alignItems="Center" style={{ width: toRem(180) }}>
|
||||||
|
<Range
|
||||||
|
step={1}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
values={[sidebarNameplateOpacity]}
|
||||||
|
onChange={(values) => setSidebarNameplateOpacity(values[0])}
|
||||||
|
renderTrack={(params) => (
|
||||||
|
<div
|
||||||
|
{...params.props}
|
||||||
|
style={{
|
||||||
|
...params.props.style,
|
||||||
|
width: '100%',
|
||||||
|
height: toRem(8),
|
||||||
|
borderRadius: toRem(4),
|
||||||
|
background: `linear-gradient(to right, ${color.Primary.Main} ${sidebarNameplateOpacity}%, ${color.Surface.ContainerLine} ${sidebarNameplateOpacity}%)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{params.children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
renderThumb={(params) => (
|
||||||
|
<div
|
||||||
|
{...params.props}
|
||||||
|
style={{
|
||||||
|
...params.props.style,
|
||||||
|
width: toRem(16),
|
||||||
|
height: toRem(16),
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: color.Primary.Main,
|
||||||
|
border: `${toRem(2)} solid ${color.Surface.Container}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Text size="T300" style={{ minWidth: toRem(32), textAlign: 'right' }}>
|
||||||
|
{sidebarNameplateOpacity}%
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type EmojiStyleSelectorProps = {
|
type EmojiStyleSelectorProps = {
|
||||||
selected: EmojiStyle;
|
selected: EmojiStyle;
|
||||||
onSelect: (style: EmojiStyle) => void;
|
onSelect: (style: EmojiStyle) => void;
|
||||||
@@ -1222,6 +1283,7 @@ export function General({ requestClose }: GeneralProps) {
|
|||||||
<PageContent>
|
<PageContent>
|
||||||
<Box direction="Column" gap="700">
|
<Box direction="Column" gap="700">
|
||||||
<Appearance />
|
<Appearance />
|
||||||
|
<SidebarNameplate />
|
||||||
<DateAndTime />
|
<DateAndTime />
|
||||||
<Editor />
|
<Editor />
|
||||||
<Spaces />
|
<Spaces />
|
||||||
|
|||||||
@@ -1,56 +1,17 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { MatrixClient, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
|
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { useMediaAuthentication } from './useMediaAuthentication';
|
import { useMediaAuthentication } from './useMediaAuthentication';
|
||||||
import { mxcUrlToHttp } from '../utils/matrix';
|
import { mxcUrlToHttp } from '../utils/matrix';
|
||||||
import { getCurrentAccessToken } from '../utils/auth';
|
import { getCurrentAccessToken } from '../utils/auth';
|
||||||
import {
|
import {
|
||||||
fetchAndExtractMetadata,
|
deleteBannerUrl,
|
||||||
extractMetadataFromImage,
|
loadBannerUrl,
|
||||||
embedMetadataInImage,
|
registerBannerCacheClear,
|
||||||
detectImageFormat,
|
saveBannerUrl,
|
||||||
getMimeType,
|
} from '../utils/profileFields';
|
||||||
getExtension,
|
|
||||||
ImageMetadata,
|
|
||||||
convertImageDataToPng,
|
|
||||||
uint8ArrayToBlob,
|
|
||||||
} from '../utils/imageMetadata';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the user's current avatar as raw image data
|
* Hook to manage the user's profile banner (MSC4427 via MSC4133).
|
||||||
*/
|
|
||||||
async function fetchAvatarData(
|
|
||||||
mx: MatrixClient,
|
|
||||||
avatarMxc: string,
|
|
||||||
useAuthentication: boolean
|
|
||||||
): Promise<ArrayBuffer | null> {
|
|
||||||
const url = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
|
||||||
if (!url) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
let response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// If we got a 401 and we tried with auth, fallback to unauthenticated request
|
|
||||||
if (!response.ok && response.status === 401 && accessToken && useAuthentication) {
|
|
||||||
response = await fetch(url, { method: 'GET' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) return null;
|
|
||||||
return await response.arrayBuffer();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to manage the user's chosen profile banner, stored in avatar image metadata
|
|
||||||
* The banner URL is embedded in avatar metadata and syncs via the avatar
|
|
||||||
* @returns The current banner URL, a setter function, and loading state
|
|
||||||
*/
|
*/
|
||||||
export function useUserBanner(): [
|
export function useUserBanner(): [
|
||||||
string | undefined,
|
string | undefined,
|
||||||
@@ -58,280 +19,124 @@ export function useUserBanner(): [
|
|||||||
boolean
|
boolean
|
||||||
] {
|
] {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
|
||||||
const [banner, setBanner] = useState<string | undefined>();
|
const [banner, setBanner] = useState<string | undefined>();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Extract banner from current avatar on mount and when profile changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadBanner = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const userId = mx.getUserId();
|
|
||||||
if (!userId) {
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = await mx.getProfileInfo(userId);
|
|
||||||
const avatarUrl = profile.avatar_url;
|
|
||||||
console.log('[useUserBanner.loadBanner] Loading banner from avatar:', avatarUrl);
|
|
||||||
|
|
||||||
if (!avatarUrl) {
|
|
||||||
setBanner(undefined);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication);
|
|
||||||
if (!httpUrl) {
|
|
||||||
setBanner(undefined);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
|
||||||
console.log('[useUserBanner.loadBanner] Extracted banner:', metadata.banner);
|
|
||||||
setBanner(metadata.banner);
|
|
||||||
} catch {
|
|
||||||
setBanner(undefined);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadBanner();
|
|
||||||
|
|
||||||
// Listen for avatar changes and reload banner
|
|
||||||
const userId = mx.getUserId();
|
|
||||||
if (!userId) return undefined;
|
|
||||||
|
|
||||||
const user = mx.getUser(userId);
|
|
||||||
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = (newAvatarUrl) => {
|
|
||||||
console.log('[useUserBanner] Avatar changed event fired, new URL:', newAvatarUrl);
|
|
||||||
loadBanner();
|
|
||||||
};
|
|
||||||
|
|
||||||
user?.on(UserEvent.AvatarUrl, onAvatarChange);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
|
|
||||||
};
|
|
||||||
}, [mx, useAuthentication]);
|
|
||||||
|
|
||||||
const updateBanner = useCallback(async (newBanner: string | undefined) => {
|
|
||||||
const userId = mx.getUserId();
|
const userId = mx.getUserId();
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new Error('No user ID');
|
setLoading(false);
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = await mx.getProfileInfo(userId);
|
let cancelled = false;
|
||||||
const avatarUrl = profile.avatar_url;
|
|
||||||
|
|
||||||
if (!avatarUrl) {
|
const load = async () => {
|
||||||
throw new Error('No avatar set. Please upload an avatar first.');
|
setLoading(true);
|
||||||
}
|
try {
|
||||||
|
const bannerMxc = await loadBannerUrl(mx, userId);
|
||||||
// Fetch current avatar
|
if (!cancelled) setBanner(bannerMxc);
|
||||||
const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication);
|
} catch {
|
||||||
if (!avatarData) {
|
if (!cancelled) setBanner(undefined);
|
||||||
throw new Error('Failed to fetch current avatar');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect image format — banner lives in PNG tEXt, so convert other formats first
|
|
||||||
let workingData: ArrayBuffer | Uint8Array = avatarData;
|
|
||||||
let format = detectImageFormat(workingData);
|
|
||||||
|
|
||||||
if (format === 'unknown') {
|
|
||||||
throw new Error('Unsupported avatar image format');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (format !== 'png') {
|
|
||||||
console.log('[updateBanner] Converting', format, 'avatar to PNG for banner metadata');
|
|
||||||
const pngData = await convertImageDataToPng(workingData);
|
|
||||||
if (!pngData) {
|
|
||||||
throw new Error('Failed to convert avatar to PNG for banner metadata');
|
|
||||||
}
|
}
|
||||||
workingData = pngData;
|
if (!cancelled) setLoading(false);
|
||||||
format = 'png';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get existing metadata to preserve
|
|
||||||
const existingMetadata = extractMetadataFromImage(workingData);
|
|
||||||
console.log('[updateBanner] Existing metadata:', existingMetadata);
|
|
||||||
|
|
||||||
// Modify image metadata with new banner, preserving color.
|
|
||||||
// Empty string clears banner (PNG embed drops falsy fields after stripping old chunks).
|
|
||||||
const newMetadata: ImageMetadata = {
|
|
||||||
color: existingMetadata.color,
|
|
||||||
banner: newBanner ?? '',
|
|
||||||
avatarBorderColor: existingMetadata.avatarBorderColor,
|
|
||||||
gradient: existingMetadata.gradient,
|
|
||||||
};
|
};
|
||||||
console.log('[updateBanner] New metadata to embed:', newMetadata);
|
|
||||||
|
|
||||||
const newAvatarData = embedMetadataInImage(workingData, newMetadata);
|
load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
if (!newAvatarData) {
|
const updateBanner = useCallback(
|
||||||
throw new Error('Failed to embed banner in avatar metadata');
|
async (newBanner: string | undefined) => {
|
||||||
}
|
if (!newBanner) {
|
||||||
|
await deleteBannerUrl(mx);
|
||||||
// Verify the banner was embedded correctly before uploading
|
setBanner(undefined);
|
||||||
const verifyMetadata = extractMetadataFromImage(newAvatarData);
|
return;
|
||||||
console.log('[updateBanner] Verification - extracted metadata from new avatar:', verifyMetadata);
|
|
||||||
|
|
||||||
if ((newBanner || undefined) !== verifyMetadata.banner) {
|
|
||||||
console.error('[updateBanner] Banner verification FAILED!', 'Expected:', newBanner, 'Got:', verifyMetadata.banner);
|
|
||||||
throw new Error('Banner verification failed');
|
|
||||||
}
|
|
||||||
console.log('[updateBanner] Banner verification passed');
|
|
||||||
|
|
||||||
// Upload modified avatar with correct MIME type
|
|
||||||
const mimeType = getMimeType(format);
|
|
||||||
const extension = getExtension(format);
|
|
||||||
const blob = uint8ArrayToBlob(newAvatarData, mimeType);
|
|
||||||
|
|
||||||
const uploadResponse = await mx.uploadContent(blob, {
|
|
||||||
name: `avatar.${extension}`,
|
|
||||||
type: mimeType,
|
|
||||||
});
|
|
||||||
console.log('[updateBanner] Avatar uploaded successfully, MXC:', uploadResponse.content_uri);
|
|
||||||
|
|
||||||
// Update profile with new avatar
|
|
||||||
try {
|
|
||||||
console.log('[updateBanner] Setting avatar URL to:', uploadResponse.content_uri);
|
|
||||||
await Promise.race([
|
|
||||||
mx.setAvatarUrl(uploadResponse.content_uri),
|
|
||||||
new Promise((_, reject) => {
|
|
||||||
setTimeout(() => reject(new Error('setAvatarUrl timeout')), 30000);
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
console.log('[updateBanner] setAvatarUrl completed');
|
|
||||||
|
|
||||||
// Manually sync user object to ensure event listeners are triggered
|
|
||||||
const user = mx.getUser(userId);
|
|
||||||
if (user && user.avatarUrl !== uploadResponse.content_uri) {
|
|
||||||
console.log('[updateBanner] Manually syncing user avatar URL');
|
|
||||||
user.setAvatarUrl(uploadResponse.content_uri);
|
|
||||||
}
|
}
|
||||||
console.log('[updateBanner] Banner update complete');
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to update avatar URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: Don't set banner here - let the avatar change event handle it
|
await saveBannerUrl(mx, newBanner);
|
||||||
// setBanner(newBanner);
|
setBanner(newBanner);
|
||||||
}, [mx, useAuthentication]);
|
},
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
|
||||||
return [banner, updateBanner, loading];
|
return [banner, updateBanner, loading];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache for user banners to avoid refetching for each message
|
|
||||||
const userBannerCache = new Map<string, { bannerMxc: string | undefined; timestamp: number }>();
|
const userBannerCache = new Map<string, { bannerMxc: string | undefined; timestamp: number }>();
|
||||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
registerBannerCacheClear((userId: string) => {
|
||||||
|
userBannerCache.delete(userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchBannerBlobUrl(
|
||||||
|
mx: ReturnType<typeof useMatrixClient>,
|
||||||
|
bannerMxc: string,
|
||||||
|
useAuthentication: boolean
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const bannerHttpUrl = mxcUrlToHttp(mx, bannerMxc, useAuthentication);
|
||||||
|
if (!bannerHttpUrl) return undefined;
|
||||||
|
|
||||||
|
const accessToken = getCurrentAccessToken();
|
||||||
|
const headers: HeadersInit = {};
|
||||||
|
if (useAuthentication && accessToken) {
|
||||||
|
headers.Authorization = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(bannerHttpUrl, { headers });
|
||||||
|
if (!response.ok) return undefined;
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to get another user's chosen profile banner from their avatar metadata
|
* Hook to get another user's profile banner as a blob URL.
|
||||||
* @param userId - The user ID to get banner for
|
|
||||||
* @param avatarMxc - The user's avatar MXC URL
|
|
||||||
* @returns The user's chosen banner as a blob URL or undefined
|
|
||||||
*/
|
*/
|
||||||
export function useOtherUserBanner(userId: string, avatarMxc: string | undefined): string | undefined {
|
export function useOtherUserBanner(userId: string): string | undefined {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const [bannerBlobUrl, setBannerBlobUrl] = useState<string | undefined>();
|
const [bannerBlobUrl, setBannerBlobUrl] = useState<string | undefined>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let blobUrl: string | undefined;
|
if (!userId) {
|
||||||
|
|
||||||
if (!avatarMxc) {
|
|
||||||
console.log('[useOtherUserBanner] No avatarMxc provided for', userId);
|
|
||||||
setBannerBlobUrl(undefined);
|
setBannerBlobUrl(undefined);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadBanner = async () => {
|
let blobUrl: string | undefined;
|
||||||
console.log('[useOtherUserBanner] Loading banner for', userId, 'avatarMxc:', avatarMxc);
|
let cancelled = false;
|
||||||
|
|
||||||
// Check cache first
|
const load = async () => {
|
||||||
const cacheKey = `${userId}:${avatarMxc}`;
|
const cached = userBannerCache.get(userId);
|
||||||
const cached = userBannerCache.get(cacheKey);
|
|
||||||
|
|
||||||
let bannerMxc: string | undefined;
|
let bannerMxc: string | undefined;
|
||||||
|
|
||||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||||
// Use cached banner MXC
|
|
||||||
console.log('[useOtherUserBanner] Using cached bannerMxc:', cached.bannerMxc);
|
|
||||||
bannerMxc = cached.bannerMxc;
|
bannerMxc = cached.bannerMxc;
|
||||||
} else {
|
} else {
|
||||||
// Fetch banner MXC from avatar metadata
|
bannerMxc = await loadBannerUrl(mx, userId);
|
||||||
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
userBannerCache.set(userId, { bannerMxc, timestamp: Date.now() });
|
||||||
console.log('[useOtherUserBanner] Fetching metadata from:', httpUrl);
|
|
||||||
|
|
||||||
if (!httpUrl) {
|
|
||||||
console.log('[useOtherUserBanner] Failed to get HTTP URL');
|
|
||||||
setBannerBlobUrl(undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
|
||||||
|
|
||||||
console.log('[useOtherUserBanner] Extracted metadata:', metadata);
|
|
||||||
|
|
||||||
bannerMxc = metadata.banner;
|
|
||||||
|
|
||||||
// Cache the banner MXC (not the blob URL)
|
|
||||||
userBannerCache.set(cacheKey, { bannerMxc, timestamp: Date.now() });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!bannerMxc) {
|
if (!bannerMxc) {
|
||||||
console.log('[useOtherUserBanner] No banner MXC found');
|
if (!cancelled) setBannerBlobUrl(undefined);
|
||||||
setBannerBlobUrl(undefined);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[useOtherUserBanner] Fetching banner image from MXC:', bannerMxc);
|
blobUrl = await fetchBannerBlobUrl(mx, bannerMxc, useAuthentication);
|
||||||
|
if (!cancelled) setBannerBlobUrl(blobUrl);
|
||||||
// Fetch the banner image data with authentication
|
|
||||||
const bannerHttpUrl = mxcUrlToHttp(mx, bannerMxc, useAuthentication);
|
|
||||||
if (!bannerHttpUrl) {
|
|
||||||
console.log('[useOtherUserBanner] Failed to convert banner MXC to HTTP URL');
|
|
||||||
setBannerBlobUrl(undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[useOtherUserBanner] Banner HTTP URL:', bannerHttpUrl);
|
|
||||||
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
const headers: HeadersInit = {};
|
|
||||||
if (useAuthentication && accessToken) {
|
|
||||||
headers.Authorization = `Bearer ${accessToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(bannerHttpUrl, { headers });
|
|
||||||
if (!response.ok) {
|
|
||||||
setBannerBlobUrl(undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
blobUrl = URL.createObjectURL(blob);
|
|
||||||
setBannerBlobUrl(blobUrl);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
loadBanner();
|
load();
|
||||||
|
|
||||||
// Cleanup blob URL when component unmounts or deps change
|
|
||||||
return () => {
|
return () => {
|
||||||
if (blobUrl) {
|
cancelled = true;
|
||||||
URL.revokeObjectURL(blobUrl);
|
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [mx, useAuthentication, avatarMxc, userId]);
|
}, [mx, useAuthentication, userId]);
|
||||||
|
|
||||||
return bannerBlobUrl;
|
return bannerBlobUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
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 };
|
||||||
|
}
|
||||||
@@ -1,227 +1,82 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { MatrixClient, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { useMediaAuthentication } from './useMediaAuthentication';
|
import { ThemeKind, useTheme } from './useTheme';
|
||||||
import { mxcUrlToHttp } from '../utils/matrix';
|
|
||||||
import { getCurrentAccessToken } from '../utils/auth';
|
|
||||||
import {
|
import {
|
||||||
embedColorInImage,
|
ColorPreference,
|
||||||
extractColorFromImage,
|
deleteColorPreference,
|
||||||
removeColorFromImage,
|
extractMemberColorPreference,
|
||||||
fetchAndExtractColor,
|
hasColorPreference,
|
||||||
detectImageFormat,
|
loadColorPreference,
|
||||||
getMimeType,
|
registerPreferenceCacheClear,
|
||||||
getExtension,
|
resolveColorForTheme,
|
||||||
uint8ArrayToBlob,
|
saveColorPreference,
|
||||||
} from '../utils/imageMetadata';
|
} from '../utils/profileFields';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the user's current avatar as raw image data
|
* Hook to manage the user's MSC4522 color preference on their global profile.
|
||||||
*/
|
*/
|
||||||
async function fetchAvatarData(
|
export function useUserColorPreference(): [
|
||||||
mx: MatrixClient,
|
ColorPreference | undefined,
|
||||||
avatarMxc: string,
|
(preference: ColorPreference | undefined) => Promise<void>,
|
||||||
useAuthentication: boolean
|
|
||||||
): Promise<ArrayBuffer | null> {
|
|
||||||
const url = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
|
||||||
if (!url) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
let response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// If we got a 401 and we tried with auth, fallback to unauthenticated request
|
|
||||||
if (!response.ok && response.status === 401 && accessToken && useAuthentication) {
|
|
||||||
console.warn('[fetchAvatarData] Auth failed (401), attempting unauthenticated fallback');
|
|
||||||
response = await fetch(url, { method: 'GET' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) return null;
|
|
||||||
return await response.arrayBuffer();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to manage the user's chosen profile color, stored in avatar image metadata
|
|
||||||
* The color is embedded in PNG tEXt chunk or WebP XMP chunk and syncs via the avatar
|
|
||||||
* @returns The current color, a setter function, and loading state
|
|
||||||
*/
|
|
||||||
export function useUserColor(): [
|
|
||||||
string | undefined,
|
|
||||||
(color: string | undefined) => Promise<void>,
|
|
||||||
boolean
|
boolean
|
||||||
] {
|
] {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const [preference, setPreference] = useState<ColorPreference | undefined>();
|
||||||
const [color, setColor] = useState<string | undefined>();
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Extract color from current avatar on mount and when profile changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadColor = async () => {
|
const userId = mx.getUserId();
|
||||||
|
if (!userId) {
|
||||||
|
setLoading(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const userId = mx.getUserId();
|
const loaded = await loadColorPreference(mx, userId);
|
||||||
if (!userId) {
|
if (!cancelled) setPreference(loaded);
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = await mx.getProfileInfo(userId);
|
|
||||||
const avatarUrl = profile.avatar_url;
|
|
||||||
|
|
||||||
if (!avatarUrl) {
|
|
||||||
setColor(undefined);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication);
|
|
||||||
if (!httpUrl) {
|
|
||||||
setColor(undefined);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null);
|
|
||||||
setColor(extractedColor);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load user color from avatar:', e);
|
console.error('Failed to load color preference:', e);
|
||||||
setColor(undefined);
|
if (!cancelled) setPreference(undefined);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
loadColor();
|
load();
|
||||||
|
|
||||||
// Listen for avatar changes and reload color
|
|
||||||
const userId = mx.getUserId();
|
|
||||||
if (!userId) return undefined;
|
|
||||||
|
|
||||||
const user = mx.getUser(userId);
|
|
||||||
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = () => {
|
|
||||||
loadColor();
|
|
||||||
};
|
|
||||||
|
|
||||||
user?.on(UserEvent.AvatarUrl, onAvatarChange);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [mx, useAuthentication]);
|
}, [mx]);
|
||||||
|
|
||||||
const updateColor = useCallback(async (newColor: string | undefined) => {
|
const updatePreference = useCallback(
|
||||||
const userId = mx.getUserId();
|
async (newPreference: ColorPreference | undefined) => {
|
||||||
if (!userId) return;
|
if (!hasColorPreference(newPreference)) {
|
||||||
|
await deleteColorPreference(mx);
|
||||||
try {
|
setPreference(undefined);
|
||||||
const profile = await mx.getProfileInfo(userId);
|
return;
|
||||||
const avatarUrl = profile.avatar_url;
|
|
||||||
|
|
||||||
if (!avatarUrl) {
|
|
||||||
// No avatar to embed color in
|
|
||||||
console.warn('Cannot set color: no avatar image set');
|
|
||||||
throw new Error('No avatar set');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch current avatar
|
await saveColorPreference(mx, newPreference!);
|
||||||
const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication);
|
setPreference(newPreference);
|
||||||
if (!avatarData) {
|
},
|
||||||
console.error('Failed to fetch current avatar');
|
[mx]
|
||||||
throw new Error('Failed to fetch avatar');
|
);
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Original avatar size:', avatarData.byteLength, 'bytes');
|
return [preference, updatePreference, loading];
|
||||||
|
|
||||||
// Detect image format
|
|
||||||
const format = detectImageFormat(avatarData);
|
|
||||||
console.log('Detected image format:', format);
|
|
||||||
|
|
||||||
if (format === 'unknown') {
|
|
||||||
console.error('Unsupported avatar image format - must be PNG, WebP, JPEG, or GIF');
|
|
||||||
throw new Error('Unsupported image format');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modify image metadata
|
|
||||||
let newAvatarData: Uint8Array | null;
|
|
||||||
if (newColor) {
|
|
||||||
newAvatarData = embedColorInImage(avatarData, newColor);
|
|
||||||
} else {
|
|
||||||
newAvatarData = removeColorFromImage(avatarData);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!newAvatarData) {
|
|
||||||
console.error('Failed to modify avatar image metadata');
|
|
||||||
throw new Error('Failed to modify image metadata');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Modified avatar size:', newAvatarData.byteLength, 'bytes');
|
|
||||||
|
|
||||||
// Verify the color was embedded correctly before uploading
|
|
||||||
const verifyColor = extractColorFromImage(newAvatarData);
|
|
||||||
console.log('Verification - embedded color:', verifyColor);
|
|
||||||
|
|
||||||
if (newColor && verifyColor !== newColor) {
|
|
||||||
console.error('Color verification failed! Expected:', newColor, 'Got:', verifyColor);
|
|
||||||
throw new Error('Color embedding verification failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload modified avatar with correct MIME type
|
|
||||||
const mimeType = getMimeType(format);
|
|
||||||
const extension = getExtension(format);
|
|
||||||
const blob = uint8ArrayToBlob(newAvatarData, mimeType);
|
|
||||||
const uploadResponse = await mx.uploadContent(blob, {
|
|
||||||
name: `avatar.${extension}`,
|
|
||||||
type: mimeType,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Uploaded new avatar:', uploadResponse.content_uri);
|
|
||||||
|
|
||||||
// Update profile with new avatar
|
|
||||||
await mx.setAvatarUrl(uploadResponse.content_uri);
|
|
||||||
|
|
||||||
// Manually sync user object to ensure event listeners are triggered
|
|
||||||
const user = mx.getUser(userId);
|
|
||||||
if (user && user.avatarUrl !== uploadResponse.content_uri) {
|
|
||||||
user.setAvatarUrl(uploadResponse.content_uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
setColor(newColor);
|
|
||||||
console.log('User color updated in avatar:', newColor);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to update user color in avatar:', e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}, [mx, useAuthentication]);
|
|
||||||
|
|
||||||
return [color, updateColor, loading];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* LRU Cache implementation with max size limit
|
|
||||||
*/
|
|
||||||
class LRUCache<K, V> {
|
class LRUCache<K, V> {
|
||||||
private cache: Map<K, V>;
|
private cache = new Map<K, V>();
|
||||||
|
|
||||||
private maxSize: number;
|
constructor(private maxSize: number) {}
|
||||||
|
|
||||||
constructor(maxSize: number) {
|
|
||||||
this.cache = new Map();
|
|
||||||
this.maxSize = maxSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
get(key: K): V | undefined {
|
get(key: K): V | undefined {
|
||||||
const value = this.cache.get(key);
|
const value = this.cache.get(key);
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
// Move to end (most recently used)
|
|
||||||
this.cache.delete(key);
|
this.cache.delete(key);
|
||||||
this.cache.set(key, value);
|
this.cache.set(key, value);
|
||||||
}
|
}
|
||||||
@@ -229,88 +84,106 @@ class LRUCache<K, V> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set(key: K, value: V): void {
|
set(key: K, value: V): void {
|
||||||
// If key exists, delete it first to re-add at end
|
|
||||||
if (this.cache.has(key)) {
|
if (this.cache.has(key)) {
|
||||||
this.cache.delete(key);
|
this.cache.delete(key);
|
||||||
}
|
} else if (this.cache.size >= this.maxSize) {
|
||||||
// If at max size, remove oldest (first) entry
|
|
||||||
else if (this.cache.size >= this.maxSize) {
|
|
||||||
const firstKey = this.cache.keys().next().value;
|
const firstKey = this.cache.keys().next().value;
|
||||||
if (firstKey !== undefined) {
|
if (firstKey !== undefined) this.cache.delete(firstKey);
|
||||||
this.cache.delete(firstKey);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this.cache.set(key, value);
|
this.cache.set(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
delete(key: K): void {
|
||||||
this.cache.clear();
|
this.cache.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LRU cache for user colors to avoid refetching for each message
|
const preferenceCache = new LRUCache<string, { preference: ColorPreference | undefined; timestamp: number }>(200);
|
||||||
const userColorCache = new LRUCache<string, { color: string | undefined; timestamp: number }>(100);
|
const CACHE_TTL_MS = 30 * 60 * 1000;
|
||||||
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
||||||
|
registerPreferenceCacheClear((userId: string) => {
|
||||||
|
preferenceCache.delete(userId);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to get another user's chosen profile color from their avatar metadata
|
* Hook to get another user's MSC4522 color preference from their global profile.
|
||||||
* @param userId - The user ID to get color for
|
|
||||||
* @param avatarMxc - The user's avatar MXC URL
|
|
||||||
* @returns The user's chosen color or undefined
|
|
||||||
*/
|
*/
|
||||||
export function useOtherUserColor(userId: string, avatarMxc: string | undefined): string | undefined {
|
export function useOtherUserColorPreference(userId: string): ColorPreference | undefined {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const cachedEntry = preferenceCache.get(userId);
|
||||||
|
const cachedPreference =
|
||||||
// Check cache synchronously BEFORE initializing state to avoid flash
|
cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL_MS ? cachedEntry.preference : undefined;
|
||||||
const cacheKey = avatarMxc ? `${userId}:${avatarMxc}` : '';
|
|
||||||
const cachedEntry = cacheKey ? userColorCache.get(cacheKey) : undefined;
|
const [preference, setPreference] = useState<ColorPreference | undefined>(cachedPreference);
|
||||||
const cachedColor = cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL_MS
|
|
||||||
? cachedEntry.color
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const [color, setColor] = useState<string | undefined>(cachedColor);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!avatarMxc) {
|
if (!userId) {
|
||||||
console.log('[useOtherUserColor] No avatarMxc provided for', userId);
|
setPreference(undefined);
|
||||||
setColor(undefined);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check cache first
|
const cached = preferenceCache.get(userId);
|
||||||
const cacheKey = `${userId}:${avatarMxc}`;
|
|
||||||
const cached = userColorCache.get(cacheKey);
|
|
||||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||||
console.log('[useOtherUserColor] Using cached color for', userId, ':', cached.color);
|
setPreference(cached.preference);
|
||||||
setColor(cached.color);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadColor = async () => {
|
let cancelled = false;
|
||||||
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
|
||||||
console.log('[useOtherUserColor] Loading color for', userId, 'from', httpUrl);
|
|
||||||
if (!httpUrl) {
|
|
||||||
console.log('[useOtherUserColor] Failed to get HTTP URL for', avatarMxc);
|
|
||||||
setColor(undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
const load = async () => {
|
||||||
const accessToken = getCurrentAccessToken();
|
try {
|
||||||
const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null);
|
const loaded = await loadColorPreference(mx, userId);
|
||||||
|
preferenceCache.set(userId, { preference: loaded, timestamp: Date.now() });
|
||||||
console.log('[useOtherUserColor] Extracted color for', userId, ':', extractedColor);
|
if (!cancelled) setPreference(loaded);
|
||||||
|
} catch {
|
||||||
// Cache the result
|
if (!cancelled) setPreference(undefined);
|
||||||
userColorCache.set(cacheKey, { color: extractedColor, timestamp: Date.now() });
|
}
|
||||||
|
|
||||||
setColor(extractedColor);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
loadColor();
|
load();
|
||||||
}, [mx, useAuthentication, avatarMxc, userId]);
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [mx, userId]);
|
||||||
|
|
||||||
return color;
|
return preference;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MSC4522 per-room member color preference (overrides global profile when set).
|
||||||
|
*/
|
||||||
|
export function useMemberColorPreference(userId: string, room?: Room): ColorPreference | undefined {
|
||||||
|
const [preference, setPreference] = useState<ColorPreference | undefined>(() =>
|
||||||
|
extractMemberColorPreference(room, userId)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPreference(extractMemberColorPreference(room, userId));
|
||||||
|
}, [room, userId]);
|
||||||
|
|
||||||
|
return preference;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a user's username color for the active theme (MSC4522).
|
||||||
|
* Per-room member colors take precedence over global profile colors.
|
||||||
|
*/
|
||||||
|
export function useOtherUserColor(userId: string, room?: Room): string | undefined {
|
||||||
|
const theme = useTheme();
|
||||||
|
const profilePreference = useOtherUserColorPreference(userId);
|
||||||
|
const memberPreference = useMemberColorPreference(userId, room);
|
||||||
|
const preference = memberPreference ?? profilePreference;
|
||||||
|
return resolveColorForTheme(preference, theme.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the current user's username color for the active theme.
|
||||||
|
*/
|
||||||
|
export function useUsernameColor(userId: string, room?: Room, themeKind?: ThemeKind): string | undefined {
|
||||||
|
const theme = useTheme();
|
||||||
|
const kind = themeKind ?? theme.kind;
|
||||||
|
const profilePreference = useOtherUserColorPreference(userId);
|
||||||
|
const memberPreference = useMemberColorPreference(userId, room);
|
||||||
|
const preference = memberPreference ?? profilePreference;
|
||||||
|
return resolveColorForTheme(preference, kind);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,276 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
|
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
|
||||||
import { useMediaAuthentication } from './useMediaAuthentication';
|
|
||||||
import { mxcUrlToHttp } from '../utils/matrix';import { getCurrentAccessToken } from '../utils/auth';import {
|
|
||||||
fetchAndExtractMetadata,
|
|
||||||
extractMetadataFromImage,
|
|
||||||
embedMetadataInImage,
|
|
||||||
detectImageFormat,
|
|
||||||
getMimeType,
|
|
||||||
getExtension,
|
|
||||||
ImageMetadata,
|
|
||||||
ProfileGradient,
|
|
||||||
convertImageDataToPng,
|
|
||||||
uint8ArrayToBlob,
|
|
||||||
} from '../utils/imageMetadata';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the user's current avatar as raw image data
|
|
||||||
*/
|
|
||||||
async function fetchAvatarData(
|
|
||||||
mx: ReturnType<typeof useMatrixClient>,
|
|
||||||
avatarMxc: string,
|
|
||||||
useAuthentication: boolean
|
|
||||||
): Promise<ArrayBuffer | null> {
|
|
||||||
const url = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
|
||||||
if (!url) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
let response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// If we got a 401 and we tried with auth, fallback to unauthenticated request
|
|
||||||
if (!response.ok && response.status === 401 && accessToken && useAuthentication) {
|
|
||||||
response = await fetch(url, { method: 'GET' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) return null;
|
|
||||||
return await response.arrayBuffer();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProfileStyleData = {
|
|
||||||
avatarBorderColor?: string;
|
|
||||||
gradient?: ProfileGradient;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to manage the user's profile style (avatar border color and gradient)
|
|
||||||
* Stored in avatar image metadata
|
|
||||||
* @returns Current style data, a setter function, and loading state
|
|
||||||
*/
|
|
||||||
export function useUserProfileStyle(): [
|
|
||||||
ProfileStyleData,
|
|
||||||
(style: Partial<ProfileStyleData>) => Promise<void>,
|
|
||||||
boolean
|
|
||||||
] {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const useAuthentication = useMediaAuthentication();
|
|
||||||
const [style, setStyle] = useState<ProfileStyleData>({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
// Extract style from current avatar on mount and when profile changes
|
|
||||||
useEffect(() => {
|
|
||||||
const loadStyle = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const userId = mx.getUserId();
|
|
||||||
if (!userId) {
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = await mx.getProfileInfo(userId);
|
|
||||||
const avatarUrl = profile.avatar_url;
|
|
||||||
|
|
||||||
if (!avatarUrl) {
|
|
||||||
setStyle({});
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication);
|
|
||||||
if (!httpUrl) {
|
|
||||||
setStyle({});
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
|
||||||
setStyle({
|
|
||||||
avatarBorderColor: metadata.avatarBorderColor,
|
|
||||||
gradient: metadata.gradient,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
setStyle({});
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadStyle();
|
|
||||||
|
|
||||||
// Listen for avatar changes and reload style
|
|
||||||
const userId = mx.getUserId();
|
|
||||||
if (!userId) return undefined;
|
|
||||||
|
|
||||||
const user = mx.getUser(userId);
|
|
||||||
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = () => {
|
|
||||||
loadStyle();
|
|
||||||
};
|
|
||||||
|
|
||||||
user?.on(UserEvent.AvatarUrl, onAvatarChange);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
|
|
||||||
};
|
|
||||||
}, [mx, useAuthentication]);
|
|
||||||
|
|
||||||
const updateStyle = useCallback(
|
|
||||||
async (newStyle: Partial<ProfileStyleData>) => {
|
|
||||||
const userId = mx.getUserId();
|
|
||||||
if (!userId) {
|
|
||||||
throw new Error('No user ID');
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = await mx.getProfileInfo(userId);
|
|
||||||
const avatarUrl = profile.avatar_url;
|
|
||||||
|
|
||||||
if (!avatarUrl) {
|
|
||||||
throw new Error('No avatar set. Please upload an avatar first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch current avatar
|
|
||||||
const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication);
|
|
||||||
if (!avatarData) {
|
|
||||||
throw new Error('Failed to fetch current avatar');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Border/gradient live in PNG tEXt — convert other formats first
|
|
||||||
let workingData: ArrayBuffer | Uint8Array = avatarData;
|
|
||||||
let format = detectImageFormat(workingData);
|
|
||||||
if (format === 'unknown') {
|
|
||||||
throw new Error('Unsupported avatar image format');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (format !== 'png') {
|
|
||||||
const pngData = await convertImageDataToPng(workingData);
|
|
||||||
if (!pngData) {
|
|
||||||
throw new Error('Failed to convert avatar to PNG for style metadata');
|
|
||||||
}
|
|
||||||
workingData = pngData;
|
|
||||||
format = 'png';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get existing metadata to preserve
|
|
||||||
const existingMetadata = extractMetadataFromImage(workingData);
|
|
||||||
|
|
||||||
// Merge with new style
|
|
||||||
const newMetadata: ImageMetadata = {
|
|
||||||
...existingMetadata,
|
|
||||||
avatarBorderColor: newStyle.avatarBorderColor !== undefined ? newStyle.avatarBorderColor : existingMetadata.avatarBorderColor,
|
|
||||||
gradient: newStyle.gradient !== undefined ? newStyle.gradient : existingMetadata.gradient,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Embed updated metadata
|
|
||||||
const newAvatarData = embedMetadataInImage(workingData, newMetadata);
|
|
||||||
if (!newAvatarData) {
|
|
||||||
throw new Error('Failed to embed style in avatar metadata');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload modified avatar with correct MIME type
|
|
||||||
const mimeType = getMimeType(format);
|
|
||||||
const extension = getExtension(format);
|
|
||||||
const blob = uint8ArrayToBlob(newAvatarData, mimeType);
|
|
||||||
|
|
||||||
const uploadResponse = await mx.uploadContent(blob, {
|
|
||||||
name: `avatar.${extension}`,
|
|
||||||
type: mimeType,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update profile with new avatar
|
|
||||||
await mx.setAvatarUrl(uploadResponse.content_uri);
|
|
||||||
|
|
||||||
// Manually sync user object
|
|
||||||
const user = mx.getUser(userId);
|
|
||||||
if (user && user.avatarUrl !== uploadResponse.content_uri) {
|
|
||||||
user.setAvatarUrl(uploadResponse.content_uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
setStyle((prev) => ({
|
|
||||||
...prev,
|
|
||||||
...newStyle,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
[mx, useAuthentication]
|
|
||||||
);
|
|
||||||
|
|
||||||
return [style, updateStyle, loading];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache for other users' profile styles
|
|
||||||
const userStyleCache = new Map<string, { style: ProfileStyleData; timestamp: number }>();
|
|
||||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to get another user's profile style from their avatar metadata
|
|
||||||
* @param userId - The user ID to get style for
|
|
||||||
* @param avatarMxc - The user's avatar MXC URL
|
|
||||||
* @returns The user's profile style data
|
|
||||||
*/
|
|
||||||
export function useOtherUserProfileStyle(userId: string, avatarMxc: string | undefined): ProfileStyleData {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const useAuthentication = useMediaAuthentication();
|
|
||||||
const [style, setStyle] = useState<ProfileStyleData>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!avatarMxc) {
|
|
||||||
console.log('[useOtherUserProfileStyle] No avatarMxc provided for', userId);
|
|
||||||
setStyle({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadStyle = async () => {
|
|
||||||
console.log('[useOtherUserProfileStyle] Loading style for', userId, 'avatarMxc:', avatarMxc);
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
const cacheKey = `${userId}:${avatarMxc}`;
|
|
||||||
const cached = userStyleCache.get(cacheKey);
|
|
||||||
|
|
||||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
|
||||||
console.log('[useOtherUserProfileStyle] Using cached style:', cached.style);
|
|
||||||
setStyle(cached.style);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
|
||||||
console.log('[useOtherUserProfileStyle] Fetching metadata from:', httpUrl);
|
|
||||||
|
|
||||||
if (!httpUrl) {
|
|
||||||
console.log('[useOtherUserProfileStyle] Failed to get HTTP URL');
|
|
||||||
setStyle({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
|
||||||
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
|
|
||||||
|
|
||||||
console.log('[useOtherUserProfileStyle] Extracted metadata:', metadata);
|
|
||||||
|
|
||||||
const styleData: ProfileStyleData = {
|
|
||||||
avatarBorderColor: metadata.avatarBorderColor,
|
|
||||||
gradient: metadata.gradient,
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('[useOtherUserProfileStyle] Final styleData:', styleData);
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
userStyleCache.set(cacheKey, { style: styleData, timestamp: Date.now() });
|
|
||||||
setStyle(styleData);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadStyle();
|
|
||||||
}, [mx, useAuthentication, avatarMxc, userId]);
|
|
||||||
|
|
||||||
return style;
|
|
||||||
}
|
|
||||||
@@ -58,6 +58,7 @@ import {
|
|||||||
listenForAndroidShares,
|
listenForAndroidShares,
|
||||||
materializeSharedFile,
|
materializeSharedFile,
|
||||||
} from '../../utils/androidShare';
|
} from '../../utils/androidShare';
|
||||||
|
import { UpdatesDialogHost } from '../../components/updates-dialog';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the selected emoji style font to the document.
|
* Applies the selected emoji style font to the document.
|
||||||
@@ -701,6 +702,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<TaskbarFlashStopper />
|
<TaskbarFlashStopper />
|
||||||
<AndroidShareIntentHandler />
|
<AndroidShareIntentHandler />
|
||||||
{children}
|
{children}
|
||||||
|
<UpdatesDialogHost />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ function NotificationItem({
|
|||||||
relation?.rel_type === RelationType.Thread ? relation.event_id : undefined;
|
relation?.rel_type === RelationType.Thread ? relation.event_id : undefined;
|
||||||
|
|
||||||
// Get custom user color from avatar metadata
|
// Get custom user color from avatar metadata
|
||||||
const customUserColor = useOtherUserColor(event.sender, senderAvatarMxc);
|
const customUserColor = useOtherUserColor(event.sender, room);
|
||||||
|
|
||||||
const memberPowerTag = getMemberPowerTag(event.sender);
|
const memberPowerTag = getMemberPowerTag(event.sender);
|
||||||
const tagColor = memberPowerTag?.color
|
const tagColor = memberPowerTag?.color
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React from 'react';
|
|||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
|
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
|
||||||
import { searchModalAtom } from '../../../state/searchModal';
|
import { searchModalAtom } from '../../../state/searchModal';
|
||||||
|
import * as sidebarCss from '../../../components/sidebar/Sidebar.css';
|
||||||
|
|
||||||
export function SearchTab() {
|
export function SearchTab() {
|
||||||
const [opened, setOpen] = useAtom(searchModalAtom);
|
const [opened, setOpen] = useAtom(searchModalAtom);
|
||||||
@@ -14,7 +15,13 @@ export function SearchTab() {
|
|||||||
<SidebarItem active={opened}>
|
<SidebarItem active={opened}>
|
||||||
<SidebarItemTooltip tooltip="Search">
|
<SidebarItemTooltip tooltip="Search">
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<SidebarAvatar as="button" ref={triggerRef} outlined onClick={open}>
|
<SidebarAvatar
|
||||||
|
as="button"
|
||||||
|
ref={triggerRef}
|
||||||
|
className={sidebarCss.SidebarSearchAvatar}
|
||||||
|
outlined
|
||||||
|
onClick={open}
|
||||||
|
>
|
||||||
<Icon src={Icons.Search} filled={opened} />
|
<Icon src={Icons.Search} filled={opened} />
|
||||||
</SidebarAvatar>
|
</SidebarAvatar>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ import { nameInitials } from '../../../utils/common';
|
|||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { Settings } from '../../../features/settings';
|
import { Settings } from '../../../features/settings';
|
||||||
import { useUserProfile } from '../../../hooks/useUserProfile';
|
import { useUserProfile } from '../../../hooks/useUserProfile';
|
||||||
|
import { useOtherUserCollectibles } from '../../../hooks/useUserCollectibles';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../../state/settings';
|
||||||
|
import { pickAvatarDecorationUrl, pickNameplateUrl } from '../../../utils/collectibleAssets';
|
||||||
|
import * as avatarDecorationCss from '../../../styles/AvatarDecoration.css';
|
||||||
|
import * as sidebarCss from '../../../components/sidebar/Sidebar.css';
|
||||||
import { Modal500 } from '../../../components/Modal500';
|
import { Modal500 } from '../../../components/Modal500';
|
||||||
import { AccountSwitcher } from '../../../components/account-switcher';
|
import { AccountSwitcher } from '../../../components/account-switcher';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
@@ -34,6 +40,13 @@ export function SettingsTab() {
|
|||||||
const avatarUrl = profile.avatarUrl && userId
|
const avatarUrl = profile.avatarUrl && userId
|
||||||
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
|
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const { assetUrls } = useOtherUserCollectibles(userId ?? '');
|
||||||
|
const avatarDecorationUrl = pickAvatarDecorationUrl(assetUrls);
|
||||||
|
const nameplateUrl = pickNameplateUrl(assetUrls);
|
||||||
|
const [sidebarNameplateOpacity] = useSetting(settingsAtom, 'sidebarNameplateOpacity');
|
||||||
|
const nameplateIsVideo = Boolean(
|
||||||
|
assetUrls['nameplate:animated'] || assetUrls['nameplate:asset.webm']
|
||||||
|
);
|
||||||
|
|
||||||
const openSettings = () => setSettings(true);
|
const openSettings = () => setSettings(true);
|
||||||
const closeSettings = () => setSettings(false);
|
const closeSettings = () => setSettings(false);
|
||||||
@@ -46,16 +59,51 @@ export function SettingsTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarItem active={settings}>
|
<SidebarItem active={settings}>
|
||||||
<SidebarAvatar
|
<SidebarAvatar
|
||||||
as="button"
|
as="button"
|
||||||
|
className={sidebarCss.SidebarAvatarLarge}
|
||||||
onClick={openSettings}
|
onClick={openSettings}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
|
style={{ overflow: 'visible' }}
|
||||||
>
|
>
|
||||||
<UserAvatar
|
<div className={avatarDecorationCss.SidebarAvatarStack}>
|
||||||
userId={userId ?? ''}
|
{sidebarNameplateOpacity > 0 &&
|
||||||
src={avatarUrl}
|
nameplateUrl &&
|
||||||
renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}
|
(nameplateIsVideo ? (
|
||||||
/>
|
<video
|
||||||
|
className={sidebarCss.SidebarAvatarNameplate}
|
||||||
|
src={nameplateUrl}
|
||||||
|
style={{ opacity: sidebarNameplateOpacity / 100 }}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
className={sidebarCss.SidebarAvatarNameplate}
|
||||||
|
src={nameplateUrl}
|
||||||
|
style={{ opacity: sidebarNameplateOpacity / 100 }}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<UserAvatar
|
||||||
|
className={sidebarCss.SidebarAvatarForeground}
|
||||||
|
userId={userId ?? ''}
|
||||||
|
src={avatarUrl}
|
||||||
|
renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}
|
||||||
|
/>
|
||||||
|
{avatarDecorationUrl && (
|
||||||
|
<img
|
||||||
|
className={avatarDecorationCss.AvatarDecorationOverlay}
|
||||||
|
src={avatarDecorationUrl}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</SidebarAvatar>
|
</SidebarAvatar>
|
||||||
|
|
||||||
<PopOut
|
<PopOut
|
||||||
|
|||||||
55
src/app/state/releaseNotes.ts
Normal file
55
src/app/state/releaseNotes.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
const LAST_SEEN_VERSION_KEY = 'paarrot:lastSeenVersion';
|
||||||
|
const PENDING_RELEASE_NOTES_KEY = 'paarrot:pendingReleaseNotes';
|
||||||
|
|
||||||
|
export type PendingReleaseNotes = {
|
||||||
|
version: string;
|
||||||
|
releaseNotes: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReleaseNotesDialogState = {
|
||||||
|
open: boolean;
|
||||||
|
/** Opened from About instead of an automatic post-update prompt. */
|
||||||
|
manual: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const releaseNotesDialogAtom = atom<ReleaseNotesDialogState>({
|
||||||
|
open: false,
|
||||||
|
manual: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function getLastSeenAppVersion(): string | null {
|
||||||
|
return localStorage.getItem(LAST_SEEN_VERSION_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLastSeenAppVersion(version: string): void {
|
||||||
|
localStorage.setItem(LAST_SEEN_VERSION_KEY, version);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storePendingReleaseNotes(version: string, releaseNotes: unknown): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
PENDING_RELEASE_NOTES_KEY,
|
||||||
|
JSON.stringify({ version, releaseNotes } satisfies PendingReleaseNotes)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ignore quota errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPendingReleaseNotes(): PendingReleaseNotes | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PENDING_RELEASE_NOTES_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as PendingReleaseNotes;
|
||||||
|
if (typeof parsed?.version !== 'string') return null;
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPendingReleaseNotes(): void {
|
||||||
|
localStorage.removeItem(PENDING_RELEASE_NOTES_KEY);
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ export interface Settings {
|
|||||||
encUrlPreview: boolean;
|
encUrlPreview: boolean;
|
||||||
showHiddenEvents: boolean;
|
showHiddenEvents: boolean;
|
||||||
legacyUsernameColor: boolean;
|
legacyUsernameColor: boolean;
|
||||||
|
sidebarNameplateOpacity: number;
|
||||||
|
|
||||||
showNotifications: boolean;
|
showNotifications: boolean;
|
||||||
isNotificationSounds: boolean;
|
isNotificationSounds: boolean;
|
||||||
@@ -93,6 +94,7 @@ const defaultSettings: Settings = {
|
|||||||
encUrlPreview: false,
|
encUrlPreview: false,
|
||||||
showHiddenEvents: false,
|
showHiddenEvents: false,
|
||||||
legacyUsernameColor: false,
|
legacyUsernameColor: false,
|
||||||
|
sidebarNameplateOpacity: 60,
|
||||||
|
|
||||||
showNotifications: true,
|
showNotifications: true,
|
||||||
isNotificationSounds: true,
|
isNotificationSounds: true,
|
||||||
|
|||||||
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',
|
||||||
|
});
|
||||||
10
src/app/utils/appVersion.ts
Normal file
10
src/app/utils/appVersion.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
|
|
||||||
|
/** Returns the running app version (Electron shim or Tauri). */
|
||||||
|
export async function getAppVersion(): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await getVersion();
|
||||||
|
} catch {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
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(' ');
|
||||||
|
}
|
||||||
297
src/app/utils/profileFields.ts
Normal file
297
src/app/utils/profileFields.ts
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import { MatrixClient, Room } from 'matrix-js-sdk';
|
||||||
|
import { ThemeKind } from '../hooks/useTheme';
|
||||||
|
|
||||||
|
/** MSC4522 stable profile field for username colors */
|
||||||
|
export const PROFILE_KEY_COLOR_PREFERENCE_STABLE = 'm.color_preference';
|
||||||
|
|
||||||
|
/** MSC4522 unstable profile field for username colors */
|
||||||
|
export const PROFILE_KEY_COLOR_PREFERENCE_UNSTABLE = 'eu.she-a.color';
|
||||||
|
|
||||||
|
/** MSC4427 unstable profile field for banner */
|
||||||
|
export const PROFILE_KEY_BANNER_URL_UNSTABLE = 'chat.commet.profile_banner';
|
||||||
|
|
||||||
|
/** MSC4427 stable profile field for banner */
|
||||||
|
export const PROFILE_KEY_BANNER_URL_STABLE = 'm.banner_url';
|
||||||
|
|
||||||
|
/** Paarrot profile effect (animated profile background) */
|
||||||
|
export const PROFILE_KEY_PROFILE_EFFECT = 'im.paarrot.profile_effect';
|
||||||
|
|
||||||
|
/** Paarrot nameplate (username bar background) */
|
||||||
|
export const PROFILE_KEY_NAMEPLATE = 'im.paarrot.nameplate';
|
||||||
|
|
||||||
|
/** Paarrot avatar decoration (avatar frame) */
|
||||||
|
export const PROFILE_KEY_AVATAR_DECORATION = 'im.paarrot.avatar_decoration';
|
||||||
|
|
||||||
|
export type StoredCollectible = {
|
||||||
|
sku_id: string;
|
||||||
|
name: string;
|
||||||
|
assets: Record<string, string>;
|
||||||
|
effect?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CollectibleProfileFields = {
|
||||||
|
profile_effect?: StoredCollectible;
|
||||||
|
nameplate?: StoredCollectible;
|
||||||
|
avatar_decoration?: StoredCollectible;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ColorPreference = {
|
||||||
|
on_dark?: string;
|
||||||
|
on_light?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||||
|
|
||||||
|
export function isValidHexColor(color: string): boolean {
|
||||||
|
return HEX_COLOR_RE.test(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseColorPreference(value: unknown): ColorPreference | undefined {
|
||||||
|
if (!value || typeof value !== 'object') return undefined;
|
||||||
|
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
const on_dark = typeof obj.on_dark === 'string' && isValidHexColor(obj.on_dark) ? obj.on_dark : undefined;
|
||||||
|
const on_light = typeof obj.on_light === 'string' && isValidHexColor(obj.on_light) ? obj.on_light : undefined;
|
||||||
|
|
||||||
|
if (!on_dark && !on_light) return undefined;
|
||||||
|
return { on_dark, on_light };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractColorPreferenceFromProfile(profile: Record<string, unknown>): ColorPreference | undefined {
|
||||||
|
const stable = parseColorPreference(profile[PROFILE_KEY_COLOR_PREFERENCE_STABLE]);
|
||||||
|
if (stable) return stable;
|
||||||
|
return parseColorPreference(profile[PROFILE_KEY_COLOR_PREFERENCE_UNSTABLE]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveColorForTheme(
|
||||||
|
preference: ColorPreference | undefined,
|
||||||
|
themeKind: ThemeKind
|
||||||
|
): string | undefined {
|
||||||
|
if (!preference) return undefined;
|
||||||
|
if (themeKind === ThemeKind.Dark) {
|
||||||
|
return preference.on_dark ?? preference.on_light;
|
||||||
|
}
|
||||||
|
return preference.on_light ?? preference.on_dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasColorPreference(preference: ColorPreference | undefined): boolean {
|
||||||
|
return Boolean(preference?.on_dark || preference?.on_light);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractBannerUrlFromProfile(profile: Record<string, unknown>): string | undefined {
|
||||||
|
const stable = profile[PROFILE_KEY_BANNER_URL_STABLE];
|
||||||
|
if (typeof stable === 'string' && stable.startsWith('mxc://')) return stable;
|
||||||
|
|
||||||
|
const unstable = profile[PROFILE_KEY_BANNER_URL_UNSTABLE];
|
||||||
|
if (typeof unstable === 'string' && unstable.startsWith('mxc://')) return unstable;
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStoredCollectible(value: unknown): StoredCollectible | undefined {
|
||||||
|
if (!value || typeof value !== 'object') return undefined;
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
const sku_id = typeof obj.sku_id === 'string' ? obj.sku_id : undefined;
|
||||||
|
const name = typeof obj.name === 'string' ? obj.name : undefined;
|
||||||
|
const assets = obj.assets;
|
||||||
|
if (!sku_id || !name || !assets || typeof assets !== 'object') return undefined;
|
||||||
|
|
||||||
|
const parsedAssets: Record<string, string> = {};
|
||||||
|
for (const [key, mxc] of Object.entries(assets as Record<string, unknown>)) {
|
||||||
|
if (typeof mxc === 'string' && mxc.startsWith('mxc://')) {
|
||||||
|
parsedAssets[key] = mxc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(parsedAssets).length === 0) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sku_id,
|
||||||
|
name,
|
||||||
|
assets: parsedAssets,
|
||||||
|
effect: obj.effect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractCollectiblesFromProfile(profile: Record<string, unknown>): CollectibleProfileFields {
|
||||||
|
return {
|
||||||
|
profile_effect: parseStoredCollectible(profile[PROFILE_KEY_PROFILE_EFFECT]),
|
||||||
|
nameplate: parseStoredCollectible(profile[PROFILE_KEY_NAMEPLATE]),
|
||||||
|
avatar_decoration: parseStoredCollectible(profile[PROFILE_KEY_AVATAR_DECORATION]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCollectiblesFromProfile(mx: MatrixClient, userId: string): Promise<CollectibleProfileFields> {
|
||||||
|
if (await mx.doesServerSupportExtendedProfiles()) {
|
||||||
|
try {
|
||||||
|
const profile = await mx.getExtendedProfile(userId);
|
||||||
|
return extractCollectiblesFromProfile(profile);
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = (await mx.getProfileInfo(userId)) as Record<string, unknown>;
|
||||||
|
return extractCollectiblesFromProfile(profile);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadUserCollectibles(mx: MatrixClient, userId: string): Promise<CollectibleProfileFields> {
|
||||||
|
return loadCollectiblesFromProfile(mx, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveCollectible(
|
||||||
|
mx: MatrixClient,
|
||||||
|
key: string,
|
||||||
|
collectible: StoredCollectible | undefined
|
||||||
|
): Promise<void> {
|
||||||
|
if (!(await mx.doesServerSupportExtendedProfiles())) {
|
||||||
|
throw new Error('Server does not support extended profile fields (MSC4133)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!collectible) {
|
||||||
|
await mx.deleteExtendedProfileProperty(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await mx.setExtendedProfileProperty(key, collectible);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveProfileEffect(mx: MatrixClient, collectible: StoredCollectible | undefined): Promise<void> {
|
||||||
|
await saveCollectible(mx, PROFILE_KEY_PROFILE_EFFECT, collectible);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveNameplate(mx: MatrixClient, collectible: StoredCollectible | undefined): Promise<void> {
|
||||||
|
await saveCollectible(mx, PROFILE_KEY_NAMEPLATE, collectible);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAvatarDecoration(mx: MatrixClient, collectible: StoredCollectible | undefined): Promise<void> {
|
||||||
|
await saveCollectible(mx, PROFILE_KEY_AVATAR_DECORATION, collectible);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBannerUrlProfileKey(mx: MatrixClient): Promise<string> {
|
||||||
|
if (await mx.isVersionSupported('v1.16')) {
|
||||||
|
return PROFILE_KEY_BANNER_URL_STABLE;
|
||||||
|
}
|
||||||
|
return PROFILE_KEY_BANNER_URL_UNSTABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadBannerUrl(mx: MatrixClient, userId: string): Promise<string | undefined> {
|
||||||
|
if (await mx.doesServerSupportExtendedProfiles()) {
|
||||||
|
try {
|
||||||
|
const profile = await mx.getExtendedProfile(userId);
|
||||||
|
return extractBannerUrlFromProfile(profile);
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = (await mx.getProfileInfo(userId)) as Record<string, unknown>;
|
||||||
|
return extractBannerUrlFromProfile(profile);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBannerUrl(mx: MatrixClient, bannerMxc: string): Promise<void> {
|
||||||
|
if (!(await mx.doesServerSupportExtendedProfiles())) {
|
||||||
|
throw new Error('Server does not support extended profile fields (MSC4133)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await getBannerUrlProfileKey(mx);
|
||||||
|
await mx.setExtendedProfileProperty(key, bannerMxc);
|
||||||
|
const userId = mx.getUserId();
|
||||||
|
if (userId) clearBannerCache(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBannerUrl(mx: MatrixClient): Promise<void> {
|
||||||
|
if (!(await mx.doesServerSupportExtendedProfiles())) {
|
||||||
|
throw new Error('Server does not support extended profile fields (MSC4133)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await getBannerUrlProfileKey(mx);
|
||||||
|
await mx.deleteExtendedProfileProperty(key);
|
||||||
|
const userId = mx.getUserId();
|
||||||
|
if (userId) clearBannerCache(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bannerCacheClearImpl: (userId: string) => void = () => {};
|
||||||
|
|
||||||
|
export function registerBannerCacheClear(fn: (userId: string) => void): void {
|
||||||
|
bannerCacheClearImpl = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBannerCache(userId: string): void {
|
||||||
|
bannerCacheClearImpl(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getColorPreferenceProfileKey(mx: MatrixClient): Promise<string> {
|
||||||
|
if (await mx.isVersionSupported('v1.16')) {
|
||||||
|
return PROFILE_KEY_COLOR_PREFERENCE_STABLE;
|
||||||
|
}
|
||||||
|
return PROFILE_KEY_COLOR_PREFERENCE_UNSTABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadColorPreference(
|
||||||
|
mx: MatrixClient,
|
||||||
|
userId: string
|
||||||
|
): Promise<ColorPreference | undefined> {
|
||||||
|
if (await mx.doesServerSupportExtendedProfiles()) {
|
||||||
|
try {
|
||||||
|
const profile = await mx.getExtendedProfile(userId);
|
||||||
|
return extractColorPreferenceFromProfile(profile);
|
||||||
|
} catch {
|
||||||
|
// fall through to standard profile endpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = (await mx.getProfileInfo(userId)) as Record<string, unknown>;
|
||||||
|
return extractColorPreferenceFromProfile(profile);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractMemberColorPreference(room: Room | undefined, userId: string): ColorPreference | undefined {
|
||||||
|
if (!room || typeof (room as Room).getMember !== 'function' || !userId) return undefined;
|
||||||
|
const member = room.getMember(userId);
|
||||||
|
const content = member?.events.member?.getContent();
|
||||||
|
if (!content) return undefined;
|
||||||
|
return extractColorPreferenceFromProfile(content as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveColorPreference(mx: MatrixClient, preference: ColorPreference): Promise<void> {
|
||||||
|
if (!(await mx.doesServerSupportExtendedProfiles())) {
|
||||||
|
throw new Error('Server does not support extended profile fields (MSC4133)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await getColorPreferenceProfileKey(mx);
|
||||||
|
await mx.setExtendedProfileProperty(key, preference);
|
||||||
|
const userId = mx.getUserId();
|
||||||
|
if (userId) clearPreferenceCache(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let preferenceCacheClearImpl: (userId: string) => void = () => {};
|
||||||
|
|
||||||
|
export function registerPreferenceCacheClear(fn: (userId: string) => void): void {
|
||||||
|
preferenceCacheClearImpl = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPreferenceCache(userId: string): void {
|
||||||
|
preferenceCacheClearImpl(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteColorPreference(mx: MatrixClient): Promise<void> {
|
||||||
|
if (!(await mx.doesServerSupportExtendedProfiles())) {
|
||||||
|
throw new Error('Server does not support extended profile fields (MSC4133)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await getColorPreferenceProfileKey(mx);
|
||||||
|
await mx.deleteExtendedProfileProperty(key);
|
||||||
|
const userId = mx.getUserId();
|
||||||
|
if (userId) clearPreferenceCache(userId);
|
||||||
|
}
|
||||||
111
src/app/utils/registerDesktopMediaSaver.ts
Normal file
111
src/app/utils/registerDesktopMediaSaver.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { registerMediaSaver } from './saveMedia';
|
||||||
|
import { isElectron, isTauri } from './tauri';
|
||||||
|
|
||||||
|
type ElectronSaveResult = {
|
||||||
|
success?: boolean;
|
||||||
|
canceled?: boolean;
|
||||||
|
path?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ElectronMediaApi = {
|
||||||
|
saveFile?: (payload: {
|
||||||
|
filename: string;
|
||||||
|
mimeType?: string;
|
||||||
|
data: Uint8Array;
|
||||||
|
}) => Promise<ElectronSaveResult>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function blobToUint8Array(blob: Blob): Promise<Uint8Array> {
|
||||||
|
const buffer = await blob.arrayBuffer();
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function guessFilters(filename: string, mimeType?: string): Array<{ name: string; extensions: string[] }> {
|
||||||
|
const ext = filename.includes('.') ? filename.split('.').pop()!.toLowerCase() : '';
|
||||||
|
const mime = (mimeType || '').toLowerCase();
|
||||||
|
|
||||||
|
if (mime.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) {
|
||||||
|
return [
|
||||||
|
{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] },
|
||||||
|
{ name: 'All Files', extensions: ['*'] },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (mime.startsWith('video/') || ['mp4', 'webm', 'mkv', 'mov'].includes(ext)) {
|
||||||
|
return [
|
||||||
|
{ name: 'Videos', extensions: ['mp4', 'webm', 'mkv', 'mov'] },
|
||||||
|
{ name: 'All Files', extensions: ['*'] },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (mime.startsWith('audio/') || ['mp3', 'ogg', 'wav', 'm4a', 'flac'].includes(ext)) {
|
||||||
|
return [
|
||||||
|
{ name: 'Audio', extensions: ['mp3', 'ogg', 'wav', 'm4a', 'flac'] },
|
||||||
|
{ name: 'All Files', extensions: ['*'] },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (mime === 'application/pdf' || ext === 'pdf') {
|
||||||
|
return [
|
||||||
|
{ name: 'PDF', extensions: ['pdf'] },
|
||||||
|
{ name: 'All Files', extensions: ['*'] },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (ext) {
|
||||||
|
return [
|
||||||
|
{ name: ext.toUpperCase(), extensions: [ext] },
|
||||||
|
{ name: 'All Files', extensions: ['*'] },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [{ name: 'All Files', extensions: ['*'] }];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveWithElectron(blob: Blob, filename: string): Promise<void> {
|
||||||
|
const media = (window.electron as { media?: ElectronMediaApi } | undefined)?.media;
|
||||||
|
if (!media?.saveFile) {
|
||||||
|
throw new Error('Electron media.saveFile is unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await blobToUint8Array(blob);
|
||||||
|
const result = await media.saveFile({
|
||||||
|
filename,
|
||||||
|
mimeType: blob.type || 'application/octet-stream',
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.canceled) return;
|
||||||
|
if (result?.success === false) {
|
||||||
|
throw new Error(result.error || 'Failed to save file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveWithTauri(blob: Blob, filename: string): Promise<void> {
|
||||||
|
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||||
|
const { writeFile } = await import('@tauri-apps/plugin-fs');
|
||||||
|
|
||||||
|
const path = await save({
|
||||||
|
defaultPath: filename,
|
||||||
|
filters: guessFilters(filename, blob.type),
|
||||||
|
});
|
||||||
|
if (!path) return;
|
||||||
|
|
||||||
|
const data = await blobToUint8Array(blob);
|
||||||
|
await writeFile(path, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire Electron / Tauri native save dialogs into core saveMedia helpers.
|
||||||
|
* Call once at desktop app startup. No-ops in plain browser.
|
||||||
|
*/
|
||||||
|
export function registerDesktopMediaSaver(): void {
|
||||||
|
if (isElectron()) {
|
||||||
|
registerMediaSaver(async (blob, filename) => {
|
||||||
|
await saveWithElectron(blob, filename);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTauri()) {
|
||||||
|
registerMediaSaver(async (blob, filename) => {
|
||||||
|
await saveWithTauri(blob, filename);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/ext.d.ts
vendored
36
src/ext.d.ts
vendored
@@ -56,6 +56,18 @@ interface ElectronAPI {
|
|||||||
clipboard?: {
|
clipboard?: {
|
||||||
writeText: (text: string) => void;
|
writeText: (text: string) => void;
|
||||||
};
|
};
|
||||||
|
media?: {
|
||||||
|
saveFile: (payload: {
|
||||||
|
filename: string;
|
||||||
|
mimeType?: string;
|
||||||
|
data: Uint8Array;
|
||||||
|
}) => Promise<{
|
||||||
|
success?: boolean;
|
||||||
|
canceled?: boolean;
|
||||||
|
path?: string;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
plugins?: PluginAPI;
|
plugins?: PluginAPI;
|
||||||
protocol?: {
|
protocol?: {
|
||||||
getStatus: () => Promise<{
|
getStatus: () => Promise<{
|
||||||
@@ -95,6 +107,30 @@ interface ElectronAPI {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
updater?: {
|
||||||
|
onUpdateDownloaded: (callback: (info: {
|
||||||
|
version: string;
|
||||||
|
releaseNotes?: unknown;
|
||||||
|
}) => void) => void;
|
||||||
|
};
|
||||||
|
discordCollectibles?: {
|
||||||
|
fetchCatalog: (force?: boolean) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { items: unknown[]; fetchedAt: string };
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
downloadAssets: (assets: Array<{ role: string; url: string; filename: string; mimeType: string }>) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: Array<{
|
||||||
|
role: string;
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
data: Uint8Array;
|
||||||
|
}>;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -147,6 +147,27 @@ body.stationery-dark-theme {
|
|||||||
background-color: #262626;
|
background-color: #262626;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Folds Overlay / PopOut portals mount here. The container must not steal taps;
|
||||||
|
* only its children should. Empty portal shells (e.g. closed overlays) must not
|
||||||
|
* block dialogs portaled elsewhere.
|
||||||
|
*/
|
||||||
|
#portalContainer {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9998;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#portalContainer > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#portalContainer > *:empty {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.twilight-theme #root {
|
.twilight-theme #root {
|
||||||
background: linear-gradient(180deg, rgba(28, 26, 46, 0.5) 0%, rgba(36, 34, 61, 0.3) 100%);
|
background: linear-gradient(180deg, rgba(28, 26, 46, 0.5) 0%, rgba(36, 34, 61, 0.3) 100%);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { trimTrailingSlash } from './app/utils/common';
|
|||||||
import App from './app/pages/App';
|
import App from './app/pages/App';
|
||||||
import { applySafeAreaInsets, isTauri } from './app/utils/tauri';
|
import { applySafeAreaInsets, isTauri } from './app/utils/tauri';
|
||||||
import { enableViewTransitionsForNavigation } from './app/utils/viewTransitions';
|
import { enableViewTransitionsForNavigation } from './app/utils/viewTransitions';
|
||||||
|
import { registerDesktopMediaSaver } from './app/utils/registerDesktopMediaSaver';
|
||||||
|
|
||||||
// import i18n (needs to be bundled ;))
|
// import i18n (needs to be bundled ;))
|
||||||
import './app/i18n';
|
import './app/i18n';
|
||||||
@@ -29,6 +30,9 @@ applySafeAreaInsets();
|
|||||||
// Enable View Transitions API for smooth navigation
|
// Enable View Transitions API for smooth navigation
|
||||||
enableViewTransitionsForNavigation();
|
enableViewTransitionsForNavigation();
|
||||||
|
|
||||||
|
// Desktop shells: native Save dialog instead of browser FileSaver downloads.
|
||||||
|
registerDesktopMediaSaver();
|
||||||
|
|
||||||
// Register Service Worker
|
// Register Service Worker
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
const swUrl =
|
const swUrl =
|
||||||
|
|||||||
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 path from 'path';
|
||||||
import buildConfig from './build.config';
|
import buildConfig from './build.config';
|
||||||
import { liveTsxPlugin, readDefaultLiveSource } from './playground-liveTsxPlugin';
|
import { liveTsxPlugin, readDefaultLiveSource } from './playground-liveTsxPlugin';
|
||||||
|
import {
|
||||||
|
createReadableVanillaExtractIdentifiers,
|
||||||
|
vanillaExtractDebugIdsPlugin,
|
||||||
|
} from './vanillaExtractIdentifiers.js';
|
||||||
|
|
||||||
const projectRoot = path.resolve();
|
const projectRoot = path.resolve();
|
||||||
|
|
||||||
@@ -53,6 +57,12 @@ const copyFiles = {
|
|||||||
src: 'public/font',
|
src: 'public/font',
|
||||||
dest: 'public/',
|
dest: 'public/',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
src: 'public/update/**/*',
|
||||||
|
dest: 'update',
|
||||||
|
// stripBase removes public/update from the matched path; rename alone only changes the filename.
|
||||||
|
rename: { stripBase: 2 },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,6 +134,37 @@ function servePublicAssets() {
|
|||||||
res.setHeader('Content-Type', contentType);
|
res.setHeader('Content-Type', contentType);
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
|
||||||
|
const fileStream = fs.createReadStream(resolvedPath);
|
||||||
|
fileStream.pipe(res);
|
||||||
|
} else {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end('File not found');
|
||||||
|
}
|
||||||
|
} else if (req.url?.startsWith('/update/')) {
|
||||||
|
const fileName = req.url.replace('/update/', '');
|
||||||
|
const resolvedPath = path.join(path.resolve(), 'public', 'update', fileName);
|
||||||
|
|
||||||
|
if (fs.existsSync(resolvedPath) && !resolvedPath.includes('..')) {
|
||||||
|
const ext = path.extname(fileName).toLowerCase();
|
||||||
|
const contentType =
|
||||||
|
ext === '.md'
|
||||||
|
? 'text/markdown; charset=utf-8'
|
||||||
|
: ext === '.json'
|
||||||
|
? 'application/json'
|
||||||
|
: ext === '.svg'
|
||||||
|
? 'image/svg+xml'
|
||||||
|
: ext === '.png'
|
||||||
|
? 'image/png'
|
||||||
|
: ext === '.jpg' || ext === '.jpeg'
|
||||||
|
? 'image/jpeg'
|
||||||
|
: ext === '.webp'
|
||||||
|
? 'image/webp'
|
||||||
|
: ext === '.gif'
|
||||||
|
? 'image/gif'
|
||||||
|
: 'application/octet-stream';
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
|
||||||
const fileStream = fs.createReadStream(resolvedPath);
|
const fileStream = fs.createReadStream(resolvedPath);
|
||||||
fileStream.pipe(res);
|
fileStream.pipe(res);
|
||||||
} else {
|
} else {
|
||||||
@@ -215,7 +256,7 @@ function corsProxyMiddleware() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(() => ({
|
||||||
appType: 'spa',
|
appType: 'spa',
|
||||||
publicDir: false,
|
publicDir: false,
|
||||||
base: buildConfig.base,
|
base: buildConfig.base,
|
||||||
@@ -267,7 +308,15 @@ export default defineConfig({
|
|||||||
promiseImportName: (i) => `__tla_${i}`,
|
promiseImportName: (i) => `__tla_${i}`,
|
||||||
}),
|
}),
|
||||||
viteStaticCopy(copyFiles),
|
viteStaticCopy(copyFiles),
|
||||||
vanillaExtractPlugin(),
|
...(process.env.VITE_VE_IDENTIFIERS === 'short' ? [] : [vanillaExtractDebugIdsPlugin()]),
|
||||||
|
vanillaExtractPlugin({
|
||||||
|
unstable_pluginFilter: ({ name }) =>
|
||||||
|
name === 'vite-tsconfig-paths' || name === 'vanilla-extract-debug-ids',
|
||||||
|
identifiers:
|
||||||
|
process.env.VITE_VE_IDENTIFIERS === 'short'
|
||||||
|
? 'short'
|
||||||
|
: createReadableVanillaExtractIdentifiers(projectRoot),
|
||||||
|
}),
|
||||||
wasm(),
|
wasm(),
|
||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
@@ -322,4 +371,4 @@ export default defineConfig({
|
|||||||
plugins: [inject({ Buffer: ['buffer', 'Buffer'] })],
|
plugins: [inject({ Buffer: ['buffer', 'Buffer'] })],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user