New invite user to room dialog (#2460)

* 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
This commit is contained in:
Ajay Bura 2025-08-24 18:04:21 +05:30 committed by GitHub
parent c881b59957
commit 13cdcbcdb1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 434 additions and 56 deletions

View file

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

View file

@ -0,0 +1 @@
export * from './InviteUserPrompt';

View 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 { Avatar, Box, Button, Spinner, Text, as } from 'folds';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { openInviteUser } from '../../../client/action/navigation';
import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room'; import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
import { getMemberDisplayName, getStateEvent } from '../../utils/room'; import { getMemberDisplayName, getStateEvent } from '../../utils/room';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
@ -17,6 +16,7 @@ import { mDirectAtom } from '../../state/mDirectList';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { InviteUserPrompt } from '../invite-user-prompt';
export type RoomIntroProps = { export type RoomIntroProps = {
room: Room; room: Room;
@ -27,6 +27,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const { navigateRoom } = useRoomNavigate(); const { navigateRoom } = useRoomNavigate();
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const [invitePrompt, setInvitePrompt] = useState(false);
const createEvent = getStateEvent(room, StateEvent.RoomCreate); const createEvent = getStateEvent(room, StateEvent.RoomCreate);
const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId)); const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
@ -76,14 +77,13 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
)} )}
</Box> </Box>
<Box gap="200" wrap="Wrap"> <Box gap="200" wrap="Wrap">
<Button <Button onClick={() => setInvitePrompt(true)} variant="Secondary" size="300" radii="300">
onClick={() => openInviteUser(room.roomId)}
variant="Secondary"
size="300"
radii="300"
>
<Text size="B300">Invite Member</Text> <Text size="B300">Invite Member</Text>
</Button> </Button>
{invitePrompt && (
<InviteUserPrompt room={room} requestClose={() => setInvitePrompt(false)} />
)}
{typeof prevRoomId === 'string' && {typeof prevRoomId === 'string' &&
(mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? ( (mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? (
<Button <Button

View file

@ -18,7 +18,6 @@ import {
import { HierarchyItem } from '../../hooks/useSpaceHierarchy'; import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room'; import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room';
import { openInviteUser } from '../../../client/action/navigation';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../components/leave-space-prompt'; import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
@ -30,6 +29,7 @@ import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
import { IPowerLevels } from '../../hooks/usePowerLevels'; import { IPowerLevels } from '../../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators'; import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions'; import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
type HierarchyItemWithParent = HierarchyItem & { type HierarchyItemWithParent = HierarchyItem & {
parentId: string; parentId: string;
@ -126,24 +126,39 @@ function InviteMenuItem({
requestClose: () => void; requestClose: () => void;
disabled?: boolean; disabled?: boolean;
}) { }) {
const mx = useMatrixClient();
const room = mx.getRoom(item.roomId);
const [invitePrompt, setInvitePrompt] = useState(false);
const handleInvite = () => { const handleInvite = () => {
openInviteUser(item.roomId); setInvitePrompt(true);
requestClose();
}; };
return ( return (
<>
<MenuItem <MenuItem
onClick={handleInvite} onClick={handleInvite}
size="300" size="300"
radii="300" radii="300"
variant="Primary" variant="Primary"
fill="None" fill="None"
disabled={disabled} aria-pressed={invitePrompt}
disabled={disabled || !room}
> >
<Text as="span" size="T300" truncate> <Text as="span" size="T300" truncate>
Invite Invite
</Text> </Text>
</MenuItem> </MenuItem>
{invitePrompt && room && (
<InviteUserPrompt
room={room}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
</>
); );
} }

View file

@ -26,7 +26,6 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { RoomAvatar } from '../../components/room-avatar'; import { RoomAvatar } from '../../components/room-avatar';
import { nameInitials } from '../../utils/common'; import { nameInitials } from '../../utils/common';
import * as css from './LobbyHeader.css'; import * as css from './LobbyHeader.css';
import { openInviteUser } from '../../../client/action/navigation';
import { IPowerLevels } from '../../hooks/usePowerLevels'; import { IPowerLevels } from '../../hooks/usePowerLevels';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../components/leave-space-prompt'; import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
@ -38,6 +37,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings'; import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
type LobbyMenuProps = { type LobbyMenuProps = {
powerLevels: IPowerLevels; powerLevels: IPowerLevels;
@ -53,9 +53,10 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
const canInvite = permissions.action('invite', mx.getSafeUserId()); const canInvite = permissions.action('invite', mx.getSafeUserId());
const openSpaceSettings = useOpenSpaceSettings(); const openSpaceSettings = useOpenSpaceSettings();
const [invitePrompt, setInvitePrompt] = useState(false);
const handleInvite = () => { const handleInvite = () => {
openInviteUser(space.roomId); setInvitePrompt(true);
requestClose();
}; };
const handleRoomSettings = () => { const handleRoomSettings = () => {
@ -65,6 +66,15 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
return ( return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}> <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 }}> <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem <MenuItem
onClick={handleInvite} onClick={handleInvite}
@ -73,6 +83,7 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
size="300" size="300"
after={<Icon size="100" src={Icons.UserPlus} />} after={<Icon size="100" src={Icons.UserPlus} />}
radii="300" radii="300"
aria-pressed={invitePrompt}
disabled={!canInvite} disabled={!canInvite}
> >
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate> <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>

View file

@ -30,7 +30,6 @@ import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { usePowerLevels } from '../../hooks/usePowerLevels'; import { usePowerLevels } from '../../hooks/usePowerLevels';
import { copyToClipboard } from '../../utils/dom'; import { copyToClipboard } from '../../utils/dom';
import { markAsRead } from '../../../client/action/notifications'; import { markAsRead } from '../../../client/action/notifications';
import { openInviteUser } from '../../../client/action/navigation';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt'; import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers'; import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
@ -51,6 +50,7 @@ import {
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher'; import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
type RoomNavItemMenuProps = { type RoomNavItemMenuProps = {
room: Room; room: Room;
@ -70,14 +70,15 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const openRoomSettings = useOpenRoomSettings(); const openRoomSettings = useOpenRoomSettings();
const space = useSpaceOptionally(); const space = useSpaceOptionally();
const [invitePrompt, setInvitePrompt] = useState(false);
const handleMarkAsRead = () => { const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity); markAsRead(mx, room.roomId, hideActivity);
requestClose(); requestClose();
}; };
const handleInvite = () => { const handleInvite = () => {
openInviteUser(room.roomId); setInvitePrompt(true);
requestClose();
}; };
const handleCopyLink = () => { const handleCopyLink = () => {
@ -94,6 +95,15 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
return ( return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}> <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 }}> <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem <MenuItem
onClick={handleMarkAsRead} onClick={handleMarkAsRead}
@ -137,6 +147,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
size="300" size="300"
after={<Icon size="100" src={Icons.UserPlus} />} after={<Icon size="100" src={Icons.UserPlus} />}
radii="300" radii="300"
aria-pressed={invitePrompt}
disabled={!canInvite} disabled={!canInvite}
> >
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate> <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>

View file

@ -45,7 +45,6 @@ import { useRoomUnread } from '../../state/hooks/unread';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { markAsRead } from '../../../client/action/notifications'; import { markAsRead } from '../../../client/action/notifications';
import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { openInviteUser } from '../../../client/action/navigation';
import { copyToClipboard } from '../../utils/dom'; import { copyToClipboard } from '../../utils/dom';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt'; import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta'; import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
@ -69,6 +68,7 @@ import { JumpToTime } from './jump-to-time';
import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
type RoomMenuProps = { type RoomMenuProps = {
room: Room; room: Room;
@ -87,14 +87,15 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId); const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
const { navigateRoom } = useRoomNavigate(); const { navigateRoom } = useRoomNavigate();
const [invitePrompt, setInvitePrompt] = useState(false);
const handleMarkAsRead = () => { const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity); markAsRead(mx, room.roomId, hideActivity);
requestClose(); requestClose();
}; };
const handleInvite = () => { const handleInvite = () => {
openInviteUser(room.roomId); setInvitePrompt(true);
requestClose();
}; };
const handleCopyLink = () => { const handleCopyLink = () => {
@ -113,6 +114,15 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
return ( return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}> <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 }}> <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem <MenuItem
onClick={handleMarkAsRead} onClick={handleMarkAsRead}
@ -156,6 +166,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
size="300" size="300"
after={<Icon size="100" src={Icons.UserPlus} />} after={<Icon size="100" src={Icons.UserPlus} />}
radii="300" radii="300"
aria-pressed={invitePrompt}
disabled={!canInvite} disabled={!canInvite}
> >
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate> <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>

View file

@ -81,6 +81,7 @@ type InviteData = {
senderId: string; senderId: string;
senderName: string; senderName: string;
inviteTs?: number; inviteTs?: number;
reason?: string;
isSpace: boolean; isSpace: boolean;
isDirect: boolean; isDirect: boolean;
@ -102,11 +103,17 @@ const makeInviteData = (mx: MatrixClient, room: Room, useAuthentication: boolean
const member = room.getMember(userId); const member = room.getMember(userId);
const memberEvent = member?.events.member; const memberEvent = member?.events.member;
const content = memberEvent?.getContent();
const senderId = memberEvent?.getSender(); const senderId = memberEvent?.getSender();
const senderName = senderId const senderName = senderId
? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId ? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId
: undefined; : undefined;
const inviteTs = memberEvent?.getTs() ?? 0; const inviteTs = memberEvent?.getTs();
const reason =
content && 'reason' in content && typeof content.reason === 'string'
? content.reason
: undefined;
return { return {
room, room,
@ -119,6 +126,7 @@ const makeInviteData = (mx: MatrixClient, room: Room, useAuthentication: boolean
senderId: senderId ?? 'Unknown', senderId: senderId ?? 'Unknown',
senderName: senderName ?? 'Unknown', senderName: senderName ?? 'Unknown',
inviteTs, inviteTs,
reason,
isSpace: isSpace(room), isSpace: isSpace(room),
isDirect: direct, isDirect: direct,
@ -130,7 +138,8 @@ const hasBadWords = (invite: InviteData): boolean =>
testBadWords(invite.roomName) || testBadWords(invite.roomName) ||
testBadWords(invite.roomTopic ?? '') || testBadWords(invite.roomTopic ?? '') ||
testBadWords(invite.senderName) || testBadWords(invite.senderName) ||
testBadWords(invite.senderId); testBadWords(invite.senderId) ||
testBadWords(invite.reason || '');
type NavigateHandler = (roomId: string, space: boolean) => void; type NavigateHandler = (roomId: string, space: boolean) => void;
@ -184,7 +193,7 @@ function InviteCard({
variant="SurfaceVariant" variant="SurfaceVariant"
direction="Column" direction="Column"
gap="300" gap="300"
style={{ padding: `${config.space.S400} ${config.space.S400} ${config.space.S200}` }} style={{ padding: config.space.S400 }}
> >
{(invite.isEncrypted || invite.isDirect || invite.isSpace) && ( {(invite.isEncrypted || invite.isDirect || invite.isSpace) && (
<Box gap="200" alignItems="Center"> <Box gap="200" alignItems="Center">
@ -298,13 +307,14 @@ function InviteCard({
</Box> </Box>
</Box> </Box>
</Box> </Box>
<Box direction="Column">
<Box gap="200" alignItems="Baseline"> <Box gap="200" alignItems="Baseline">
<Box grow="Yes"> <Box grow="Yes">
<Text size="T200" priority="300"> <Text size="T200" priority="300">
From: <b>{invite.senderId}</b> From: <b>{invite.senderId}</b>
</Text> </Text>
</Box> </Box>
{invite.inviteTs && ( {typeof invite.inviteTs === 'number' && invite.inviteTs !== 0 && (
<Box shrink="No"> <Box shrink="No">
<Time <Time
size="T200" size="T200"
@ -316,6 +326,12 @@ function InviteCard({
</Box> </Box>
)} )}
</Box> </Box>
{invite.reason && (
<Text size="T200" priority="300">
Reason: {invite.reason}
</Text>
)}
</Box>
</SequenceCard> </SequenceCard>
); );
} }

View file

@ -82,7 +82,6 @@ import { useRoomsUnread } from '../../../state/hooks/unread';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { markAsRead } from '../../../../client/action/notifications'; import { markAsRead } from '../../../../client/action/notifications';
import { copyToClipboard } from '../../../utils/dom'; import { copyToClipboard } from '../../../utils/dom';
import { openInviteUser } from '../../../../client/action/navigation';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoom } from '../../../plugins/matrix-to'; import { getMatrixToRoom } from '../../../plugins/matrix-to';
import { getViaServers } from '../../../plugins/via-servers'; import { getViaServers } from '../../../plugins/via-servers';
@ -93,6 +92,7 @@ import { settingsAtom } from '../../../state/settings';
import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../../components/invite-user-prompt';
type SpaceMenuProps = { type SpaceMenuProps = {
room: Room; room: Room;
@ -111,6 +111,8 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
const canInvite = permissions.action('invite', mx.getSafeUserId()); const canInvite = permissions.action('invite', mx.getSafeUserId());
const openSpaceSettings = useOpenSpaceSettings(); const openSpaceSettings = useOpenSpaceSettings();
const [invitePrompt, setInvitePrompt] = useState(false);
const allChild = useSpaceChildren( const allChild = useSpaceChildren(
allRoomsAtom, allRoomsAtom,
room.roomId, room.roomId,
@ -136,8 +138,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
}; };
const handleInvite = () => { const handleInvite = () => {
openInviteUser(room.roomId); setInvitePrompt(true);
requestClose();
}; };
const handleRoomSettings = () => { const handleRoomSettings = () => {
@ -147,6 +148,15 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
return ( return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}> <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 }}> <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem <MenuItem
onClick={handleMarkAsRead} onClick={handleMarkAsRead}
@ -181,6 +191,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
size="300" size="300"
after={<Icon size="100" src={Icons.UserPlus} />} after={<Icon size="100" src={Icons.UserPlus} />}
radii="300" radii="300"
aria-pressed={invitePrompt}
disabled={!canInvite} disabled={!canInvite}
> >
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate> <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>

View file

@ -57,7 +57,6 @@ import { useSpaceJoinedHierarchy } from '../../../hooks/useSpaceHierarchy';
import { allRoomsAtom } from '../../../state/room-list/roomList'; import { allRoomsAtom } from '../../../state/room-list/roomList';
import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page'; import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
import { usePowerLevels } from '../../../hooks/usePowerLevels'; import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { openInviteUser } from '../../../../client/action/navigation';
import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList'; import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList';
import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { markAsRead } from '../../../../client/action/notifications'; import { markAsRead } from '../../../../client/action/notifications';
@ -84,6 +83,7 @@ import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { ContainerColor } from '../../../styles/ContainerColor.css'; import { ContainerColor } from '../../../styles/ContainerColor.css';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { BreakWord } from '../../../styles/Text.css'; import { BreakWord } from '../../../styles/Text.css';
import { InviteUserPrompt } from '../../../components/invite-user-prompt';
type SpaceMenuProps = { type SpaceMenuProps = {
room: Room; room: Room;
@ -102,6 +102,8 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
const openSpaceSettings = useOpenSpaceSettings(); const openSpaceSettings = useOpenSpaceSettings();
const { navigateRoom } = useRoomNavigate(); const { navigateRoom } = useRoomNavigate();
const [invitePrompt, setInvitePrompt] = useState(false);
const allChild = useSpaceChildren( const allChild = useSpaceChildren(
allRoomsAtom, allRoomsAtom,
room.roomId, room.roomId,
@ -122,8 +124,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
}; };
const handleInvite = () => { const handleInvite = () => {
openInviteUser(room.roomId); setInvitePrompt(true);
requestClose();
}; };
const handleRoomSettings = () => { const handleRoomSettings = () => {
@ -139,6 +140,15 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
return ( return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}> <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}> <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{invitePrompt && room && (
<InviteUserPrompt
room={room}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
<MenuItem <MenuItem
onClick={handleMarkAsRead} onClick={handleMarkAsRead}
size="300" size="300"
@ -160,6 +170,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
size="300" size="300"
after={<Icon size="100" src={Icons.UserPlus} />} after={<Icon size="100" src={Icons.UserPlus} />}
radii="300" radii="300"
aria-pressed={invitePrompt}
disabled={!canInvite} disabled={!canInvite}
> >
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate> <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>