From 275b5bb18fc5ab2ec749e972bb63a2615a46b98d Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Thu, 17 Jul 2025 09:48:01 +0530 Subject: [PATCH] WIP - new profile view --- .../components/UserRoomProfileRenderer.tsx | 55 ++++ .../components/message/layout/layout.css.ts | 2 +- src/app/components/user-profile/UserHero.tsx | 59 ++++ .../user-profile/UserRoomProfile.tsx | 311 ++++++++++++++++++ src/app/components/user-profile/index.ts | 1 + src/app/components/user-profile/styles.css.ts | 41 +++ src/app/features/room/MembersDrawer.tsx | 8 +- src/app/features/room/RoomTimeline.tsx | 14 +- src/app/hooks/useMutualRooms.ts | 30 ++ src/app/pages/Router.tsx | 2 + src/app/state/hooks/userRoomProfile.ts | 40 +++ src/app/state/userRoomProfile.ts | 11 + 12 files changed, 568 insertions(+), 6 deletions(-) create mode 100644 src/app/components/UserRoomProfileRenderer.tsx create mode 100644 src/app/components/user-profile/UserHero.tsx create mode 100644 src/app/components/user-profile/UserRoomProfile.tsx create mode 100644 src/app/components/user-profile/index.ts create mode 100644 src/app/components/user-profile/styles.css.ts create mode 100644 src/app/hooks/useMutualRooms.ts create mode 100644 src/app/state/hooks/userRoomProfile.ts create mode 100644 src/app/state/userRoomProfile.ts diff --git a/src/app/components/UserRoomProfileRenderer.tsx b/src/app/components/UserRoomProfileRenderer.tsx new file mode 100644 index 00000000..81d4bf6e --- /dev/null +++ b/src/app/components/UserRoomProfileRenderer.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { Menu, PopOut, toRem } from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { useCloseUserRoomProfile, useUserRoomProfileState } from '../state/hooks/userRoomProfile'; +import { UserRoomProfile } from './user-profile'; +import { UserRoomProfileState } from '../state/userRoomProfile'; +import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom'; +import { stopPropagation } from '../utils/keyboard'; +import { SpaceProvider } from '../hooks/useSpace'; +import { RoomProvider } from '../hooks/useRoom'; + +function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) { + const { roomId, spaceId, userId, cords } = state; + const allJoinedRooms = useAllJoinedRoomsSet(); + const getRoom = useGetRoom(allJoinedRooms); + const room = getRoom(roomId); + const space = spaceId ? getRoom(spaceId) : undefined; + + const close = useCloseUserRoomProfile(); + + if (!room) return null; + + return ( + + + + + + + + + + } + /> + ); +} + +export function UserRoomProfileRenderer() { + const state = useUserRoomProfileState(); + + if (!state) return null; + return ; +} diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index a9b3f35f..43949cef 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -124,7 +124,7 @@ export const AvatarBase = style({ selectors: { '&:hover': { - transform: `translateY(${toRem(-4)})`, + transform: `translateY(${toRem(-2)})`, }, }, }); diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx new file mode 100644 index 00000000..3376ff37 --- /dev/null +++ b/src/app/components/user-profile/UserHero.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { Avatar, Box, Icon, Icons, Text } from 'folds'; +import classNames from 'classnames'; +import * as css from './styles.css'; +import { UserAvatar } from '../user-avatar'; +import colorMXID from '../../../util/colorMXID'; +import { getMxIdLocalPart } from '../../utils/matrix'; +import { BreakWord, LineClamp3 } from '../../styles/Text.css'; + +type UserHeroProps = { + userId: string; + avatarUrl?: string; +}; +export function UserHero({ userId, avatarUrl }: UserHeroProps) { + return ( + +
+ {avatarUrl && {userId}} +
+
+ + } + /> + +
+
+ ); +} + +type UserHeroNameProps = { + displayName?: string; + userId: string; +}; +export function UserHeroName({ displayName, userId }: UserHeroNameProps) { + const username = getMxIdLocalPart(userId); + + return ( + + + + {displayName ?? username ?? userId} + + + + + @{username} + + + + ); +} diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx new file mode 100644 index 00000000..db00f6e3 --- /dev/null +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -0,0 +1,311 @@ +import { + Box, + Button, + Chip, + config, + Icon, + Icons, + Line, + Menu, + MenuItem, + PopOut, + RectCords, + Spinner, + Text, +} from 'folds'; +import React, { MouseEventHandler, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import FocusTrap from 'focus-trap-react'; +import { isKeyHotkey } from 'is-hotkey'; +import { UserHero, UserHeroName } from './UserHero'; +import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix'; +import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { PowerColorBadge, PowerIcon } from '../power'; +import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels'; +import { getPowers, getTagIconSrc, usePowerLevelTags } from '../../hooks/usePowerLevelTags'; +import { useMutualRooms, useMutualRoomsSupport } from '../../hooks/useMutualRooms'; +import { AsyncStatus } from '../../hooks/useAsyncCallback'; +import { stopPropagation } from '../../utils/keyboard'; +import { getExploreServerPath } from '../../pages/pathUtils'; +import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { copyToClipboard } from '../../../util/common'; +import { StateEvent } from '../../../types/matrix/room'; +import { useOpenRoomSettings } from '../../state/hooks/roomSettings'; +import { RoomSettingsPage } from '../../state/roomSettings'; +import { useRoom } from '../../hooks/useRoom'; +import { useSpaceOptionally } from '../../hooks/useSpace'; + +function ServerChip({ server }: { server: string }) { + const mx = useMatrixClient(); + const myServer = getMxIdServer(mx.getSafeUserId()); + const navigate = useNavigate(); + const closeProfile = useCloseUserRoomProfile(); + + const [cords, setCords] = useState(); + + const open: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + const close = () => setCords(undefined); + + return ( + isKeyHotkey('arrowdown', evt), + isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), + }} + > + +
+ { + copyToClipboard(server); + close(); + }} + > + Copy Server + + { + navigate(getExploreServerPath(server)); + closeProfile(); + }} + > + Explore Community + +
+ +
+ window.open(`https://${server}`, '_blank')} + > + Open in Browser + +
+
+ + } + > + + ) : ( + + ) + } + onClick={open} + aria-pressed={!!cords} + > + + {server} + + +
+ ); +} + +function PowerChip({ userId }: { userId: string }) { + const mx = useMatrixClient(); + const room = useRoom(); + const space = useSpaceOptionally(); + const useAuthentication = useMediaAuthentication(); + const openRoomSettings = useOpenRoomSettings(); + + const powerLevels = usePowerLevels(room); + const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels); + const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels); + const myPower = getPowerLevel(mx.getSafeUserId()); + const canChangePowers = canSendStateEvent(StateEvent.RoomPowerLevels, myPower); + + const userPower = getPowerLevel(userId); + const tag = getPowerLevelTag(userPower); + const tagIconSrc = tag.icon && getTagIconSrc(mx, useAuthentication, tag.icon); + + const [cords, setCords] = useState(); + + const open: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + const close = () => setCords(undefined); + + return ( + isKeyHotkey('arrowdown', evt), + isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), + }} + > + +
+ {getPowers(powerLevelTags).map((power) => { + const powerTag = powerLevelTags[power]; + const powerTagIconSrc = + powerTag.icon && getTagIconSrc(mx, useAuthentication, powerTag.icon); + + return ( + } + after={ + powerTagIconSrc ? ( + + ) : undefined + } + onClick={canChangePowers ? undefined : undefined} + > + {powerTag.name} + + ); + })} +
+ + +
+ { + openRoomSettings(room.roomId, space?.roomId, RoomSettingsPage.PermissionsPage); + close(); + }} + > + Manage Powers + +
+
+ + } + > + + ) : ( + + ) + } + after={tagIconSrc ? : undefined} + onClick={open} + aria-pressed={!!cords} + > + + {tag.name} + + +
+ ); +} + +function MutualRoomsChip({ userId }: { userId: string }) { + const mx = useMatrixClient(); + const mutualRoomSupported = useMutualRoomsSupport(); + const mutualRoomsState = useMutualRooms(userId); + + if ( + userId === mx.getSafeUserId() || + !mutualRoomSupported || + mutualRoomsState.status === AsyncStatus.Error + ) + return null; + + return ( + } + disabled={mutualRoomsState.status !== AsyncStatus.Success} + > + + {mutualRoomsState.status === AsyncStatus.Success && + `${mutualRoomsState.data.length} Mutual Rooms`} + {mutualRoomsState.status === AsyncStatus.Loading && 'Mutual Rooms'} + + + ); +} + +type UserRoomProfileProps = { + userId: string; +}; +export function UserRoomProfile({ userId }: UserRoomProfileProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const room = useRoom(); + + const server = getMxIdServer(userId); + const displayName = getMemberDisplayName(room, userId); + const avatarMxc = getMemberAvatarMxc(room, userId); + const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined; + + return ( + + + + + + + + + + + + {server && } + + + + + + + ); +} diff --git a/src/app/components/user-profile/index.ts b/src/app/components/user-profile/index.ts new file mode 100644 index 00000000..54542c76 --- /dev/null +++ b/src/app/components/user-profile/index.ts @@ -0,0 +1 @@ +export * from './UserRoomProfile'; diff --git a/src/app/components/user-profile/styles.css.ts b/src/app/components/user-profile/styles.css.ts new file mode 100644 index 00000000..ac3ef416 --- /dev/null +++ b/src/app/components/user-profile/styles.css.ts @@ -0,0 +1,41 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +export const UserHeader = style({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 1, + padding: config.space.S200, +}); + +export const UserHero = style({ + position: 'relative', +}); + +export const UserHeroCoverContainer = style({ + height: toRem(96), + overflow: 'hidden', +}); +export const UserHeroCover = style({ + height: '100%', + width: '100%', + objectFit: 'cover', + filter: 'blur(16px)', + transform: 'scale(2)', +}); + +export const UserHeroAvatarContainer = style({ + position: 'relative', + height: toRem(29), +}); +export const UserHeroAvatar = style({ + position: 'absolute', + left: config.space.S400, + top: 0, + transform: 'translateY(-50%)', + background: color.Surface.Container, + + outline: `${config.borderWidth.B500} solid ${color.Surface.Container}`, +}); diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index 5edb4f2b..81d97db4 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -30,7 +30,6 @@ import { Room, RoomMember } from 'matrix-js-sdk'; import { useVirtualizer } from '@tanstack/react-virtual'; import classNames from 'classnames'; -import { openProfileViewer } from '../../../client/action/navigation'; import * as css from './MembersDrawer.css'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { UseStateProvider } from '../../components/UseStateProvider'; @@ -56,6 +55,8 @@ import { useMemberSort, useMemberSortMenu } from '../../hooks/useMemberSort'; import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { MembershipFilterMenu } from '../../components/MembershipFilterMenu'; import { MemberSortMenu } from '../../components/MemberSortMenu'; +import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { useSpaceOptionally } from '../../hooks/useSpace'; const SEARCH_OPTIONS: UseAsyncSearchOptions = { limit: 1000, @@ -82,6 +83,8 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { const [, getPowerLevelTag] = usePowerLevelTags(room, powerLevels); const fetchingMembers = members.length < room.getJoinedMemberCount(); const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer'); + const openUserRoomProfile = useOpenUserRoomProfile(); + const space = useSpaceOptionally(); const membershipFilterMenu = useMembershipFilterMenu(); const sortFilterMenu = useMemberSortMenu(); @@ -142,7 +145,8 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { const handleMemberClick: MouseEventHandler = (evt) => { const btn = evt.currentTarget as HTMLButtonElement; const userId = btn.getAttribute('data-user-id'); - openProfileViewer(userId, room.roomId); + if (!userId) return; + openUserRoomProfile(room.roomId, space?.roomId, userId, btn.getBoundingClientRect()); }; return ( diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 773e115b..0a030e50 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -85,7 +85,6 @@ import { } from '../../utils/room'; import { useSetting } from '../../state/hooks/settings'; import { MessageLayout, settingsAtom } from '../../state/settings'; -import { openProfileViewer } from '../../../client/action/navigation'; import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer'; import { Reactions, Message, Event, EncryptedContent } from './message'; import { useMemberEventParser } from '../../hooks/useMemberEventParser'; @@ -120,6 +119,8 @@ import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { useImagePackRooms } from '../../hooks/useImagePackRooms'; import { GetPowerLevelTag } from '../../hooks/usePowerLevelTags'; import { useIsDirectRoom } from '../../hooks/useRoom'; +import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { useSpaceOptionally } from '../../hooks/useSpace'; const TimelineFloat = as<'div', css.TimelineFloatVariants>( ({ position, className, ...props }, ref) => ( @@ -469,6 +470,8 @@ export function RoomTimeline({ const { navigateRoom } = useRoomNavigate(); const mentionClickHandler = useMentionClickHandler(room.roomId); const spoilerClickHandler = useSpoilerClickHandler(); + const openUserRoomProfile = useOpenUserRoomProfile(); + const space = useSpaceOptionally(); const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents); @@ -906,9 +909,14 @@ export function RoomTimeline({ console.warn('Button should have "data-user-id" attribute!'); return; } - openProfileViewer(userId, room.roomId); + openUserRoomProfile( + room.roomId, + space?.roomId, + userId, + evt.currentTarget.getBoundingClientRect() + ); }, - [room] + [room, space, openUserRoomProfile] ); const handleUsernameClick: MouseEventHandler = useCallback( (evt) => { diff --git a/src/app/hooks/useMutualRooms.ts b/src/app/hooks/useMutualRooms.ts new file mode 100644 index 00000000..a7b38938 --- /dev/null +++ b/src/app/hooks/useMutualRooms.ts @@ -0,0 +1,30 @@ +import { useCallback } from 'react'; +import { useMatrixClient } from './useMatrixClient'; +import { AsyncState, useAsyncCallbackValue } from './useAsyncCallback'; +import { useSpecVersions } from './useSpecVersions'; + +export const useMutualRoomsSupport = (): boolean => { + const { unstable_features: unstableFeatures } = useSpecVersions(); + + const supported = + unstableFeatures?.['uk.half-shot.msc2666'] || + unstableFeatures?.['uk.half-shot.msc2666.mutual_rooms'] || + unstableFeatures?.['uk.half-shot.msc2666.query_mutual_rooms']; + + return !!supported; +}; + +export const useMutualRooms = (userId: string): AsyncState => { + const mx = useMatrixClient(); + + const supported = useMutualRoomsSupport(); + + const [mutualRoomsState] = useAsyncCallbackValue( + useCallback( + () => (supported ? mx._unstable_getSharedRooms(userId) : Promise.resolve([])), + [mx, userId, supported] + ) + ); + + return mutualRoomsState; +}; diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 89743693..e0f7a5f4 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -61,6 +61,7 @@ import { AutoRestoreBackupOnVerification } from '../components/BackupRestore'; import { RoomSettingsRenderer } from '../features/room-settings'; import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences'; import { SpaceSettingsRenderer } from '../features/space-settings'; +import { UserRoomProfileRenderer } from '../components/UserRoomProfileRenderer'; export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => { const { hashRouter } = clientConfig; @@ -125,6 +126,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) > + diff --git a/src/app/state/hooks/userRoomProfile.ts b/src/app/state/hooks/userRoomProfile.ts new file mode 100644 index 00000000..75275f2e --- /dev/null +++ b/src/app/state/hooks/userRoomProfile.ts @@ -0,0 +1,40 @@ +import { useCallback } from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { RectCords } from 'folds'; +import { userRoomProfileAtom, UserRoomProfileState } from '../userRoomProfile'; + +export const useUserRoomProfileState = (): UserRoomProfileState | undefined => { + const data = useAtomValue(userRoomProfileAtom); + + return data; +}; + +type CloseCallback = () => void; +export const useCloseUserRoomProfile = (): CloseCallback => { + const setUserRoomProfile = useSetAtom(userRoomProfileAtom); + + const close: CloseCallback = useCallback(() => { + setUserRoomProfile(undefined); + }, [setUserRoomProfile]); + + return close; +}; + +type OpenCallback = ( + roomId: string, + spaceId: string | undefined, + userId: string, + cords: RectCords +) => void; +export const useOpenUserRoomProfile = (): OpenCallback => { + const setUserRoomProfile = useSetAtom(userRoomProfileAtom); + + const open: OpenCallback = useCallback( + (roomId, spaceId, userId, cords) => { + setUserRoomProfile({ roomId, spaceId, userId, cords }); + }, + [setUserRoomProfile] + ); + + return open; +}; diff --git a/src/app/state/userRoomProfile.ts b/src/app/state/userRoomProfile.ts new file mode 100644 index 00000000..bbad8f02 --- /dev/null +++ b/src/app/state/userRoomProfile.ts @@ -0,0 +1,11 @@ +import { RectCords } from 'folds'; +import { atom } from 'jotai'; + +export type UserRoomProfileState = { + userId: string; + roomId: string; + spaceId?: string; + cords: RectCords; +}; + +export const userRoomProfileAtom = atom(undefined);