New invite user to room dialog (#2460)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Sun, 24 Aug 2025 12:34:21 +0000 (18:04 +0530)
committerGitHub <noreply@github.com>
Sun, 24 Aug 2025 12:34:21 +0000 (22:34 +1000)
* fix 0 displayed in invite with no timestamp

* support displaying invite reason for receiver

* show invite reason as compact message

* remove unused import

* revert: show invite reason as compact message

* remove unused import

* add new invite prompt

src/app/components/invite-user-prompt/InviteUserPrompt.tsx [new file with mode: 0644]
src/app/components/invite-user-prompt/index.ts [new file with mode: 0644]
src/app/components/room-intro/RoomIntro.tsx
src/app/features/lobby/HierarchyItemMenu.tsx
src/app/features/lobby/LobbyHeader.tsx
src/app/features/room-nav/RoomNavItem.tsx
src/app/features/room/RoomViewHeader.tsx
src/app/pages/client/inbox/Invites.tsx
src/app/pages/client/sidebar/SpaceTabs.tsx
src/app/pages/client/space/Space.tsx

diff --git a/src/app/components/invite-user-prompt/InviteUserPrompt.tsx b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx
new file mode 100644 (file)
index 0000000..82313c3
--- /dev/null
@@ -0,0 +1,291 @@
+import React, {
+  ChangeEventHandler,
+  FormEventHandler,
+  KeyboardEventHandler,
+  useCallback,
+  useMemo,
+  useRef,
+  useState,
+} from 'react';
+import {
+  Overlay,
+  OverlayBackdrop,
+  OverlayCenter,
+  Box,
+  Header,
+  config,
+  Text,
+  IconButton,
+  Icon,
+  Icons,
+  Input,
+  Button,
+  Spinner,
+  color,
+  TextArea,
+  Dialog,
+  Menu,
+  toRem,
+  Scroll,
+  MenuItem,
+} from 'folds';
+import { Room } from 'matrix-js-sdk';
+import { isKeyHotkey } from 'is-hotkey';
+import FocusTrap from 'focus-trap-react';
+import { stopPropagation } from '../../utils/keyboard';
+import { useDirectUsers } from '../../hooks/useDirectUsers';
+import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../utils/matrix';
+import { Membership } from '../../../types/matrix/room';
+import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
+import { highlightText, makeHighlightRegex } from '../../plugins/react-custom-html-parser';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { BreakWord } from '../../styles/Text.css';
+import { useAlive } from '../../hooks/useAlive';
+
+const SEARCH_OPTIONS: UseAsyncSearchOptions = {
+  limit: 1000,
+  matchOptions: {
+    contain: true,
+  },
+};
+const getUserIdString = (userId: string) => getMxIdLocalPart(userId) ?? userId;
+
+type InviteUserProps = {
+  room: Room;
+  requestClose: () => void;
+};
+export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
+  const mx = useMatrixClient();
+  const alive = useAlive();
+
+  const inputRef = useRef<HTMLInputElement>(null);
+  const directUsers = useDirectUsers();
+  const [validUserId, setValidUserId] = useState<string>();
+
+  const filteredUsers = useMemo(
+    () =>
+      directUsers.filter((userId) => {
+        const membership = room.getMember(userId)?.membership;
+        return membership !== Membership.Join;
+      }),
+    [directUsers, room]
+  );
+  const [result, search, resetSearch] = useAsyncSearch(
+    filteredUsers,
+    getUserIdString,
+    SEARCH_OPTIONS
+  );
+  const queryHighlighRegex = result?.query
+    ? makeHighlightRegex(result.query.split(' '))
+    : undefined;
+
+  const [inviteState, invite] = useAsyncCallback<void, Error, [string, string | undefined]>(
+    useCallback(
+      async (userId, reason) => {
+        await mx.invite(room.roomId, userId, reason);
+      },
+      [mx, room]
+    )
+  );
+
+  const inviting = inviteState.status === AsyncStatus.Loading;
+
+  const handleReset = () => {
+    if (inputRef.current) inputRef.current.value = '';
+    setValidUserId(undefined);
+    resetSearch();
+  };
+
+  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+    evt.preventDefault();
+    const target = evt.target as HTMLFormElement | undefined;
+
+    if (inviting || !validUserId) return;
+
+    const reasonInput = target?.reasonInput as HTMLTextAreaElement | undefined;
+    const reason = reasonInput?.value.trim();
+
+    invite(validUserId, reason || undefined).then(() => {
+      if (alive()) {
+        handleReset();
+        if (reasonInput) reasonInput.value = '';
+      }
+    });
+  };
+
+  const handleSearchChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
+    const value = evt.currentTarget.value.trim();
+    if (isUserId(value)) {
+      setValidUserId(value);
+    } else {
+      setValidUserId(undefined);
+      const term = getMxIdLocalPart(value) ?? (value.startsWith('@') ? value.slice(1) : value);
+      if (term) {
+        search(term);
+      } else {
+        resetSearch();
+      }
+    }
+  };
+
+  const handleUserId = (userId: string) => {
+    if (inputRef.current) {
+      inputRef.current.value = userId;
+      setValidUserId(userId);
+      resetSearch();
+      inputRef.current.focus();
+    }
+  };
+
+  const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
+    if (isKeyHotkey('escape', evt)) {
+      resetSearch();
+      return;
+    }
+    if (isKeyHotkey('tab', evt) && result && result.items.length > 0) {
+      evt.preventDefault();
+      const userId = result.items[0];
+      handleUserId(userId);
+    }
+  };
+
+  return (
+    <Overlay open backdrop={<OverlayBackdrop />}>
+      <OverlayCenter>
+        <FocusTrap
+          focusTrapOptions={{
+            initialFocus: () => inputRef.current,
+            clickOutsideDeactivates: true,
+            onDeactivate: requestClose,
+            escapeDeactivates: stopPropagation,
+          }}
+        >
+          <Dialog>
+            <Box grow="Yes" direction="Column">
+              <Header
+                size="500"
+                style={{ padding: `0 ${config.space.S200} 0 ${config.space.S400}` }}
+              >
+                <Box grow="Yes">
+                  <Text size="H4" truncate>
+                    Invite
+                  </Text>
+                </Box>
+                <Box shrink="No">
+                  <IconButton size="300" radii="300" onClick={requestClose}>
+                    <Icon src={Icons.Cross} />
+                  </IconButton>
+                </Box>
+              </Header>
+              <Box
+                as="form"
+                onSubmit={handleSubmit}
+                shrink="No"
+                style={{ padding: config.space.S400 }}
+                direction="Column"
+                gap="400"
+              >
+                <Box direction="Column" gap="100">
+                  <Text size="L400">User ID</Text>
+                  <div>
+                    <Input
+                      size="500"
+                      ref={inputRef}
+                      onChange={handleSearchChange}
+                      onKeyDown={handleKeyDown}
+                      placeholder="@john:server"
+                      name="userIdInput"
+                      variant="Background"
+                      disabled={inviting}
+                      autoComplete="off"
+                      required
+                    />
+                    {result && result.items.length > 0 && (
+                      <FocusTrap
+                        focusTrapOptions={{
+                          initialFocus: false,
+                          onDeactivate: resetSearch,
+                          returnFocusOnDeactivate: false,
+                          clickOutsideDeactivates: true,
+                          allowOutsideClick: true,
+                          isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
+                          isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
+                          escapeDeactivates: stopPropagation,
+                        }}
+                      >
+                        <Box style={{ position: 'relative' }}>
+                          <Menu style={{ position: 'absolute', top: 0, zIndex: 1, width: '100%' }}>
+                            <Scroll size="300" style={{ maxHeight: toRem(100) }}>
+                              <div style={{ padding: config.space.S100 }}>
+                                {result.items.map((userId) => {
+                                  const username = `${getMxIdLocalPart(userId)}`;
+                                  const userServer = getMxIdServer(userId);
+
+                                  return (
+                                    <MenuItem
+                                      key={userId}
+                                      type="button"
+                                      size="300"
+                                      variant="Surface"
+                                      radii="300"
+                                      onClick={() => handleUserId(userId)}
+                                      after={
+                                        <Text size="T200" truncate>
+                                          {userServer}
+                                        </Text>
+                                      }
+                                      disabled={inviting}
+                                    >
+                                      <Box grow="Yes">
+                                        <Text size="T300" truncate>
+                                          <b>
+                                            {queryHighlighRegex
+                                              ? highlightText(queryHighlighRegex, [
+                                                  username ?? userId,
+                                                ])
+                                              : username}
+                                          </b>
+                                        </Text>
+                                      </Box>
+                                    </MenuItem>
+                                  );
+                                })}
+                              </div>
+                            </Scroll>
+                          </Menu>
+                        </Box>
+                      </FocusTrap>
+                    )}
+                  </div>
+                </Box>
+                <Box direction="Column" gap="100">
+                  <Text size="L400">Reason (Optional)</Text>
+                  <TextArea
+                    size="500"
+                    name="reasonInput"
+                    variant="Background"
+                    rows={4}
+                    resize="None"
+                  />
+                </Box>
+                {inviteState.status === AsyncStatus.Error && (
+                  <Text size="T200" style={{ color: color.Critical.Main }} className={BreakWord}>
+                    <b>{inviteState.error.message}</b>
+                  </Text>
+                )}
+                <Button
+                  type="submit"
+                  disabled={!validUserId || inviting}
+                  before={inviting && <Spinner size="200" variant="Primary" fill="Solid" />}
+                >
+                  <Text size="B400">Invite</Text>
+                </Button>
+              </Box>
+            </Box>
+          </Dialog>
+        </FocusTrap>
+      </OverlayCenter>
+    </Overlay>
+  );
+}
diff --git a/src/app/components/invite-user-prompt/index.ts b/src/app/components/invite-user-prompt/index.ts
new file mode 100644 (file)
index 0000000..156810b
--- /dev/null
@@ -0,0 +1 @@
+export * from './InviteUserPrompt';
index c388efd460f41aa0dab40efa2fd7507dfe60e214..ce550992da5944735be04615493e5a9692bf114e 100644 (file)
@@ -1,8 +1,7 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useState } from 'react';
 import { Avatar, Box, Button, Spinner, Text, as } from 'folds';
 import { Room } from 'matrix-js-sdk';
 import { useAtomValue } from 'jotai';
-import { openInviteUser } from '../../../client/action/navigation';
 import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
 import { getMemberDisplayName, getStateEvent } from '../../utils/room';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -17,6 +16,7 @@ import { mDirectAtom } from '../../state/mDirectList';
 import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 import { useSetting } from '../../state/hooks/settings';
 import { settingsAtom } from '../../state/settings';
+import { InviteUserPrompt } from '../invite-user-prompt';
 
 export type RoomIntroProps = {
   room: Room;
@@ -27,6 +27,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
   const useAuthentication = useMediaAuthentication();
   const { navigateRoom } = useRoomNavigate();
   const mDirects = useAtomValue(mDirectAtom);
+  const [invitePrompt, setInvitePrompt] = useState(false);
 
   const createEvent = getStateEvent(room, StateEvent.RoomCreate);
   const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
@@ -76,14 +77,13 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
           )}
         </Box>
         <Box gap="200" wrap="Wrap">
-          <Button
-            onClick={() => openInviteUser(room.roomId)}
-            variant="Secondary"
-            size="300"
-            radii="300"
-          >
+          <Button onClick={() => setInvitePrompt(true)} variant="Secondary" size="300" radii="300">
             <Text size="B300">Invite Member</Text>
           </Button>
+
+          {invitePrompt && (
+            <InviteUserPrompt room={room} requestClose={() => setInvitePrompt(false)} />
+          )}
           {typeof prevRoomId === 'string' &&
             (mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? (
               <Button
index ccbbe179d3c2cd307ede9682e296d0ccf33d7d2d..c6aff741d6d6f3c09168e88caaa9f398499bbb92 100644 (file)
@@ -18,7 +18,6 @@ import {
 import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room';
-import { openInviteUser } from '../../../client/action/navigation';
 import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 import { UseStateProvider } from '../../components/UseStateProvider';
 import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
@@ -30,6 +29,7 @@ import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
 import { IPowerLevels } from '../../hooks/usePowerLevels';
 import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
 import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
+import { InviteUserPrompt } from '../../components/invite-user-prompt';
 
 type HierarchyItemWithParent = HierarchyItem & {
   parentId: string;
@@ -126,24 +126,39 @@ function InviteMenuItem({
   requestClose: () => void;
   disabled?: boolean;
 }) {
+  const mx = useMatrixClient();
+  const room = mx.getRoom(item.roomId);
+  const [invitePrompt, setInvitePrompt] = useState(false);
+
   const handleInvite = () => {
-    openInviteUser(item.roomId);
-    requestClose();
+    setInvitePrompt(true);
   };
 
   return (
-    <MenuItem
-      onClick={handleInvite}
-      size="300"
-      radii="300"
-      variant="Primary"
-      fill="None"
-      disabled={disabled}
-    >
-      <Text as="span" size="T300" truncate>
-        Invite
-      </Text>
-    </MenuItem>
+    <>
+      <MenuItem
+        onClick={handleInvite}
+        size="300"
+        radii="300"
+        variant="Primary"
+        fill="None"
+        aria-pressed={invitePrompt}
+        disabled={disabled || !room}
+      >
+        <Text as="span" size="T300" truncate>
+          Invite
+        </Text>
+      </MenuItem>
+      {invitePrompt && room && (
+        <InviteUserPrompt
+          room={room}
+          requestClose={() => {
+            setInvitePrompt(false);
+            requestClose();
+          }}
+        />
+      )}
+    </>
   );
 }
 
index 77287123c1d0a40e9ef2c7ea7abfd5f185e78ea4..a0c4d3ab420de025903f046003f02747de3d8298 100644 (file)
@@ -26,7 +26,6 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { RoomAvatar } from '../../components/room-avatar';
 import { nameInitials } from '../../utils/common';
 import * as css from './LobbyHeader.css';
-import { openInviteUser } from '../../../client/action/navigation';
 import { IPowerLevels } from '../../hooks/usePowerLevels';
 import { UseStateProvider } from '../../components/UseStateProvider';
 import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
@@ -38,6 +37,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
 import { useRoomCreators } from '../../hooks/useRoomCreators';
 import { useRoomPermissions } from '../../hooks/useRoomPermissions';
+import { InviteUserPrompt } from '../../components/invite-user-prompt';
 
 type LobbyMenuProps = {
   powerLevels: IPowerLevels;
@@ -53,9 +53,10 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
     const canInvite = permissions.action('invite', mx.getSafeUserId());
     const openSpaceSettings = useOpenSpaceSettings();
 
+    const [invitePrompt, setInvitePrompt] = useState(false);
+
     const handleInvite = () => {
-      openInviteUser(space.roomId);
-      requestClose();
+      setInvitePrompt(true);
     };
 
     const handleRoomSettings = () => {
@@ -65,6 +66,15 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
 
     return (
       <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
+        {invitePrompt && (
+          <InviteUserPrompt
+            room={space}
+            requestClose={() => {
+              setInvitePrompt(false);
+              requestClose();
+            }}
+          />
+        )}
         <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
           <MenuItem
             onClick={handleInvite}
@@ -73,6 +83,7 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
             size="300"
             after={<Icon size="100" src={Icons.UserPlus} />}
             radii="300"
+            aria-pressed={invitePrompt}
             disabled={!canInvite}
           >
             <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
index ee8b678718df06687eea0ac124dff410397cc318..7eac21dae744cfa2ba30b199d2f1baefad72626d 100644 (file)
@@ -30,7 +30,6 @@ import { roomToUnreadAtom } from '../../state/room/roomToUnread';
 import { usePowerLevels } from '../../hooks/usePowerLevels';
 import { copyToClipboard } from '../../utils/dom';
 import { markAsRead } from '../../../client/action/notifications';
-import { openInviteUser } from '../../../client/action/navigation';
 import { UseStateProvider } from '../../components/UseStateProvider';
 import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
 import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
@@ -51,6 +50,7 @@ import {
 import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
 import { useRoomCreators } from '../../hooks/useRoomCreators';
 import { useRoomPermissions } from '../../hooks/useRoomPermissions';
+import { InviteUserPrompt } from '../../components/invite-user-prompt';
 
 type RoomNavItemMenuProps = {
   room: Room;
@@ -70,14 +70,15 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
     const openRoomSettings = useOpenRoomSettings();
     const space = useSpaceOptionally();
 
+    const [invitePrompt, setInvitePrompt] = useState(false);
+
     const handleMarkAsRead = () => {
       markAsRead(mx, room.roomId, hideActivity);
       requestClose();
     };
 
     const handleInvite = () => {
-      openInviteUser(room.roomId);
-      requestClose();
+      setInvitePrompt(true);
     };
 
     const handleCopyLink = () => {
@@ -94,6 +95,15 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
 
     return (
       <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
+        {invitePrompt && room && (
+          <InviteUserPrompt
+            room={room}
+            requestClose={() => {
+              setInvitePrompt(false);
+              requestClose();
+            }}
+          />
+        )}
         <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
           <MenuItem
             onClick={handleMarkAsRead}
@@ -137,6 +147,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
             size="300"
             after={<Icon size="100" src={Icons.UserPlus} />}
             radii="300"
+            aria-pressed={invitePrompt}
             disabled={!canInvite}
           >
             <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
index 291c21c0a6cbd93ec5023d8bbebfa7ff6cc80d9f..d2f65fcb21c80c086f104eb81c5150e1848c98a5 100644 (file)
@@ -45,7 +45,6 @@ import { useRoomUnread } from '../../state/hooks/unread';
 import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
 import { markAsRead } from '../../../client/action/notifications';
 import { roomToUnreadAtom } from '../../state/room/roomToUnread';
-import { openInviteUser } from '../../../client/action/navigation';
 import { copyToClipboard } from '../../utils/dom';
 import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
 import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
@@ -69,6 +68,7 @@ import { JumpToTime } from './jump-to-time';
 import { useRoomNavigate } from '../../hooks/useRoomNavigate';
 import { useRoomCreators } from '../../hooks/useRoomCreators';
 import { useRoomPermissions } from '../../hooks/useRoomPermissions';
+import { InviteUserPrompt } from '../../components/invite-user-prompt';
 
 type RoomMenuProps = {
   room: Room;
@@ -87,14 +87,15 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
   const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
   const { navigateRoom } = useRoomNavigate();
 
+  const [invitePrompt, setInvitePrompt] = useState(false);
+
   const handleMarkAsRead = () => {
     markAsRead(mx, room.roomId, hideActivity);
     requestClose();
   };
 
   const handleInvite = () => {
-    openInviteUser(room.roomId);
-    requestClose();
+    setInvitePrompt(true);
   };
 
   const handleCopyLink = () => {
@@ -113,6 +114,15 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
 
   return (
     <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
+      {invitePrompt && (
+        <InviteUserPrompt
+          room={room}
+          requestClose={() => {
+            setInvitePrompt(false);
+            requestClose();
+          }}
+        />
+      )}
       <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
         <MenuItem
           onClick={handleMarkAsRead}
@@ -156,6 +166,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
           size="300"
           after={<Icon size="100" src={Icons.UserPlus} />}
           radii="300"
+          aria-pressed={invitePrompt}
           disabled={!canInvite}
         >
           <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
index bd9b694d166b3596ad8f28af39f31c2b6bd38d68..20518e568605f1ce62f6fce7fefb555753eb9f54 100644 (file)
@@ -81,6 +81,7 @@ type InviteData = {
   senderId: string;
   senderName: string;
   inviteTs?: number;
+  reason?: string;
 
   isSpace: boolean;
   isDirect: boolean;
@@ -102,11 +103,17 @@ const makeInviteData = (mx: MatrixClient, room: Room, useAuthentication: boolean
   const member = room.getMember(userId);
   const memberEvent = member?.events.member;
 
+  const content = memberEvent?.getContent();
   const senderId = memberEvent?.getSender();
+
   const senderName = senderId
     ? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId
     : undefined;
-  const inviteTs = memberEvent?.getTs() ?? 0;
+  const inviteTs = memberEvent?.getTs();
+  const reason =
+    content && 'reason' in content && typeof content.reason === 'string'
+      ? content.reason
+      : undefined;
 
   return {
     room,
@@ -119,6 +126,7 @@ const makeInviteData = (mx: MatrixClient, room: Room, useAuthentication: boolean
     senderId: senderId ?? 'Unknown',
     senderName: senderName ?? 'Unknown',
     inviteTs,
+    reason,
 
     isSpace: isSpace(room),
     isDirect: direct,
@@ -130,7 +138,8 @@ const hasBadWords = (invite: InviteData): boolean =>
   testBadWords(invite.roomName) ||
   testBadWords(invite.roomTopic ?? '') ||
   testBadWords(invite.senderName) ||
-  testBadWords(invite.senderId);
+  testBadWords(invite.senderId) ||
+  testBadWords(invite.reason || '');
 
 type NavigateHandler = (roomId: string, space: boolean) => void;
 
@@ -184,7 +193,7 @@ function InviteCard({
       variant="SurfaceVariant"
       direction="Column"
       gap="300"
-      style={{ padding: `${config.space.S400} ${config.space.S400} ${config.space.S200}` }}
+      style={{ padding: config.space.S400 }}
     >
       {(invite.isEncrypted || invite.isDirect || invite.isSpace) && (
         <Box gap="200" alignItems="Center">
@@ -298,22 +307,29 @@ function InviteCard({
           </Box>
         </Box>
       </Box>
-      <Box gap="200" alignItems="Baseline">
-        <Box grow="Yes">
+      <Box direction="Column">
+        <Box gap="200" alignItems="Baseline">
+          <Box grow="Yes">
+            <Text size="T200" priority="300">
+              From: <b>{invite.senderId}</b>
+            </Text>
+          </Box>
+          {typeof invite.inviteTs === 'number' && invite.inviteTs !== 0 && (
+            <Box shrink="No">
+              <Time
+                size="T200"
+                ts={invite.inviteTs}
+                hour24Clock={hour24Clock}
+                dateFormatString={dateFormatString}
+                priority="300"
+              />
+            </Box>
+          )}
+        </Box>
+        {invite.reason && (
           <Text size="T200" priority="300">
-            From: <b>{invite.senderId}</b>
+            Reason: {invite.reason}
           </Text>
-        </Box>
-        {invite.inviteTs && (
-          <Box shrink="No">
-            <Time
-              size="T200"
-              ts={invite.inviteTs}
-              hour24Clock={hour24Clock}
-              dateFormatString={dateFormatString}
-              priority="300"
-            />
-          </Box>
         )}
       </Box>
     </SequenceCard>
index 3ee6c725e52c5b15198f271379b2d321dbafde61..c06fab92d0029eebeb41b5c440db93d8c5c4ff88 100644 (file)
@@ -82,7 +82,6 @@ import { useRoomsUnread } from '../../../state/hooks/unread';
 import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
 import { markAsRead } from '../../../../client/action/notifications';
 import { copyToClipboard } from '../../../utils/dom';
-import { openInviteUser } from '../../../../client/action/navigation';
 import { stopPropagation } from '../../../utils/keyboard';
 import { getMatrixToRoom } from '../../../plugins/matrix-to';
 import { getViaServers } from '../../../plugins/via-servers';
@@ -93,6 +92,7 @@ import { settingsAtom } from '../../../state/settings';
 import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
 import { useRoomCreators } from '../../../hooks/useRoomCreators';
 import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
+import { InviteUserPrompt } from '../../../components/invite-user-prompt';
 
 type SpaceMenuProps = {
   room: Room;
@@ -111,6 +111,8 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
     const canInvite = permissions.action('invite', mx.getSafeUserId());
     const openSpaceSettings = useOpenSpaceSettings();
 
+    const [invitePrompt, setInvitePrompt] = useState(false);
+
     const allChild = useSpaceChildren(
       allRoomsAtom,
       room.roomId,
@@ -136,8 +138,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
     };
 
     const handleInvite = () => {
-      openInviteUser(room.roomId);
-      requestClose();
+      setInvitePrompt(true);
     };
 
     const handleRoomSettings = () => {
@@ -147,6 +148,15 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
 
     return (
       <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
+        {invitePrompt && room && (
+          <InviteUserPrompt
+            room={room}
+            requestClose={() => {
+              setInvitePrompt(false);
+              requestClose();
+            }}
+          />
+        )}
         <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
           <MenuItem
             onClick={handleMarkAsRead}
@@ -181,6 +191,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
             size="300"
             after={<Icon size="100" src={Icons.UserPlus} />}
             radii="300"
+            aria-pressed={invitePrompt}
             disabled={!canInvite}
           >
             <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
index a335a0ad5db16344208579964d85b5e7d7148a57..fba3de507bad132fd2e671de5ed5d9f5c7f683e0 100644 (file)
@@ -57,7 +57,6 @@ import { useSpaceJoinedHierarchy } from '../../../hooks/useSpaceHierarchy';
 import { allRoomsAtom } from '../../../state/room-list/roomList';
 import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
 import { usePowerLevels } from '../../../hooks/usePowerLevels';
-import { openInviteUser } from '../../../../client/action/navigation';
 import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList';
 import { roomToParentsAtom } from '../../../state/room/roomToParents';
 import { markAsRead } from '../../../../client/action/notifications';
@@ -84,6 +83,7 @@ import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
 import { ContainerColor } from '../../../styles/ContainerColor.css';
 import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 import { BreakWord } from '../../../styles/Text.css';
+import { InviteUserPrompt } from '../../../components/invite-user-prompt';
 
 type SpaceMenuProps = {
   room: Room;
@@ -102,6 +102,8 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
   const openSpaceSettings = useOpenSpaceSettings();
   const { navigateRoom } = useRoomNavigate();
 
+  const [invitePrompt, setInvitePrompt] = useState(false);
+
   const allChild = useSpaceChildren(
     allRoomsAtom,
     room.roomId,
@@ -122,8 +124,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
   };
 
   const handleInvite = () => {
-    openInviteUser(room.roomId);
-    requestClose();
+    setInvitePrompt(true);
   };
 
   const handleRoomSettings = () => {
@@ -139,6 +140,15 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
   return (
     <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
       <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+        {invitePrompt && room && (
+          <InviteUserPrompt
+            room={room}
+            requestClose={() => {
+              setInvitePrompt(false);
+              requestClose();
+            }}
+          />
+        )}
         <MenuItem
           onClick={handleMarkAsRead}
           size="300"
@@ -160,6 +170,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
           size="300"
           after={<Icon size="100" src={Icons.UserPlus} />}
           radii="300"
+          aria-pressed={invitePrompt}
           disabled={!canInvite}
         >
           <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>