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);