feat: enhance user profile customization with avatar border color and gradient options

- Added support for setting avatar border color and gradient in user profile settings.
- Introduced new hooks for managing user profile styles and fetching metadata from avatars.
- Implemented an AngleSelector component for selecting gradient direction.
- Updated RoomNavItem to display parent space information for direct messages.
- Improved caching mechanism for user banners and profile styles.
- Refactored image metadata handling to include new profile style attributes.
This commit is contained in:
2026-03-12 20:50:00 +11:00
parent fc30d81f8f
commit 834de012b4
13 changed files with 911 additions and 90 deletions

View File

@@ -0,0 +1,189 @@
import React, { useRef, useCallback, useState } from 'react';
import { Box, Text, toRem, color } from 'folds';
/**
* Props for the AngleSelector component
*/
export interface AngleSelectorProps {
/** Current angle in degrees (0-360) */
value: number;
/** Callback when angle changes */
onChange: (angle: number) => void;
/** Size of the selector in pixels */
size?: number;
/** Whether the component is disabled */
disabled?: boolean;
}
/**
* A circular angle selector similar to Photoshop's drop shadow angle control.
* Click or drag around the circle to set an angle in degrees.
*/
export function AngleSelector({
value,
onChange,
size = 80,
disabled = false,
}: AngleSelectorProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
/**
* Calculate angle from mouse/touch position relative to center
*/
const calculateAngle = useCallback(
(clientX: number, clientY: number) => {
if (!containerRef.current) return value;
const rect = containerRef.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const deltaX = clientX - centerX;
const deltaY = clientY - centerY;
// Calculate angle in radians, then convert to degrees
// atan2 returns angle from -PI to PI, where 0 is pointing right
// We adjust so 0deg is pointing up (like CSS gradients)
let angle = Math.atan2(deltaX, -deltaY) * (180 / Math.PI);
// Normalize to 0-360 range
if (angle < 0) angle += 360;
return Math.round(angle);
},
[value]
);
/**
* Handle mouse/touch start
*/
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (disabled) return;
e.preventDefault();
setIsDragging(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
const newAngle = calculateAngle(e.clientX, e.clientY);
onChange(newAngle);
},
[disabled, calculateAngle, onChange]
);
/**
* Handle mouse/touch move
*/
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!isDragging || disabled) return;
const newAngle = calculateAngle(e.clientX, e.clientY);
onChange(newAngle);
},
[isDragging, disabled, calculateAngle, onChange]
);
/**
* Handle mouse/touch end
*/
const handlePointerUp = useCallback(() => {
setIsDragging(false);
}, []);
// Calculate line endpoint position
const lineLength = (size / 2) - 8;
const radians = ((value - 90) * Math.PI) / 180;
const lineX = Math.cos(radians) * lineLength;
const lineY = Math.sin(radians) * lineLength;
return (
<Box direction="Column" gap="100" alignItems="Center">
<Box
ref={containerRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
style={{
width: toRem(size),
height: toRem(size),
borderRadius: '50%',
border: `${toRem(2)} solid ${color.Surface.ContainerLine}`,
backgroundColor: color.Surface.Container,
position: 'relative',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
touchAction: 'none',
userSelect: 'none',
}}
>
{/* Center dot */}
<Box
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: toRem(6),
height: toRem(6),
borderRadius: '50%',
backgroundColor: color.Secondary.Main,
}}
/>
{/* Direction line */}
<Box
style={{
position: 'absolute',
top: '50%',
left: '50%',
width: toRem(2),
height: toRem(lineLength),
backgroundColor: color.Primary.Main,
transformOrigin: 'center top',
transform: `translate(-50%, 0) rotate(${value}deg)`,
borderRadius: toRem(1),
}}
/>
{/* Angle indicator dot at end of line */}
<Box
style={{
position: 'absolute',
top: '50%',
left: '50%',
width: toRem(10),
height: toRem(10),
borderRadius: '50%',
backgroundColor: color.Primary.Main,
transform: `translate(calc(-50% + ${lineX}px), calc(-50% + ${lineY}px))`,
boxShadow: `0 0 ${toRem(4)} ${color.Primary.Main}`,
}}
/>
{/* Cardinal direction markers */}
{[0, 90, 180, 270].map((deg) => {
const markerRadians = ((deg - 90) * Math.PI) / 180;
const markerDist = (size / 2) - 3;
const markerX = Math.cos(markerRadians) * markerDist;
const markerY = Math.sin(markerRadians) * markerDist;
return (
<Box
key={deg}
style={{
position: 'absolute',
top: '50%',
left: '50%',
width: toRem(4),
height: toRem(4),
borderRadius: '50%',
backgroundColor: color.Surface.OnContainer,
opacity: 0.3,
transform: `translate(calc(-50% + ${markerX}px), calc(-50% + ${markerY}px))`,
}}
/>
);
})}
</Box>
<Text size="T200">{value}°</Text>
</Box>
);
}
export default AngleSelector;

View File

@@ -31,11 +31,12 @@ export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
const authMetadata = promiseFulfilledResult(result[2]); const authMetadata = promiseFulfilledResult(result[2]);
let validatedAuthMetadata: ValidatedAuthMetadata | undefined; let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
try { if (authMetadata) {
validatedAuthMetadata = validateAuthMetadata(authMetadata); try {
} catch (e) { validatedAuthMetadata = validateAuthMetadata(authMetadata);
// Silently ignore OIDC configuration errors when server doesn't support it } catch (e) {
// This is expected for most Matrix servers // Server returned auth metadata but it failed validation — ignore silently.
}
} }
return { return {

View File

@@ -3,7 +3,7 @@ import { Text, as } from 'folds';
import classNames from 'classnames'; import classNames from 'classnames';
import * as css from './styles.css'; import * as css from './styles.css';
export const NavItemContent = as<'p', ComponentProps<typeof Text>>( export const NavItemContent = as<'span', ComponentProps<typeof Text>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<Text className={classNames(css.NavItemContent, className)} size="T300" {...props} ref={ref} /> <Text className={classNames(css.NavItemContent, className)} size="T300" {...props} ref={ref} />
) )

View File

@@ -307,7 +307,6 @@ export function TitleBar({ mx, children }: TitleBarProps) {
// Ignore if it's the currently active room // Ignore if it's the currently active room
if (room.roomId === roomId) { if (room.roomId === roomId) {
console.log('[TitleBar] Skipping: active room');
return; return;
} }

View File

@@ -17,7 +17,7 @@ import FocusTrap from 'focus-trap-react';
import * as css from './styles.css'; import * as css from './styles.css';
import { UserAvatar } from '../user-avatar'; import { UserAvatar } from '../user-avatar';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { getMxIdLocalPart } from '../../utils/matrix';
import { BreakWord, LineClamp3 } from '../../styles/Text.css'; import { BreakWord, LineClamp3 } from '../../styles/Text.css';
import { UserPresence } from '../../hooks/useUserPresence'; import { UserPresence } from '../../hooks/useUserPresence';
import { AvatarPresence, PresenceBadge } from '../presence'; import { AvatarPresence, PresenceBadge } from '../presence';
@@ -25,8 +25,7 @@ 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 { useMatrixClient } from '../../hooks/useMatrixClient'; import { useOtherUserProfileStyle } from '../../hooks/useUserProfileStyle';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
type UserHeroProps = { type UserHeroProps = {
userId: string; userId: string;
@@ -35,13 +34,20 @@ type UserHeroProps = {
presence?: UserPresence; presence?: UserPresence;
}; };
export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroProps) { export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [viewAvatar, setViewAvatar] = useState<string>(); const [viewAvatar, setViewAvatar] = useState<string>();
const bannerUrl = useOtherUserBanner(userId, avatarMxc); // Now returns blob URL directly const bannerUrl = useOtherUserBanner(userId, avatarMxc); // Now returns blob URL directly
const profileStyle = useOtherUserProfileStyle(userId, avatarMxc);
// Build gradient CSS if configured
const gradientStyle = profileStyle.gradient
? `linear-gradient(${profileStyle.gradient.direction}, ${profileStyle.gradient.startColor}, ${profileStyle.gradient.stopColor})`
: undefined;
return ( return (
<Box direction="Column" className={css.UserHero}> <Box
direction="Column"
className={css.UserHero}
>
<div <div
className={css.UserHeroCoverContainer} className={css.UserHeroCoverContainer}
style={{ style={{
@@ -57,9 +63,10 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
) )
)} )}
</div> </div>
<div className={css.UserHeroAvatarContainer}> <div className={css.UserHeroAvatarContainer} style={gradientStyle ? { background: gradientStyle } : undefined}>
<AvatarPresence <AvatarPresence
className={css.UserAvatarContainer} className={css.UserAvatarContainer}
style={gradientStyle ? { background: gradientStyle } : undefined}
badge={ badge={
presence && <PresenceBadge presence={presence.presence} status={presence.status} /> presence && <PresenceBadge presence={presence.presence} status={presence.status} />
} }
@@ -69,6 +76,10 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined} onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined}
className={css.UserHeroAvatar} className={css.UserHeroAvatar}
size="500" size="500"
style={profileStyle.avatarBorderColor ? {
outline: `${toRem(4)} solid ${profileStyle.avatarBorderColor}`,
outlineOffset: toRem(-1),
} : undefined}
> >
<UserAvatar <UserAvatar
className={css.UserHeroAvatarImg} className={css.UserHeroAvatarImg}

View File

@@ -15,6 +15,7 @@ export const UserHero = style({
}); });
export const UserHeroCoverContainer = style({ export const UserHeroCoverContainer = style({
position: 'relative',
height: toRem(96), height: toRem(96),
overflow: 'hidden', overflow: 'hidden',
}); });

View File

@@ -1,5 +1,6 @@
import React, { MouseEventHandler, forwardRef, useState } from 'react'; import React, { MouseEventHandler, forwardRef, useState } from 'react';
import { Room } from 'matrix-js-sdk'; import { Room, EventTimeline } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { import {
Avatar, Avatar,
Box, Box,
@@ -16,13 +17,15 @@ import {
RectCords, RectCords,
Badge, Badge,
Spinner, Spinner,
Tooltip,
TooltipProvider,
} from 'folds'; } from 'folds';
import { useFocusWithin, useHover } from 'react-aria'; import { useFocusWithin, useHover } from 'react-aria';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav'; import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge'; import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; import { getDirectRoomAvatarUrl, getRoomAvatarUrl, guessPerfectParent, getOrphanParents } from '../../utils/room';
import { nameInitials } from '../../utils/common'; import { nameInitials } from '../../utils/common';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomUnread } from '../../state/hooks/unread'; import { useRoomUnread } from '../../state/hooks/unread';
@@ -36,7 +39,7 @@ import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
import { TypingIndicator } from '../../components/typing-indicator'; import { TypingIndicator } from '../../components/typing-indicator';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { getMatrixToRoom } from '../../plugins/matrix-to'; import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../utils/matrix'; import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
import { getViaServers } from '../../plugins/via-servers'; import { getViaServers } from '../../plugins/via-servers';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
@@ -54,6 +57,9 @@ import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { CallParticipantsIndicator } from './CallParticipantsIndicator'; import { CallParticipantsIndicator } from './CallParticipantsIndicator';
import { useCall } from '../call/useCall'; import { useCall } from '../call/useCall';
import { CallType } from '../call/types'; import { CallType } from '../call/types';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { StateEvent } from '../../../types/matrix/room';
import { mDirectAtom } from '../../state/mDirectList';
type RoomNavItemMenuProps = { type RoomNavItemMenuProps = {
room: Room; room: Room;
@@ -273,7 +279,31 @@ export function RoomNavItem({
const typingMember = useRoomTypingMember(room.roomId).filter( const typingMember = useRoomTypingMember(room.roomId).filter(
(receipt) => receipt.userId !== mx.getUserId() (receipt) => receipt.userId !== mx.getUserId()
); );
const roomToParents = useAtomValue(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom);
// Get parent space for DMs
const parentSpaceInfo = (() => {
if (!direct || !mDirects.has(room.roomId)) return undefined;
const orphanParents = getOrphanParents(roomToParents, room.roomId);
if (orphanParents.length === 0) return undefined;
const parentSpaceId = guessPerfectParent(mx, room.roomId, orphanParents) ?? orphanParents[0];
const parentSpace = mx.getRoom(parentSpaceId);
if (!parentSpace) return undefined;
const avatarEvent = parentSpace.getLiveTimeline().getState(EventTimeline.FORWARDS)?.getStateEvents(StateEvent.RoomAvatar, '');
const avatarContent = avatarEvent?.getContent();
const avatarMxc = avatarContent && typeof avatarContent.url === 'string' ? avatarContent.url : undefined;
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined;
return {
name: parentSpace.name,
avatarUrl,
roomId: parentSpace.roomId,
};
})();
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => { const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
evt.preventDefault(); evt.preventDefault();
setMenuAnchor({ setMenuAnchor({
@@ -353,43 +383,72 @@ export function RoomNavItem({
</NavLink> </NavLink>
{optionsVisible && ( {optionsVisible && (
<NavItemOptions> <NavItemOptions>
<PopOut <Box alignItems="Center" gap="100">
anchor={menuAnchor} {parentSpaceInfo && (
offset={menuAnchor?.width === 0 ? 0 : undefined} <TooltipProvider
alignOffset={menuAnchor?.width === 0 ? 0 : -5} position="Top"
position="Bottom" tooltip={
align={menuAnchor?.width === 0 ? 'Start' : 'End'} <Tooltip>
content={ <Text size="T200">Space: {parentSpaceInfo.name}</Text>
<FocusTrap </Tooltip>
focusTrapOptions={{ }
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
> >
<RoomNavItemMenu {(triggerRef) => (
room={room} <Avatar ref={triggerRef} size="200" radii="Pill" style={{ flexShrink: 0, width: '16px', height: '16px' }}>
requestClose={() => setMenuAnchor(undefined)} <RoomAvatar
notificationMode={notificationMode} roomId={parentSpaceInfo.roomId}
/> src={parentSpaceInfo.avatarUrl}
</FocusTrap> alt={parentSpaceInfo.name}
} renderFallback={() => (
> <Text size="Inherit">
<IconButton {nameInitials(parentSpaceInfo.name, 1)}
onClick={handleOpenMenu} </Text>
aria-pressed={!!menuAnchor} )}
variant="Background" />
fill="None" </Avatar>
size="300" )}
radii="300" </TooltipProvider>
> )}
<Icon size="50" src={Icons.VerticalDots} /> {optionsVisible && (
</IconButton> <PopOut
</PopOut> anchor={menuAnchor}
offset={menuAnchor?.width === 0 ? 0 : undefined}
alignOffset={menuAnchor?.width === 0 ? 0 : -5}
position="Bottom"
align={menuAnchor?.width === 0 ? 'Start' : 'End'}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<RoomNavItemMenu
room={room}
requestClose={() => setMenuAnchor(undefined)}
notificationMode={notificationMode}
/>
</FocusTrap>
}
>
<IconButton
onClick={handleOpenMenu}
aria-pressed={!!menuAnchor}
variant="Background"
fill="None"
size="300"
radii="300"
>
<Icon size="50" src={Icons.VerticalDots} />
</IconButton>
</PopOut>
)}
</Box>
</NavItemOptions> </NavItemOptions>
)} )}
</NavItem> </NavItem>

View File

@@ -29,7 +29,7 @@ import {
color, color,
toRem, toRem,
} from 'folds'; } from 'folds';
import { HexColorPicker } from 'react-colorful'; import { HexColorPicker, RgbaColorPicker, RgbaColor } 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';
@@ -48,8 +48,10 @@ 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 { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
import { AngleSelector } from '../../../components/AngleSelector';
import { useUserColor, useOtherUserColor } from '../../../hooks/useUserColor'; import { useUserColor, useOtherUserColor } from '../../../hooks/useUserColor';
import { useUserBanner, useOtherUserBanner } from '../../../hooks/useUserBanner'; import { useUserBanner, useOtherUserBanner } from '../../../hooks/useUserBanner';
import { useUserProfileStyle } from '../../../hooks/useUserProfileStyle';
import { useUserPresence } from '../../../hooks/useUserPresence'; import { useUserPresence } from '../../../hooks/useUserPresence';
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';
@@ -146,6 +148,134 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
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);
@@ -419,6 +549,20 @@ 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,
// otherwise falls back to saved profile style)
const previewGradient = editingGradient
? `linear-gradient(${localGradientDirection}deg, ${rgbaToHex(localGradientStart)}, ${rgbaToHex(localGradientStop)})`
: profileStyle.gradient
? `linear-gradient(${profileStyle.gradient.direction}, ${profileStyle.gradient.startColor}, ${profileStyle.gradient.stopColor})`
: undefined;
// Build border color for preview (shows local editing value when user is editing,
// otherwise falls back to saved profile style)
const previewBorderColor = editingBorder
? rgbaToHex(localBorderColor)
: profileStyle.avatarBorderColor;
return ( return (
<Box direction="Column" gap="300"> <Box direction="Column" gap="300">
<Box <Box
@@ -641,6 +785,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
paddingLeft: config.space.S400, paddingLeft: config.space.S400,
paddingRight: config.space.S400, paddingRight: config.space.S400,
paddingBottom: config.space.S400, paddingBottom: config.space.S400,
background: previewGradient,
}} }}
> >
{/* Avatar */} {/* Avatar */}
@@ -661,7 +806,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
as="button" as="button"
onClick={handleAvatarClick} onClick={handleAvatarClick}
style={{ style={{
backgroundColor: color.Surface.Container, background: previewGradient || color.Surface.Container,
border: 'none', border: 'none',
padding: 0, padding: 0,
cursor: 'pointer', cursor: 'pointer',
@@ -678,7 +823,9 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
<Avatar <Avatar
size="500" size="500"
style={{ style={{
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`, outline: previewBorderColor
? `${toRem(4)} solid ${previewBorderColor}`
: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
}} }}
> >
<UserAvatar <UserAvatar
@@ -885,6 +1032,126 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject<HTML
</Box> </Box>
</Box> </Box>
</Box> </Box>
{/* Profile Style Settings */}
<Box
direction="Column"
gap="200"
style={{
padding: config.space.S300,
border: `${toRem(1)} solid ${color.Surface.ContainerLine}`,
borderRadius: toRem(8),
width: '100%',
}}
>
<Text size="H6">Profile Style</Text>
{/* Avatar Border Color */}
<Box direction="Column" gap="100">
<Text size="T300">Avatar Border Color</Text>
<Box gap="200" alignItems="Center" wrap="Wrap">
<RgbaColorPicker color={localBorderColor} onChange={(c) => { setLocalBorderColor(c); setEditingBorder(true); }} />
<Box direction="Column" gap="100">
<Text size="T200" style={{ opacity: 0.7 }}>
Preview: {rgbaToHex(localBorderColor)}
</Text>
<Box
style={{
width: toRem(48),
height: toRem(48),
borderRadius: '50%',
border: `${toRem(4)} solid ${rgbaToHex(localBorderColor)}`,
backgroundColor: color.Surface.Container,
}}
/>
<Box gap="100">
<Button
size="300"
variant="Primary"
fill="Solid"
radii="300"
onClick={handleBorderColorSave}
disabled={savingStyle}
>
<Text size="B300">Save</Text>
</Button>
{profileStyle.avatarBorderColor && (
<Button
size="300"
variant="Critical"
fill="Soft"
radii="300"
onClick={handleBorderColorRemove}
disabled={savingStyle}
>
<Text size="B300">Remove</Text>
</Button>
)}
</Box>
</Box>
</Box>
</Box>
{/* Profile Gradient */}
<Box direction="Column" gap="100">
<Text size="T300">Profile Card Gradient</Text>
<Box gap="200" alignItems="Start" wrap="Wrap">
<Box direction="Column" gap="100">
<Text size="T200">Start Color</Text>
<RgbaColorPicker color={localGradientStart} onChange={(c) => { setLocalGradientStart(c); setEditingGradient(true); }} />
</Box>
<Box direction="Column" gap="100">
<Text size="T200">End Color</Text>
<RgbaColorPicker color={localGradientStop} onChange={(c) => { setLocalGradientStop(c); setEditingGradient(true); }} />
</Box>
<Box direction="Column" gap="100">
<Text size="T200">Direction</Text>
<AngleSelector
value={localGradientDirection}
onChange={(deg) => { setLocalGradientDirection(deg); setEditingGradient(true); }}
/>
<Box
style={{
width: toRem(100),
height: toRem(60),
borderRadius: toRem(8),
background: `linear-gradient(${localGradientDirection}deg, ${rgbaToHex(localGradientStart)}, ${rgbaToHex(localGradientStop)})`,
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>
{styleError && (
<Text size="T200" style={{ color: color.Critical.Main }}>{styleError}</Text>
)}
</Box>
{uploadAtom && ( {uploadAtom && (
<Box gap="200" direction="Column" style={{ width: '100%' }}> <Box gap="200" direction="Column" style={{ width: '100%' }}>
<CompactUploadCardRenderer <CompactUploadCardRenderer

View File

@@ -206,7 +206,7 @@ export function useUserBanner(): [
} }
// Cache for user banners to avoid refetching for each message // Cache for user banners to avoid refetching for each message
const userBannerCache = new Map<string, { banner: 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; // 5 minutes
/** /**
@@ -228,38 +228,46 @@ export function useOtherUserBanner(userId: string, avatarMxc: string | undefined
return; return;
} }
// Check cache first
const cacheKey = `${userId}:${avatarMxc}`;
const cached = userBannerCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
setBannerBlobUrl(cached.banner);
return;
}
const loadBanner = async () => { const loadBanner = async () => {
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication); // Check cache first
if (!httpUrl) { const cacheKey = `${userId}:${avatarMxc}`;
setBannerBlobUrl(undefined); const cached = userBannerCache.get(cacheKey);
return;
}
const accessToken = mx.getAccessToken();
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
if (!metadata.banner) { let bannerMxc: string | undefined;
// Cache the result
userBannerCache.set(cacheKey, { banner: undefined, timestamp: Date.now() }); if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
// Use cached banner MXC
bannerMxc = cached.bannerMxc;
} else {
// Fetch banner MXC from avatar metadata
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
if (!httpUrl) {
setBannerBlobUrl(undefined);
return;
}
const accessToken = mx.getAccessToken();
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
bannerMxc = metadata.banner;
// Cache the banner MXC (not the blob URL)
userBannerCache.set(cacheKey, { bannerMxc, timestamp: Date.now() });
}
if (!bannerMxc) {
setBannerBlobUrl(undefined); setBannerBlobUrl(undefined);
return; return;
} }
// Fetch the banner image data with authentication // Fetch the banner image data with authentication
const bannerHttpUrl = mxcUrlToHttp(mx, metadata.banner, useAuthentication); const bannerHttpUrl = mxcUrlToHttp(mx, bannerMxc, useAuthentication);
if (!bannerHttpUrl) { if (!bannerHttpUrl) {
setBannerBlobUrl(undefined); setBannerBlobUrl(undefined);
return; return;
} }
const accessToken = mx.getAccessToken();
const headers: HeadersInit = {}; const headers: HeadersInit = {};
if (useAuthentication && accessToken) { if (useAuthentication && accessToken) {
headers.Authorization = `Bearer ${accessToken}`; headers.Authorization = `Bearer ${accessToken}`;
@@ -274,10 +282,6 @@ export function useOtherUserBanner(userId: string, avatarMxc: string | undefined
const blob = await response.blob(); const blob = await response.blob();
blobUrl = URL.createObjectURL(blob); blobUrl = URL.createObjectURL(blob);
// Cache the blob URL
userBannerCache.set(cacheKey, { banner: blobUrl, timestamp: Date.now() });
setBannerBlobUrl(blobUrl); setBannerBlobUrl(blobUrl);
}; };

View File

@@ -0,0 +1,245 @@
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 {
fetchAndExtractMetadata,
extractMetadataFromImage,
embedMetadataInImage,
detectImageFormat,
getMimeType,
getExtension,
ImageMetadata,
ProfileGradient,
} 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 {
const accessToken = mx.getAccessToken();
const response = await fetch(url, {
method: 'GET',
headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
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;
}
const accessToken = mx.getAccessToken();
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');
}
// Detect image format
const format = detectImageFormat(avatarData);
if (format === 'unknown') {
throw new Error('Unsupported avatar image format');
}
// Get existing metadata to preserve
const existingMetadata = extractMetadataFromImage(avatarData);
// 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(avatarData, 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 = new Blob([newAvatarData], { type: 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) {
setStyle({});
return;
}
const loadStyle = async () => {
// Check cache first
const cacheKey = `${userId}:${avatarMxc}`;
const cached = userStyleCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
setStyle(cached.style);
return;
}
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
if (!httpUrl) {
setStyle({});
return;
}
const accessToken = mx.getAccessToken();
const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null);
const styleData: ProfileStyleData = {
avatarBorderColor: metadata.avatarBorderColor,
gradient: metadata.gradient,
};
// Cache the result
userStyleCache.set(cacheKey, { style: styleData, timestamp: Date.now() });
setStyle(styleData);
};
loadStyle();
}, [mx, useAuthentication, avatarMxc, userId]);
return style;
}

View File

@@ -212,7 +212,6 @@ export function ClientRoot({ children }: ClientRootProps) {
useSyncState( useSyncState(
mx, mx,
useCallback((state, prevState, data) => { useCallback((state, prevState, data) => {
console.log('[ClientRoot] Sync state changed:', state, 'Error:', data);
if (state === 'PREPARED') { if (state === 'PREPARED') {
setLoading(false); setLoading(false);
setSyncError(null); setSyncError(null);

View File

@@ -18,9 +18,28 @@ import { embedColorInWebP, extractColorFromWebP, removeColorFromWebP } from './w
import { embedColorInJPEG, extractColorFromJPEG, removeColorFromJPEG } from './jpegMetadata'; import { embedColorInJPEG, extractColorFromJPEG, removeColorFromJPEG } from './jpegMetadata';
import { embedColorInGIF, extractColorFromGIF, removeColorFromGIF } from './gifMetadata'; import { embedColorInGIF, extractColorFromGIF, removeColorFromGIF } from './gifMetadata';
/**
* Gradient configuration for profile styling
* All colors support RGBA format (e.g., #FF550080 for 50% opacity)
*/
export type ProfileGradient = {
/** CSS gradient direction (e.g., "to bottom", "45deg", "to bottom right") */
direction: string;
/** Start color in hex format with optional alpha (#RRGGBB or #RRGGBBAA) */
startColor: string;
/** End color in hex format with optional alpha (#RRGGBB or #RRGGBBAA) */
stopColor: string;
};
export type ImageMetadata = { export type ImageMetadata = {
/** Profile name color */
color?: string; color?: string;
/** Banner MXC URL */
banner?: string; banner?: string;
/** Avatar border color with optional transparency (#RRGGBB or #RRGGBBAA) */
avatarBorderColor?: string;
/** Profile card gradient */
gradient?: ProfileGradient;
}; };
/** PNG signature bytes */ /** PNG signature bytes */

View File

@@ -17,6 +17,12 @@ export const PAARROT_COLOR_KEY = 'paarrot:color';
/** Key used to store banner URL in PNG metadata */ /** Key used to store banner URL in PNG metadata */
export const PAARROT_BANNER_KEY = 'paarrot:banner'; export const PAARROT_BANNER_KEY = 'paarrot:banner';
/** Key used to store avatar border color in PNG metadata */
export const PAARROT_BORDER_COLOR_KEY = 'paarrot:borderColor';
/** Key used to store profile gradient (JSON) in PNG metadata */
export const PAARROT_GRADIENT_KEY = 'paarrot:gradient';
let crc32Table: Uint32Array | null = null; let crc32Table: Uint32Array | null = null;
function getCRC32Table(): Uint32Array { function getCRC32Table(): Uint32Array {
if (crc32Table) return crc32Table; if (crc32Table) return crc32Table;
@@ -194,14 +200,16 @@ export function extractBannerFromPNG(imageData: ArrayBuffer | Uint8Array): strin
return undefined; return undefined;
} }
import type { ImageMetadata, ProfileGradient } from './imageMetadata';
/** /**
* Extract all paarrot metadata from PNG image data * Extract all paarrot metadata from PNG image data
* @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array * @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array
* @returns Object with color and banner if found * @returns Object with all metadata fields if found
*/ */
export function extractMetadataFromPNG(imageData: ArrayBuffer | Uint8Array): { color?: string; banner?: string } { export function extractMetadataFromPNG(imageData: ArrayBuffer | Uint8Array): ImageMetadata {
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData); const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
const metadata: { color?: string; banner?: string } = {}; const metadata: ImageMetadata = {};
// Verify PNG signature // Verify PNG signature
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
@@ -220,6 +228,14 @@ export function extractMetadataFromPNG(imageData: ArrayBuffer | Uint8Array): { c
metadata.color = text.value; metadata.color = text.value;
} else if (text.key === PAARROT_BANNER_KEY) { } else if (text.key === PAARROT_BANNER_KEY) {
metadata.banner = text.value; metadata.banner = text.value;
} else if (text.key === PAARROT_BORDER_COLOR_KEY) {
metadata.avatarBorderColor = text.value;
} else if (text.key === PAARROT_GRADIENT_KEY) {
try {
metadata.gradient = JSON.parse(text.value) as ProfileGradient;
} catch {
// Invalid JSON, skip
}
} }
} }
} }
@@ -287,16 +303,19 @@ export function embedColorInPNG(imageData: ArrayBuffer | Uint8Array, color: stri
return newData; return newData;
} }
/** All paarrot metadata keys */
const PAARROT_KEYS = [PAARROT_COLOR_KEY, PAARROT_BANNER_KEY, PAARROT_BORDER_COLOR_KEY, PAARROT_GRADIENT_KEY];
/** /**
* Embed paarrot metadata (color and/or banner) into PNG image data * Embed paarrot metadata into PNG image data
* Preserves existing metadata that is not being updated * Preserves existing metadata that is not being updated
* @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array * @param imageData - Raw PNG image data as ArrayBuffer or Uint8Array
* @param metadata - Object with color and/or banner to embed * @param metadata - Object with metadata fields to embed
* @returns New PNG data with embedded metadata, or null if not a valid PNG * @returns New PNG data with embedded metadata, or null if not a valid PNG
*/ */
export function embedMetadataInPNG( export function embedMetadataInPNG(
imageData: ArrayBuffer | Uint8Array, imageData: ArrayBuffer | Uint8Array,
metadata: { color?: string; banner?: string } metadata: ImageMetadata
): Uint8Array | null { ): Uint8Array | null {
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData); const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
@@ -313,9 +332,11 @@ export function embedMetadataInPNG(
const existing = extractMetadataFromPNG(data); const existing = extractMetadataFromPNG(data);
// Merge with new metadata // Merge with new metadata
const finalMetadata = { const finalMetadata: ImageMetadata = {
color: metadata.color !== undefined ? metadata.color : existing.color, color: metadata.color !== undefined ? metadata.color : existing.color,
banner: metadata.banner !== undefined ? metadata.banner : existing.banner, banner: metadata.banner !== undefined ? metadata.banner : existing.banner,
avatarBorderColor: metadata.avatarBorderColor !== undefined ? metadata.avatarBorderColor : existing.avatarBorderColor,
gradient: metadata.gradient !== undefined ? metadata.gradient : existing.gradient,
}; };
// Find IHDR chunk and existing paarrot chunks // Find IHDR chunk and existing paarrot chunks
@@ -327,7 +348,7 @@ export function embedMetadataInPNG(
insertOffset = chunk.offset + chunk.length; insertOffset = chunk.offset + chunk.length;
} else if (chunk.type === 'tEXt') { } else if (chunk.type === 'tEXt') {
const text = parseTextChunk(chunk.data); const text = parseTextChunk(chunk.data);
if (text && (text.key === PAARROT_COLOR_KEY || text.key === PAARROT_BANNER_KEY)) { if (text && PAARROT_KEYS.includes(text.key)) {
existingChunks.push({ key: text.key, offset: chunk.offset, length: chunk.length }); existingChunks.push({ key: text.key, offset: chunk.offset, length: chunk.length });
} }
} }
@@ -341,6 +362,12 @@ export function embedMetadataInPNG(
if (finalMetadata.banner) { if (finalMetadata.banner) {
newChunks.push(createTextChunk(PAARROT_BANNER_KEY, finalMetadata.banner)); newChunks.push(createTextChunk(PAARROT_BANNER_KEY, finalMetadata.banner));
} }
if (finalMetadata.avatarBorderColor) {
newChunks.push(createTextChunk(PAARROT_BORDER_COLOR_KEY, finalMetadata.avatarBorderColor));
}
if (finalMetadata.gradient) {
newChunks.push(createTextChunk(PAARROT_GRADIENT_KEY, JSON.stringify(finalMetadata.gradient)));
}
// Calculate new size // Calculate new size
const removedSize = existingChunks.reduce((sum, chunk) => sum + chunk.length, 0); const removedSize = existingChunks.reduce((sum, chunk) => sum + chunk.length, 0);