--- /dev/null
+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>
+ );
+}
--- /dev/null
+export * from './InviteUserPrompt';
-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';
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;
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));
)}
</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
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';
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;
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();
+ }}
+ />
+ )}
+ </>
);
}
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';
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;
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 = () => {
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}
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>
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';
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;
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 = () => {
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}
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>
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';
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;
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 = () => {
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}
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>
senderId: string;
senderName: string;
inviteTs?: number;
+ reason?: string;
isSpace: boolean;
isDirect: 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,
senderId: senderId ?? 'Unknown',
senderName: senderName ?? 'Unknown',
inviteTs,
+ reason,
isSpace: isSpace(room),
isDirect: direct,
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;
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">
</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>
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';
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;
const canInvite = permissions.action('invite', mx.getSafeUserId());
const openSpaceSettings = useOpenSpaceSettings();
+ const [invitePrompt, setInvitePrompt] = useState(false);
+
const allChild = useSpaceChildren(
allRoomsAtom,
room.roomId,
};
const handleInvite = () => {
- openInviteUser(room.roomId);
- requestClose();
+ setInvitePrompt(true);
};
const handleRoomSettings = () => {
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}
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>
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';
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;
const openSpaceSettings = useOpenSpaceSettings();
const { navigateRoom } = useRoomNavigate();
+ const [invitePrompt, setInvitePrompt] = useState(false);
+
const allChild = useSpaceChildren(
allRoomsAtom,
room.roomId,
};
const handleInvite = () => {
- openInviteUser(room.roomId);
- requestClose();
+ setInvitePrompt(true);
};
const handleRoomSettings = () => {
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"
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>