import React, { ChangeEvent, ChangeEventHandler, FormEventHandler, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import classNames from 'classnames'; import { Box, Text, IconButton, Input, Avatar, Button, Overlay, OverlayBackdrop, OverlayCenter, Modal, Dialog, Header, config, Spinner, color, toRem } from 'folds'; import { Icon, Icons } from '../../../components/icons'; import { HexColorPicker } 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 { 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 { useUserColorPreference } from '../../../hooks/useUserColor'; import { useUserBanner } from '../../../hooks/useUserBanner'; import { useUserPresence } from '../../../hooks/useUserPresence'; import { useTheme, ThemeKind } from '../../../hooks/useTheme'; import { AvatarPresence, PresenceBadge } from '../../../components/presence'; import { BreakWord, LineClamp3 } from '../../../styles/Text.css'; import colorMXID, { getColorMXIDValue } from '../../../../util/colorMXID'; import { getCurrentAccessToken } from '../../../utils/auth'; import { ColorPreference, hasColorPreference, resolveColorForTheme, } from '../../../utils/profileFields'; /** * Banner upload component for user's profile banner (MSC4427 via MSC4133). */ 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 theme = useTheme(); const [colorPreference, setColorPreference, colorLoading] = useUserColorPreference(); const [localOnDark, setLocalOnDark] = useState('#ffd9f5'); const [localOnLight, setLocalOnLight] = useState('#440000'); const [savingColor, setSavingColor] = useState(false); const [colorError, setColorError] = useState(); useEffect(() => { if (colorPreference?.on_dark) setLocalOnDark(colorPreference.on_dark); if (colorPreference?.on_light) setLocalOnLight(colorPreference.on_light); }, [colorPreference]); const handleColorSave = async () => { setSavingColor(true); setColorError(undefined); try { await setColorPreference({ on_dark: localOnDark, on_light: localOnLight }); } catch (e) { setColorError('Failed to save colors. Your server may not support profile fields (MSC4133).'); } setSavingColor(false); }; const handleColorRemove = async () => { setSavingColor(true); setColorError(undefined); try { await setColorPreference(undefined); setLocalOnDark('#ffd9f5'); setLocalOnLight('#440000'); } catch (e) { setColorError('Failed to remove colors'); } setSavingColor(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; } // Always use current session's token to avoid stale tokens during account switches const accessToken = getCurrentAccessToken(); const headers: HeadersInit = {}; if (useAuthentication && accessToken) { headers.Authorization = `Bearer ${accessToken}`; } 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 { await mx.setAvatarUrl(upload.mxc); const userId = mx.getUserId(); if (userId) { const user = mx.getUser(userId); if (user && user.avatarUrl !== upload.mxc) { user.setAvatarUrl(upload.mxc); } } setAvatarFile(undefined); await syncUserAvatar(); } catch (e: any) { setError(`Failed to save avatar: ${e?.message || 'Unknown error'}`); } setSaving(false); }, [mx, 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); }; const previewPreference: ColorPreference = { on_dark: localOnDark, on_light: localOnLight }; const previewProfileColor = resolveColorForTheme(previewPreference, theme.kind) || getColorMXIDValue(userId, theme.kind === ThemeKind.Dark); const previewTextShadow = `0 1px 4px ${getTextShadowColor(previewProfileColor)}`; const hasSavedColors = hasColorPreference(colorPreference); 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 */} {/* Display Name */} {isEditingName ? ( setEditedName(e.target.value)} onKeyDown={handleNameKeyDown} onBlur={handleNameSave} variant="Background" size="400" disabled={savingName} style={{ width: '100%', 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={{ width: '100%', border: 'none', background: 'none', cursor: 'pointer', padding: `${toRem(4)} ${toRem(8)}`, position: 'relative', display: 'flex', justifyContent: 'center', alignItems: 'center', lineHeight: 1.2, }} > {profile.displayName || getMxIdLocalPart(userId) || userId} {hoveredArea === 'name' && ( )} )} {/* Username */} {userId} {/* Status Pill - Editable */} setHoveredArea('status')} onMouseLeave={() => setHoveredArea(null)} style={{ backgroundColor: color.Surface.Container, border: `${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 {/* MSC4522 username colors */} Username colors Set how your name appears on dark and light themes (MSC4522). Other clients that support this spec will see your chosen colors. On dark themes Bright colors work best { setLocalOnDark(c); setColorError(undefined); }} /> { setLocalOnDark(e.target.value); setColorError(undefined); }} /> On light themes Darker colors work best { setLocalOnLight(c); setColorError(undefined); }} /> { setLocalOnLight(e.target.value); setColorError(undefined); }} /> {hasSavedColors && ( )} {colorError && ( {colorError} )} {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 ( ); }