diff --git a/src/app/components/AngleSelector.tsx b/src/app/components/AngleSelector.tsx new file mode 100644 index 0000000..92e9e51 --- /dev/null +++ b/src/app/components/AngleSelector.tsx @@ -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(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) => { + 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) => { + 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 ( + + + {/* Center dot */} + + {/* Direction line */} + + {/* Angle indicator dot at end of line */} + + {/* 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 ( + + ); + })} + + {value}° + + ); +} + +export default AngleSelector; diff --git a/src/app/components/ServerConfigsLoader.tsx b/src/app/components/ServerConfigsLoader.tsx index ba27bd7..3bf6feb 100644 --- a/src/app/components/ServerConfigsLoader.tsx +++ b/src/app/components/ServerConfigsLoader.tsx @@ -31,11 +31,12 @@ export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) { const authMetadata = promiseFulfilledResult(result[2]); let validatedAuthMetadata: ValidatedAuthMetadata | undefined; - try { - validatedAuthMetadata = validateAuthMetadata(authMetadata); - } catch (e) { - // Silently ignore OIDC configuration errors when server doesn't support it - // This is expected for most Matrix servers + if (authMetadata) { + try { + validatedAuthMetadata = validateAuthMetadata(authMetadata); + } catch (e) { + // Server returned auth metadata but it failed validation — ignore silently. + } } return { diff --git a/src/app/components/nav/NavItemContent.tsx b/src/app/components/nav/NavItemContent.tsx index d6a5d13..d835dfe 100644 --- a/src/app/components/nav/NavItemContent.tsx +++ b/src/app/components/nav/NavItemContent.tsx @@ -3,7 +3,7 @@ import { Text, as } from 'folds'; import classNames from 'classnames'; import * as css from './styles.css'; -export const NavItemContent = as<'p', ComponentProps>( +export const NavItemContent = as<'span', ComponentProps>( ({ className, ...props }, ref) => ( ) diff --git a/src/app/components/title-bar/TitleBar.tsx b/src/app/components/title-bar/TitleBar.tsx index 1e1adf2..bd7a275 100644 --- a/src/app/components/title-bar/TitleBar.tsx +++ b/src/app/components/title-bar/TitleBar.tsx @@ -307,7 +307,6 @@ export function TitleBar({ mx, children }: TitleBarProps) { // Ignore if it's the currently active room if (room.roomId === roomId) { - console.log('[TitleBar] Skipping: active room'); return; } diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index aacb06f..553e297 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -17,7 +17,7 @@ import FocusTrap from 'focus-trap-react'; import * as css from './styles.css'; import { UserAvatar } from '../user-avatar'; import colorMXID from '../../../util/colorMXID'; -import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; +import { getMxIdLocalPart } from '../../utils/matrix'; import { BreakWord, LineClamp3 } from '../../styles/Text.css'; import { UserPresence } from '../../hooks/useUserPresence'; import { AvatarPresence, PresenceBadge } from '../presence'; @@ -25,8 +25,7 @@ import { ImageViewer } from '../image-viewer'; import { stopPropagation } from '../../utils/keyboard'; import { useOtherUserColor } from '../../hooks/useUserColor'; import { useOtherUserBanner } from '../../hooks/useUserBanner'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useOtherUserProfileStyle } from '../../hooks/useUserProfileStyle'; type UserHeroProps = { userId: string; @@ -35,13 +34,20 @@ type UserHeroProps = { presence?: UserPresence; }; export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroProps) { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); const [viewAvatar, setViewAvatar] = useState(); 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 ( - +
-
+
} @@ -69,6 +76,10 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined} className={css.UserHeroAvatar} size="500" + style={profileStyle.avatarBorderColor ? { + outline: `${toRem(4)} solid ${profileStyle.avatarBorderColor}`, + outlineOffset: toRem(-1), + } : undefined} > 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 = (evt) => { evt.preventDefault(); setMenuAnchor({ @@ -353,43 +383,72 @@ export function RoomNavItem({ {optionsVisible && ( - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} + + {parentSpaceInfo && ( + + Space: {parentSpaceInfo.name} + + } > - setMenuAnchor(undefined)} - notificationMode={notificationMode} - /> - - } - > - - - - + {(triggerRef) => ( + + ( + + {nameInitials(parentSpaceInfo.name, 1)} + + )} + /> + + )} + + )} + {optionsVisible && ( + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} + notificationMode={notificationMode} + /> + + } + > + + + + + )} + )} diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 9ef2b1e..0db68fa 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -29,7 +29,7 @@ import { color, toRem, } from 'folds'; -import { HexColorPicker } from 'react-colorful'; +import { HexColorPicker, RgbaColorPicker, RgbaColor } from 'react-colorful'; import FocusTrap from 'focus-trap-react'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile'; @@ -48,8 +48,10 @@ import { createUploadAtom, UploadSuccess } from '../../../state/upload'; import { CompactUploadCardRenderer } from '../../../components/upload-card'; import { useCapabilities } from '../../../hooks/useCapabilities'; import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut'; +import { AngleSelector } from '../../../components/AngleSelector'; import { useUserColor, useOtherUserColor } from '../../../hooks/useUserColor'; import { useUserBanner, useOtherUserBanner } from '../../../hooks/useUserBanner'; +import { useUserProfileStyle } from '../../../hooks/useUserProfileStyle'; import { useUserPresence } from '../../../hooks/useUserPresence'; import { AvatarPresence, PresenceBadge } from '../../../components/presence'; import { BreakWord, LineClamp3 } from '../../../styles/Text.css'; @@ -146,6 +148,134 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject({ r: 59, g: 130, b: 246, a: 0 }); + const [localGradientStart, setLocalGradientStart] = useState({ r: 0, g: 0, b: 0, a: 0 }); + const [localGradientStop, setLocalGradientStop] = useState({ 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(); + // 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 = { + '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 [editedName, setEditedName] = useState(profile.displayName || ''); const [savingName, setSavingName] = useState(false); @@ -419,6 +549,20 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject {/* Avatar */} @@ -661,7 +806,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject + + {/* Profile Style Settings */} + + Profile Style + + {/* Avatar Border Color */} + + Avatar Border Color + + { setLocalBorderColor(c); setEditingBorder(true); }} /> + + + Preview: {rgbaToHex(localBorderColor)} + + + + + {profileStyle.avatarBorderColor && ( + + )} + + + + + + {/* Profile Gradient */} + + Profile Card Gradient + + + Start Color + { setLocalGradientStart(c); setEditingGradient(true); }} /> + + + End Color + { setLocalGradientStop(c); setEditingGradient(true); }} /> + + + Direction + { setLocalGradientDirection(deg); setEditingGradient(true); }} + /> + + + + {profileStyle.gradient && ( + + )} + + + + + + {styleError && ( + {styleError} + )} + + {uploadAtom && ( (); +const userBannerCache = new Map(); const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes /** @@ -228,38 +228,46 @@ export function useOtherUserBanner(userId: string, avatarMxc: string | undefined 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 httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication); - if (!httpUrl) { - setBannerBlobUrl(undefined); - return; - } - - const accessToken = mx.getAccessToken(); - const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null); + // Check cache first + const cacheKey = `${userId}:${avatarMxc}`; + const cached = userBannerCache.get(cacheKey); - if (!metadata.banner) { - // Cache the result - userBannerCache.set(cacheKey, { banner: undefined, timestamp: Date.now() }); + let bannerMxc: string | undefined; + + 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); return; } // Fetch the banner image data with authentication - const bannerHttpUrl = mxcUrlToHttp(mx, metadata.banner, useAuthentication); + const bannerHttpUrl = mxcUrlToHttp(mx, bannerMxc, useAuthentication); if (!bannerHttpUrl) { setBannerBlobUrl(undefined); return; } + const accessToken = mx.getAccessToken(); const headers: HeadersInit = {}; if (useAuthentication && accessToken) { headers.Authorization = `Bearer ${accessToken}`; @@ -274,10 +282,6 @@ export function useOtherUserBanner(userId: string, avatarMxc: string | undefined const blob = await response.blob(); blobUrl = URL.createObjectURL(blob); - - // Cache the blob URL - userBannerCache.set(cacheKey, { banner: blobUrl, timestamp: Date.now() }); - setBannerBlobUrl(blobUrl); }; diff --git a/src/app/hooks/useUserProfileStyle.ts b/src/app/hooks/useUserProfileStyle.ts new file mode 100644 index 0000000..47f0516 --- /dev/null +++ b/src/app/hooks/useUserProfileStyle.ts @@ -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, + avatarMxc: string, + useAuthentication: boolean +): Promise { + 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) => Promise, + boolean +] { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const [style, setStyle] = useState({}); + 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) => { + 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(); +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({}); + + 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; +} diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index b116237..3de5fc4 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -212,7 +212,6 @@ export function ClientRoot({ children }: ClientRootProps) { useSyncState( mx, useCallback((state, prevState, data) => { - console.log('[ClientRoot] Sync state changed:', state, 'Error:', data); if (state === 'PREPARED') { setLoading(false); setSyncError(null); diff --git a/src/app/utils/imageMetadata.ts b/src/app/utils/imageMetadata.ts index 33d86b8..d1d694d 100644 --- a/src/app/utils/imageMetadata.ts +++ b/src/app/utils/imageMetadata.ts @@ -18,9 +18,28 @@ import { embedColorInWebP, extractColorFromWebP, removeColorFromWebP } from './w import { embedColorInJPEG, extractColorFromJPEG, removeColorFromJPEG } from './jpegMetadata'; 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 = { + /** Profile name color */ color?: string; + /** Banner MXC URL */ banner?: string; + /** Avatar border color with optional transparency (#RRGGBB or #RRGGBBAA) */ + avatarBorderColor?: string; + /** Profile card gradient */ + gradient?: ProfileGradient; }; /** PNG signature bytes */ diff --git a/src/app/utils/pngMetadata.ts b/src/app/utils/pngMetadata.ts index b3dbf08..39310d9 100644 --- a/src/app/utils/pngMetadata.ts +++ b/src/app/utils/pngMetadata.ts @@ -17,6 +17,12 @@ export const PAARROT_COLOR_KEY = 'paarrot:color'; /** Key used to store banner URL in PNG metadata */ 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; function getCRC32Table(): Uint32Array { if (crc32Table) return crc32Table; @@ -194,14 +200,16 @@ export function extractBannerFromPNG(imageData: ArrayBuffer | Uint8Array): strin return undefined; } +import type { ImageMetadata, ProfileGradient } from './imageMetadata'; + /** * Extract all paarrot metadata from PNG image data * @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 metadata: { color?: string; banner?: string } = {}; + const metadata: ImageMetadata = {}; // Verify PNG signature for (let i = 0; i < 8; i++) { @@ -220,6 +228,14 @@ export function extractMetadataFromPNG(imageData: ArrayBuffer | Uint8Array): { c metadata.color = text.value; } else if (text.key === PAARROT_BANNER_KEY) { 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; } +/** 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 * @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 */ export function embedMetadataInPNG( imageData: ArrayBuffer | Uint8Array, - metadata: { color?: string; banner?: string } + metadata: ImageMetadata ): Uint8Array | null { const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData); @@ -313,9 +332,11 @@ export function embedMetadataInPNG( const existing = extractMetadataFromPNG(data); // Merge with new metadata - const finalMetadata = { + const finalMetadata: ImageMetadata = { color: metadata.color !== undefined ? metadata.color : existing.color, 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 @@ -327,7 +348,7 @@ export function embedMetadataInPNG( insertOffset = chunk.offset + chunk.length; } else if (chunk.type === 'tEXt') { 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 }); } } @@ -341,6 +362,12 @@ export function embedMetadataInPNG( if (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 const removedSize = existingChunks.reduce((sum, chunk) => sum + chunk.length, 0);