import React, { ChangeEvent, ChangeEventHandler, FormEventHandler, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import classNames from 'classnames'; import { Box, Text, IconButton, Icon, Icons, Input, Avatar, Button, Overlay, OverlayBackdrop, OverlayCenter, Modal, Dialog, Header, config, Spinner, color, toRem, } from 'folds'; import { HexColorPicker, RgbaColorPicker, RgbaColor } from 'react-colorful'; import FocusTrap from 'focus-trap-react'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; import { UserAvatar } from '../../../components/user-avatar'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useAuthenticatedMediaUrl } from '../../../hooks/useAuthenticatedMediaUrl'; import { nameInitials, getContrastingTextColor, stripAlphaFromColor, getTextShadowColor } from '../../../utils/common'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useFilePicker } from '../../../hooks/useFilePicker'; import { useObjectURL } from '../../../hooks/useObjectURL'; import { stopPropagation } from '../../../utils/keyboard'; import { ImageEditor } from '../../../components/image-editor'; import { ModalWide } from '../../../styles/Modal.css'; 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'; import colorMXID from '../../../../util/colorMXID'; import { extractMetadataFromImage, embedMetadataInImage, detectImageFormat, getMimeType, getExtension, ImageMetadata, } from '../../../utils/imageMetadata'; /** * Banner upload component for user's profile banner * Stored in avatar image metadata, visible to other Paarrot users */ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject; nameRef: React.RefObject }) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const userId = mx.getUserId()!; const profile = useUserProfile(userId); const presence = useUserPresence(userId); const [userBanner, setUserBanner, loading] = useUserBanner(); const [saving, setSaving] = useState(false); const [error, setError] = useState(); const [hoveredArea, setHoveredArea] = useState(null); const [bannerBlobUrl, setBannerBlobUrl] = useState(); const [refreshTrigger, setRefreshTrigger] = useState(0); // Helper to sync Matrix client's user object with server after avatar changes const syncUserAvatar = useCallback(async () => { try { const updatedProfile = await mx.getProfileInfo(userId); const user = mx.getUser(userId); if (user) { // Manually sync avatar URL to trigger UserEvent.AvatarUrl listeners if (updatedProfile.avatar_url !== user.avatarUrl) { user.setAvatarUrl(updatedProfile.avatar_url || ''); } // Manually sync display name to trigger UserEvent.DisplayName listeners if (updatedProfile.displayname !== user.displayName) { user.setDisplayName(updatedProfile.displayname || ''); } } setRefreshTrigger((prev) => prev + 1); } catch (err) { console.error('Failed to sync user profile:', err); } }, [mx, userId]); const avatarUrl = profile.avatarUrl ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined : undefined; // Larger avatar URL for the blurred cover fallback const avatarCoverUrl = profile.avatarUrl ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication) ?? undefined : undefined; const authenticatedCoverUrl = useAuthenticatedMediaUrl(avatarCoverUrl, useAuthentication); const [userColor, setUserColor] = useUserColor(); const [localColor, setLocalColor] = useState(userColor ?? '#3b82f6'); const [savingColor, setSavingColor] = useState(false); const [colorError, setColorError] = useState(); useEffect(() => { if (userColor) { setLocalColor(userColor); } }, [userColor]); const handleColorSave = async () => { setSavingColor(true); setColorError(undefined); try { await setUserColor(localColor); // Sync user object to propagate changes throughout app await syncUserAvatar(); } catch (e) { setColorError('Failed to save color'); } setSavingColor(false); }; const handleColorRemove = async () => { setSavingColor(true); setColorError(undefined); try { await setUserColor(undefined); } catch (e) { setColorError('Failed to remove color'); } 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({ 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); const [isEditingStatus, setIsEditingStatus] = useState(false); const [editedStatus, setEditedStatus] = useState(presence?.status || ''); const [savingStatus, setSavingStatus] = useState(false); // Update edited name when profile changes useEffect(() => { if (!isEditingName) { setEditedName(profile.displayName || ''); } }, [profile.displayName, isEditingName]); // Update edited status when presence changes useEffect(() => { if (!isEditingStatus) { setEditedStatus(presence?.status || ''); } }, [presence?.status, isEditingStatus]); const handleAvatarClick = () => { pickAvatarFile('image/*'); }; const handleNameSave = async () => { if (!editedName.trim() || savingName) return; setSavingName(true); try { await mx.setDisplayName(editedName); setIsEditingName(false); // Force refresh to update name display setTimeout(() => syncUserAvatar(), 100); } catch (err) { console.error('Failed to update name:', err); } setSavingName(false); }; const handleNameKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); handleNameSave(); } else if (e.key === 'Escape') { setEditedName(profile.displayName || ''); setIsEditingName(false); } }; const handleStatusSave = async () => { if (savingStatus) return; setSavingStatus(true); try { await mx.setPresence({ presence: presence?.presence || 'online', status_msg: editedStatus || undefined, }); setIsEditingStatus(false); // Force refresh to update status display setTimeout(() => setRefreshTrigger((prev) => prev + 1), 100); } catch (err) { console.error('Failed to update status:', err); } setSavingStatus(false); }; const handleStatusKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); handleStatusSave(); } else if (e.key === 'Escape') { setEditedStatus(presence?.status || ''); setIsEditingStatus(false); } }; const handleRemoveStatus = async () => { if (savingStatus) return; setSavingStatus(true); try { await mx.setPresence({ presence: presence?.presence || 'online', status_msg: undefined, }); setEditedStatus(''); setIsEditingStatus(false); // Force refresh to update UI setTimeout(() => setRefreshTrigger((prev) => prev + 1), 100); } catch (err) { console.error('Failed to remove status:', err); } setSavingStatus(false); }; const handleRemoveAvatar = async () => { try { await mx.setAvatarUrl(''); // Sync user object to propagate changes throughout app await syncUserAvatar(); } catch (err) { console.error('Failed to remove avatar:', err); } }; // Load banner as blob URL useEffect(() => { let blobUrl: string | undefined; const loadBanner = async () => { if (!userBanner) { setBannerBlobUrl(undefined); return; } const bannerHttpUrl = mxcUrlToHttp(mx, userBanner, useAuthentication); if (!bannerHttpUrl) { setBannerBlobUrl(undefined); return; } const accessToken = mx.getAccessToken(); const headers: HeadersInit = {}; if (useAuthentication && accessToken) { headers.Authorization = `Bearer ${accessToken}`; } try { const response = await fetch(bannerHttpUrl, { headers }); if (!response.ok) { console.warn('Failed to fetch banner preview:', response.status); setBannerBlobUrl(undefined); return; } const blob = await response.blob(); blobUrl = URL.createObjectURL(blob); setBannerBlobUrl(blobUrl); } catch (err) { console.error('Error loading banner preview:', err); setBannerBlobUrl(undefined); } }; loadBanner(); return () => { if (blobUrl) { URL.revokeObjectURL(blobUrl); } }; }, [mx, useAuthentication, userBanner, refreshTrigger]); const [imageFile, setImageFile] = useState(); const imageFileURL = useObjectURL(imageFile); const uploadAtom = useMemo(() => { if (imageFile) return createUploadAtom(imageFile); return undefined; }, [imageFile]); const pickFile = useFilePicker(setImageFile, false); // Avatar-specific upload const [avatarFile, setAvatarFile] = useState(); const avatarUploadAtom = useMemo(() => { if (avatarFile) return createUploadAtom(avatarFile); return undefined; }, [avatarFile]); const pickAvatarFile = useFilePicker(setAvatarFile, false); // Warn before leaving when an upload or save is in progress useEffect(() => { if (!imageFile && !avatarFile && !saving) return undefined; const handleBeforeUnload = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = ''; }; window.addEventListener('beforeunload', handleBeforeUnload); return () => window.removeEventListener('beforeunload', handleBeforeUnload); }, [imageFile, avatarFile, saving]); const handleRemoveUpload = useCallback(() => { setImageFile(undefined); }, []); const handleUploaded = useCallback( async (upload: UploadSuccess) => { const { mxc } = upload; console.log('[ProfileBanner] Banner image uploaded, MXC:', mxc); setSaving(true); setError(undefined); try { console.log('[ProfileBanner] Calling setUserBanner...'); await setUserBanner(mxc); console.log('[ProfileBanner] setUserBanner completed successfully'); handleRemoveUpload(); // Sync user object to propagate changes throughout app await syncUserAvatar(); } catch (e: any) { const errorMsg = e?.message || 'Unknown error'; console.error('[ProfileBanner] setUserBanner failed:', e); setError(`Failed to save banner: ${errorMsg}`); } setSaving(false); }, [setUserBanner, handleRemoveUpload, syncUserAvatar] ); const handleAvatarUploaded = useCallback( async (upload: UploadSuccess) => { setSaving(true); setError(undefined); try { // Fetch the raw uploaded image so we can embed existing banner/color metadata const httpUrl = mxcUrlToHttp(mx, upload.mxc, useAuthentication); if (!httpUrl) throw new Error('Could not resolve uploaded avatar URL'); const accessToken = mx.getAccessToken(); const response = await fetch(httpUrl, { headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined, }); if (!response.ok) throw new Error('Failed to fetch uploaded avatar'); const avatarData = await response.arrayBuffer(); const format = detectImageFormat(avatarData); if (format === 'unknown') throw new Error('Unsupported avatar image format'); // Re-embed the existing banner URL and profile color into the new avatar const metadata: ImageMetadata = { banner: userBanner, color: userColor, }; const modifiedData = embedMetadataInImage(avatarData, metadata); if (!modifiedData) throw new Error('Failed to embed metadata in avatar'); const mimeType = getMimeType(format); const extension = getExtension(format); const blob = new Blob([modifiedData], { type: mimeType }); const uploadResponse = await mx.uploadContent(blob, { name: `avatar.${extension}`, type: mimeType }); await mx.setAvatarUrl(uploadResponse.content_uri); const userId = mx.getUserId(); if (userId) { const user = mx.getUser(userId); if (user && user.avatarUrl !== uploadResponse.content_uri) { user.setAvatarUrl(uploadResponse.content_uri); } } setAvatarFile(undefined); await syncUserAvatar(); } catch (e: any) { setError(`Failed to save avatar: ${e?.message || 'Unknown error'}`); } setSaving(false); }, [mx, useAuthentication, userBanner, userColor, syncUserAvatar] ); const handleRemoveBanner = async () => { setSaving(true); setError(undefined); try { await setUserBanner(undefined); // Sync user object to propagate changes throughout app await syncUserAvatar(); } catch (e) { setError('Failed to remove banner.'); } 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; const previewPillBgColor = previewBorderColor ? stripAlphaFromColor(previewBorderColor) : undefined; const previewProfileColor = previewBorderColor ? stripAlphaFromColor(previewBorderColor) : (userColor || colorMXID(userId)); const previewTextShadow = `0 1px 4px ${getTextShadowColor(previewProfileColor)}`; return ( {/* Banner/Cover wrapper - provides positioning context so status bubble is not inside the button */} pickFile('image/*')} onMouseEnter={() => setHoveredArea('banner')} onMouseLeave={() => setHoveredArea(null)} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', overflow: 'hidden', cursor: 'pointer', border: 'none', padding: 0, backgroundColor: bannerBlobUrl ? 'transparent' : colorMXID(userId), filter: bannerBlobUrl || authenticatedCoverUrl ? 'none' : 'brightness(50%)', display: 'flex', alignItems: 'center', }} > {bannerBlobUrl ? ( Banner ) : ( authenticatedCoverUrl && ( Cover ) )} {/* Banner hover overlay - right side action bar */} {hoveredArea === 'banner' && ( {userBanner && ( { e.preventDefault(); e.stopPropagation(); handleRemoveBanner(); }} style={{ backgroundColor: color.Critical.Main, borderRadius: toRem(16), padding: toRem(8), display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', border: 'none', }} > )} )} {/* Avatar Container - matches UserHeroAvatarContainer height */} {/* Avatar - centered */} setHoveredArea('avatar')} onMouseLeave={() => setHoveredArea(null)} > ) } > } /> {/* Avatar action icons - shown on hover */} {hoveredArea === 'avatar' && ( {avatarUrl && ( { e.preventDefault(); e.stopPropagation(); handleRemoveAvatar(); }} style={{ backgroundColor: color.Critical.Main, borderRadius: toRem(8), padding: toRem(4), display: 'flex', alignItems: 'center', justifyContent: 'center', border: 'none', cursor: 'pointer', }} > )} )} {/* Profile Info Section - matches UserRoomProfile gradient section */} {/* Display Name with Color Picker */} { setLocalColor(e.target.value); setColorError(undefined); }} /> {colorError && ( {colorError} )} } onRemove={userColor ? handleColorRemove : undefined} > {(onOpen) => ( )} {isEditingName ? ( setEditedName(e.target.value)} onKeyDown={handleNameKeyDown} onBlur={handleNameSave} variant="Background" size="400" disabled={savingName} style={{ fontSize: 'var(--token.font-size.H400)', fontWeight: 'var(--token.font-weight.H400)', padding: `${toRem(4)} ${toRem(8)}`, textAlign: 'center', }} /> ) : ( { setEditedName(profile.displayName || getMxIdLocalPart(userId) || userId); setIsEditingName(true); }} onMouseEnter={() => setHoveredArea('name')} onMouseLeave={() => setHoveredArea(null)} style={{ border: 'none', background: 'none', cursor: 'pointer', padding: `${toRem(4)} ${toRem(8)}`, margin: `${toRem(-4)} ${toRem(-8)}`, position: 'relative', }} > {profile.displayName || getMxIdLocalPart(userId) || userId} {hoveredArea === 'name' && ( )} )} {/* Username */} {userId} {/* Status Pill - Editable */} setHoveredArea('status')} onMouseLeave={() => setHoveredArea(null)} style={{ backgroundColor: previewPillBgColor || color.Surface.Container, border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`, padding: `${toRem(6)} ${toRem(10)}`, borderRadius: toRem(16), maxWidth: toRem(250), cursor: 'pointer', }} > {isEditingStatus ? ( ) => setEditedStatus(e.target.value) } onKeyDown={handleStatusKeyDown} onBlur={handleStatusSave} disabled={savingStatus} placeholder="Set a custom status..." /> ) : ( { setEditedStatus(presence?.status || ''); setIsEditingStatus(true); }} style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer', display: 'flex', alignItems: 'center', }} > {presence?.status || 'Click to set a custom status...'} {presence?.status && hoveredArea === 'status' && ( <> { e.preventDefault(); e.stopPropagation(); handleRemoveStatus(); }} style={{ backgroundColor: color.Critical.Main, borderRadius: toRem(8), padding: toRem(2), display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', border: 'none', }} > )} )} {/* Chips Row - Placeholder chips */} ruv.wtf Share Admin {/* 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 && ( )} {avatarUploadAtom && ( setAvatarFile(undefined)} onComplete={handleAvatarUploaded} /> )} {saving && ( Saving to server… )} {error && ( {error} )} ); } export function Profile() { const mx = useMatrixClient(); const userId = mx.getUserId()!; const avatarRef = useRef(null); const nameRef = useRef(null); return ( ); }