Redesign user profile view (#2396)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Sat, 9 Aug 2025 12:16:10 +0000 (17:46 +0530)
committerGitHub <noreply@github.com>
Sat, 9 Aug 2025 12:16:10 +0000 (22:16 +1000)
* WIP - new profile view

* render common rooms in user profile

* add presence component

* WIP - room user profile

* temp hide profile button

* show mutual rooms in spaces, rooms and direct messages categories

* add message button

* add option to change user powers in profile

* improve ban info and option to unban

* add share user button in user profile

* add option to block user in user profile

* improve blocked user alert body

* add moderation tool in user profile

* open profile view on left side in member drawer

* open new user profile in all places

25 files changed:
src/app/components/UserRoomProfileRenderer.tsx [new file with mode: 0644]
src/app/components/event-readers/EventReaders.tsx
src/app/components/message/layout/layout.css.ts
src/app/components/presence/Presence.tsx [new file with mode: 0644]
src/app/components/presence/index.ts [new file with mode: 0644]
src/app/components/presence/styles.css.ts [new file with mode: 0644]
src/app/components/user-profile/PowerChip.tsx [new file with mode: 0644]
src/app/components/user-profile/UserChips.tsx [new file with mode: 0644]
src/app/components/user-profile/UserHero.tsx [new file with mode: 0644]
src/app/components/user-profile/UserModeration.tsx [new file with mode: 0644]
src/app/components/user-profile/UserRoomProfile.tsx [new file with mode: 0644]
src/app/components/user-profile/index.ts [new file with mode: 0644]
src/app/components/user-profile/styles.css.ts [new file with mode: 0644]
src/app/features/common-settings/members/Members.tsx
src/app/features/room/MembersDrawer.tsx
src/app/features/room/RoomTimeline.tsx
src/app/features/room/reaction-viewer/ReactionViewer.tsx
src/app/features/space-settings/SpaceSettingsRenderer.tsx
src/app/hooks/useMembership.ts [new file with mode: 0644]
src/app/hooks/useMentionClickHandler.ts
src/app/hooks/useMutualRooms.ts [new file with mode: 0644]
src/app/hooks/useUserPresence.ts [new file with mode: 0644]
src/app/pages/Router.tsx
src/app/state/hooks/userRoomProfile.ts [new file with mode: 0644]
src/app/state/userRoomProfile.ts [new file with mode: 0644]

diff --git a/src/app/components/UserRoomProfileRenderer.tsx b/src/app/components/UserRoomProfileRenderer.tsx
new file mode 100644 (file)
index 0000000..ca7aa83
--- /dev/null
@@ -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 (
+    <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} />;
+}
index de1416b6b113e1b126aba9d57fe029e0b840e0bd..75fdf0da844d57c1070ea9725977d383a8fd3288 100644 (file)
@@ -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>(
             <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">
index a9b3f35fd6281523f87caf611f61c390caa0d8af..43949cefcbc8296e982cea23dbf6bed96d770e33 100644 (file)
@@ -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 (file)
index 0000000..108852f
--- /dev/null
@@ -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, 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>
+  )
+);
diff --git a/src/app/components/presence/index.ts b/src/app/components/presence/index.ts
new file mode 100644 (file)
index 0000000..88fcdf7
--- /dev/null
@@ -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 (file)
index 0000000..12ea7f1
--- /dev/null
@@ -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 (file)
index 0000000..78f539a
--- /dev/null
@@ -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 (
+    <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}
+    </>
+  );
+}
diff --git a/src/app/components/user-profile/UserChips.tsx b/src/app/components/user-profile/UserChips.tsx
new file mode 100644 (file)
index 0000000..53e6618
--- /dev/null
@@ -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<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>
+  );
+}
diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx
new file mode 100644 (file)
index 0000000..cf4c815
--- /dev/null
@@ -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 (
+    <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>
+  );
+}
diff --git a/src/app/components/user-profile/UserModeration.tsx b/src/app/components/user-profile/UserModeration.tsx
new file mode 100644 (file)
index 0000000..814bb5b
--- /dev/null
@@ -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 (
+    <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>
+  );
+}
diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx
new file mode 100644 (file)
index 0000000..ad23fef
--- /dev/null
@@ -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<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>
+  );
+}
diff --git a/src/app/components/user-profile/index.ts b/src/app/components/user-profile/index.ts
new file mode 100644 (file)
index 0000000..54542c7
--- /dev/null
@@ -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 (file)
index 0000000..ad6d5a9
--- /dev/null
@@ -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}`,
+});
index 8d7f89fd927c3e253570dee80f9f0c35b4ca6a0f..dc802a1c1ea5c5668808cec57b4fd43d1469de6e 100644 (file)
@@ -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<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 (
@@ -317,6 +325,7 @@ export function Members({ requestClose }: MembersProps) {
                           <MemberTile
                             data-user-id={tagOrMember.userId}
                             onClick={handleMemberClick}
+                            aria-pressed={profileUser?.userId === tagOrMember.userId}
                             mx={mx}
                             room={room}
                             member={tagOrMember}
index 5edb4f2bf4b2c2d6069caad3772ebaeca58d65e5..bdb0eb3df629abbd24cb6e87d0db67b72c7d5e2f 100644 (file)
@@ -30,7 +30,6 @@ import { Room, RoomMember } from 'matrix-js-sdk';
 import { useVirtualizer } from '@tanstack/react-virtual';
 import classNames from 'classnames';
 
-import { openProfileViewer } from '../../../client/action/navigation';
 import * as css from './MembersDrawer.css';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { UseStateProvider } from '../../components/UseStateProvider';
@@ -56,6 +55,8 @@ import { useMemberSort, useMemberSortMenu } from '../../hooks/useMemberSort';
 import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
 import { MembershipFilterMenu } from '../../components/MembershipFilterMenu';
 import { MemberSortMenu } from '../../components/MemberSortMenu';
+import { useOpenUserRoomProfile, useUserRoomProfileState } from '../../state/hooks/userRoomProfile';
+import { useSpaceOptionally } from '../../hooks/useSpace';
 
 const SEARCH_OPTIONS: UseAsyncSearchOptions = {
   limit: 1000,
@@ -82,6 +83,9 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
   const [, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
   const fetchingMembers = members.length < room.getJoinedMemberCount();
   const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
+  const openUserRoomProfile = useOpenUserRoomProfile();
+  const space = useSpaceOptionally();
+  const openProfileUserId = useUserRoomProfileState()?.userId;
 
   const membershipFilterMenu = useMembershipFilterMenu();
   const sortFilterMenu = useMemberSortMenu();
@@ -142,7 +146,8 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
   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 (
@@ -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}
index 244eb327367061f249ee94e13a480a4c9f7bd81d..90f09012be82055d2ab93f962891dd31693fcfcc 100644 (file)
@@ -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<HTMLButtonElement> = useCallback(
     (evt) => {
index d4b39845f931e051597d88f49bbe140f3a7666a7..0e7ca833b824b63dcc8a329a9345fa92c5a47f5b 100644 (file)
@@ -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<string>(() => {
       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 (
                     <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">
index 085c5e2b54693e0abc29aa72672958619e1c0196..79947e0cacbbff0c71297cdbda54cc7ac82d74ab 100644 (file)
@@ -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 (file)
index 0000000..dbdd527
--- /dev/null
@@ -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<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;
+};
index 49c291c72eed863ea7ed769d2712e742f61489ae..9dc81d4e3ccb8c7badbd28c7e553d3c28e7ae760 100644 (file)
@@ -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<HTMLElement> => {
   const mx = useMatrixClient();
   const { navigateRoom, navigateSpace } = useRoomNavigate();
   const navigate = useNavigate();
+  const openProfile = useOpenUserRoomProfile();
+  const space = useSpaceOptionally();
 
   const handleClick: ReactEventHandler<HTMLElement> = useCallback(
     (evt) => {
@@ -21,7 +24,7 @@ export const useMentionClickHandler = (roomId: string): ReactEventHandler<HTMLEl
       if (typeof mentionId !== 'string') return;
 
       if (isUserId(mentionId)) {
-        openProfileViewer(mentionId, roomId);
+        openProfile(roomId, space?.roomId, mentionId, target.getBoundingClientRect());
         return;
       }
 
@@ -37,7 +40,7 @@ export const useMentionClickHandler = (roomId: string): ReactEventHandler<HTMLEl
 
       navigate(viaServers ? withSearchParam<_RoomSearchParams>(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 (file)
index 0000000..a7b3893
--- /dev/null
@@ -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<string[], unknown> => {
+  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 (file)
index 0000000..5137950
--- /dev/null
@@ -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<Presence, string> =>
+  useMemo(
+    () => ({
+      [Presence.Online]: 'Active',
+      [Presence.Unavailable]: 'Busy',
+      [Presence.Offline]: 'Away',
+    }),
+    []
+  );
index d6d93aa4ce12efe1bf524d7d7aedf2ef03706210..247885c0fb55d535eb5489130f96c082349a3102 100644 (file)
@@ -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)
                       >
                         <Outlet />
                       </ClientLayout>
+                      <UserRoomProfileRenderer />
                       <CreateRoomModalRenderer />
                       <CreateSpaceModalRenderer />
                       <RoomSettingsRenderer />
diff --git a/src/app/state/hooks/userRoomProfile.ts b/src/app/state/hooks/userRoomProfile.ts
new file mode 100644 (file)
index 0000000..7fed0c0
--- /dev/null
@@ -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 (file)
index 0000000..cf4e403
--- /dev/null
@@ -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<UserRoomProfileState | undefined>(undefined);