--- /dev/null
+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 (
+ <PopOut
+ anchor={cords}
+ position={position ?? 'Top'}
+ align="Start"
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: close,
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+ <Menu style={{ width: toRem(340) }}>
+ <SpaceProvider value={space ?? null}>
+ <RoomProvider value={room}>
+ <UserRoomProfile userId={userId} />
+ </RoomProvider>
+ </SpaceProvider>
+ </Menu>
+ </FocusTrap>
+ }
+ />
+ );
+}
+
+export function UserRoomProfileRenderer() {
+ const state = useUserRoomProfileState();
+
+ if (!state) return null;
+ return <UserRoomProfileContextMenu state={state} />;
+}
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;
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;
<Box className={css.Content} direction="Column">
{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 (
<MenuItem
key={readerId}
style={{ padding: `0 ${config.space.S200}` }}
radii="400"
- onClick={() => {
- requestClose();
- openProfileViewer(readerId, room.roomId);
+ onClick={(event) => {
+ openProfile(
+ room.roomId,
+ space?.roomId,
+ readerId,
+ event.currentTarget.getBoundingClientRect(),
+ 'Bottom'
+ );
}}
before={
<Avatar size="200">
selectors: {
'&:hover': {
- transform: `translateY(${toRem(-4)})`,
+ transform: `translateY(${toRem(-2)})`,
},
},
});
--- /dev/null
+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, MainColor> = {
+ [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 (
+ <TooltipProvider
+ position="Right"
+ align="Center"
+ offset={4}
+ delay={200}
+ tooltip={
+ <Tooltip id={badgeLabelId}>
+ <Box style={{ maxWidth: toRem(250) }} alignItems="Baseline" gap="100">
+ <Text size="L400">{label[presence]}</Text>
+ {status && <Text size="T200">•</Text>}
+ {status && <Text size="T200">{status}</Text>}
+ </Box>
+ </Tooltip>
+ }
+ >
+ {(triggerRef) => (
+ <Badge
+ aria-labelledby={badgeLabelId}
+ ref={triggerRef}
+ size={size}
+ variant={PresenceToColor[presence]}
+ fill={presence === Presence.Offline ? 'Soft' : 'Solid'}
+ radii="Pill"
+ />
+ )}
+ </TooltipProvider>
+ );
+}
+
+type AvatarPresenceProps = {
+ badge: ReactNode;
+ variant?: ContainerColor;
+};
+export const AvatarPresence = as<'div', AvatarPresenceProps>(
+ ({ as: AsAvatarPresence, badge, variant = 'Surface', children, ...props }, ref) => (
+ <Box as={AsAvatarPresence} className={css.AvatarPresence} {...props} ref={ref}>
+ {badge && (
+ <div
+ className={css.AvatarPresenceBadge}
+ style={{ backgroundColor: color[variant].Container }}
+ >
+ {badge}
+ </div>
+ )}
+ {children}
+ </Box>
+ )
+);
--- /dev/null
+export * from './Presence';
--- /dev/null
+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',
+});
--- /dev/null
+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 (
+ <Overlay open backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: onCancel,
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+ <Dialog variant="Surface">
+ <Header
+ style={{ padding: `0 ${config.space.S200} 0 ${config.space.S400}` }}
+ variant="Surface"
+ size="500"
+ >
+ <Box grow="Yes">
+ <Text size="H4">Self Demotion</Text>
+ </Box>
+ <IconButton size="300" onClick={onCancel} radii="300">
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ </Header>
+ <Box style={{ padding: config.space.S400, paddingTop: 0 }} direction="Column" gap="500">
+ <Box direction="Column" gap="200">
+ <Text priority="400">
+ You are about to demote yourself! You will not be able to regain this power
+ yourself. Are you sure?
+ </Text>
+ </Box>
+ <Box direction="Column" gap="200">
+ <Button type="submit" variant="Warning" onClick={() => onChange(power)}>
+ <Text size="B400">Demote</Text>
+ </Button>
+ </Box>
+ </Box>
+ </Dialog>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ );
+}
+
+type SharedPowerAlertProps = {
+ power: number;
+ onCancel: () => void;
+ onChange: (power: number) => void;
+};
+function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps) {
+ return (
+ <Overlay open backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: onCancel,
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+ <Dialog variant="Surface">
+ <Header
+ style={{ padding: `0 ${config.space.S200} 0 ${config.space.S400}` }}
+ variant="Surface"
+ size="500"
+ >
+ <Box grow="Yes">
+ <Text size="H4">Shared Power</Text>
+ </Box>
+ <IconButton size="300" onClick={onCancel} radii="300">
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ </Header>
+ <Box style={{ padding: config.space.S400, paddingTop: 0 }} direction="Column" gap="500">
+ <Box direction="Column" gap="200">
+ <Text priority="400">
+ 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?
+ </Text>
+ </Box>
+ <Box direction="Column" gap="200">
+ <Button type="submit" variant="Warning" onClick={() => onChange(power)}>
+ <Text size="B400">Promote</Text>
+ </Button>
+ </Box>
+ </Box>
+ </Dialog>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ );
+}
+
+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<RectCords>();
+
+ const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ setCords(evt.currentTarget.getBoundingClientRect());
+ };
+
+ const close = () => setCords(undefined);
+
+ const [powerState, changePower] = useAsyncCallback<undefined, Error, [number]>(
+ 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<number>();
+ const [sharedPower, setSharedPower] = useState<number>();
+
+ 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 (
+ <>
+ <PopOut
+ anchor={cords}
+ position="Bottom"
+ align="Start"
+ offset={4}
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: close,
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
+ isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
+ }}
+ >
+ <Menu>
+ <Box
+ direction="Column"
+ gap="100"
+ style={{ padding: config.space.S100, maxWidth: toRem(200) }}
+ >
+ {error && (
+ <CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
+ <Text size="L400">Error: {powerState.error.name}</Text>
+ <Text className={BreakWord} size="T200">
+ {powerState.error.message}
+ </Text>
+ </CutoutCard>
+ )}
+ {getPowers(powerLevelTags).map((power) => {
+ const powerTag = powerLevelTags[power];
+ const powerTagIconSrc =
+ powerTag.icon && getTagIconSrc(mx, useAuthentication, powerTag.icon);
+
+ const canAssignPower = power <= myPower;
+
+ return (
+ <MenuItem
+ key={power}
+ variant={userPower === power ? 'Primary' : 'Surface'}
+ fill="None"
+ size="300"
+ radii="300"
+ aria-disabled={changing || !canChangePowers || !canAssignPower}
+ aria-pressed={userPower === power}
+ before={<PowerColorBadge color={powerTag.color} />}
+ after={
+ powerTagIconSrc ? (
+ <PowerIcon size="50" iconSrc={powerTagIconSrc} />
+ ) : undefined
+ }
+ onClick={
+ canChangePowers && canAssignPower
+ ? () => handlePowerSelect(power)
+ : undefined
+ }
+ >
+ <Text size="B300">{powerTag.name}</Text>
+ </MenuItem>
+ );
+ })}
+ </Box>
+ <Line size="300" />
+ <div style={{ padding: config.space.S100 }}>
+ <MenuItem
+ variant="Surface"
+ fill="None"
+ size="300"
+ radii="300"
+ onClick={() => {
+ if (room.isSpaceRoom()) {
+ openSpaceSettings(
+ room.roomId,
+ space?.roomId,
+ SpaceSettingsPage.PermissionsPage
+ );
+ } else {
+ openRoomSettings(
+ room.roomId,
+ space?.roomId,
+ RoomSettingsPage.PermissionsPage
+ );
+ }
+ close();
+ }}
+ >
+ <Text size="B300">Manage Powers</Text>
+ </MenuItem>
+ </div>
+ </Menu>
+ </FocusTrap>
+ }
+ >
+ <Chip
+ variant={error ? 'Critical' : 'SurfaceVariant'}
+ radii="Pill"
+ before={
+ cords ? (
+ <Icon size="50" src={Icons.ChevronBottom} />
+ ) : (
+ <>
+ {!changing && <PowerColorBadge color={tag.color} />}
+ {changing && <Spinner size="50" variant="Secondary" fill="Soft" />}
+ </>
+ )
+ }
+ after={tagIconSrc ? <PowerIcon size="50" iconSrc={tagIconSrc} /> : undefined}
+ onClick={open}
+ aria-pressed={!!cords}
+ >
+ <Text size="B300" truncate>
+ {tag.name}
+ </Text>
+ </Chip>
+ </PopOut>
+ {typeof selfDemote === 'number' ? (
+ <SelfDemoteAlert
+ power={selfDemote}
+ onCancel={() => setSelfDemote(undefined)}
+ onChange={handleSelfDemote}
+ />
+ ) : null}
+ {typeof sharedPower === 'number' ? (
+ <SharedPowerAlert
+ power={sharedPower}
+ onCancel={() => setSharedPower(undefined)}
+ onChange={handleSharedPower}
+ />
+ ) : null}
+ </>
+ );
+}
--- /dev/null
+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<RectCords>();
+
+ const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ setCords(evt.currentTarget.getBoundingClientRect());
+ };
+
+ const close = () => setCords(undefined);
+
+ return (
+ <PopOut
+ anchor={cords}
+ position="Bottom"
+ align="Start"
+ offset={4}
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: close,
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
+ isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
+ }}
+ >
+ <Menu>
+ <div style={{ padding: config.space.S100 }}>
+ <MenuItem
+ variant="Surface"
+ fill="None"
+ size="300"
+ radii="300"
+ onClick={() => {
+ copyToClipboard(server);
+ setCopied();
+ close();
+ }}
+ >
+ <Text size="B300">Copy Server</Text>
+ </MenuItem>
+ <MenuItem
+ variant="Surface"
+ fill="None"
+ size="300"
+ radii="300"
+ onClick={() => {
+ navigate(getExploreServerPath(server));
+ closeProfile();
+ }}
+ >
+ <Text size="B300">Explore Community</Text>
+ </MenuItem>
+ </div>
+ <Line size="300" />
+ <div style={{ padding: config.space.S100 }}>
+ <MenuItem
+ variant={myServer === server ? 'Surface' : 'Critical'}
+ fill="None"
+ size="300"
+ radii="300"
+ onClick={() => {
+ window.open(`https://${server}`, '_blank');
+ close();
+ }}
+ >
+ <Text size="B300">Open in Browser</Text>
+ </MenuItem>
+ </div>
+ </Menu>
+ </FocusTrap>
+ }
+ >
+ <Chip
+ variant={myServer === server ? 'SurfaceVariant' : 'Warning'}
+ radii="Pill"
+ before={
+ cords ? (
+ <Icon size="50" src={Icons.ChevronBottom} />
+ ) : (
+ <Icon size="50" src={copied ? Icons.Check : Icons.Server} />
+ )
+ }
+ onClick={open}
+ aria-pressed={!!cords}
+ >
+ <Text size="B300" truncate>
+ {server}
+ </Text>
+ </Chip>
+ </PopOut>
+ );
+}
+
+export function ShareChip({ userId }: { userId: string }) {
+ const [cords, setCords] = useState<RectCords>();
+
+ const [copied, setCopied] = useTimeoutToggle();
+
+ const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ setCords(evt.currentTarget.getBoundingClientRect());
+ };
+
+ const close = () => setCords(undefined);
+
+ return (
+ <PopOut
+ anchor={cords}
+ position="Bottom"
+ align="Start"
+ offset={4}
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: close,
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
+ isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
+ }}
+ >
+ <Menu>
+ <div style={{ padding: config.space.S100 }}>
+ <MenuItem
+ variant="Surface"
+ fill="None"
+ size="300"
+ radii="300"
+ onClick={() => {
+ copyToClipboard(userId);
+ setCopied();
+ close();
+ }}
+ >
+ <Text size="B300">Copy User ID</Text>
+ </MenuItem>
+ <MenuItem
+ variant="Surface"
+ fill="None"
+ size="300"
+ radii="300"
+ onClick={() => {
+ copyToClipboard(getMatrixToUser(userId));
+ setCopied();
+ close();
+ }}
+ >
+ <Text size="B300">Copy User Link</Text>
+ </MenuItem>
+ </div>
+ </Menu>
+ </FocusTrap>
+ }
+ >
+ <Chip
+ variant={copied ? 'Success' : 'SurfaceVariant'}
+ radii="Pill"
+ before={
+ cords ? (
+ <Icon size="50" src={Icons.ChevronBottom} />
+ ) : (
+ <Icon size="50" src={copied ? Icons.Check : Icons.Link} />
+ )
+ }
+ onClick={open}
+ aria-pressed={!!cords}
+ >
+ <Text size="B300" truncate>
+ Share
+ </Text>
+ </Chip>
+ </PopOut>
+ );
+}
+
+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<RectCords>();
+
+ const open: MouseEventHandler<HTMLButtonElement> = (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 (
+ <MenuItem
+ key={roomId}
+ variant="Surface"
+ fill="None"
+ size="300"
+ radii="300"
+ style={{ paddingLeft: config.space.S100 }}
+ onClick={() => {
+ if (room.isSpaceRoom()) {
+ navigateSpace(roomId);
+ } else {
+ navigateRoom(roomId);
+ }
+ closeUserRoomProfile();
+ }}
+ before={
+ <Avatar size="200" radii={dm ? '400' : '300'}>
+ {dm || room.isSpaceRoom() ? (
+ <RoomAvatar
+ roomId={room.roomId}
+ src={
+ dm
+ ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
+ : getRoomAvatarUrl(mx, room, 96, useAuthentication)
+ }
+ alt={room.name}
+ renderFallback={() => (
+ <Text as="span" size="H6">
+ {nameInitials(room.name)}
+ </Text>
+ )}
+ />
+ ) : (
+ <RoomIcon size="100" joinRule={room.getJoinRule()} />
+ )}
+ </Avatar>
+ }
+ >
+ <Text size="B300" truncate>
+ {room.name}
+ </Text>
+ </MenuItem>
+ );
+ };
+
+ return (
+ <PopOut
+ anchor={cords}
+ position="Bottom"
+ align="Start"
+ offset={4}
+ content={
+ mutualRoomsState.status === AsyncStatus.Success ? (
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: close,
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
+ isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
+ }}
+ >
+ <Menu
+ style={{
+ display: 'flex',
+ maxWidth: toRem(200),
+ maxHeight: '80vh',
+ }}
+ >
+ <Box grow="Yes">
+ <Scroll size="300" hideTrack>
+ <Box
+ direction="Column"
+ gap="400"
+ style={{ padding: config.space.S200, paddingRight: 0 }}
+ >
+ {mutual.spaces.length > 0 && (
+ <Box direction="Column" gap="100">
+ <Text style={{ paddingLeft: config.space.S100 }} size="L400">
+ Spaces
+ </Text>
+ {mutual.spaces.map(renderItem)}
+ </Box>
+ )}
+ {mutual.rooms.length > 0 && (
+ <Box direction="Column" gap="100">
+ <Text style={{ paddingLeft: config.space.S100 }} size="L400">
+ Rooms
+ </Text>
+ {mutual.rooms.map(renderItem)}
+ </Box>
+ )}
+ {mutual.directs.length > 0 && (
+ <Box direction="Column" gap="100">
+ <Text style={{ paddingLeft: config.space.S100 }} size="L400">
+ Direct Messages
+ </Text>
+ {mutual.directs.map(renderItem)}
+ </Box>
+ )}
+ </Box>
+ </Scroll>
+ </Box>
+ </Menu>
+ </FocusTrap>
+ ) : null
+ }
+ >
+ <Chip
+ variant="SurfaceVariant"
+ radii="Pill"
+ before={mutualRoomsState.status === AsyncStatus.Loading && <Spinner size="50" />}
+ disabled={
+ mutualRoomsState.status !== AsyncStatus.Success || mutualRoomsState.data.length === 0
+ }
+ onClick={open}
+ aria-pressed={!!cords}
+ >
+ <Text size="B300">
+ {mutualRoomsState.status === AsyncStatus.Success &&
+ `${mutualRoomsState.data.length} Mutual Rooms`}
+ {mutualRoomsState.status === AsyncStatus.Loading && 'Mutual Rooms'}
+ </Text>
+ </Chip>
+ </PopOut>
+ );
+}
+
+export function IgnoredUserAlert() {
+ return (
+ <CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
+ <SettingTile>
+ <Box direction="Column" gap="200">
+ <Box gap="200" justifyContent="SpaceBetween">
+ <Text size="L400">Blocked User</Text>
+ </Box>
+ <Box direction="Column">
+ <Text size="T200">You do not receive any messages or invites from this user.</Text>
+ </Box>
+ </Box>
+ </SettingTile>
+ </CutoutCard>
+ );
+}
+
+export function OptionsChip({ userId }: { userId: string }) {
+ const mx = useMatrixClient();
+ const [cords, setCords] = useState<RectCords>();
+
+ const open: MouseEventHandler<HTMLButtonElement> = (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 (
+ <PopOut
+ anchor={cords}
+ position="Bottom"
+ align="Start"
+ offset={4}
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: close,
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
+ isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
+ }}
+ >
+ <Menu>
+ <div style={{ padding: config.space.S100 }}>
+ <MenuItem
+ variant="Critical"
+ fill="None"
+ size="300"
+ radii="300"
+ onClick={() => {
+ toggleIgnore();
+ close();
+ }}
+ before={
+ ignoring ? (
+ <Spinner variant="Critical" size="50" />
+ ) : (
+ <Icon size="50" src={Icons.Prohibited} />
+ )
+ }
+ disabled={ignoring}
+ >
+ <Text size="B300">{ignored ? 'Unblock User' : 'Block User'}</Text>
+ </MenuItem>
+ </div>
+ </Menu>
+ </FocusTrap>
+ }
+ >
+ <Chip variant="SurfaceVariant" radii="Pill" onClick={open} aria-pressed={!!cords}>
+ {ignoring ? (
+ <Spinner variant="Secondary" size="50" />
+ ) : (
+ <Icon size="50" src={Icons.HorizontalDots} />
+ )}
+ </Chip>
+ </PopOut>
+ );
+}
--- /dev/null
+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 (
+ <Box direction="Column" className={css.UserHero}>
+ <div
+ className={css.UserHeroCoverContainer}
+ style={{
+ backgroundColor: colorMXID(userId),
+ filter: avatarUrl ? undefined : 'brightness(50%)',
+ }}
+ >
+ {avatarUrl && <img className={css.UserHeroCover} src={avatarUrl} alt={userId} />}
+ </div>
+ <div className={css.UserHeroAvatarContainer}>
+ <AvatarPresence
+ className={css.UserAvatarContainer}
+ badge={
+ presence && <PresenceBadge presence={presence.presence} status={presence.status} />
+ }
+ >
+ <Avatar className={css.UserHeroAvatar} size="500">
+ <UserAvatar
+ userId={userId}
+ src={avatarUrl}
+ alt={userId}
+ renderFallback={() => <Icon size="500" src={Icons.User} filled />}
+ />
+ </Avatar>
+ </AvatarPresence>
+ </div>
+ </Box>
+ );
+}
+
+type UserHeroNameProps = {
+ displayName?: string;
+ userId: string;
+};
+export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
+ const username = getMxIdLocalPart(userId);
+
+ return (
+ <Box grow="Yes" direction="Column" gap="0">
+ <Box alignItems="Baseline" gap="200" wrap="Wrap">
+ <Text
+ size="H4"
+ className={classNames(BreakWord, LineClamp3)}
+ title={displayName ?? username}
+ >
+ {displayName ?? username ?? userId}
+ </Text>
+ </Box>
+ <Box alignItems="Center" gap="100" wrap="Wrap">
+ <Text size="T200" className={classNames(BreakWord, LineClamp3)} title={username}>
+ @{username}
+ </Text>
+ </Box>
+ </Box>
+ );
+}
--- /dev/null
+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 (
+ <CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
+ <SettingTile>
+ <Box direction="Column" gap="200">
+ <Box gap="200" justifyContent="SpaceBetween">
+ <Text size="L400">Kicked User</Text>
+ {time && date && (
+ <Text size="T200">
+ {date} {time}
+ </Text>
+ )}
+ </Box>
+ <Box direction="Column">
+ {kickedBy && (
+ <Text size="T200">
+ Kicked by: <b>{kickedBy}</b>
+ </Text>
+ )}
+ <Text size="T200">
+ {reason ? (
+ <>
+ Reason: <b>{reason}</b>
+ </>
+ ) : (
+ <i>No Reason Provided.</i>
+ )}
+ </Text>
+ </Box>
+ </Box>
+ </SettingTile>
+ </CutoutCard>
+ );
+}
+
+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<undefined, Error, []>(
+ useCallback(async () => {
+ await mx.unban(room.roomId, userId);
+ }, [mx, room, userId])
+ );
+ const banning = unbanState.status === AsyncStatus.Loading;
+ const error = unbanState.status === AsyncStatus.Error;
+
+ return (
+ <CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
+ <SettingTile>
+ <Box direction="Column" gap="200">
+ <Box gap="200" justifyContent="SpaceBetween">
+ <Text size="L400">Banned User</Text>
+ {time && date && (
+ <Text size="T200">
+ {date} {time}
+ </Text>
+ )}
+ </Box>
+ <Box direction="Column">
+ {bannedBy && (
+ <Text size="T200">
+ Banned by: <b>{bannedBy}</b>
+ </Text>
+ )}
+ <Text size="T200">
+ {reason ? (
+ <>
+ Reason: <b>{reason}</b>
+ </>
+ ) : (
+ <i>No Reason Provided.</i>
+ )}
+ </Text>
+ </Box>
+ {error && (
+ <Text className={BreakWord} size="T200" style={{ color: color.Critical.Main }}>
+ <b>{unbanState.error.message}</b>
+ </Text>
+ )}
+ {canUnban && (
+ <Button
+ size="300"
+ variant="Critical"
+ radii="300"
+ onClick={unban}
+ before={banning && <Spinner size="100" variant="Critical" fill="Solid" />}
+ disabled={banning}
+ >
+ <Text size="B300">Unban</Text>
+ </Button>
+ )}
+ </Box>
+ </SettingTile>
+ </CutoutCard>
+ );
+}
+
+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<undefined, Error, []>(
+ useCallback(async () => {
+ await mx.kick(room.roomId, userId);
+ }, [mx, room, userId])
+ );
+ const kicking = kickState.status === AsyncStatus.Loading;
+ const error = kickState.status === AsyncStatus.Error;
+
+ return (
+ <CutoutCard style={{ padding: config.space.S200 }} variant="Success">
+ <SettingTile>
+ <Box direction="Column" gap="200">
+ <Box gap="200" justifyContent="SpaceBetween">
+ <Text size="L400">Invited User</Text>
+ {time && date && (
+ <Text size="T200">
+ {date} {time}
+ </Text>
+ )}
+ </Box>
+ <Box direction="Column">
+ {invitedBy && (
+ <Text size="T200">
+ Invited by: <b>{invitedBy}</b>
+ </Text>
+ )}
+ <Text size="T200">
+ {reason ? (
+ <>
+ Reason: <b>{reason}</b>
+ </>
+ ) : (
+ <i>No Reason Provided.</i>
+ )}
+ </Text>
+ </Box>
+ {error && (
+ <Text className={BreakWord} size="T200" style={{ color: color.Critical.Main }}>
+ <b>{kickState.error.message}</b>
+ </Text>
+ )}
+ {canKick && (
+ <Button
+ size="300"
+ variant="Success"
+ fill="Soft"
+ outlined
+ radii="300"
+ onClick={kick}
+ before={kicking && <Spinner size="100" variant="Success" fill="Soft" />}
+ disabled={kicking}
+ >
+ <Text size="B300">Cancel Invite</Text>
+ </Button>
+ )}
+ </Box>
+ </SettingTile>
+ </CutoutCard>
+ );
+}
+
+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<HTMLInputElement>(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<undefined, Error, []>(
+ useCallback(async () => {
+ await mx.kick(room.roomId, userId, getReason());
+ }, [mx, room, userId, getReason])
+ );
+
+ const [banState, ban] = useAsyncCallback<undefined, Error, []>(
+ useCallback(async () => {
+ await mx.ban(room.roomId, userId, getReason());
+ }, [mx, room, userId, getReason])
+ );
+
+ const [inviteState, invite] = useAsyncCallback<undefined, Error, []>(
+ 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 (
+ <Box direction="Column" gap="400">
+ <Box direction="Column" gap="200">
+ <Box grow="Yes" direction="Column" gap="100">
+ <Text size="L400">Moderation</Text>
+ <Input
+ ref={reasonInputRef}
+ placeholder="Reason"
+ size="300"
+ variant="Background"
+ radii="300"
+ disabled={disabled}
+ />
+ {kickState.status === AsyncStatus.Error && (
+ <Text style={{ color: color.Critical.Main }} className={BreakWord} size="T200">
+ <b>{kickState.error.message}</b>
+ </Text>
+ )}
+ {banState.status === AsyncStatus.Error && (
+ <Text style={{ color: color.Critical.Main }} className={BreakWord} size="T200">
+ <b>{banState.error.message}</b>
+ </Text>
+ )}
+ {inviteState.status === AsyncStatus.Error && (
+ <Text style={{ color: color.Critical.Main }} className={BreakWord} size="T200">
+ <b>{inviteState.error.message}</b>
+ </Text>
+ )}
+ </Box>
+ <Box shrink="No" gap="200">
+ {canInvite && (
+ <Button
+ style={{ flexGrow: 1 }}
+ size="300"
+ variant="Secondary"
+ fill="Soft"
+ radii="300"
+ before={
+ inviteState.status === AsyncStatus.Loading ? (
+ <Spinner size="50" variant="Secondary" fill="Soft" />
+ ) : (
+ <Icon size="50" src={Icons.ArrowRight} />
+ )
+ }
+ onClick={invite}
+ disabled={disabled}
+ >
+ <Text size="B300">Invite</Text>
+ </Button>
+ )}
+ {canKick && (
+ <Button
+ style={{ flexGrow: 1 }}
+ size="300"
+ variant="Critical"
+ fill="Soft"
+ radii="300"
+ before={
+ kickState.status === AsyncStatus.Loading ? (
+ <Spinner size="50" variant="Critical" fill="Soft" />
+ ) : (
+ <Icon size="50" src={Icons.ArrowLeft} />
+ )
+ }
+ onClick={kick}
+ disabled={disabled}
+ >
+ <Text size="B300">Kick</Text>
+ </Button>
+ )}
+ {canBan && (
+ <Button
+ style={{ flexGrow: 1 }}
+ size="300"
+ variant="Critical"
+ fill="Solid"
+ radii="300"
+ before={
+ banState.status === AsyncStatus.Loading ? (
+ <Spinner size="50" variant="Critical" fill="Solid" />
+ ) : (
+ <Icon size="50" src={Icons.Prohibited} />
+ )
+ }
+ onClick={ban}
+ disabled={disabled}
+ >
+ <Text size="B300">Ban</Text>
+ </Button>
+ )}
+ </Box>
+ </Box>
+ </Box>
+ );
+}
--- /dev/null
+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<string, Error, []>(
+ 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 (
+ <Box direction="Column">
+ <UserHero
+ userId={userId}
+ avatarUrl={avatarUrl}
+ presence={presence && presence.lastActiveTs !== 0 ? presence : undefined}
+ />
+ <Box direction="Column" gap="500" style={{ padding: config.space.S400 }}>
+ <Box direction="Column" gap="400">
+ <Box gap="400" alignItems="Start">
+ <UserHeroName displayName={displayName} userId={userId} />
+ <Box shrink="No">
+ <Button
+ size="300"
+ variant="Primary"
+ fill="Solid"
+ radii="300"
+ disabled={directMessageState.status === AsyncStatus.Loading}
+ before={
+ directMessageState.status === AsyncStatus.Loading ? (
+ <Spinner size="50" variant="Primary" fill="Solid" />
+ ) : (
+ <Icon size="50" src={Icons.Message} filled />
+ )
+ }
+ onClick={handleMessage}
+ >
+ <Text size="B300">Message</Text>
+ </Button>
+ </Box>
+ </Box>
+ {directMessageState.status === AsyncStatus.Error && (
+ <Text style={{ color: color.Critical.Main }}>
+ <b>{directMessageState.error.message}</b>
+ </Text>
+ )}
+ <Box alignItems="Center" gap="200" wrap="Wrap">
+ {server && <ServerChip server={server} />}
+ <ShareChip userId={userId} />
+ <PowerChip userId={userId} />
+ <MutualRoomsChip userId={userId} />
+ <OptionsChip userId={userId} />
+ </Box>
+ </Box>
+ {ignored && <IgnoredUserAlert />}
+ {member && membership === Membership.Ban && (
+ <UserBanAlert
+ userId={userId}
+ reason={member.events.member?.getContent().reason}
+ canUnban={canBan}
+ bannedBy={member.events.member?.getSender()}
+ ts={member.events.member?.getTs()}
+ />
+ )}
+ {member &&
+ membership === Membership.Leave &&
+ member.events.member &&
+ member.events.member.getSender() !== userId && (
+ <UserKickAlert
+ reason={member.events.member?.getContent().reason}
+ kickedBy={member.events.member?.getSender()}
+ ts={member.events.member?.getTs()}
+ />
+ )}
+ {member && membership === Membership.Invite && (
+ <UserInviteAlert
+ userId={userId}
+ reason={member.events.member?.getContent().reason}
+ canKick={canKick}
+ invitedBy={member.events.member?.getSender()}
+ ts={member.events.member?.getTs()}
+ />
+ )}
+ <UserModeration
+ userId={userId}
+ canInvite={canInvite && membership === Membership.Leave}
+ canKick={canKick && membership === Membership.Join}
+ canBan={canBan && membership !== Membership.Ban}
+ />
+ </Box>
+ </Box>
+ );
+}
--- /dev/null
+export * from './UserRoomProfile';
--- /dev/null
+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}`,
+});
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,
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,
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);
const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (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 (
<MemberTile
data-user-id={tagOrMember.userId}
onClick={handleMemberClick}
+ aria-pressed={profileUser?.userId === tagOrMember.userId}
mx={mx}
room={room}
member={tagOrMember}
import { useVirtualizer } from '@tanstack/react-virtual';
import classNames from 'classnames';
-import { openProfileViewer } from '../../../client/action/navigation';
import * as css from './MembersDrawer.css';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { UseStateProvider } from '../../components/UseStateProvider';
import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { MembershipFilterMenu } from '../../components/MembershipFilterMenu';
import { MemberSortMenu } from '../../components/MemberSortMenu';
+import { useOpenUserRoomProfile, useUserRoomProfileState } from '../../state/hooks/userRoomProfile';
+import { useSpaceOptionally } from '../../hooks/useSpace';
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000,
const [, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
const fetchingMembers = members.length < room.getJoinedMemberCount();
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
+ const openUserRoomProfile = useOpenUserRoomProfile();
+ const space = useSpaceOptionally();
+ const openProfileUserId = useUserRoomProfileState()?.userId;
const membershipFilterMenu = useMembershipFilterMenu();
const sortFilterMenu = useMemberSortMenu();
const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (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 (
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}
} 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';
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) => (
const { navigateRoom } = useRoomNavigate();
const mentionClickHandler = useMentionClickHandler(room.roomId);
const spoilerClickHandler = useSpoilerClickHandler();
+ const openUserRoomProfile = useOpenUserRoomProfile();
+ const space = useSpaceOptionally();
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
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<HTMLButtonElement> = useCallback(
(evt) => {
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;
relations,
useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], [])
);
+ const space = useSpaceOptionally();
+ const openProfile = useOpenUserRoomProfile();
const [selectedKey, setSelectedKey] = useState<string>(() => {
if (initialKey) return initialKey;
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 (
<MenuItem
key={senderId}
style={{ padding: `0 ${config.space.S200}` }}
radii="400"
- onClick={() => {
- requestClose();
- openProfileViewer(senderId, room.roomId);
+ onClick={(event) => {
+ openProfile(
+ room.roomId,
+ space?.roomId,
+ senderId,
+ event.currentTarget.getBoundingClientRect(),
+ 'Bottom'
+ );
}}
before={
<Avatar size="200">
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;
--- /dev/null
+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<Membership>(
+ () => (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;
+};
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<HTMLElement> => {
const mx = useMatrixClient();
const { navigateRoom, navigateSpace } = useRoomNavigate();
const navigate = useNavigate();
+ const openProfile = useOpenUserRoomProfile();
+ const space = useSpaceOptionally();
const handleClick: ReactEventHandler<HTMLElement> = useCallback(
(evt) => {
if (typeof mentionId !== 'string') return;
if (isUserId(mentionId)) {
- openProfileViewer(mentionId, roomId);
+ openProfile(roomId, space?.roomId, mentionId, target.getBoundingClientRect());
return;
}
navigate(viaServers ? withSearchParam<_RoomSearchParams>(path, { viaServers }) : path);
},
- [mx, navigate, navigateRoom, navigateSpace, roomId]
+ [mx, navigate, navigateRoom, navigateSpace, roomId, space, openProfile]
);
return handleClick;
--- /dev/null
+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<string[], unknown> => {
+ const mx = useMatrixClient();
+
+ const supported = useMutualRoomsSupport();
+
+ const [mutualRoomsState] = useAsyncCallbackValue(
+ useCallback(
+ () => (supported ? mx._unstable_getSharedRooms(userId) : Promise.resolve([])),
+ [mx, userId, supported]
+ )
+ );
+
+ return mutualRoomsState;
+};
--- /dev/null
+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<Presence, string> =>
+ useMemo(
+ () => ({
+ [Presence.Online]: 'Active',
+ [Presence.Unavailable]: 'Busy',
+ [Presence.Offline]: 'Away',
+ }),
+ []
+ );
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';
>
<Outlet />
</ClientLayout>
+ <UserRoomProfileRenderer />
<CreateRoomModalRenderer />
<CreateSpaceModalRenderer />
<RoomSettingsRenderer />
--- /dev/null
+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;
+};
--- /dev/null
+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<UserRoomProfileState | undefined>(undefined);