Add MSC4522 username colors, release notes dialog, and profile layout fixes.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
Replace legacy profile style metadata with MSC4133 profile fields, add in-app update notes, tighten account profile name spacing, and wire desktop media save helpers through the client.
This commit is contained in:
1464
package-lock.json
generated
1464
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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/currentupdate.md
Normal file
22
public/update/currentupdate.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.
|
||||||
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
296
src/app/components/updates-dialog/UpdatesDialog.css.ts
Normal file
296
src/app/components/updates-dialog/UpdatesDialog.css.ts
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = '480px';
|
||||||
|
|
||||||
|
/** Dialog height cap — 85% of the viewport, minus safe areas. */
|
||||||
|
const DIALOG_MAX_HEIGHT =
|
||||||
|
'calc(85vh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))';
|
||||||
|
|
||||||
|
export const OverlayFrame = style({
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
maxHeight: '100vh',
|
||||||
|
maxWidth: '100vw',
|
||||||
|
padding:
|
||||||
|
'env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DialogShell = style({
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: `min(${toRem(560)}, calc(100vw - ${config.space.S800}))`,
|
||||||
|
maxHeight: DIALOG_MAX_HEIGHT,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
'@media': {
|
||||||
|
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||||
|
maxWidth: `calc(100vw - ${config.space.S400})`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Hero = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: config.space.S200,
|
||||||
|
padding: config.space.S500,
|
||||||
|
paddingBottom: config.space.S400,
|
||||||
|
backgroundColor: color.Primary.Container,
|
||||||
|
color: color.Primary.OnContainer,
|
||||||
|
borderBottom: `${config.borderWidth.B300} solid ${color.Primary.ContainerLine}`,
|
||||||
|
'@media': {
|
||||||
|
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||||
|
padding: config.space.S400,
|
||||||
|
paddingBottom: config.space.S300,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const HeroRow = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S300,
|
||||||
|
minWidth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const HeroLogo = style({
|
||||||
|
width: toRem(48),
|
||||||
|
height: toRem(48),
|
||||||
|
flexShrink: 0,
|
||||||
|
'@media': {
|
||||||
|
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||||
|
width: toRem(40),
|
||||||
|
height: toRem(40),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const HeroTitleWrap = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: config.space.S100,
|
||||||
|
minWidth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BodyScroll = style({
|
||||||
|
flex: '1 1 auto',
|
||||||
|
minHeight: 0,
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BodyContent = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
padding: config.space.S500,
|
||||||
|
paddingTop: config.space.S400,
|
||||||
|
'@media': {
|
||||||
|
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||||
|
padding: config.space.S400,
|
||||||
|
paddingTop: config.space.S300,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BodyLoading = style({
|
||||||
|
padding: config.space.S800,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Markdown = style({
|
||||||
|
width: '100%',
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
fontSize: toRem(14),
|
||||||
|
lineHeight: 1.55,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} h1, ${Markdown} h2, ${Markdown} h3, ${Markdown} h4`, {
|
||||||
|
margin: 0,
|
||||||
|
marginTop: config.space.S400,
|
||||||
|
marginBottom: config.space.S200,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} h1:first-child, ${Markdown} h2:first-child, ${Markdown} h3:first-child, ${Markdown} h4:first-child`, {
|
||||||
|
marginTop: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} h2`, { fontSize: toRem(18) });
|
||||||
|
globalStyle(`${Markdown} h3`, { fontSize: toRem(16) });
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} p`, {
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} p:last-child`, { marginBottom: 0 });
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} ul, ${Markdown} ol`, {
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
paddingLeft: config.space.S500,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} li`, { marginBottom: config.space.S100 });
|
||||||
|
globalStyle(`${Markdown} li:last-child`, { marginBottom: 0 });
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} a`, {
|
||||||
|
color: color.Primary.Main,
|
||||||
|
textDecoration: 'underline',
|
||||||
|
textUnderlineOffset: toRem(2),
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} a:hover`, { color: color.Primary.MainHover });
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} img`, {
|
||||||
|
display: 'block',
|
||||||
|
maxWidth: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
marginTop: config.space.S200,
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} code`, {
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||||
|
fontSize: toRem(13),
|
||||||
|
padding: `0 ${config.space.S100}`,
|
||||||
|
borderRadius: config.radii.R200,
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} pre`, {
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
padding: config.space.S300,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
overflow: 'auto',
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} pre code`, {
|
||||||
|
padding: 0,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} blockquote`, {
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
paddingLeft: config.space.S300,
|
||||||
|
borderLeft: `${toRem(3)} solid ${color.Primary.Main}`,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
opacity: 0.9,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} hr`, {
|
||||||
|
border: 'none',
|
||||||
|
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
margin: `${config.space.S400} 0`,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} table`, {
|
||||||
|
width: '100%',
|
||||||
|
borderCollapse: 'collapse',
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
fontSize: toRem(13),
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} th, ${Markdown} td`, {
|
||||||
|
padding: config.space.S200,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
textAlign: 'left',
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Markdown} th`, {
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OlderSection = style({
|
||||||
|
width: '100%',
|
||||||
|
marginTop: config.space.S500,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OlderToggle = style({
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OlderList = style({
|
||||||
|
listStyle: 'none',
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: config.space.S100,
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OlderListItem = style({
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OlderItem = style({
|
||||||
|
all: 'unset',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: config.space.S100,
|
||||||
|
width: '100%',
|
||||||
|
minHeight: toRem(48),
|
||||||
|
padding: config.space.S300,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: color.SurfaceVariant.ContainerHover,
|
||||||
|
},
|
||||||
|
'&[data-active="true"]': {
|
||||||
|
borderColor: color.Primary.Main,
|
||||||
|
backgroundColor: color.Primary.Container,
|
||||||
|
color: color.Primary.OnContainer,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Footer = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: config.space.S200,
|
||||||
|
padding: config.space.S400,
|
||||||
|
paddingTop: config.space.S300,
|
||||||
|
paddingBottom: `calc(${config.space.S400} + env(safe-area-inset-bottom, 0px))`,
|
||||||
|
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
'@media': {
|
||||||
|
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||||
|
padding: config.space.S300,
|
||||||
|
paddingTop: config.space.S300,
|
||||||
|
paddingBottom: `calc(${config.space.S300} + env(safe-area-inset-bottom, 0px))`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FooterButton = style({
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CloseButton = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: config.space.S300,
|
||||||
|
right: config.space.S300,
|
||||||
|
'@media': {
|
||||||
|
[`(max-width: ${MOBILE_BREAKPOINT})`]: {
|
||||||
|
top: config.space.S200,
|
||||||
|
right: config.space.S200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
181
src/app/components/updates-dialog/UpdatesDialog.tsx
Normal file
181
src/app/components/updates-dialog/UpdatesDialog.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
IconButton,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Scroll,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
} from 'folds';
|
||||||
|
import { Icon, Icons } from '../icons';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import type { ParsedUpdateDoc, UpdateManifest } from '../../data/updateNotes';
|
||||||
|
import PaarrotSVG from '../../../../public/res/svg/paarrot.svg';
|
||||||
|
import { UpdateMarkdownViewer } from './UpdateMarkdownViewer';
|
||||||
|
import * as css from './UpdatesDialog.css';
|
||||||
|
|
||||||
|
type UpdatesDialogProps = {
|
||||||
|
manifest: UpdateManifest | null;
|
||||||
|
activeDoc: ParsedUpdateDoc | null;
|
||||||
|
loading: boolean;
|
||||||
|
onSelectFile: (file: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UpdatesDialog({
|
||||||
|
manifest,
|
||||||
|
activeDoc,
|
||||||
|
loading,
|
||||||
|
onSelectFile,
|
||||||
|
onClose,
|
||||||
|
}: UpdatesDialogProps) {
|
||||||
|
const [showOlderList, setShowOlderList] = useState(false);
|
||||||
|
|
||||||
|
const displayVersion = activeDoc?.version;
|
||||||
|
const displayTitle = activeDoc?.title ?? (displayVersion ? `Version ${displayVersion}` : 'Release notes');
|
||||||
|
const isCurrent = manifest && activeDoc?.file === manifest.current;
|
||||||
|
const olderEntries = manifest?.older ?? [];
|
||||||
|
|
||||||
|
const handleSelectOlder = (file: string) => {
|
||||||
|
onSelectFile(file);
|
||||||
|
setShowOlderList(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter className={css.OverlayFrame}>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: onClose,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog variant="Surface" className={css.DialogShell}>
|
||||||
|
<Box className={css.Hero}>
|
||||||
|
<Box className={css.HeroRow}>
|
||||||
|
<img className={css.HeroLogo} src={PaarrotSVG} alt="" draggable={false} />
|
||||||
|
<Box className={css.HeroTitleWrap}>
|
||||||
|
<Text size="L400" priority="300">What's new</Text>
|
||||||
|
<Text size="H4" truncate>
|
||||||
|
{displayVersion ? `Paarrot ${displayVersion}` : 'Paarrot updates'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Text size="H6">{displayTitle}</Text>
|
||||||
|
{activeDoc?.date && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
{activeDoc.date}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!loading && !isCurrent && manifest?.current && (
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => onSelectFile(manifest.current)}
|
||||||
|
>
|
||||||
|
<Text size="B300">Back to current update</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Scroll className={css.BodyScroll} visibility="Hover">
|
||||||
|
<Box className={css.BodyContent} direction="Column">
|
||||||
|
{loading && (
|
||||||
|
<Box justifyContent="Center" alignItems="Center" className={css.BodyLoading}>
|
||||||
|
<Spinner size="400" variant="Secondary" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && activeDoc?.markdown && (
|
||||||
|
<UpdateMarkdownViewer markdown={activeDoc.markdown} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !activeDoc?.markdown && (
|
||||||
|
<Text size="T300" priority="400">
|
||||||
|
No update notes are available right now.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && olderEntries.length > 0 && (
|
||||||
|
<Box className={css.OlderSection} direction="Column" gap="200">
|
||||||
|
<Button
|
||||||
|
className={css.OlderToggle}
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
aria-expanded={showOlderList}
|
||||||
|
onClick={() => setShowOlderList((open) => !open)}
|
||||||
|
>
|
||||||
|
<Text size="B300">
|
||||||
|
{showOlderList
|
||||||
|
? 'Hide older versions'
|
||||||
|
: `Older versions (${olderEntries.length})`}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{showOlderList && (
|
||||||
|
<ul className={css.OlderList}>
|
||||||
|
{olderEntries.map((entry) => {
|
||||||
|
const isActive = activeDoc?.file === entry.file;
|
||||||
|
const label = entry.version ?? entry.file.replace(/\.md$/i, '');
|
||||||
|
return (
|
||||||
|
<li key={entry.file} className={css.OlderListItem}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={css.OlderItem}
|
||||||
|
data-active={isActive ? 'true' : 'false'}
|
||||||
|
onClick={() => handleSelectOlder(entry.file)}
|
||||||
|
>
|
||||||
|
<Text size="B300">{label}</Text>
|
||||||
|
{entry.title && (
|
||||||
|
<Text size="T200" priority="400">
|
||||||
|
{entry.title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{entry.date && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
{entry.date}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
|
||||||
|
<Box className={css.Footer}>
|
||||||
|
<Button
|
||||||
|
className={css.FooterButton}
|
||||||
|
variant="Primary"
|
||||||
|
fill="Solid"
|
||||||
|
radii="300"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<Text size="B400">Got it</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box className={css.CloseButton}>
|
||||||
|
<IconButton variant="Surface" onClick={onClose} aria-label="Close">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
src/app/components/updates-dialog/UpdatesDialogHost.tsx
Normal file
149
src/app/components/updates-dialog/UpdatesDialogHost.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useAtom, useSetAtom } from 'jotai';
|
||||||
|
import {
|
||||||
|
loadCurrentUpdateDocument,
|
||||||
|
loadUpdateDocument,
|
||||||
|
loadUpdateManifest,
|
||||||
|
type ParsedUpdateDoc,
|
||||||
|
type UpdateManifest,
|
||||||
|
} from '../../data/updateNotes';
|
||||||
|
import {
|
||||||
|
clearPendingReleaseNotes,
|
||||||
|
getLastSeenAppVersion,
|
||||||
|
releaseNotesDialogAtom,
|
||||||
|
setLastSeenAppVersion,
|
||||||
|
storePendingReleaseNotes,
|
||||||
|
} from '../../state/releaseNotes';
|
||||||
|
import { getAppVersion } from '../../utils/appVersion';
|
||||||
|
import { UpdatesDialog } from './UpdatesDialog';
|
||||||
|
|
||||||
|
type UpdaterInfo = {
|
||||||
|
version: string;
|
||||||
|
releaseNotes?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Open the What's New dialog from Settings → About (or anywhere else). */
|
||||||
|
export function useOpenReleaseNotesDialog(): () => void {
|
||||||
|
const setDialogState = useSetAtom(releaseNotesDialogAtom);
|
||||||
|
return useCallback(() => {
|
||||||
|
setDialogState({ open: true, manual: true });
|
||||||
|
}, [setDialogState]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpdatesDialogHost() {
|
||||||
|
const [dialogState, setDialogState] = useAtom(releaseNotesDialogAtom);
|
||||||
|
const [manifest, setManifest] = useState<UpdateManifest | null>(null);
|
||||||
|
const [activeDoc, setActiveDoc] = useState<ParsedUpdateDoc | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const closeDialog = useCallback(() => {
|
||||||
|
setDialogState({ open: false, manual: false });
|
||||||
|
setManifest(null);
|
||||||
|
setActiveDoc(null);
|
||||||
|
setLoading(false);
|
||||||
|
}, [setDialogState]);
|
||||||
|
|
||||||
|
const selectFile = useCallback(async (file: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
const doc = await loadUpdateDocument(file);
|
||||||
|
setActiveDoc(doc);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Persist updater metadata when a download finishes (version tracking after restart).
|
||||||
|
useEffect(() => {
|
||||||
|
const electron = window.electron;
|
||||||
|
if (!electron?.updater?.onUpdateDownloaded) return undefined;
|
||||||
|
|
||||||
|
const onDownloaded = (info: UpdaterInfo) => {
|
||||||
|
if (!info?.version) return;
|
||||||
|
storePendingReleaseNotes(info.version, info.releaseNotes);
|
||||||
|
};
|
||||||
|
|
||||||
|
electron.updater.onUpdateDownloaded(onDownloaded);
|
||||||
|
return undefined;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Show What's New after the app version changes (post-update restart).
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const checkVersion = async () => {
|
||||||
|
const currentVersion = await getAppVersion();
|
||||||
|
if (cancelled || currentVersion === 'unknown') return;
|
||||||
|
|
||||||
|
const lastSeen = getLastSeenAppVersion();
|
||||||
|
const shouldAutoShow = Boolean(lastSeen && lastSeen !== currentVersion);
|
||||||
|
|
||||||
|
if (shouldAutoShow) {
|
||||||
|
const loadedManifest = await loadUpdateManifest();
|
||||||
|
if (cancelled || !loadedManifest) return;
|
||||||
|
|
||||||
|
const doc = await loadUpdateDocument(loadedManifest.current);
|
||||||
|
if (cancelled || !doc) return;
|
||||||
|
|
||||||
|
setManifest(loadedManifest);
|
||||||
|
setActiveDoc(doc);
|
||||||
|
setDialogState({ open: true, manual: false });
|
||||||
|
clearPendingReleaseNotes();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastSeen || lastSeen !== currentVersion) {
|
||||||
|
setLastSeenAppVersion(currentVersion);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = window.setTimeout(checkVersion, 1500);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [setDialogState]);
|
||||||
|
|
||||||
|
// Load markdown when the dialog opens (manual open from About, etc.).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dialogState.open) return undefined;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const loadedManifest = await loadUpdateManifest();
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (!loadedManifest) {
|
||||||
|
setManifest(null);
|
||||||
|
setActiveDoc(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = await loadUpdateDocument(loadedManifest.current);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
setManifest(loadedManifest);
|
||||||
|
setActiveDoc(doc);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [dialogState.open]);
|
||||||
|
|
||||||
|
if (!dialogState.open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UpdatesDialog
|
||||||
|
manifest={manifest}
|
||||||
|
activeDoc={activeDoc}
|
||||||
|
loading={loading}
|
||||||
|
onSelectFile={selectFile}
|
||||||
|
onClose={closeDialog}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
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';
|
||||||
@@ -14,7 +14,6 @@ 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';
|
|
||||||
|
|
||||||
type UserHeroProps = {
|
type UserHeroProps = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -24,15 +23,7 @@ type UserHeroProps = {
|
|||||||
};
|
};
|
||||||
export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroProps) {
|
export function UserHero({ userId, avatarUrl, avatarMxc, presence }: 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);
|
|
||||||
|
|
||||||
console.log('[UserHero]', {
|
|
||||||
userId,
|
|
||||||
avatarMxc,
|
|
||||||
bannerUrl,
|
|
||||||
profileStyle,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -57,8 +48,6 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
|
|||||||
<div className={css.UserHeroAvatarContainer}>
|
<div className={css.UserHeroAvatarContainer}>
|
||||||
<AvatarPresence
|
<AvatarPresence
|
||||||
className={css.UserAvatarContainer}
|
className={css.UserAvatarContainer}
|
||||||
style={profileStyle.avatarBorderColor ? { backgroundColor: profileStyle.avatarBorderColor } : undefined}
|
|
||||||
badgeBackgroundColor={profileStyle.avatarBorderColor}
|
|
||||||
badge={
|
badge={
|
||||||
presence && <PresenceBadge presence={presence.presence} status={presence.status} />
|
presence && <PresenceBadge presence={presence.presence} status={presence.status} />
|
||||||
}
|
}
|
||||||
@@ -71,10 +60,6 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
|
|||||||
style={{
|
style={{
|
||||||
width: toRem(72),
|
width: toRem(72),
|
||||||
height: toRem(72),
|
height: toRem(72),
|
||||||
...(profileStyle.avatarBorderColor ? {
|
|
||||||
outline: `${toRem(4)} solid ${profileStyle.avatarBorderColor}`,
|
|
||||||
outlineOffset: toRem(-1),
|
|
||||||
} : {}),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
@@ -114,16 +99,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">
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -122,12 +99,11 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
direction="Column"
|
direction="Column"
|
||||||
gap="200"
|
gap="200"
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
style={{
|
style={{
|
||||||
padding: config.space.S400,
|
padding: config.space.S400,
|
||||||
paddingTop: `calc(${config.space.S200} + ${toRem(36)})`,
|
paddingTop: `calc(${config.space.S200} + ${toRem(36)})`,
|
||||||
marginTop: toRem(-36),
|
marginTop: toRem(-36),
|
||||||
background: gradientStyle,
|
textAlign: 'center',
|
||||||
textAlign: 'center'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Display Name */}
|
{/* Display Name */}
|
||||||
@@ -152,20 +128,14 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
{presence?.status && (
|
{presence?.status && (
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: pillBgColor || color.Surface.Container,
|
backgroundColor: color.Surface.Container,
|
||||||
border: pillBgColor ? '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),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text size="T300" className={BreakWord}>
|
||||||
size="T300"
|
|
||||||
className={BreakWord}
|
|
||||||
style={{
|
|
||||||
color: pillBgColor ? getContrastingTextColor(pillBgColor) : undefined
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{presence.status}
|
{presence.status}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -173,9 +143,9 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
|
|
||||||
{/* 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,8 +165,8 @@ 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>
|
||||||
|
|||||||
125
src/app/data/updateNotes.ts
Normal file
125
src/app/data/updateNotes.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { trimTrailingSlash } from '../utils/common';
|
||||||
|
|
||||||
|
const UPDATE_BASE_PATH = `${trimTrailingSlash(import.meta.env.BASE_URL)}/update`;
|
||||||
|
|
||||||
|
export type UpdateManifestEntry = {
|
||||||
|
file: string;
|
||||||
|
version?: string;
|
||||||
|
title?: string;
|
||||||
|
date?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateManifest = {
|
||||||
|
current: string;
|
||||||
|
older: UpdateManifestEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParsedUpdateDoc = {
|
||||||
|
file: string;
|
||||||
|
version?: string;
|
||||||
|
title?: string;
|
||||||
|
date?: string;
|
||||||
|
summary?: string;
|
||||||
|
markdown: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
||||||
|
|
||||||
|
function parseFrontmatter(source: string): { frontmatter: Record<string, string>; body: string } {
|
||||||
|
const match = FRONTMATTER_RE.exec(source.trimStart());
|
||||||
|
if (!match) {
|
||||||
|
return { frontmatter: {}, body: source };
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontmatter: Record<string, string> = {};
|
||||||
|
match[1].split('\n').forEach((line) => {
|
||||||
|
const colon = line.indexOf(':');
|
||||||
|
if (colon <= 0) return;
|
||||||
|
const key = line.slice(0, colon).trim();
|
||||||
|
const raw = line.slice(colon + 1).trim();
|
||||||
|
frontmatter[key] = raw.replace(/^['"]|['"]$/g, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
return { frontmatter, body: match[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUpdateMarkdown(file: string, source: string): ParsedUpdateDoc {
|
||||||
|
const { frontmatter, body } = parseFrontmatter(source);
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
version: frontmatter.version,
|
||||||
|
title: frontmatter.title,
|
||||||
|
date: frontmatter.date,
|
||||||
|
summary: frontmatter.summary,
|
||||||
|
markdown: body.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveUpdateAssetUrl(src: string | undefined): string | undefined {
|
||||||
|
if (!src) return undefined;
|
||||||
|
if (/^(https?:|data:|blob:)/i.test(src)) return src;
|
||||||
|
if (src.startsWith('/')) return src;
|
||||||
|
|
||||||
|
const normalized = src.replace(/^\.\//, '');
|
||||||
|
return `${UPDATE_BASE_PATH}/${normalized}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadUpdateManifest(): Promise<UpdateManifest | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${UPDATE_BASE_PATH}/manifest.json`, { cache: 'no-cache' });
|
||||||
|
if (!response.ok) return null;
|
||||||
|
|
||||||
|
const data = (await response.json()) as UpdateManifest;
|
||||||
|
if (!data?.current || !Array.isArray(data.older)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
current: data.current,
|
||||||
|
older: data.older.filter((entry) => entry?.file),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadUpdateDocument(file: string): Promise<ParsedUpdateDoc | null> {
|
||||||
|
const safeFile = file.replace(/^\/+/, '');
|
||||||
|
if (!safeFile || safeFile.includes('..')) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${UPDATE_BASE_PATH}/${safeFile}`, { cache: 'no-cache' });
|
||||||
|
if (!response.ok) return null;
|
||||||
|
|
||||||
|
const source = await response.text();
|
||||||
|
return parseUpdateMarkdown(safeFile, source);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCurrentUpdateDocument(): Promise<ParsedUpdateDoc | null> {
|
||||||
|
const manifest = await loadUpdateManifest();
|
||||||
|
if (!manifest) return null;
|
||||||
|
return loadUpdateDocument(manifest.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentUpdatePreview(): Promise<{
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
} | null> {
|
||||||
|
const doc = await loadCurrentUpdateDocument();
|
||||||
|
if (!doc) return null;
|
||||||
|
|
||||||
|
const title = doc.title ?? (doc.version ? `Paarrot ${doc.version}` : 'Release notes');
|
||||||
|
const description =
|
||||||
|
doc.summary ??
|
||||||
|
doc.markdown
|
||||||
|
.replace(/^#+\s+/gm, '')
|
||||||
|
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.replace(/^[-*•]\s*/, '').trim())
|
||||||
|
.find(Boolean) ??
|
||||||
|
'See what changed in this version.';
|
||||||
|
|
||||||
|
return { title, description };
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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))`
|
||||||
|
|||||||
@@ -785,7 +785,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)
|
||||||
|
|||||||
@@ -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,6 +17,7 @@ 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);
|
||||||
@@ -93,10 +96,13 @@ 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]);
|
||||||
|
|
||||||
|
const [updatePreview, setUpdatePreview] = useState<{ title: string; description: string } | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader outlined={false}>
|
<PageHeader outlined={false}>
|
||||||
@@ -151,6 +157,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
|
||||||
|
|||||||
@@ -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,22 @@ 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 {
|
||||||
|
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();
|
||||||
@@ -98,26 +90,25 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
: 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 +117,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 +340,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 +355,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,24 +371,11 @@ 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);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="300">
|
<Box direction="Column" gap="300">
|
||||||
@@ -627,7 +411,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',
|
||||||
@@ -740,7 +524,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
as="button"
|
as="button"
|
||||||
onClick={handleAvatarClick}
|
onClick={handleAvatarClick}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: previewBorderColor || color.Surface.Container,
|
backgroundColor: color.Surface.Container,
|
||||||
border: 'none',
|
border: 'none',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@@ -748,7 +532,6 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AvatarPresence
|
<AvatarPresence
|
||||||
badgeBackgroundColor={previewBorderColor}
|
|
||||||
badge={
|
badge={
|
||||||
presence && (
|
presence && (
|
||||||
<PresenceBadge presence={presence.presence} status={presence.status} />
|
<PresenceBadge presence={presence.presence} status={presence.status} />
|
||||||
@@ -760,9 +543,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
style={{
|
style={{
|
||||||
width: toRem(72),
|
width: toRem(72),
|
||||||
height: toRem(72),
|
height: toRem(72),
|
||||||
outline: previewBorderColor
|
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
|
||||||
? `${toRem(4)} solid ${previewBorderColor}`
|
|
||||||
: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
@@ -832,73 +613,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 +638,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 +655,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 +684,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 +719,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 +771,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,63 +808,42 @@ 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.S300,
|
||||||
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
@@ -1134,111 +851,91 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="H6">Profile Style</Text>
|
<Text size="H6">Username colors</Text>
|
||||||
|
<Text size="T200" style={{ opacity: 0.8 }}>
|
||||||
{/* Avatar Border Color */}
|
Set how your name appears on dark and light themes (MSC4522). Other clients that support this
|
||||||
<Box direction="Column" gap="100">
|
spec will see your chosen colors.
|
||||||
<Text size="T300">Avatar Border Color</Text>
|
</Text>
|
||||||
<Box gap="200" alignItems="Center" wrap="Wrap">
|
|
||||||
<RgbaColorPicker color={localBorderColor} onChange={(c) => { setLocalBorderColor(c); setEditingBorder(true); }} />
|
<Box gap="400" wrap="Wrap">
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="200">
|
||||||
<Text size="T200" style={{ opacity: 0.7 }}>
|
<Text size="T300">On dark themes</Text>
|
||||||
Preview: {rgbaToHex(localBorderColor)}
|
<Text size="T200" style={{ opacity: 0.7 }}>Bright colors work best</Text>
|
||||||
</Text>
|
<HexColorPicker color={localOnDark} onChange={(c) => { setLocalOnDark(c); setColorError(undefined); }} />
|
||||||
<Box
|
<Input
|
||||||
style={{
|
size="300"
|
||||||
width: toRem(48),
|
variant="Secondary"
|
||||||
height: toRem(48),
|
style={{ width: toRem(120) }}
|
||||||
borderRadius: '50%',
|
value={localOnDark}
|
||||||
border: `${toRem(4)} solid ${rgbaToHex(localBorderColor)}`,
|
onChange={(e) => {
|
||||||
backgroundColor: color.Surface.Container,
|
setLocalOnDark(e.target.value);
|
||||||
}}
|
setColorError(undefined);
|
||||||
/>
|
}}
|
||||||
<Box gap="100">
|
/>
|
||||||
<Button
|
<Box
|
||||||
size="300"
|
style={{
|
||||||
variant="Primary"
|
width: toRem(48),
|
||||||
fill="Solid"
|
height: toRem(48),
|
||||||
radii="300"
|
borderRadius: toRem(8),
|
||||||
onClick={handleBorderColorSave}
|
backgroundColor: localOnDark,
|
||||||
disabled={savingStyle}
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
>
|
}}
|
||||||
<Text size="B300">Save</Text>
|
/>
|
||||||
</Button>
|
</Box>
|
||||||
{profileStyle.avatarBorderColor && (
|
|
||||||
<Button
|
<Box direction="Column" gap="200">
|
||||||
size="300"
|
<Text size="T300">On light themes</Text>
|
||||||
variant="Critical"
|
<Text size="T200" style={{ opacity: 0.7 }}>Darker colors work best</Text>
|
||||||
fill="Soft"
|
<HexColorPicker color={localOnLight} onChange={(c) => { setLocalOnLight(c); setColorError(undefined); }} />
|
||||||
radii="300"
|
<Input
|
||||||
onClick={handleBorderColorRemove}
|
size="300"
|
||||||
disabled={savingStyle}
|
variant="Secondary"
|
||||||
>
|
style={{ width: toRem(120) }}
|
||||||
<Text size="B300">Remove</Text>
|
value={localOnLight}
|
||||||
</Button>
|
onChange={(e) => {
|
||||||
)}
|
setLocalOnLight(e.target.value);
|
||||||
</Box>
|
setColorError(undefined);
|
||||||
</Box>
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: toRem(48),
|
||||||
|
height: toRem(48),
|
||||||
|
borderRadius: toRem(8),
|
||||||
|
backgroundColor: localOnLight,
|
||||||
|
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -698,6 +699,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<MessageNotifications />
|
<MessageNotifications />
|
||||||
<BackgroundSyncSetup />
|
<BackgroundSyncSetup />
|
||||||
<PaarrotAPIInitializer />
|
<PaarrotAPIInitializer />
|
||||||
|
<UpdatesDialogHost />
|
||||||
<TaskbarFlashStopper />
|
<TaskbarFlashStopper />
|
||||||
<AndroidShareIntentHandler />
|
<AndroidShareIntentHandler />
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
192
src/app/utils/profileFields.ts
Normal file
192
src/app/utils/profileFields.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { MatrixClient, Room } from 'matrix-js-sdk';
|
||||||
|
import { ThemeKind } from '../hooks/useTheme';
|
||||||
|
|
||||||
|
/** MSC4522 stable profile field for username colors */
|
||||||
|
export const PROFILE_KEY_COLOR_PREFERENCE_STABLE = 'm.color_preference';
|
||||||
|
|
||||||
|
/** MSC4522 unstable profile field for username colors */
|
||||||
|
export const PROFILE_KEY_COLOR_PREFERENCE_UNSTABLE = 'eu.she-a.color';
|
||||||
|
|
||||||
|
/** MSC4427 unstable profile field for banner */
|
||||||
|
export const PROFILE_KEY_BANNER_URL_UNSTABLE = 'chat.commet.profile_banner';
|
||||||
|
|
||||||
|
/** MSC4427 stable profile field for banner */
|
||||||
|
export const PROFILE_KEY_BANNER_URL_STABLE = 'm.banner_url';
|
||||||
|
|
||||||
|
export type ColorPreference = {
|
||||||
|
on_dark?: string;
|
||||||
|
on_light?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||||
|
|
||||||
|
export function isValidHexColor(color: string): boolean {
|
||||||
|
return HEX_COLOR_RE.test(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseColorPreference(value: unknown): ColorPreference | undefined {
|
||||||
|
if (!value || typeof value !== 'object') return undefined;
|
||||||
|
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
const on_dark = typeof obj.on_dark === 'string' && isValidHexColor(obj.on_dark) ? obj.on_dark : undefined;
|
||||||
|
const on_light = typeof obj.on_light === 'string' && isValidHexColor(obj.on_light) ? obj.on_light : undefined;
|
||||||
|
|
||||||
|
if (!on_dark && !on_light) return undefined;
|
||||||
|
return { on_dark, on_light };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractColorPreferenceFromProfile(profile: Record<string, unknown>): ColorPreference | undefined {
|
||||||
|
const stable = parseColorPreference(profile[PROFILE_KEY_COLOR_PREFERENCE_STABLE]);
|
||||||
|
if (stable) return stable;
|
||||||
|
return parseColorPreference(profile[PROFILE_KEY_COLOR_PREFERENCE_UNSTABLE]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveColorForTheme(
|
||||||
|
preference: ColorPreference | undefined,
|
||||||
|
themeKind: ThemeKind
|
||||||
|
): string | undefined {
|
||||||
|
if (!preference) return undefined;
|
||||||
|
if (themeKind === ThemeKind.Dark) {
|
||||||
|
return preference.on_dark ?? preference.on_light;
|
||||||
|
}
|
||||||
|
return preference.on_light ?? preference.on_dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasColorPreference(preference: ColorPreference | undefined): boolean {
|
||||||
|
return Boolean(preference?.on_dark || preference?.on_light);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractBannerUrlFromProfile(profile: Record<string, unknown>): string | undefined {
|
||||||
|
const stable = profile[PROFILE_KEY_BANNER_URL_STABLE];
|
||||||
|
if (typeof stable === 'string' && stable.startsWith('mxc://')) return stable;
|
||||||
|
|
||||||
|
const unstable = profile[PROFILE_KEY_BANNER_URL_UNSTABLE];
|
||||||
|
if (typeof unstable === 'string' && unstable.startsWith('mxc://')) return unstable;
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBannerUrlProfileKey(mx: MatrixClient): Promise<string> {
|
||||||
|
if (await mx.isVersionSupported('v1.16')) {
|
||||||
|
return PROFILE_KEY_BANNER_URL_STABLE;
|
||||||
|
}
|
||||||
|
return PROFILE_KEY_BANNER_URL_UNSTABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadBannerUrl(mx: MatrixClient, userId: string): Promise<string | undefined> {
|
||||||
|
if (await mx.doesServerSupportExtendedProfiles()) {
|
||||||
|
try {
|
||||||
|
const profile = await mx.getExtendedProfile(userId);
|
||||||
|
return extractBannerUrlFromProfile(profile);
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = (await mx.getProfileInfo(userId)) as Record<string, unknown>;
|
||||||
|
return extractBannerUrlFromProfile(profile);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBannerUrl(mx: MatrixClient, bannerMxc: string): Promise<void> {
|
||||||
|
if (!(await mx.doesServerSupportExtendedProfiles())) {
|
||||||
|
throw new Error('Server does not support extended profile fields (MSC4133)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await getBannerUrlProfileKey(mx);
|
||||||
|
await mx.setExtendedProfileProperty(key, bannerMxc);
|
||||||
|
const userId = mx.getUserId();
|
||||||
|
if (userId) clearBannerCache(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBannerUrl(mx: MatrixClient): Promise<void> {
|
||||||
|
if (!(await mx.doesServerSupportExtendedProfiles())) {
|
||||||
|
throw new Error('Server does not support extended profile fields (MSC4133)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await getBannerUrlProfileKey(mx);
|
||||||
|
await mx.deleteExtendedProfileProperty(key);
|
||||||
|
const userId = mx.getUserId();
|
||||||
|
if (userId) clearBannerCache(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bannerCacheClearImpl: (userId: string) => void = () => {};
|
||||||
|
|
||||||
|
export function registerBannerCacheClear(fn: (userId: string) => void): void {
|
||||||
|
bannerCacheClearImpl = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBannerCache(userId: string): void {
|
||||||
|
bannerCacheClearImpl(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getColorPreferenceProfileKey(mx: MatrixClient): Promise<string> {
|
||||||
|
if (await mx.isVersionSupported('v1.16')) {
|
||||||
|
return PROFILE_KEY_COLOR_PREFERENCE_STABLE;
|
||||||
|
}
|
||||||
|
return PROFILE_KEY_COLOR_PREFERENCE_UNSTABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadColorPreference(
|
||||||
|
mx: MatrixClient,
|
||||||
|
userId: string
|
||||||
|
): Promise<ColorPreference | undefined> {
|
||||||
|
if (await mx.doesServerSupportExtendedProfiles()) {
|
||||||
|
try {
|
||||||
|
const profile = await mx.getExtendedProfile(userId);
|
||||||
|
return extractColorPreferenceFromProfile(profile);
|
||||||
|
} catch {
|
||||||
|
// fall through to standard profile endpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = (await mx.getProfileInfo(userId)) as Record<string, unknown>;
|
||||||
|
return extractColorPreferenceFromProfile(profile);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractMemberColorPreference(room: Room | undefined, userId: string): ColorPreference | undefined {
|
||||||
|
if (!room) return undefined;
|
||||||
|
const member = room.getMember(userId);
|
||||||
|
const content = member?.events.member?.getContent();
|
||||||
|
if (!content) return undefined;
|
||||||
|
return extractColorPreferenceFromProfile(content as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveColorPreference(mx: MatrixClient, preference: ColorPreference): Promise<void> {
|
||||||
|
if (!(await mx.doesServerSupportExtendedProfiles())) {
|
||||||
|
throw new Error('Server does not support extended profile fields (MSC4133)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await getColorPreferenceProfileKey(mx);
|
||||||
|
await mx.setExtendedProfileProperty(key, preference);
|
||||||
|
const userId = mx.getUserId();
|
||||||
|
if (userId) clearPreferenceCache(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let preferenceCacheClearImpl: (userId: string) => void = () => {};
|
||||||
|
|
||||||
|
export function registerPreferenceCacheClear(fn: (userId: string) => void): void {
|
||||||
|
preferenceCacheClearImpl = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPreferenceCache(userId: string): void {
|
||||||
|
preferenceCacheClearImpl(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteColorPreference(mx: MatrixClient): Promise<void> {
|
||||||
|
if (!(await mx.doesServerSupportExtendedProfiles())) {
|
||||||
|
throw new Error('Server does not support extended profile fields (MSC4133)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await getColorPreferenceProfileKey(mx);
|
||||||
|
await mx.deleteExtendedProfileProperty(key);
|
||||||
|
const userId = mx.getUserId();
|
||||||
|
if (userId) clearPreferenceCache(userId);
|
||||||
|
}
|
||||||
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/ext.d.ts
vendored
18
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,12 @@ interface ElectronAPI {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
updater?: {
|
||||||
|
onUpdateDownloaded: (callback: (info: {
|
||||||
|
version: string;
|
||||||
|
releaseNotes?: unknown;
|
||||||
|
}) => void) => void;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ const copyFiles = {
|
|||||||
src: 'public/font',
|
src: 'public/font',
|
||||||
dest: 'public/',
|
dest: 'public/',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
src: 'public/update/**/*',
|
||||||
|
dest: 'update',
|
||||||
|
rename: (_name, _ext, fullPath) => fullPath.replace(/^public[/\\]update[/\\]/, ''),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,6 +129,37 @@ function servePublicAssets() {
|
|||||||
res.setHeader('Content-Type', contentType);
|
res.setHeader('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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user