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:
189
src/app/components/AngleSelector.tsx
Normal file
189
src/app/components/AngleSelector.tsx
Normal 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;
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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} />
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
245
src/app/hooks/useUserProfileStyle.ts
Normal file
245
src/app/hooks/useUserProfileStyle.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user