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 { CollectiblesSection } from './CollectiblesSection'; import { useUserCollectibles } from '../../../hooks/useUserCollectibles'; import { pickAvatarDecorationUrl } from '../../../utils/collectibleAssets'; import * as avatarDecorationCss from '../../../styles/AvatarDecoration.css'; 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 [collectibles, updateCollectible] = useUserCollectibles(); 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; const avatarDecorationUrl = useMemo(() => { const decoration = collectibles.avatar_decoration; if (!decoration) return undefined; const assetUrls: Record = {}; for (const [role, mxc] of Object.entries(decoration.assets)) { assetUrls[`avatar_decoration:${role}`] = mxcUrlToHttp(mx, mxc, useAuthentication) ?? undefined; } return pickAvatarDecorationUrl(assetUrls); }, [collectibles.avatar_decoration, mx, useAuthentication]); // 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); const colorPickerSize = toRem(200); const colorPreviewPlateStyle = { width: '100%', boxSizing: 'border-box' as const, padding: `${toRem(4)} ${toRem(8)}`, borderRadius: toRem(8), border: `${toRem(1)} solid ${color.Surface.ContainerLine}`, display: 'flex', justifyContent: 'center', alignItems: 'center', lineHeight: 1.2, }; const displayName = profile.displayName || getMxIdLocalPart(userId) || userId; 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)} > ) } > } /> {avatarDecorationUrl && ( )} {/* 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 On dark themes Bright colors work best {displayName} { setLocalOnDark(c); setColorError(undefined); }} style={{ width: '100%', height: colorPickerSize }} /> { setLocalOnDark(e.target.value); setColorError(undefined); }} /> On light themes Darker colors work best {displayName} { setLocalOnLight(c); setColorError(undefined); }} style={{ width: '100%', height: colorPickerSize }} /> { setLocalOnLight(e.target.value); setColorError(undefined); }} /> {hasSavedColors && ( )} {colorError && ( {colorError} )} Discord profile overlays {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 ( ); }