f5c301d5c6
- DeviceVerification, InviteUserPrompt, LogoutDialog: apply useModalStyle for fullscreen on mobile, capped box on desktop - chatBackground: inject willChange + contain:paint on animated variants to promote compositor layer and prevent descendant repaint flickering (Bug #2) - LOTUS_BUGS.md: mark #7-#10 FIXED/UNTESTED, #2 FIXED/UNTESTED, chatBackground backgroundColor values as PATTERN CONTENT EXCEPTION Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
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 {
|
|
getCanonicalAliasOrRoomId,
|
|
getMxIdLocalPart,
|
|
getMxIdServer,
|
|
isRoomAlias,
|
|
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';
|
|
import { copyToClipboard } from '../../utils/dom';
|
|
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
|
import { getViaServers } from '../../plugins/via-servers';
|
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
|
|
|
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 modalStyle = useModalStyle(560);
|
|
const alive = useAlive();
|
|
const [linkCopied, setLinkCopied] = useState(false);
|
|
const [showQr, setShowQr] = useState(false);
|
|
|
|
const inviteUrl = (() => {
|
|
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
|
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
|
|
return getMatrixToRoom(roomIdOrAlias, viaServers);
|
|
})();
|
|
|
|
const handleCopyLink = () => {
|
|
copyToClipboard(inviteUrl);
|
|
setLinkCopied(true);
|
|
setTimeout(() => setLinkCopied(false), 2000);
|
|
};
|
|
|
|
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 style={modalStyle}>
|
|
<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" gap="100" alignItems="Center">
|
|
<Button
|
|
size="300"
|
|
variant={linkCopied ? 'Success' : 'Secondary'}
|
|
fill="Soft"
|
|
radii="300"
|
|
before={<Icon size="100" src={linkCopied ? Icons.Check : Icons.Link} />}
|
|
onClick={handleCopyLink}
|
|
aria-label="Copy room link"
|
|
>
|
|
<Text size="B300">{linkCopied ? 'Copied!' : 'Copy Link'}</Text>
|
|
</Button>
|
|
<Button
|
|
size="300"
|
|
radii="300"
|
|
variant={showQr ? 'Primary' : 'Secondary'}
|
|
fill={showQr ? 'Soft' : 'None'}
|
|
aria-label="Toggle QR code"
|
|
aria-pressed={showQr}
|
|
onClick={() => setShowQr((v) => !v)}
|
|
>
|
|
<Text size="B300">QR Code</Text>
|
|
</Button>
|
|
<IconButton size="300" radii="300" onClick={requestClose} aria-label="Close">
|
|
<Icon src={Icons.Cross} />
|
|
</IconButton>
|
|
</Box>
|
|
</Header>
|
|
{showQr && (
|
|
<Box
|
|
direction="Column"
|
|
alignItems="Center"
|
|
gap="200"
|
|
style={{
|
|
padding: config.space.S300,
|
|
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
|
|
}}
|
|
>
|
|
<img
|
|
src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(inviteUrl)}`}
|
|
alt="QR code for room invite link"
|
|
width={180}
|
|
height={180}
|
|
style={{ display: 'block', borderRadius: config.radii.R300 }}
|
|
/>
|
|
<Text
|
|
size="T200"
|
|
style={{ opacity: 0.6, wordBreak: 'break-all', textAlign: 'center' }}
|
|
>
|
|
{inviteUrl}
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
<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="@username: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>
|
|
);
|
|
}
|