From 62fe517b1d8d7db061fa22d4e4efab50a40f329e Mon Sep 17 00:00:00 2001 From: Delta Date: Sun, 9 Aug 2026 11:21:17 +0100 Subject: [PATCH 1/7] Fix persona picker displaying wrong in change persona menu & refactor --- src/app/components/message/modals/Options.tsx | 1 - src/app/features/room/RoomInput.tsx | 6 +- .../persona-picker/PersonaPicker.test.tsx | 10 +- .../room/persona-picker/PersonaPicker.tsx | 594 ++++++++++++------ 4 files changed, 393 insertions(+), 218 deletions(-) diff --git a/src/app/components/message/modals/Options.tsx b/src/app/components/message/modals/Options.tsx index 79e47e4cb..bf018c863 100644 --- a/src/app/components/message/modals/Options.tsx +++ b/src/app/components/message/modals/Options.tsx @@ -430,7 +430,6 @@ function OptionsReproxyPersonaPicker({ <> ( )} - {pmpPickerEnable && !editingEvent && ( - ({ getAll: vi.fn<(mx: MatrixClient) => Promise>(), @@ -128,7 +128,7 @@ const profiles: PerMessageProfileMsc4461[] = [ function renderPicker(mx = {} as MatrixClient, tab = PersonaPickerTab.Global) { return render( - { const view = renderPicker(firstClient); view.rerender( - void>()} @@ -230,7 +230,7 @@ describe('PersonaPicker async flows', () => { fireEvent.click(grabPersonaButton('First')); view.rerender( - { }); view.rerender( - void; + onPersonaSelect: (persona: PerMessageProfileMsc4461 | undefined) => void | Promise; + requestClose: () => void; + anchor?: RectCords; +}; -export function TemporaryPersonaPicker(props: PersonaPickerProps) { - return ( - - ); -} - -export function PersistentPersonaPicker(props: PersonaPickerProps) { - return ; -} - -function PersonaPickerMenu({ - tab: tabProp = PersonaPickerTab.Global, - mx, - roomId, - suppressEditorRefocus, - onTabChange, - latchedPersona, - onPersonaSelect, - anchor, - requestClose, -}: PersonaPickerProps & { presentation: PersonaPickerPresentation }) { +function useProfileCosmetics(mx: MatrixClient) { const useAuthentication = useMediaAuthentication(); - const [tab, setTab] = useState(tabProp); const activeTheme = useActiveTheme(); - const [AddPersonaMenuAnchor, setAddPersonaMenuAnchor] = useState(anchor); - const [profiles, setProfiles] = useState(undefined); - const [selectedGlobalPersona, setSelectedGlobalPersona] = - useState(null); - const [selectedRoomPersona, setSelectedRoomPersona] = useState( - latchedPersona ?? null - ); - const mountedRef = useRef(false); - const profileFetchGenerationRef = useRef(0); - // Bumped on each click so an in-flight sync cannot undo a fresher choice. Global and - // per-room selections are independent, so one must not invalidate the other's rollback. - const globalSelectionRef = useRef(0); - const roomSelectionRef = useRef(0); - const isPickerMenuItemSelected = (persona: PerMessageProfileMsc4461) => { - const selectedPersona = - tab === PersonaPickerTab.Global ? selectedGlobalPersona : selectedRoomPersona; - return persona.id === selectedPersona?.id ? true : undefined; - }; const nameColor = useCallback( (persona: PerMessageProfileMsc4461) => @@ -121,30 +90,143 @@ function PersonaPickerMenu({ [activeTheme] ); - const defactoPersona = () => selectedRoomPersona ?? selectedGlobalPersona; + const avatarUrl = useCallback( + (profile: PerMessageProfileMsc4461) => { + if (profile.avatar_url !== undefined) { + return mxcUrlToHttp(mx, profile.avatar_url, useAuthentication, 96, 96, 'crop') ?? undefined; + } else { + return undefined; + } + }, + [mx, useAuthentication] + ); - const searchInputRef = useRef(null); + return { nameColor, avatarUrl }; +} +function useProfiles( + mx: MatrixClient, + mountedRef: MutableRefObject, + profileFetchGenerationRef: MutableRefObject +) { + const [profiles, setProfiles] = useState(undefined); - const scrollRef = useRef(null); + const fetchProfiles = useCallback(async () => { + const fetchGeneration = ++profileFetchGenerationRef.current; + try { + const fetchedProfiles = await new ProfileCatalog(mx).list(); + if (!mountedRef.current || fetchGeneration !== profileFetchGenerationRef.current) { + return; + } + setProfiles(fetchedProfiles); + } catch { + // Profile loading is best effort; keep the existing list when it fails. + } + }, [mountedRef, mx, profileFetchGenerationRef]); + + useEffect(() => { + void fetchProfiles(); + return () => { + profileFetchGenerationRef.current += 1; + }; + }, [fetchProfiles, profileFetchGenerationRef]); + + return { profiles, fetchProfiles }; +} + +function useFilteredProfiles( + profiles: Persona[], + mountedRef: MutableRefObject, + searchInputRef: RefObject, + profileFetchGenerationRef: MutableRefObject +) { const [filteredProfiles, setFilteredProfiles] = useState( - undefined + profiles ?? undefined ); - + // useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; profileFetchGenerationRef.current += 1; }; - }, []); + }, [mountedRef, profileFetchGenerationRef]); - const clearFilterInput = () => { + const clearFilter = () => { if (searchInputRef.current) { searchInputRef.current.value = ''; } setFilteredProfiles(profiles); }; + const filter = useCallback( + (e: FormEvent) => { + const term = (e.target as HTMLInputElement).value.toLocaleLowerCase(); + + const filtered = term + ? profiles?.filter((profile) => + searchInputRef.current + ? profile.displayname.toLocaleLowerCase().includes(searchInputRef.current?.value) || + profile.id.toLocaleLowerCase().includes(searchInputRef.current?.value) + : true + ) + : profiles; + + setFilteredProfiles(filtered); + }, + [profiles, searchInputRef] + ); + + useEffect(() => { + setFilteredProfiles(profiles); + }, [profiles]); + + return { filteredProfiles, filter, clearFilter }; +} + +function useSelectedProfiles( + mx: MatrixClient, + roomId: string, + latchedPersona: Persona | undefined, + mountedRef: MutableRefObject +) { + const [selectedGlobalPersona, setSelectedGlobalPersona] = + useState(null); + const [selectedRoomPersona, setSelectedRoomPersona] = useState( + latchedPersona ?? null + ); + + // Bumped on each click so an in-flight sync cannot undo a fresher choice. Global and + // per-room selections are independent, so one must not invalidate the other's rollback. + const globalSelectionRef = useRef(0); + const roomSelectionRef = useRef(0); + + const toggle = async (profile: Persona, isGlobal: boolean) => { + const previousPersona = isGlobal ? selectedGlobalPersona : selectedRoomPersona; + const disabling = profile.id === previousPersona?.id; + + const setPersona = isGlobal ? setSelectedGlobalPersona : setSelectedRoomPersona; + const generationRef = isGlobal ? globalSelectionRef : roomSelectionRef; + const selectionGeneration = ++generationRef.current; + + setPersona(disabling ? null : profile); + + try { + await new ProfileCatalog(mx).setSelection( + isGlobal ? 'account' : { roomId: roomId! }, + disabling ? undefined : profile.id, + undefined, + disabling + ); + } catch { + if (mountedRef.current && selectionGeneration === generationRef.current) { + setPersona(previousPersona); + } + } + }; + + const setGlobal = async (profile: Persona) => toggle(profile, true); + const setRoom = async (profile: Persona) => toggle(profile, false); + useEffect(() => { let cancelled = false; @@ -184,55 +266,56 @@ function PersonaPickerMenu({ }; }, [mx, roomId, latchedPersona, selectedRoomPersona]); - const fetchProfiles = useCallback(async (mx_: MatrixClient) => { - const fetchGeneration = ++profileFetchGenerationRef.current; - try { - const fetchedProfiles = await new ProfileCatalog(mx_).list(); - if (!mountedRef.current || fetchGeneration !== profileFetchGenerationRef.current) { - return; - } - setProfiles(fetchedProfiles); - setFilteredProfiles(fetchedProfiles); - } catch { - // Profile loading is best effort; keep the existing list when it fails. - } - }, []); - - useEffect(() => { - void fetchProfiles(mx); - return () => { - profileFetchGenerationRef.current += 1; - }; - }, [fetchProfiles, mx]); + return { + room: selectedRoomPersona, + global: selectedGlobalPersona, + setRoom, + setGlobal, + }; +} - const filter = useCallback( - (e: FormEvent) => { - const term = (e.target as HTMLInputElement).value.toLocaleLowerCase(); +export function PersonaPicker({ + tab: tabProp = PersonaPickerTab.Global, + mx, + roomId, + suppressEditorRefocus, + onTabChange, + latchedPersona, + anchor, +}: PersonaPickerProps) { + const [tab, setTab] = useState(tabProp); + const [AddPersonaMenuAnchor, setAddPersonaMenuAnchor] = useState(anchor); + const mountedRef = useRef(false); + const profileFetchGenerationRef = useRef(0); - const filtered = term - ? profiles?.filter((profile) => - searchInputRef.current - ? profile.displayname.toLocaleLowerCase().includes(searchInputRef.current?.value) || - profile.id.toLocaleLowerCase().includes(searchInputRef.current?.value) - : true - ) - : profiles; + const { profiles } = useProfiles(mx, mountedRef, profileFetchGenerationRef); - setFilteredProfiles(filtered); - }, - [profiles] + const searchInputRef = useRef(null); + const { filteredProfiles, filter, clearFilter } = useFilteredProfiles( + profiles ?? [], + mountedRef, + searchInputRef, + profileFetchGenerationRef ); - const avatarUrl = useCallback( - (profile: PerMessageProfileMsc4461) => { - if (profile.avatar_url !== undefined) { - return mxcUrlToHttp(mx, profile.avatar_url, useAuthentication, 96, 96, 'crop') ?? undefined; - } else { - return undefined; - } - }, - [mx, useAuthentication] - ); + const { + room: selectedRoomPersona, + global: selectedGlobalPersona, + setRoom, + setGlobal, + } = useSelectedProfiles(mx, roomId ?? '', latchedPersona, mountedRef); + + const isPickerMenuItemSelected = (persona: PerMessageProfileMsc4461) => { + const selectedPersona = + tab === PersonaPickerTab.Global ? selectedGlobalPersona : selectedRoomPersona; + return persona.id === selectedPersona?.id ? true : undefined; + }; + + const { nameColor, avatarUrl } = useProfileCosmetics(mx); + + const defactoPersona = () => selectedRoomPersona ?? selectedGlobalPersona; + + const scrollRef = useRef(null); return ( { setAddPersonaMenuAnchor(undefined); - clearFilterInput(); + clearFilter(); }} menu={ @@ -284,123 +367,92 @@ function PersonaPickerMenu({ + - <> - - - - {filteredProfiles?.map((profile) => ( - { - if (onPersonaSelect) { - await onPersonaSelect(profile); - requestClose?.(); - return; - } - const isGlobal = tab === PersonaPickerTab.Global; - const previousPersona = isGlobal - ? selectedGlobalPersona - : selectedRoomPersona; - const disabling = profile.id === previousPersona?.id; - const setPersona = isGlobal - ? setSelectedGlobalPersona - : setSelectedRoomPersona; - const generationRef = isGlobal ? globalSelectionRef : roomSelectionRef; - const selectionGeneration = ++generationRef.current; - - setPersona(disabling ? null : profile); - - try { - await new ProfileCatalog(mx).setSelection( - isGlobal ? 'account' : { roomId: roomId! }, - disabling ? undefined : profile.id, - undefined, - disabling - ); - } catch { - if (mountedRef.current && selectionGeneration === generationRef.current) { - setPersona(previousPersona); - } - } - }} - before={ - - ( - - {nameInitials(profile.displayname)} - - )} - alt={`Avatar for profile ${profile.id}`} - /> - - } - > - - - {profile.displayname} - - - {profile.id} - - - - ))} - - - Message will use your per-room persona. - - ) : selectedGlobalPersona ? ( - <> - Message will use your global persona. - - ) : ( - <>No persona chosen. - ) - } - /> - + + {filteredProfiles?.map((profile) => ( + { + const setPersona = tab === PersonaPickerTab.Global ? setGlobal : setRoom; + setPersona(profile); + }} + before={ + + ( + + {nameInitials(profile.displayname)} + + )} + alt={`Avatar for profile ${profile.id}`} + /> + + } + > + + + {profile.displayname} + + + {profile.id} + + + + ))} + + + Message will use your per-room persona. + + ) : selectedGlobalPersona ? ( + <> + Message will use your global persona. + + ) : ( + <>No persona chosen. + ) + } + /> } @@ -447,3 +499,127 @@ function PersonaPickerMenu({ ); } + +export function TemporaryPersonaPicker({ + mx, + onPersonaSelect, + anchor, + requestClose, +}: TemporaryPersonaPickerProps) { + const [AddPersonaMenuAnchor, setAddPersonaMenuAnchor] = useState(anchor); + // const [selectedGlobalPersona, setSelectedGlobalPersona] = + // useState(null); + // const [selectedRoomPersona, setSelectedRoomPersona] = useState( + // latchedPersona ?? null + // ); + const mountedRef = useRef(false); + const profileFetchGenerationRef = useRef(0); + + const { profiles } = useProfiles(mx, mountedRef, profileFetchGenerationRef); + + const searchInputRef = useRef(null); + const { filteredProfiles, filter, clearFilter } = useFilteredProfiles( + profiles ?? [], + mountedRef, + searchInputRef, + profileFetchGenerationRef + ); + + const { nameColor, avatarUrl } = useProfileCosmetics(mx); + + const scrollRef = useRef(null); + + return ( + { + setAddPersonaMenuAnchor(undefined); + clearFilter(); + requestClose(); + }} + menu={ + + + + + + {filteredProfiles?.map((profile) => ( + { + await onPersonaSelect(profile); + requestClose(); + return; + }} + before={ + + ( + + {nameInitials(profile.displayname)} + + )} + alt={`Avatar for profile ${profile.id}`} + /> + + } + > + + + {profile.displayname} + + + {profile.id} + + + + ))} + + + + } + > + ); +} From 31fa6c42e89e0100f609b6b5a98ebcb9a6e50f7a Mon Sep 17 00:00:00 2001 From: Null Date: Sun, 9 Aug 2026 11:21:17 +0100 Subject: [PATCH 2/7] Add PMP User Profile --- ..._user_heroes_fixrefactor_persona_picker.md | 5 ++ .../components/UserRoomProfileRenderer.tsx | 3 +- .../components/event-history/EventHistory.tsx | 1 + .../components/event-readers/EventReaders.tsx | 1 + .../message-preview/MessagePreview.tsx | 3 +- .../components/overlay-stack/OverlayStack.tsx | 2 +- src/app/components/user-profile/UserHero.tsx | 51 +++++++++++++++++-- .../user-profile/UserRoomProfile.tsx | 25 +++++++-- src/app/components/user-profile/styles.css.ts | 8 +++ src/app/features/call-status/LiveChip.tsx | 1 + src/app/features/call-status/MemberGlance.tsx | 1 + src/app/features/call/CallMemberCard.tsx | 1 + .../common-settings/members/Members.tsx | 8 ++- src/app/features/room-nav/RoomNavUser.tsx | 8 ++- src/app/features/room/MembersDrawer.tsx | 2 +- src/app/features/room/RoomTimeline.tsx | 2 + src/app/features/room/ThreadDrawer.tsx | 2 + src/app/features/room/message/Message.tsx | 1 + .../room/persona-picker/PersonaPicker.tsx | 17 ++++--- .../room/poll-modals/PollResponses.tsx | 1 + .../room/reaction-viewer/ReactionViewer.tsx | 1 + src/app/hooks/timeline/useTimelineActions.ts | 17 ++++++- .../timeline/useTimelineEventRenderer.tsx | 7 ++- src/app/hooks/useMemberEventParser.tsx | 8 ++- src/app/hooks/useMentionClickHandler.ts | 2 +- src/app/state/hooks/userRoomProfile.ts | 5 +- src/app/state/userRoomProfile.ts | 2 + 27 files changed, 160 insertions(+), 25 deletions(-) create mode 100644 .changeset/pmp_user_heroes_fixrefactor_persona_picker.md diff --git a/.changeset/pmp_user_heroes_fixrefactor_persona_picker.md b/.changeset/pmp_user_heroes_fixrefactor_persona_picker.md new file mode 100644 index 000000000..5331bc273 --- /dev/null +++ b/.changeset/pmp_user_heroes_fixrefactor_persona_picker.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +# PMP User Heroes, Fix+Refactor Persona Picker diff --git a/src/app/components/UserRoomProfileRenderer.tsx b/src/app/components/UserRoomProfileRenderer.tsx index 462e51b6b..9bbdcc99a 100644 --- a/src/app/components/UserRoomProfileRenderer.tsx +++ b/src/app/components/UserRoomProfileRenderer.tsx @@ -9,7 +9,7 @@ import { UserRoomProfile } from './user-profile'; import { ResponsiveMenu } from './ResponsiveMenu'; function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) { - const { roomId, spaceId, userId, cords, position, initialProfile } = state; + const { roomId, spaceId, userId, pmp, cords, position, initialProfile } = state; const allJoinedRooms = useAllJoinedRoomsSet(); const getRoom = useGetRoom(allJoinedRooms); const room = getRoom(roomId); @@ -37,6 +37,7 @@ function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) userId={userId} initialProfile={initialProfile} onSurfaceColorChange={setSurfaceColor} + pmp={pmp} /> diff --git a/src/app/components/event-history/EventHistory.tsx b/src/app/components/event-history/EventHistory.tsx index 810cbd9b0..6cc59126c 100644 --- a/src/app/components/event-history/EventHistory.tsx +++ b/src/app/components/event-history/EventHistory.tsx @@ -253,6 +253,7 @@ export const EventHistory = as<'div', EventHistoryProps>( room.roomId, space?.roomId, readerId, + undefined, getMouseEventCords(event.nativeEvent), 'Bottom' ); diff --git a/src/app/components/event-readers/EventReaders.tsx b/src/app/components/event-readers/EventReaders.tsx index 6bc4842eb..e1a4d08a6 100644 --- a/src/app/components/event-readers/EventReaders.tsx +++ b/src/app/components/event-readers/EventReaders.tsx @@ -93,6 +93,7 @@ export const EventReaders = as<'div', EventReadersProps>( room.roomId, space?.roomId, readerId, + undefined, getMouseEventCords(event.nativeEvent), 'Bottom' ); diff --git a/src/app/components/message-preview/MessagePreview.tsx b/src/app/components/message-preview/MessagePreview.tsx index f78a64a12..a47645c7c 100644 --- a/src/app/components/message-preview/MessagePreview.tsx +++ b/src/app/components/message-preview/MessagePreview.tsx @@ -451,10 +451,11 @@ export function MessagePreview({ room.roomId, undefined, sender, + perMessageProfile, evt.currentTarget.getBoundingClientRect() ); }, - [openUserRoomProfile, room.roomId, sender] + [openUserRoomProfile, room.roomId, perMessageProfile, sender] ); return ( diff --git a/src/app/components/overlay-stack/OverlayStack.tsx b/src/app/components/overlay-stack/OverlayStack.tsx index 5ba40c97e..7e1b4f5a7 100644 --- a/src/app/components/overlay-stack/OverlayStack.tsx +++ b/src/app/components/overlay-stack/OverlayStack.tsx @@ -31,7 +31,7 @@ export function OverlayStackProvider({ children }: { children: ReactNode }) { setClaims((prev) => prev.some((claim) => claim.id === id) ? prev - : [...prev, { id, seq }].sort((a, b) => a.seq - b.seq) + : [...prev, { id, seq }].toSorted((a, b) => a.seq - b.seq) ), release: (id) => setClaims((prev) => prev.filter((claim) => claim.id !== id)), }), diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index 0a1251161..7e766b966 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -43,6 +43,9 @@ import { useTimeoutToggle } from '$hooks/useTimeoutToggle'; import { CopyIcon, CrossIcon } from '@phosphor-icons/react'; import { useOpenSettings } from '$features/settings'; import { ModalOverlay } from '$components/modal-overlay/ModalOverlay'; +import type { Persona } from '$app/persona'; +import { useProfileCosmetics } from '$features/room/persona-picker/PersonaPicker'; +import type { MatrixClient } from 'matrix-js-sdk'; type UserHeroProps = { userId: string; @@ -258,10 +261,13 @@ export function UserHero({ } type UserHeroNameProps = { + mx?: MatrixClient; displayName?: string; userId: string; server?: string; customHeroCards?: boolean; + pmp?: Persona; + clearPmp?: () => void; }; type UserHeroNameInnerProps = { @@ -272,6 +278,8 @@ type UserHeroNameInnerProps = { color?: string; font?: string; customHeroCards?: boolean; + isPmp?: boolean; + clearPmp?: () => void; }; function UserHeroNameInner({ @@ -281,6 +289,8 @@ function UserHeroNameInner({ server, color, font, + isPmp, + clearPmp, }: UserHeroNameInnerProps) { const [copied, setCopied] = useTimeoutToggle(); const [isHovered, setIsHovered] = useState(false); @@ -332,26 +342,61 @@ function UserHeroNameInner({ ) } /> + {isPmp && ( + <> + {' - '} + { + evt.stopPropagation(); + clearPmp?.(); + }} + style={{ backgroundColor: 'transparent', color: 'inherit', padding: '0' }} + before={ + + View account profile + + } + /> + + )} ); } -export function UserHeroName({ displayName, userId, server, customHeroCards }: UserHeroNameProps) { +export function UserHeroName({ + mx, + displayName, + userId, + server, + customHeroCards, + pmp, + clearPmp, +}: UserHeroNameProps) { const username = getMxIdLocalPart(userId); const nick = useNickname(userId); + // personas + const { nameColor: getPmpNameColor } = useProfileCosmetics(mx); + const pmpNameColor = pmp?.['eu.she-a.color'] ? getPmpNameColor?.(pmp) : null; + // Sable username color and fonts const { color, font } = useSableCosmetics(userId, useRoom(), customHeroCards); - const shownName = nick ?? displayName ?? username ?? userId; + const shownName = pmp?.displayname ?? nick ?? displayName ?? username ?? userId; return ( ); } diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index d1b07d58f..8e582b331 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -62,6 +62,8 @@ import { KnownMembership } from '$types/matrix-sdk'; import { useRoomMemberHydration } from '$hooks/useRoomMemberHydration'; import * as css from './styles.css'; import * as prefix from '$unstable/prefixes'; +import type { Persona } from '$app/persona'; +import { useProfileCosmetics } from '$features/room/persona-picker/PersonaPicker'; const KNOWN_KEYS = new Set([ prefix.MATRIX_SABLE_UNSTABLE_PROFILE_BIOGRAPHY_PROPERTY_NAME, @@ -80,6 +82,7 @@ const KNOWN_KEYS = new Set([ type UserExtendedSectionProps = { profile: UserProfile; + pmp?: Persona; htmlReactParserOptions: HTMLReactParserOptions; linkifyOpts: LinkifyOpts; innerColor?: string; @@ -96,6 +99,7 @@ const renderValue = (val: unknown) => { function UserExtendedSection({ profile, + pmp, htmlReactParserOptions, linkifyOpts, innerColor, @@ -127,7 +131,7 @@ function UserExtendedSection({ const languagesToFilterFor = getSettings().filterPronounsLanguages ?? ['en']; const pronouns = filterPronounsByLanguage( - profile.pronouns, + pmp?.['io.fsky.nyx.pronouns'] ?? profile.pronouns, languageFilterEnabled, languagesToFilterFor ) @@ -403,11 +407,13 @@ function UserExtendedSection({ type UserRoomProfileProps = { userId: string; + pmp?: Persona; initialProfile?: Partial; onSurfaceColorChange?: (color: string) => void; }; export function UserRoomProfile({ userId, + pmp: initialPmp, initialProfile, onSurfaceColorChange, }: Readonly) { @@ -456,8 +462,17 @@ export function UserRoomProfile({ useRoomMemberHydration(room, userId); + const [pmp, setPmp] = useState(initialPmp); + const { avatarUrl: getPmpAvatarUrl } = useProfileCosmetics(mx); + const pmpAvatarUrl = pmp?.avatar_url ? getPmpAvatarUrl?.(pmp) : null; + + const handleClearPmp = () => { + setPmp(undefined); + }; + const avatarMxc = getMemberAvatarMxc(room, userId) ?? extendedProfile.avatarUrl; - const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined; + const avatarUrl = + pmpAvatarUrl ?? (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined; const parsedBanner = typeof extendedProfile.bannerUrl === 'string' @@ -607,10 +622,13 @@ export function UserRoomProfile({ > {userId !== myUserId && (