From 4d1ae4eafdc7bc9de2c3f6cbc42a97c165bac124 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sat, 9 Aug 2025 17:46:10 +0530 Subject: [PATCH 1/2] Redesign user profile view (#2396) * WIP - new profile view * render common rooms in user profile * add presence component * WIP - room user profile * temp hide profile button * show mutual rooms in spaces, rooms and direct messages categories * add message button * add option to change user powers in profile * improve ban info and option to unban * add share user button in user profile * add option to block user in user profile * improve blocked user alert body * add moderation tool in user profile * open profile view on left side in member drawer * open new user profile in all places --- .../components/UserRoomProfileRenderer.tsx | 55 ++ .../components/event-readers/EventReaders.tsx | 32 +- .../components/message/layout/layout.css.ts | 2 +- src/app/components/presence/Presence.tsx | 80 +++ src/app/components/presence/index.ts | 1 + src/app/components/presence/styles.css.ts | 22 + src/app/components/user-profile/PowerChip.tsx | 344 ++++++++++++ src/app/components/user-profile/UserChips.tsx | 514 ++++++++++++++++++ src/app/components/user-profile/UserHero.tsx | 75 +++ .../user-profile/UserModeration.tsx | 349 ++++++++++++ .../user-profile/UserRoomProfile.tsx | 159 ++++++ src/app/components/user-profile/index.ts | 1 + src/app/components/user-profile/styles.css.ts | 42 ++ .../common-settings/members/Members.tsx | 15 +- src/app/features/room/MembersDrawer.tsx | 10 +- src/app/features/room/RoomTimeline.tsx | 14 +- .../room/reaction-viewer/ReactionViewer.tsx | 36 +- .../space-settings/SpaceSettingsRenderer.tsx | 2 +- src/app/hooks/useMembership.ts | 28 + src/app/hooks/useMentionClickHandler.ts | 9 +- src/app/hooks/useMutualRooms.ts | 30 + src/app/hooks/useUserPresence.ts | 58 ++ src/app/pages/Router.tsx | 2 + src/app/state/hooks/userRoomProfile.ts | 41 ++ src/app/state/userRoomProfile.ts | 12 + 25 files changed, 1899 insertions(+), 34 deletions(-) create mode 100644 src/app/components/UserRoomProfileRenderer.tsx create mode 100644 src/app/components/presence/Presence.tsx create mode 100644 src/app/components/presence/index.ts create mode 100644 src/app/components/presence/styles.css.ts create mode 100644 src/app/components/user-profile/PowerChip.tsx create mode 100644 src/app/components/user-profile/UserChips.tsx create mode 100644 src/app/components/user-profile/UserHero.tsx create mode 100644 src/app/components/user-profile/UserModeration.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/useMembership.ts create mode 100644 src/app/hooks/useMutualRooms.ts create mode 100644 src/app/hooks/useUserPresence.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..ca7aa837 --- /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, position } = 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/event-readers/EventReaders.tsx b/src/app/components/event-readers/EventReaders.tsx index de1416b6..75fdf0da 100644 --- a/src/app/components/event-readers/EventReaders.tsx +++ b/src/app/components/event-readers/EventReaders.tsx @@ -19,9 +19,10 @@ import { getMemberDisplayName } from '../../utils/room'; import { getMxIdLocalPart } from '../../utils/matrix'; import * as css from './EventReaders.css'; import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { openProfileViewer } from '../../../client/action/navigation'; import { UserAvatar } from '../user-avatar'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { useSpaceOptionally } from '../../hooks/useSpace'; export type EventReadersProps = { room: Room; @@ -33,6 +34,8 @@ export const EventReaders = as<'div', EventReadersProps>( const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const latestEventReaders = useRoomEventReaders(room, eventId); + const openProfile = useOpenUserRoomProfile(); + const space = useSpaceOptionally(); const getName = (userId: string) => getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; @@ -57,19 +60,32 @@ export const EventReaders = as<'div', EventReadersProps>( {latestEventReaders.map((readerId) => { const name = getName(readerId); - const avatarMxcUrl = room - .getMember(readerId) - ?.getMxcAvatarUrl(); - const avatarUrl = avatarMxcUrl ? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication) : undefined; + const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl(); + const avatarUrl = avatarMxcUrl + ? mx.mxcUrlToHttp( + avatarMxcUrl, + 100, + 100, + 'crop', + undefined, + false, + useAuthentication + ) + : undefined; return ( { - requestClose(); - openProfileViewer(readerId, room.roomId); + onClick={(event) => { + openProfile( + room.roomId, + space?.roomId, + readerId, + event.currentTarget.getBoundingClientRect(), + 'Bottom' + ); }} before={ 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/presence/Presence.tsx b/src/app/components/presence/Presence.tsx new file mode 100644 index 00000000..108852f2 --- /dev/null +++ b/src/app/components/presence/Presence.tsx @@ -0,0 +1,80 @@ +import { + as, + Badge, + Box, + color, + ContainerColor, + MainColor, + Text, + Tooltip, + TooltipProvider, + toRem, +} from 'folds'; +import React, { ReactNode, useId } from 'react'; +import * as css from './styles.css'; +import { Presence, usePresenceLabel } from '../../hooks/useUserPresence'; + +const PresenceToColor: Record = { + [Presence.Online]: 'Success', + [Presence.Unavailable]: 'Warning', + [Presence.Offline]: 'Secondary', +}; + +type PresenceBadgeProps = { + presence: Presence; + status?: string; + size?: '200' | '300' | '400' | '500'; +}; +export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) { + const label = usePresenceLabel(); + const badgeLabelId = useId(); + + return ( + + + {label[presence]} + {status && } + {status && {status}} + + + } + > + {(triggerRef) => ( + + )} + + ); +} + +type AvatarPresenceProps = { + badge: ReactNode; + variant?: ContainerColor; +}; +export const AvatarPresence = as<'div', AvatarPresenceProps>( + ({ as: AsAvatarPresence, badge, variant = 'Surface', children, ...props }, ref) => ( + + {badge && ( +
+ {badge} +
+ )} + {children} +
+ ) +); diff --git a/src/app/components/presence/index.ts b/src/app/components/presence/index.ts new file mode 100644 index 00000000..88fcdf78 --- /dev/null +++ b/src/app/components/presence/index.ts @@ -0,0 +1 @@ +export * from './Presence'; diff --git a/src/app/components/presence/styles.css.ts b/src/app/components/presence/styles.css.ts new file mode 100644 index 00000000..12ea7f18 --- /dev/null +++ b/src/app/components/presence/styles.css.ts @@ -0,0 +1,22 @@ +import { style } from '@vanilla-extract/css'; +import { config } from 'folds'; + +export const AvatarPresence = style({ + display: 'flex', + position: 'relative', + flexShrink: 0, +}); + +export const AvatarPresenceBadge = style({ + position: 'absolute', + bottom: 0, + right: 0, + transform: 'translate(25%, 25%)', + zIndex: 1, + + display: 'flex', + padding: config.borderWidth.B600, + backgroundColor: 'inherit', + borderRadius: config.radii.Pill, + overflow: 'hidden', +}); diff --git a/src/app/components/user-profile/PowerChip.tsx b/src/app/components/user-profile/PowerChip.tsx new file mode 100644 index 00000000..78f539a1 --- /dev/null +++ b/src/app/components/user-profile/PowerChip.tsx @@ -0,0 +1,344 @@ +import { + Box, + Button, + Chip, + config, + Dialog, + Header, + Icon, + IconButton, + Icons, + Line, + Menu, + MenuItem, + Overlay, + OverlayBackdrop, + OverlayCenter, + PopOut, + RectCords, + Spinner, + Text, + toRem, +} from 'folds'; +import React, { MouseEventHandler, useCallback, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { isKeyHotkey } from 'is-hotkey'; +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 { stopPropagation } from '../../utils/keyboard'; +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'; +import { CutoutCard } from '../cutout-card'; +import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings'; +import { SpaceSettingsPage } from '../../state/spaceSettings'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { BreakWord } from '../../styles/Text.css'; + +type SelfDemoteAlertProps = { + power: number; + onCancel: () => void; + onChange: (power: number) => void; +}; +function SelfDemoteAlert({ power, onCancel, onChange }: SelfDemoteAlertProps) { + return ( + }> + + + +
+ + Self Demotion + + + + +
+ + + + You are about to demote yourself! You will not be able to regain this power + yourself. Are you sure? + + + + + + +
+
+
+
+ ); +} + +type SharedPowerAlertProps = { + power: number; + onCancel: () => void; + onChange: (power: number) => void; +}; +function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps) { + return ( + }> + + + +
+ + Shared Power + + + + +
+ + + + You are promoting the user to have the same power as yourself! You will not be + able to change their power afterward. Are you sure? + + + + + + +
+
+
+
+ ); +} + +export function PowerChip({ userId }: { userId: string }) { + const mx = useMatrixClient(); + const room = useRoom(); + const space = useSpaceOptionally(); + const useAuthentication = useMediaAuthentication(); + const openRoomSettings = useOpenRoomSettings(); + const openSpaceSettings = useOpenSpaceSettings(); + + const powerLevels = usePowerLevels(room); + const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels); + const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels); + const myPower = getPowerLevel(mx.getSafeUserId()); + const userPower = getPowerLevel(userId); + const canChangePowers = + canSendStateEvent(StateEvent.RoomPowerLevels, myPower) && + (mx.getSafeUserId() === userId ? true : myPower > userPower); + + 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); + + const [powerState, changePower] = useAsyncCallback( + useCallback( + async (power: number) => { + await mx.setPowerLevel(room.roomId, userId, power); + }, + [mx, userId, room] + ) + ); + const changing = powerState.status === AsyncStatus.Loading; + const error = powerState.status === AsyncStatus.Error; + const [selfDemote, setSelfDemote] = useState(); + const [sharedPower, setSharedPower] = useState(); + + const handlePowerSelect = (power: number): void => { + close(); + if (!canChangePowers) return; + if (power === userPower) return; + + if (userId === mx.getSafeUserId()) { + setSelfDemote(power); + return; + } + if (power === myPower) { + setSharedPower(power); + return; + } + + changePower(power); + }; + + const handleSelfDemote = (power: number) => { + setSelfDemote(undefined); + changePower(power); + }; + const handleSharedPower = (power: number) => { + setSharedPower(undefined); + changePower(power); + }; + + return ( + <> + isKeyHotkey('arrowdown', evt), + isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), + }} + > + + + {error && ( + + Error: {powerState.error.name} + + {powerState.error.message} + + + )} + {getPowers(powerLevelTags).map((power) => { + const powerTag = powerLevelTags[power]; + const powerTagIconSrc = + powerTag.icon && getTagIconSrc(mx, useAuthentication, powerTag.icon); + + const canAssignPower = power <= myPower; + + return ( + } + after={ + powerTagIconSrc ? ( + + ) : undefined + } + onClick={ + canChangePowers && canAssignPower + ? () => handlePowerSelect(power) + : undefined + } + > + {powerTag.name} + + ); + })} + + +
+ { + if (room.isSpaceRoom()) { + openSpaceSettings( + room.roomId, + space?.roomId, + SpaceSettingsPage.PermissionsPage + ); + } else { + openRoomSettings( + room.roomId, + space?.roomId, + RoomSettingsPage.PermissionsPage + ); + } + close(); + }} + > + Manage Powers + +
+
+ + } + > + + ) : ( + <> + {!changing && } + {changing && } + + ) + } + after={tagIconSrc ? : undefined} + onClick={open} + aria-pressed={!!cords} + > + + {tag.name} + + +
+ {typeof selfDemote === 'number' ? ( + setSelfDemote(undefined)} + onChange={handleSelfDemote} + /> + ) : null} + {typeof sharedPower === 'number' ? ( + setSharedPower(undefined)} + onChange={handleSharedPower} + /> + ) : null} + + ); +} diff --git a/src/app/components/user-profile/UserChips.tsx b/src/app/components/user-profile/UserChips.tsx new file mode 100644 index 00000000..53e6618b --- /dev/null +++ b/src/app/components/user-profile/UserChips.tsx @@ -0,0 +1,514 @@ +import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import FocusTrap from 'focus-trap-react'; +import { isKeyHotkey } from 'is-hotkey'; +import { Room } from 'matrix-js-sdk'; +import { + PopOut, + Menu, + MenuItem, + config, + Text, + Line, + Chip, + Icon, + Icons, + RectCords, + Spinner, + toRem, + Box, + Scroll, + Avatar, +} from 'folds'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { getMxIdServer } from '../../utils/matrix'; +import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { stopPropagation } from '../../utils/keyboard'; +import { copyToClipboard } from '../../utils/dom'; +import { getExploreServerPath } from '../../pages/pathUtils'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { factoryRoomIdByAtoZ } from '../../utils/sort'; +import { useMutualRooms, useMutualRoomsSupport } from '../../hooks/useMutualRooms'; +import { useRoomNavigate } from '../../hooks/useRoomNavigate'; +import { useDirectRooms } from '../../pages/client/direct/useDirectRooms'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom'; +import { RoomAvatar, RoomIcon } from '../room-avatar'; +import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; +import { nameInitials } from '../../utils/common'; +import { getMatrixToUser } from '../../plugins/matrix-to'; +import { useTimeoutToggle } from '../../hooks/useTimeoutToggle'; +import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; +import { CutoutCard } from '../cutout-card'; +import { SettingTile } from '../setting-tile'; + +export function ServerChip({ server }: { server: string }) { + const mx = useMatrixClient(); + const myServer = getMxIdServer(mx.getSafeUserId()); + const navigate = useNavigate(); + const closeProfile = useCloseUserRoomProfile(); + const [copied, setCopied] = useTimeoutToggle(); + + 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); + setCopied(); + close(); + }} + > + Copy Server + + { + navigate(getExploreServerPath(server)); + closeProfile(); + }} + > + Explore Community + +
+ +
+ { + window.open(`https://${server}`, '_blank'); + close(); + }} + > + Open in Browser + +
+
+ + } + > + + ) : ( + + ) + } + onClick={open} + aria-pressed={!!cords} + > + + {server} + + +
+ ); +} + +export function ShareChip({ userId }: { userId: string }) { + const [cords, setCords] = useState(); + + const [copied, setCopied] = useTimeoutToggle(); + + const open: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + const close = () => setCords(undefined); + + return ( + isKeyHotkey('arrowdown', evt), + isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), + }} + > + +
+ { + copyToClipboard(userId); + setCopied(); + close(); + }} + > + Copy User ID + + { + copyToClipboard(getMatrixToUser(userId)); + setCopied(); + close(); + }} + > + Copy User Link + +
+
+ + } + > + + ) : ( + + ) + } + onClick={open} + aria-pressed={!!cords} + > + + Share + + +
+ ); +} + +type MutualRoomsData = { + rooms: Room[]; + spaces: Room[]; + directs: Room[]; +}; + +export function MutualRoomsChip({ userId }: { userId: string }) { + const mx = useMatrixClient(); + const mutualRoomSupported = useMutualRoomsSupport(); + const mutualRoomsState = useMutualRooms(userId); + const { navigateRoom, navigateSpace } = useRoomNavigate(); + const closeUserRoomProfile = useCloseUserRoomProfile(); + const directs = useDirectRooms(); + const useAuthentication = useMediaAuthentication(); + + const allJoinedRooms = useAllJoinedRoomsSet(); + const getRoom = useGetRoom(allJoinedRooms); + + const [cords, setCords] = useState(); + + const open: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + const close = () => setCords(undefined); + + const mutual: MutualRoomsData = useMemo(() => { + const data: MutualRoomsData = { + rooms: [], + spaces: [], + directs: [], + }; + + if (mutualRoomsState.status === AsyncStatus.Success) { + const mutualRooms = mutualRoomsState.data + .sort(factoryRoomIdByAtoZ(mx)) + .map(getRoom) + .filter((room) => !!room); + mutualRooms.forEach((room) => { + if (room.isSpaceRoom()) { + data.spaces.push(room); + return; + } + if (directs.includes(room.roomId)) { + data.directs.push(room); + return; + } + data.rooms.push(room); + }); + } + return data; + }, [mutualRoomsState, getRoom, directs, mx]); + + if ( + userId === mx.getSafeUserId() || + !mutualRoomSupported || + mutualRoomsState.status === AsyncStatus.Error + ) { + return null; + } + + const renderItem = (room: Room) => { + const { roomId } = room; + const dm = directs.includes(roomId); + + return ( + { + if (room.isSpaceRoom()) { + navigateSpace(roomId); + } else { + navigateRoom(roomId); + } + closeUserRoomProfile(); + }} + before={ + + {dm || room.isSpaceRoom() ? ( + ( + + {nameInitials(room.name)} + + )} + /> + ) : ( + + )} + + } + > + + {room.name} + + + ); + }; + + return ( + isKeyHotkey('arrowdown', evt), + isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), + }} + > + + + + + {mutual.spaces.length > 0 && ( + + + Spaces + + {mutual.spaces.map(renderItem)} + + )} + {mutual.rooms.length > 0 && ( + + + Rooms + + {mutual.rooms.map(renderItem)} + + )} + {mutual.directs.length > 0 && ( + + + Direct Messages + + {mutual.directs.map(renderItem)} + + )} + + + + + + ) : null + } + > + } + disabled={ + mutualRoomsState.status !== AsyncStatus.Success || mutualRoomsState.data.length === 0 + } + onClick={open} + aria-pressed={!!cords} + > + + {mutualRoomsState.status === AsyncStatus.Success && + `${mutualRoomsState.data.length} Mutual Rooms`} + {mutualRoomsState.status === AsyncStatus.Loading && 'Mutual Rooms'} + + + + ); +} + +export function IgnoredUserAlert() { + return ( + + + + + Blocked User + + + You do not receive any messages or invites from this user. + + + + + ); +} + +export function OptionsChip({ userId }: { userId: string }) { + const mx = useMatrixClient(); + const [cords, setCords] = useState(); + + const open: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + const close = () => setCords(undefined); + + const ignoredUsers = useIgnoredUsers(); + const ignored = ignoredUsers.includes(userId); + + const [ignoreState, toggleIgnore] = useAsyncCallback( + useCallback(async () => { + const users = ignoredUsers.filter((u) => u !== userId); + if (!ignored) users.push(userId); + await mx.setIgnoredUsers(users); + }, [mx, ignoredUsers, userId, ignored]) + ); + const ignoring = ignoreState.status === AsyncStatus.Loading; + + return ( + isKeyHotkey('arrowdown', evt), + isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), + }} + > + +
+ { + toggleIgnore(); + close(); + }} + before={ + ignoring ? ( + + ) : ( + + ) + } + disabled={ignoring} + > + {ignored ? 'Unblock User' : 'Block User'} + +
+
+ + } + > + + {ignoring ? ( + + ) : ( + + )} + +
+ ); +} diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx new file mode 100644 index 00000000..cf4c815f --- /dev/null +++ b/src/app/components/user-profile/UserHero.tsx @@ -0,0 +1,75 @@ +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'; +import { UserPresence } from '../../hooks/useUserPresence'; +import { AvatarPresence, PresenceBadge } from '../presence'; + +type UserHeroProps = { + userId: string; + avatarUrl?: string; + presence?: UserPresence; +}; +export function UserHero({ userId, avatarUrl, presence }: 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/UserModeration.tsx b/src/app/components/user-profile/UserModeration.tsx new file mode 100644 index 00000000..814bb5ba --- /dev/null +++ b/src/app/components/user-profile/UserModeration.tsx @@ -0,0 +1,349 @@ +import { Box, Button, color, config, Icon, Icons, Spinner, Text, Input } from 'folds'; +import React, { useCallback, useRef } from 'react'; +import { useRoom } from '../../hooks/useRoom'; +import { CutoutCard } from '../cutout-card'; +import { SettingTile } from '../setting-tile'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { BreakWord } from '../../styles/Text.css'; +import { useSetting } from '../../state/hooks/settings'; +import { settingsAtom } from '../../state/settings'; +import { timeDayMonYear, timeHourMinute } from '../../utils/time'; + +type UserKickAlertProps = { + reason?: string; + kickedBy?: string; + ts?: number; +}; +export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) { + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); + + const time = ts ? timeHourMinute(ts, hour24Clock) : undefined; + const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined; + + return ( + + + + + Kicked User + {time && date && ( + + {date} {time} + + )} + + + {kickedBy && ( + + Kicked by: {kickedBy} + + )} + + {reason ? ( + <> + Reason: {reason} + + ) : ( + No Reason Provided. + )} + + + + + + ); +} + +type UserBanAlertProps = { + userId: string; + reason?: string; + canUnban?: boolean; + bannedBy?: string; + ts?: number; +}; +export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBanAlertProps) { + const mx = useMatrixClient(); + const room = useRoom(); + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); + + const time = ts ? timeHourMinute(ts, hour24Clock) : undefined; + const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined; + + const [unbanState, unban] = useAsyncCallback( + useCallback(async () => { + await mx.unban(room.roomId, userId); + }, [mx, room, userId]) + ); + const banning = unbanState.status === AsyncStatus.Loading; + const error = unbanState.status === AsyncStatus.Error; + + return ( + + + + + Banned User + {time && date && ( + + {date} {time} + + )} + + + {bannedBy && ( + + Banned by: {bannedBy} + + )} + + {reason ? ( + <> + Reason: {reason} + + ) : ( + No Reason Provided. + )} + + + {error && ( + + {unbanState.error.message} + + )} + {canUnban && ( + + )} + + + + ); +} + +type UserInviteAlertProps = { + userId: string; + reason?: string; + canKick?: boolean; + invitedBy?: string; + ts?: number; +}; +export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: UserInviteAlertProps) { + const mx = useMatrixClient(); + const room = useRoom(); + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); + + const time = ts ? timeHourMinute(ts, hour24Clock) : undefined; + const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined; + + const [kickState, kick] = useAsyncCallback( + useCallback(async () => { + await mx.kick(room.roomId, userId); + }, [mx, room, userId]) + ); + const kicking = kickState.status === AsyncStatus.Loading; + const error = kickState.status === AsyncStatus.Error; + + return ( + + + + + Invited User + {time && date && ( + + {date} {time} + + )} + + + {invitedBy && ( + + Invited by: {invitedBy} + + )} + + {reason ? ( + <> + Reason: {reason} + + ) : ( + No Reason Provided. + )} + + + {error && ( + + {kickState.error.message} + + )} + {canKick && ( + + )} + + + + ); +} + +type UserModerationProps = { + userId: string; + canKick: boolean; + canBan: boolean; + canInvite: boolean; +}; +export function UserModeration({ userId, canKick, canBan, canInvite }: UserModerationProps) { + const mx = useMatrixClient(); + const room = useRoom(); + const reasonInputRef = useRef(null); + + const getReason = useCallback((): string | undefined => { + const reason = reasonInputRef.current?.value.trim() || undefined; + if (reasonInputRef.current) { + reasonInputRef.current.value = ''; + } + return reason; + }, []); + + const [kickState, kick] = useAsyncCallback( + useCallback(async () => { + await mx.kick(room.roomId, userId, getReason()); + }, [mx, room, userId, getReason]) + ); + + const [banState, ban] = useAsyncCallback( + useCallback(async () => { + await mx.ban(room.roomId, userId, getReason()); + }, [mx, room, userId, getReason]) + ); + + const [inviteState, invite] = useAsyncCallback( + useCallback(async () => { + await mx.invite(room.roomId, userId, getReason()); + }, [mx, room, userId, getReason]) + ); + + const disabled = + kickState.status === AsyncStatus.Loading || + banState.status === AsyncStatus.Loading || + inviteState.status === AsyncStatus.Loading; + + if (!canBan && !canKick && !canInvite) return null; + + return ( + + + + Moderation + + {kickState.status === AsyncStatus.Error && ( + + {kickState.error.message} + + )} + {banState.status === AsyncStatus.Error && ( + + {banState.error.message} + + )} + {inviteState.status === AsyncStatus.Error && ( + + {inviteState.error.message} + + )} + + + {canInvite && ( + + )} + {canKick && ( + + )} + {canBan && ( + + )} + + + + ); +} diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx new file mode 100644 index 00000000..ad23fef1 --- /dev/null +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -0,0 +1,159 @@ +import { Box, Button, color, config, Icon, Icons, Spinner, Text } from 'folds'; +import React, { useCallback } from 'react'; +import { UserHero, UserHeroName } from './UserHero'; +import { getDMRoomFor, getMxIdServer, mxcUrlToHttp } from '../../utils/matrix'; +import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels'; +import { useRoom } from '../../hooks/useRoom'; +import { useUserPresence } from '../../hooks/useUserPresence'; +import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { createDM, ignore } from '../../../client/action/room'; +import { hasDevices } from '../../../util/matrixUtil'; +import { useRoomNavigate } from '../../hooks/useRoomNavigate'; +import { useAlive } from '../../hooks/useAlive'; +import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { PowerChip } from './PowerChip'; +import { UserInviteAlert, UserBanAlert, UserModeration, UserKickAlert } from './UserModeration'; +import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; +import { useMembership } from '../../hooks/useMembership'; +import { Membership } from '../../../types/matrix/room'; + +type UserRoomProfileProps = { + userId: string; +}; +export function UserRoomProfile({ userId }: UserRoomProfileProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const { navigateRoom } = useRoomNavigate(); + const alive = useAlive(); + const closeUserRoomProfile = useCloseUserRoomProfile(); + const ignoredUsers = useIgnoredUsers(); + const ignored = ignoredUsers.includes(userId); + + const room = useRoom(); + const powerlevels = usePowerLevels(room); + const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerlevels); + const myPowerLevel = getPowerLevel(mx.getSafeUserId()); + const userPowerLevel = getPowerLevel(userId); + const canKick = canDoAction('kick', myPowerLevel) && myPowerLevel > userPowerLevel; + const canBan = canDoAction('ban', myPowerLevel) && myPowerLevel > userPowerLevel; + const canInvite = canDoAction('invite', myPowerLevel); + + const member = room.getMember(userId); + const membership = useMembership(room, userId); + + const server = getMxIdServer(userId); + const displayName = getMemberDisplayName(room, userId); + const avatarMxc = getMemberAvatarMxc(room, userId); + const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined; + + const presence = useUserPresence(userId); + + const [directMessageState, directMessage] = useAsyncCallback( + useCallback(async () => { + const result = await createDM(mx, userId, await hasDevices(mx, userId)); + return result.room_id as string; + }, [userId, mx]) + ); + + const handleMessage = () => { + const dmRoomId = getDMRoomFor(mx, userId)?.roomId; + if (dmRoomId) { + navigateRoom(dmRoomId); + closeUserRoomProfile(); + return; + } + directMessage().then((rId) => { + if (alive()) { + navigateRoom(rId); + closeUserRoomProfile(); + } + }); + }; + + return ( + + + + + + + + + + + {directMessageState.status === AsyncStatus.Error && ( + + {directMessageState.error.message} + + )} + + {server && } + + + + + + + {ignored && } + {member && membership === Membership.Ban && ( + + )} + {member && + membership === Membership.Leave && + member.events.member && + member.events.member.getSender() !== userId && ( + + )} + {member && membership === Membership.Invite && ( + + )} + + + + ); +} 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..ad6d5a95 --- /dev/null +++ b/src/app/components/user-profile/styles.css.ts @@ -0,0 +1,42 @@ +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 UserAvatarContainer = style({ + position: 'absolute', + left: config.space.S400, + top: 0, + transform: 'translateY(-50%)', + backgroundColor: color.Surface.Container, +}); +export const UserHeroAvatar = style({ + outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`, +}); diff --git a/src/app/features/common-settings/members/Members.tsx b/src/app/features/common-settings/members/Members.tsx index 8d7f89fd..dc802a1c 100644 --- a/src/app/features/common-settings/members/Members.tsx +++ b/src/app/features/common-settings/members/Members.tsx @@ -37,7 +37,6 @@ import { MemberTile } from '../../../components/member-tile'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix'; import { ServerBadge } from '../../../components/server-badge'; -import { openProfileViewer } from '../../../../client/action/navigation'; import { useDebounce } from '../../../hooks/useDebounce'; import { SearchItemStrGetter, @@ -53,6 +52,11 @@ import { UseStateProvider } from '../../../components/UseStateProvider'; import { MembershipFilterMenu } from '../../../components/MembershipFilterMenu'; import { MemberSortMenu } from '../../../components/MemberSortMenu'; import { ScrollTopContainer } from '../../../components/scroll-top-container'; +import { + useOpenUserRoomProfile, + useUserRoomProfileState, +} from '../../../state/hooks/userRoomProfile'; +import { useSpaceOptionally } from '../../../hooks/useSpace'; const SEARCH_OPTIONS: UseAsyncSearchOptions = { limit: 1000, @@ -77,6 +81,9 @@ export function Members({ requestClose }: MembersProps) { const room = useRoom(); const members = useRoomMembers(mx, room.roomId); const fetchingMembers = members.length < room.getJoinedMemberCount(); + const openProfile = useOpenUserRoomProfile(); + const profileUser = useUserRoomProfileState(); + const space = useSpaceOptionally(); const powerLevels = usePowerLevels(room); const { getPowerLevel } = usePowerLevelsAPI(powerLevels); @@ -142,8 +149,9 @@ export function Members({ requestClose }: MembersProps) { const handleMemberClick: MouseEventHandler = (evt) => { const btn = evt.currentTarget as HTMLButtonElement; const userId = btn.getAttribute('data-user-id'); - openProfileViewer(userId, room.roomId); - requestClose(); + if (userId) { + openProfile(room.roomId, space?.roomId, userId, btn.getBoundingClientRect()); + } }; return ( @@ -317,6 +325,7 @@ export function Members({ requestClose }: MembersProps) { = (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(), 'Left'); }; return ( @@ -350,6 +355,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { padding: `0 ${config.space.S200}`, transform: `translateY(${vItem.start}px)`, }} + aria-pressed={openProfileUserId === member.userId} data-index={vItem.index} data-user-id={member.userId} ref={virtualizer.measureElement} diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 244eb327..90f09012 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) => ( @@ -472,6 +473,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); @@ -909,9 +912,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/features/room/reaction-viewer/ReactionViewer.tsx b/src/app/features/room/reaction-viewer/ReactionViewer.tsx index d4b39845..0e7ca833 100644 --- a/src/app/features/room/reaction-viewer/ReactionViewer.tsx +++ b/src/app/features/room/reaction-viewer/ReactionViewer.tsx @@ -20,12 +20,13 @@ import { getMemberDisplayName } from '../../../utils/room'; import { eventWithShortcode, getMxIdLocalPart } from '../../../utils/matrix'; import * as css from './ReactionViewer.css'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { openProfileViewer } from '../../../../client/action/navigation'; import { useRelations } from '../../../hooks/useRelations'; import { Reaction } from '../../../components/message'; import { getHexcodeForEmoji, getShortcodeFor } from '../../../plugins/emoji'; import { UserAvatar } from '../../../components/user-avatar'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile'; +import { useSpaceOptionally } from '../../../hooks/useSpace'; export type ReactionViewerProps = { room: Room; @@ -41,6 +42,8 @@ export const ReactionViewer = as<'div', ReactionViewerProps>( relations, useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], []) ); + const space = useSpaceOptionally(); + const openProfile = useOpenUserRoomProfile(); const [selectedKey, setSelectedKey] = useState(() => { if (initialKey) return initialKey; @@ -111,24 +114,31 @@ export const ReactionViewer = as<'div', ReactionViewerProps>( const name = (member ? getName(member) : getMxIdLocalPart(senderId)) ?? senderId; const avatarMxcUrl = member?.getMxcAvatarUrl(); - const avatarUrl = avatarMxcUrl ? mx.mxcUrlToHttp( - avatarMxcUrl, - 100, - 100, - 'crop', - undefined, - false, - useAuthentication - ) : undefined; + const avatarUrl = avatarMxcUrl + ? mx.mxcUrlToHttp( + avatarMxcUrl, + 100, + 100, + 'crop', + undefined, + false, + useAuthentication + ) + : undefined; return ( { - requestClose(); - openProfileViewer(senderId, room.roomId); + onClick={(event) => { + openProfile( + room.roomId, + space?.roomId, + senderId, + event.currentTarget.getBoundingClientRect(), + 'Bottom' + ); }} before={ diff --git a/src/app/features/space-settings/SpaceSettingsRenderer.tsx b/src/app/features/space-settings/SpaceSettingsRenderer.tsx index 085c5e2b..79947e0c 100644 --- a/src/app/features/space-settings/SpaceSettingsRenderer.tsx +++ b/src/app/features/space-settings/SpaceSettingsRenderer.tsx @@ -16,7 +16,7 @@ function RenderSettings({ state }: RenderSettingsProps) { const allJoinedRooms = useAllJoinedRoomsSet(); const getRoom = useGetRoom(allJoinedRooms); const room = getRoom(roomId); - const space = spaceId ? getRoom(spaceId) : undefined; + const space = spaceId && spaceId !== roomId ? getRoom(spaceId) : undefined; if (!room) return null; diff --git a/src/app/hooks/useMembership.ts b/src/app/hooks/useMembership.ts new file mode 100644 index 00000000..dbdd527e --- /dev/null +++ b/src/app/hooks/useMembership.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; +import { Room, RoomMemberEvent, RoomMemberEventHandlerMap } from 'matrix-js-sdk'; +import { Membership } from '../../types/matrix/room'; + +export const useMembership = (room: Room, userId: string): Membership => { + const member = room.getMember(userId); + + const [membership, setMembership] = useState( + () => (member?.membership as Membership | undefined) ?? Membership.Leave + ); + + useEffect(() => { + const handleMembershipChange: RoomMemberEventHandlerMap[RoomMemberEvent.Membership] = ( + event, + m + ) => { + if (event.getRoomId() === room.roomId && m.userId === userId) { + setMembership((m.membership as Membership | undefined) ?? Membership.Leave); + } + }; + member?.on(RoomMemberEvent.Membership, handleMembershipChange); + return () => { + member?.removeListener(RoomMemberEvent.Membership, handleMembershipChange); + }; + }, [room, member, userId]); + + return membership; +}; diff --git a/src/app/hooks/useMentionClickHandler.ts b/src/app/hooks/useMentionClickHandler.ts index 49c291c7..9dc81d4e 100644 --- a/src/app/hooks/useMentionClickHandler.ts +++ b/src/app/hooks/useMentionClickHandler.ts @@ -3,14 +3,17 @@ import { useNavigate } from 'react-router-dom'; import { useRoomNavigate } from './useRoomNavigate'; import { useMatrixClient } from './useMatrixClient'; import { isRoomId, isUserId } from '../utils/matrix'; -import { openProfileViewer } from '../../client/action/navigation'; import { getHomeRoomPath, withSearchParam } from '../pages/pathUtils'; import { _RoomSearchParams } from '../pages/paths'; +import { useOpenUserRoomProfile } from '../state/hooks/userRoomProfile'; +import { useSpaceOptionally } from './useSpace'; export const useMentionClickHandler = (roomId: string): ReactEventHandler => { const mx = useMatrixClient(); const { navigateRoom, navigateSpace } = useRoomNavigate(); const navigate = useNavigate(); + const openProfile = useOpenUserRoomProfile(); + const space = useSpaceOptionally(); const handleClick: ReactEventHandler = useCallback( (evt) => { @@ -21,7 +24,7 @@ export const useMentionClickHandler = (roomId: string): ReactEventHandler(path, { viaServers }) : path); }, - [mx, navigate, navigateRoom, navigateSpace, roomId] + [mx, navigate, navigateRoom, navigateSpace, roomId, space, openProfile] ); return handleClick; 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/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts new file mode 100644 index 00000000..5137950e --- /dev/null +++ b/src/app/hooks/useUserPresence.ts @@ -0,0 +1,58 @@ +import { useEffect, useMemo, useState } from 'react'; +import { User, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk'; +import { useMatrixClient } from './useMatrixClient'; + +export enum Presence { + Online = 'online', + Unavailable = 'unavailable', + Offline = 'offline', +} + +export type UserPresence = { + presence: Presence; + status?: string; + active: boolean; + lastActiveTs?: number; +}; + +const getUserPresence = (user: User): UserPresence => ({ + presence: user.presence as Presence, + status: user.presenceStatusMsg, + active: user.currentlyActive, + lastActiveTs: user.getLastActiveTs(), +}); + +export const useUserPresence = (userId: string): UserPresence | undefined => { + const mx = useMatrixClient(); + const user = mx.getUser(userId); + + const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined)); + + useEffect(() => { + const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => { + if (u.userId === user?.userId) { + setPresence(getUserPresence(user)); + } + }; + user?.on(UserEvent.Presence, updatePresence); + user?.on(UserEvent.CurrentlyActive, updatePresence); + user?.on(UserEvent.LastPresenceTs, updatePresence); + return () => { + user?.removeListener(UserEvent.Presence, updatePresence); + user?.removeListener(UserEvent.CurrentlyActive, updatePresence); + user?.removeListener(UserEvent.LastPresenceTs, updatePresence); + }; + }, [user]); + + return presence; +}; + +export const usePresenceLabel = (): Record => + useMemo( + () => ({ + [Presence.Online]: 'Active', + [Presence.Unavailable]: 'Busy', + [Presence.Offline]: 'Away', + }), + [] + ); diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index d6d93aa4..247885c0 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -62,6 +62,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'; import { CreateRoomModalRenderer } from '../features/create-room'; import { HomeCreateRoom } from './client/home/CreateRoom'; import { Create } from './client/create'; @@ -130,6 +131,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..7fed0c08 --- /dev/null +++ b/src/app/state/hooks/userRoomProfile.ts @@ -0,0 +1,41 @@ +import { useCallback } from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { Position, 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, + position?: Position +) => void; +export const useOpenUserRoomProfile = (): OpenCallback => { + const setUserRoomProfile = useSetAtom(userRoomProfileAtom); + + const open: OpenCallback = useCallback( + (roomId, spaceId, userId, cords, position) => { + setUserRoomProfile({ roomId, spaceId, userId, cords, position }); + }, + [setUserRoomProfile] + ); + + return open; +}; diff --git a/src/app/state/userRoomProfile.ts b/src/app/state/userRoomProfile.ts new file mode 100644 index 00000000..cf4e403a --- /dev/null +++ b/src/app/state/userRoomProfile.ts @@ -0,0 +1,12 @@ +import { Position, RectCords } from 'folds'; +import { atom } from 'jotai'; + +export type UserRoomProfileState = { + userId: string; + roomId: string; + spaceId?: string; + cords: RectCords; + position?: Position; +}; + +export const userRoomProfileAtom = atom(undefined); From f82cfead46db36a4d4b62af426499333ff3807aa Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 12 Aug 2025 19:42:30 +0530 Subject: [PATCH 2/2] Support room version 12 (#2399) * WIP - support room version 12 * add room creators hook * revert changes from powerlevels * improve use room creators hook * add hook to get dm users * add options to add creators in create room/space * add member item component in member drawer * remove unused import * extract member drawer header component * get room creators as set only if room version support them * add room permissions hook * support room v12 creators power * make predecessor event id optional * add info about founders in permissions * allow to create infinite powers to room creators * allow everyone with permission to create infinite power * handle additional creators in room upgrade * add option to follow space tombstone --- .../create-room/AdditionalCreatorInput.tsx | 306 ++++++++++++++++++ .../create-room/RoomVersionSelector.tsx | 2 +- src/app/components/create-room/index.ts | 1 + src/app/components/create-room/utils.ts | 13 +- .../image-pack-view/RoomImagePack.tsx | 9 +- src/app/components/message/Reply.tsx | 11 +- src/app/components/room-intro/RoomIntro.tsx | 2 +- .../components/user-profile/CreatorChip.tsx | 101 ++++++ src/app/components/user-profile/PowerChip.tsx | 47 ++- .../user-profile/UserRoomProfile.tsx | 38 ++- .../developer-tools/StateEventEditor.tsx | 10 +- .../emojis-stickers/RoomPacks.tsx | 10 +- .../common-settings/general/RoomAddress.tsx | 22 +- .../general/RoomEncryption.tsx | 14 +- .../general/RoomHistoryVisibility.tsx | 14 +- .../common-settings/general/RoomJoinRules.tsx | 13 +- .../common-settings/general/RoomProfile.tsx | 14 +- .../common-settings/general/RoomPublish.tsx | 13 +- .../common-settings/general/RoomUpgrade.tsx | 242 ++++++++------ .../common-settings/members/Members.tsx | 25 +- .../permissions/PermissionGroups.tsx | 31 +- .../common-settings/permissions/Powers.tsx | 42 ++- .../permissions/PowersEditor.tsx | 29 +- src/app/features/create-room/CreateRoom.tsx | 33 +- src/app/features/create-space/CreateSpace.tsx | 34 +- src/app/features/lobby/HierarchyItemMenu.tsx | 22 +- src/app/features/lobby/Lobby.tsx | 63 ++-- src/app/features/lobby/LobbyHeader.tsx | 21 +- src/app/features/lobby/SpaceHierarchy.tsx | 59 ++-- .../message-search/SearchResultGroup.tsx | 39 ++- src/app/features/room-nav/RoomNavItem.tsx | 10 +- .../room-settings/general/General.tsx | 20 +- .../room-settings/permissions/Permissions.tsx | 21 +- src/app/features/room/MembersDrawer.css.ts | 4 +- src/app/features/room/MembersDrawer.tsx | 237 ++++++++------ src/app/features/room/RoomInput.tsx | 31 +- src/app/features/room/RoomTimeline.tsx | 70 ++-- src/app/features/room/RoomView.tsx | 21 +- src/app/features/room/RoomViewHeader.tsx | 10 +- src/app/features/room/message/Message.tsx | 18 +- .../room/room-pin-menu/RoomPinMenu.tsx | 91 ++++-- .../space-settings/general/General.tsx | 16 +- .../permissions/Permissions.tsx | 21 +- src/app/hooks/useDirectUsers.ts | 27 ++ src/app/hooks/useMemberPowerCompare.ts | 28 ++ src/app/hooks/useMemberPowerTag.ts | 87 +++++ src/app/hooks/useMemberSort.ts | 19 +- src/app/hooks/usePowerLevelTags.ts | 115 ++----- src/app/hooks/usePowerLevels.ts | 93 +----- src/app/hooks/useRoomCreators.ts | 49 +++ src/app/hooks/useRoomCreatorsTag.ts | 8 + src/app/hooks/useRoomPermissions.ts | 60 ++++ src/app/pages/client/inbox/Notifications.tsx | 39 ++- src/app/pages/client/sidebar/SpaceTabs.tsx | 10 +- src/app/pages/client/space/Space.tsx | 95 +++++- src/app/utils/matrix.ts | 4 + src/types/matrix/accountData.ts | 2 + src/types/matrix/room.ts | 14 +- 58 files changed, 1717 insertions(+), 783 deletions(-) create mode 100644 src/app/components/create-room/AdditionalCreatorInput.tsx create mode 100644 src/app/components/user-profile/CreatorChip.tsx create mode 100644 src/app/hooks/useDirectUsers.ts create mode 100644 src/app/hooks/useMemberPowerCompare.ts create mode 100644 src/app/hooks/useMemberPowerTag.ts create mode 100644 src/app/hooks/useRoomCreators.ts create mode 100644 src/app/hooks/useRoomCreatorsTag.ts create mode 100644 src/app/hooks/useRoomPermissions.ts diff --git a/src/app/components/create-room/AdditionalCreatorInput.tsx b/src/app/components/create-room/AdditionalCreatorInput.tsx new file mode 100644 index 00000000..51334b49 --- /dev/null +++ b/src/app/components/create-room/AdditionalCreatorInput.tsx @@ -0,0 +1,306 @@ +import { + Box, + Button, + Chip, + config, + Icon, + Icons, + Input, + Line, + Menu, + MenuItem, + PopOut, + RectCords, + Scroll, + Text, + toRem, +} from 'folds'; +import { isKeyHotkey } from 'is-hotkey'; +import FocusTrap from 'focus-trap-react'; +import React, { + ChangeEventHandler, + KeyboardEventHandler, + MouseEventHandler, + useMemo, + useState, +} from 'react'; +import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../utils/matrix'; +import { useDirectUsers } from '../../hooks/useDirectUsers'; +import { SettingTile } from '../setting-tile'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { stopPropagation } from '../../utils/keyboard'; +import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch'; +import { findAndReplace } from '../../utils/findAndReplace'; +import { highlightText } from '../../styles/CustomHtml.css'; +import { makeHighlightRegex } from '../../plugins/react-custom-html-parser'; + +export const useAdditionalCreators = (defaultCreators?: string[]) => { + const mx = useMatrixClient(); + const [additionalCreators, setAdditionalCreators] = useState( + () => defaultCreators?.filter((id) => id !== mx.getSafeUserId()) ?? [] + ); + + const addAdditionalCreator = (userId: string) => { + if (userId === mx.getSafeUserId()) return; + + setAdditionalCreators((creators) => { + const creatorsSet = new Set(creators); + creatorsSet.add(userId); + return Array.from(creatorsSet); + }); + }; + + const removeAdditionalCreator = (userId: string) => { + setAdditionalCreators((creators) => { + const creatorsSet = new Set(creators); + creatorsSet.delete(userId); + return Array.from(creatorsSet); + }); + }; + + return { + additionalCreators, + addAdditionalCreator, + removeAdditionalCreator, + }; +}; + +const SEARCH_OPTIONS: UseAsyncSearchOptions = { + limit: 1000, + matchOptions: { + contain: true, + }, +}; +const getUserIdString = (userId: string) => getMxIdLocalPart(userId) ?? userId; + +type AdditionalCreatorInputProps = { + additionalCreators: string[]; + onSelect: (userId: string) => void; + onRemove: (userId: string) => void; + disabled?: boolean; +}; +export function AdditionalCreatorInput({ + additionalCreators, + onSelect, + onRemove, + disabled, +}: AdditionalCreatorInputProps) { + const mx = useMatrixClient(); + const [menuCords, setMenuCords] = useState(); + const directUsers = useDirectUsers(); + + const [validUserId, setValidUserId] = useState(); + const filteredUsers = useMemo( + () => directUsers.filter((userId) => !additionalCreators.includes(userId)), + [directUsers, additionalCreators] + ); + const [result, search, resetSearch] = useAsyncSearch( + filteredUsers, + getUserIdString, + SEARCH_OPTIONS + ); + const queryHighlighRegex = result?.query ? makeHighlightRegex([result.query]) : undefined; + + const suggestionUsers = result + ? result.items + : filteredUsers.sort((a, b) => (a.toLocaleLowerCase() >= b.toLocaleLowerCase() ? 1 : -1)); + + const handleOpenMenu: MouseEventHandler = (evt) => { + setMenuCords(evt.currentTarget.getBoundingClientRect()); + }; + const handleCloseMenu = () => { + setMenuCords(undefined); + setValidUserId(undefined); + resetSearch(); + }; + + const handleCreatorChange: ChangeEventHandler = (evt) => { + const creatorInput = evt.currentTarget; + const creator = creatorInput.value.trim(); + if (isUserId(creator)) { + setValidUserId(creator); + } else { + setValidUserId(undefined); + const term = + getMxIdLocalPart(creator) ?? (creator.startsWith('@') ? creator.slice(1) : creator); + if (term) { + search(term); + } else { + resetSearch(); + } + } + }; + + const handleSelectUserId = (userId?: string) => { + if (userId && isUserId(userId)) { + onSelect(userId); + handleCloseMenu(); + } + }; + + const handleCreatorKeyDown: KeyboardEventHandler = (evt) => { + if (isKeyHotkey('enter', evt)) { + evt.preventDefault(); + const creator = evt.currentTarget.value.trim(); + handleSelectUserId(isUserId(creator) ? creator : suggestionUsers[0]); + } + }; + + const handleEnterClick = () => { + handleSelectUserId(validUserId); + }; + + return ( + + + + + {mx.getSafeUserId()} + + {additionalCreators.map((creator) => ( + } + onClick={() => onRemove(creator)} + disabled={disabled} + > + {creator} + + ))} + evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + + + + + + + + + {!validUserId && suggestionUsers.length > 0 ? ( + + + {suggestionUsers.map((userId) => ( + handleSelectUserId(userId)} + after={ + + {getMxIdServer(userId)} + + } + > + + + + {queryHighlighRegex + ? findAndReplace( + getMxIdLocalPart(userId) ?? userId, + queryHighlighRegex, + (match, pushIndex) => ( + + {match[0]} + + ), + (txt) => txt + ) + : getMxIdLocalPart(userId)} + + + + + ))} + + + ) : ( + + + No Suggestions + + + Please provide the user ID and hit Enter. + + + )} + + + + + } + > + + + + + + + + ); +} diff --git a/src/app/components/create-room/RoomVersionSelector.tsx b/src/app/components/create-room/RoomVersionSelector.tsx index 281f520a..219ded0c 100644 --- a/src/app/components/create-room/RoomVersionSelector.tsx +++ b/src/app/components/create-room/RoomVersionSelector.tsx @@ -47,7 +47,7 @@ export function RoomVersionSelector({ gap="500" > { const content: Record = {}; if (typeof type === 'string') { @@ -23,6 +24,9 @@ export const createRoomCreationContent = ( if (allowFederation === false) { content['m.federate'] = false; } + if (Array.isArray(additionalCreators)) { + content.additional_creators = additionalCreators; + } return content; }; @@ -89,6 +93,7 @@ export type CreateRoomData = { encryption?: boolean; knock: boolean; allowFederation: boolean; + additionalCreators?: string[]; }; export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise => { const initialState: ICreateRoomStateEvent[] = []; @@ -108,7 +113,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis name: data.name, topic: data.topic, room_alias_name: data.aliasLocalPart, - creation_content: createRoomCreationContent(data.type, data.allowFederation), + creation_content: createRoomCreationContent( + data.type, + data.allowFederation, + data.additionalCreators + ), initial_state: initialState, }; diff --git a/src/app/components/image-pack-view/RoomImagePack.tsx b/src/app/components/image-pack-view/RoomImagePack.tsx index 9dd45c1f..92b4ff21 100644 --- a/src/app/components/image-pack-view/RoomImagePack.tsx +++ b/src/app/components/image-pack-view/RoomImagePack.tsx @@ -1,12 +1,14 @@ import React, { useCallback, useMemo } from 'react'; import { Room } from 'matrix-js-sdk'; -import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels'; +import { usePowerLevels } from '../../hooks/usePowerLevels'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { ImagePackContent } from './ImagePackContent'; import { ImagePack, PackContent } from '../../plugins/custom-emoji'; import { StateEvent } from '../../../types/matrix/room'; import { useRoomImagePack } from '../../hooks/useImagePacks'; import { randomStr } from '../../utils/common'; +import { useRoomPermissions } from '../../hooks/useRoomPermissions'; +import { useRoomCreators } from '../../hooks/useRoomCreators'; type RoomImagePackProps = { room: Room; @@ -17,9 +19,10 @@ export function RoomImagePack({ room, stateKey }: RoomImagePackProps) { const mx = useMatrixClient(); const userId = mx.getUserId()!; const powerLevels = usePowerLevels(room); + const creators = useRoomCreators(room); - const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels); - const canEditImagePack = canSendStateEvent(StateEvent.PoniesRoomEmotes, getPowerLevel(userId)); + const permissions = useRoomPermissions(creators, powerLevels); + const canEditImagePack = permissions.stateEvent(StateEvent.PoniesRoomEmotes, userId); const fallbackPack = useMemo(() => { const fakePackId = randomStr(4); diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index dc92bf83..57bf2af9 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -10,8 +10,8 @@ import * as css from './Reply.css'; import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content'; import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser'; import { useRoomEvent } from '../../hooks/useRoomEvent'; -import { GetPowerLevelTag } from '../../hooks/usePowerLevelTags'; import colorMXID from '../../../util/colorMXID'; +import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag'; type ReplyLayoutProps = { userColor?: string; @@ -57,8 +57,7 @@ type ReplyProps = { replyEventId: string; threadRootId?: string | undefined; onClick?: MouseEventHandler | undefined; - getPowerLevel?: (userId: string) => number; - getPowerLevelTag?: GetPowerLevelTag; + getMemberPowerTag?: GetMemberPowerTag; accessibleTagColors?: Map; legacyUsernameColor?: boolean; }; @@ -71,8 +70,7 @@ export const Reply = as<'div', ReplyProps>( replyEventId, threadRootId, onClick, - getPowerLevel, - getPowerLevelTag, + getMemberPowerTag, accessibleTagColors, legacyUsernameColor, ...props @@ -88,8 +86,7 @@ export const Reply = as<'div', ReplyProps>( const { body } = replyEvent?.getContent() ?? {}; const sender = replyEvent?.getSender(); - const senderPL = sender && getPowerLevel?.(sender); - const powerTag = typeof senderPL === 'number' ? getPowerLevelTag?.(senderPL) : undefined; + const powerTag = sender ? getMemberPowerTag?.(sender) : undefined; const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined; const usernameColor = legacyUsernameColor ? colorMXID(sender ?? replyEventId) : tagColor; diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx index b02d9f5a..c388efd4 100644 --- a/src/app/components/room-intro/RoomIntro.tsx +++ b/src/app/components/room-intro/RoomIntro.tsx @@ -87,7 +87,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => {typeof prevRoomId === 'string' && (mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? (