vojo/src/app/features/room/RoomInput.tsx

793 lines
27 KiB
TypeScript

import React, {
KeyboardEventHandler,
MouseEvent as ReactMouseEvent,
RefObject,
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useAtom, useAtomValue } from 'jotai';
import { isKeyHotkey } from 'is-hotkey';
import { useTranslation } from 'react-i18next';
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
import { Transforms, Editor } from 'slate';
import {
Box,
Dialog,
Icon,
IconButton,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
PopOut,
Scroll,
Text,
config,
toRem,
} from 'folds';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import {
CustomEditor,
toMatrixCustomHTML,
toPlainText,
AUTOCOMPLETE_PREFIXES,
AutocompletePrefix,
AutocompleteQuery,
getAutocompleteQuery,
getPrevWorldRange,
resetEditor,
RoomMentionAutocomplete,
UserMentionAutocomplete,
EmoticonAutocomplete,
createEmoticonElement,
moveCursor,
resetEditorHistory,
customHtmlEqualsPlainText,
trimCustomHtml,
isEmptyEditor,
getBeginCommand,
trimCommand,
getMentions,
} from '../../components/editor';
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
import { UseStateProvider } from '../../components/UseStateProvider';
import {
TUploadContent,
encryptFile,
getImageInfo,
getMxIdLocalPart,
mxcUrlToHttp,
} from '../../utils/matrix';
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
import { useFilePicker } from '../../hooks/useFilePicker';
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
import { useFileDropZone } from '../../hooks/useFileDrop';
import {
TUploadItem,
TUploadMetadata,
draftKey,
roomIdToMsgDraftAtomFamily,
roomIdToReplyDraftAtomFamily,
roomIdToUploadItemsAtomFamily,
roomUploadAtomFamily,
} from '../../state/room/roomInputDrafts';
import { UploadCardRenderer } from '../../components/upload-card';
import {
UploadBoard,
UploadBoardContent,
UploadBoardHeader,
UploadBoardImperativeHandlers,
} from '../../components/upload-board';
import {
Upload,
UploadStatus,
UploadSuccess,
createUploadFamilyObserverAtom,
} from '../../state/upload';
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
import { safeFile } from '../../utils/mimeTypes';
import { fulfilledPromiseSettledResult } from '../../utils/common';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import {
getAudioMsgContent,
getFileMsgContent,
getImageMsgContent,
getVideoMsgContent,
} from './msgContent';
import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room';
import { CommandAutocomplete } from './CommandAutocomplete';
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
import { mobileOrTablet } from '../../utils/user-agent';
import { ReplyLayout, ThreadIndicator } from '../../components/message';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import colorMXID from '../../../util/colorMXID';
import { useIsOneOnOne } from '../../hooks/useRoom';
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useComposingCheck } from '../../hooks/useComposingCheck';
// Placeholder rotation set — 12 i18n keys (1 default + 11 alternates) under
// the `Room` namespace; the hour-of-day slot picks one. With 12 variants
// over 24 hours each variant occupies two specific hour slots per day, so
// the placeholder is stable within an hour and a user is most likely to
// notice the change when they navigate to another room across an hour
// boundary. Keep this list in sync with `public/locales/{en,ru}.json`
// `Room.send_message*` entries.
const COMPOSER_PLACEHOLDER_KEYS = [
'Room.send_message',
'Room.send_message_alt_1',
'Room.send_message_alt_2',
'Room.send_message_alt_3',
'Room.send_message_alt_4',
'Room.send_message_alt_5',
'Room.send_message_alt_6',
'Room.send_message_alt_7',
'Room.send_message_alt_8',
'Room.send_message_alt_9',
'Room.send_message_alt_10',
'Room.send_message_alt_11',
] as const;
// Composer action-row icons — stroke-based outline style from the Dawn
// canon (docs/design/new-direct-messages-design/project/shared.jsx line
// 3-22). folds Icon wraps these in `<svg viewBox="0 0 24 24" fill="none">`,
// so each IconSrc returns just the path/shape elements. Matches the
// Gemini-style flat composer aesthetic: thin 1.6-1.8 strokes, round caps,
// no fills.
const StreamComposerIcons = {
Plus: () => (
<path
d="M12 5v14M5 12h14"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
),
Smile: () => (
<>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
<path
d="M8 14s1.5 2 4 2 4-2 4-2M9 10h.01M15 10h.01"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</>
),
Send: () => (
<>
<path
d="M22 2L11 13"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M22 2L15 22L11 13L2 9L22 2Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
),
};
interface RoomInputProps {
editor: Editor;
fileDropContainerRef: RefObject<HTMLElement>;
roomId: string;
room: Room;
// M2: when set, all sendMessage / sendEvent paths emit with this
// threadId so the SDK attaches the m.thread relation. Channel and DM
// composers leave this undefined so messages land in the main timeline
// unchanged. The drawer composer passes threadId={rootId}.
threadId?: string;
}
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
({ editor, fileDropContainerRef, roomId, room, threadId }, ref) => {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const isOneOnOne = useIsOneOnOne();
const commands = useCommands(mx, room);
const emojiBtnRef = useRef<HTMLButtonElement>(null);
const roomToParents = useAtomValue(roomToParentsAtom);
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
// Per-(room, thread) draft scope: drawer composer (threadId={rootId})
// and channel/DM composer (threadId=undefined → 'main') no longer
// clobber each other's drafts. See `roomInputDrafts.ts::DraftKey`.
const inputDraftKey = draftKey(roomId, threadId);
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(inputDraftKey));
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(inputDraftKey));
const replyUserID = replyDraft?.userId;
const powerLevelTags = usePowerLevelTags(room, powerLevels);
const creatorsTag = useRoomCreatorsTag();
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
const theme = useTheme();
const accessibleTagColors = useAccessiblePowerTagColors(
theme.kind,
creatorsTag,
powerLevelTags
);
const replyPowerTag = replyUserID ? getMemberPowerTag(replyUserID) : undefined;
const replyPowerColor = replyPowerTag?.color
? accessibleTagColors.get(replyPowerTag.color)
: undefined;
const replyUsernameColor = isOneOnOne ? colorMXID(replyUserID ?? '') : replyPowerColor;
const [uploadBoard, setUploadBoard] = useState(true);
const [selectedFiles, setSelectedFiles] = useAtom(
roomIdToUploadItemsAtomFamily(inputDraftKey)
);
const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
roomUploadAtomFamily,
selectedFiles.map((f) => f.file)
);
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
const [autocompleteQuery, setAutocompleteQuery] =
useState<AutocompleteQuery<AutocompletePrefix>>();
// Suppress typing notifications from the drawer composer — see
// useTypingStatusUpdater for the spec-level reason (m.typing has
// no thread_id, so a thread-composer typing event would surface
// in the main channel chat as if the user were typing there).
const sendTypingStatus = useTypingStatusUpdater(mx, roomId, !!threadId);
const handleFiles = useCallback(
async (files: File[]) => {
setUploadBoard(true);
const safeFiles = files.map(safeFile);
const fileItems: TUploadItem[] = [];
if (room.hasEncryptionStateEvent()) {
const encryptFiles = fulfilledPromiseSettledResult(
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
);
encryptFiles.forEach((ef) =>
fileItems.push({
...ef,
metadata: {
markedAsSpoiler: false,
},
})
);
} else {
safeFiles.forEach((f) =>
fileItems.push({
file: f,
originalFile: f,
encInfo: undefined,
metadata: {
markedAsSpoiler: false,
},
})
);
}
setSelectedFiles({
type: 'PUT',
item: fileItems,
});
},
[setSelectedFiles, room]
);
const pickFile = useFilePicker(handleFiles, true);
const handlePaste = useFilePasteHandler(handleFiles);
const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
const isComposing = useComposingCheck();
useEffect(() => {
Transforms.insertFragment(editor, msgDraft);
}, [editor, msgDraft]);
useEffect(
() => () => {
if (!isEmptyEditor(editor)) {
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
setMsgDraft(parsedDraft);
} else {
setMsgDraft([]);
}
resetEditor(editor);
resetEditorHistory(editor);
},
// `threadId` included so a future caller passing dynamic
// threadId without remount still routes the cleanup-on-unmount
// setter to the correct atom. Today the drawer is fully
// remounted on rootId change via Room.tsx key, but the dep
// pins the invariant explicitly.
[roomId, threadId, editor, setMsgDraft]
);
const handleFileMetadata = useCallback(
(fileItem: TUploadItem, metadata: TUploadMetadata) => {
setSelectedFiles({
type: 'REPLACE',
item: fileItem,
replacement: { ...fileItem, metadata },
});
},
[setSelectedFiles]
);
const handleRemoveUpload = useCallback(
(upload: TUploadContent | TUploadContent[]) => {
const uploads = Array.isArray(upload) ? upload : [upload];
setSelectedFiles({
type: 'DELETE',
item: selectedFiles.filter((f) => uploads.find((u) => u === f.file)),
});
uploads.forEach((u) => roomUploadAtomFamily.remove(u));
},
[setSelectedFiles, selectedFiles]
);
const handleCancelUpload = (uploads: Upload[]) => {
uploads.forEach((upload) => {
if (upload.status === UploadStatus.Loading) {
mx.cancelUpload(upload.promise);
}
});
handleRemoveUpload(uploads.map((upload) => upload.file));
};
const handleSendUpload = async (uploads: UploadSuccess[]) => {
const contentsPromises = uploads.map(async (upload) => {
const fileItem = selectedFiles.find((f) => f.file === upload.file);
if (!fileItem) throw new Error('Broken upload');
if (fileItem.file.type.startsWith('image')) {
return getImageMsgContent(mx, fileItem, upload.mxc);
}
if (fileItem.file.type.startsWith('video')) {
return getVideoMsgContent(mx, fileItem, upload.mxc);
}
if (fileItem.file.type.startsWith('audio')) {
return getAudioMsgContent(fileItem, upload.mxc);
}
return getFileMsgContent(fileItem, upload.mxc);
});
handleCancelUpload(uploads);
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
contents.forEach((content) => mx.sendMessage(roomId, threadId ?? null, content as any));
};
const submit = useCallback(() => {
uploadBoardHandlers.current?.handleSend();
const commandName = getBeginCommand(editor);
let plainText = toPlainText(editor.children, isMarkdown).trim();
let customHtml = trimCustomHtml(
toMatrixCustomHTML(editor.children, {
allowTextFormatting: true,
allowBlockMarkdown: isMarkdown,
allowInlineMarkdown: isMarkdown,
})
);
let msgType = MsgType.Text;
if (commandName) {
plainText = trimCommand(commandName, plainText);
customHtml = trimCommand(commandName, customHtml);
}
if (commandName === Command.Me) {
msgType = MsgType.Emote;
} else if (commandName === Command.Notice) {
msgType = MsgType.Notice;
} else if (commandName === Command.Shrug) {
plainText = `${SHRUG} ${plainText}`;
customHtml = `${SHRUG} ${customHtml}`;
} else if (commandName === Command.TableFlip) {
plainText = `${TABLEFLIP} ${plainText}`;
customHtml = `${TABLEFLIP} ${customHtml}`;
} else if (commandName === Command.UnFlip) {
plainText = `${UNFLIP} ${plainText}`;
customHtml = `${UNFLIP} ${customHtml}`;
} else if (commandName) {
const commandContent = commands[commandName as Command];
if (commandContent) {
commandContent.exe(plainText);
}
resetEditor(editor);
resetEditorHistory(editor);
sendTypingStatus(false);
return;
}
if (plainText === '') return;
const body = plainText;
const formattedBody = customHtml;
const mentionData = getMentions(mx, roomId, editor);
const content: IContent = {
msgtype: msgType,
body,
};
if (replyDraft && replyDraft.userId !== mx.getUserId()) {
mentionData.users.add(replyDraft.userId);
}
const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room);
content['m.mentions'] = mMentions;
if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
content.format = 'org.matrix.custom.html';
content.formatted_body = formattedBody;
}
if (replyDraft) {
content['m.relates_to'] = {
'm.in_reply_to': {
event_id: replyDraft.eventId,
},
};
if (replyDraft.relation?.rel_type === RelationType.Thread) {
content['m.relates_to'].event_id = replyDraft.relation.event_id;
content['m.relates_to'].rel_type = RelationType.Thread;
content['m.relates_to'].is_falling_back = false;
}
}
mx.sendMessage(roomId, threadId ?? null, content as any);
resetEditor(editor);
resetEditorHistory(editor);
setReplyDraft(undefined);
sendTypingStatus(false);
}, [
mx,
roomId,
threadId,
editor,
replyDraft,
sendTypingStatus,
setReplyDraft,
isMarkdown,
commands,
]);
const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => {
if (
(isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
!isComposing(evt)
) {
evt.preventDefault();
submit();
}
if (isKeyHotkey('escape', evt)) {
evt.preventDefault();
if (autocompleteQuery) {
setAutocompleteQuery(undefined);
return;
}
setReplyDraft(undefined);
}
},
[submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing]
);
const handleKeyUp: KeyboardEventHandler = useCallback(
(evt) => {
if (isKeyHotkey('escape', evt)) {
evt.preventDefault();
return;
}
if (!hideActivity) {
sendTypingStatus(!isEmptyEditor(editor));
}
const prevWordRange = getPrevWorldRange(editor);
const query = prevWordRange
? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
: undefined;
setAutocompleteQuery(query);
},
[editor, sendTypingStatus, hideActivity]
);
const handleCloseAutocomplete = useCallback(() => {
setAutocompleteQuery(undefined);
ReactEditor.focus(editor);
}, [editor]);
const handleEmoticonSelect = (key: string, shortcode: string) => {
editor.insertNode(createEmoticonElement(key, shortcode));
moveCursor(editor);
};
const handleStickerSelect = async (mxc: string, shortcode: string, label: string) => {
const stickerUrl = mxcUrlToHttp(mx, mxc, useAuthentication);
if (!stickerUrl) return;
const info = await getImageInfo(
await loadImageElement(stickerUrl),
await getImageUrlBlob(stickerUrl)
);
mx.sendEvent(roomId, threadId ?? null, EventType.Sticker, {
body: label,
url: mxc,
info,
});
};
// Action-row buttons extracted into JSX consts so the bottom-slot
// markup stays readable. Single source for each button across whatever
// layout the composer evolves into; today the layout is a two-row
// strip — textarea on top, this trio below — on every platform.
const plusButton = (
<IconButton
onClick={() => pickFile('*')}
variant="SurfaceVariant"
fill="None"
size="300"
radii="300"
>
<Icon src={StreamComposerIcons.Plus} />
</IconButton>
);
const emojiButton = (
<UseStateProvider initial={undefined}>
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
<PopOut
offset={16}
alignOffset={-44}
position="Top"
align="End"
anchor={
emojiBoardTab === undefined
? undefined
: emojiBtnRef.current?.getBoundingClientRect() ?? undefined
}
content={
<EmojiBoard
tab={emojiBoardTab ?? EmojiBoardTab.Emoji}
onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
requestClose={() => {
setEmojiBoardTab((tab) => {
if (tab) {
if (!mobileOrTablet()) ReactEditor.focus(editor);
return undefined;
}
return tab;
});
}}
/>
}
>
<IconButton
ref={emojiBtnRef}
aria-pressed={!!emojiBoardTab}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
variant="SurfaceVariant"
fill="None"
size="300"
radii="300"
>
<Icon src={StreamComposerIcons.Smile} />
</IconButton>
</PopOut>
)}
</UseStateProvider>
);
const sendButton = (
<IconButton
onClick={(evt: ReactMouseEvent<HTMLButtonElement>) => {
submit();
// Defense-in-depth against the Android WebView's synthetic
// :hover/:focus-visible persistence (the CSS gate in
// RoomView.css.ts handles the same class of bug, but
// explicitly blurring the tap target also clears any
// lingering :focus state regardless of input-mode
// attribution).
evt.currentTarget.blur();
}}
variant="SurfaceVariant"
fill="None"
size="300"
radii="300"
>
<Icon src={StreamComposerIcons.Send} />
</IconButton>
);
// Hour-of-day picks the placeholder variant. Memoised against
// (roomId, threadId) so the placeholder stays stable while the user is
// in one chat/thread and only re-rolls when they navigate elsewhere —
// and even then it's stable within the same wall-clock hour, so the
// change is most likely to surface to the user when they cross an
// hour boundary AND switch rooms (or reload the app). That cadence
// keeps the joke surprising without flipping mid-session.
const placeholderKey = useMemo(() => {
const idx = new Date().getHours() % COMPOSER_PLACEHOLDER_KEYS.length;
return COMPOSER_PLACEHOLDER_KEYS[idx];
// roomId and threadId are intentional re-roll triggers; the
// exhaustive-deps lint is happy with them too.
}, [roomId, threadId]);
return (
<div ref={ref}>
{selectedFiles.length > 0 && (
<UploadBoard
header={
<UploadBoardHeader
open={uploadBoard}
onToggle={() => setUploadBoard(!uploadBoard)}
uploadFamilyObserverAtom={uploadFamilyObserverAtom}
onSend={handleSendUpload}
imperativeHandlerRef={uploadBoardHandlers}
onCancel={handleCancelUpload}
/>
}
>
{uploadBoard && (
<Scroll size="300" hideTrack visibility="Hover">
<UploadBoardContent>
{Array.from(selectedFiles)
.reverse()
.map((fileItem, index) => (
<UploadCardRenderer
// eslint-disable-next-line react/no-array-index-key
key={index}
isEncrypted={!!fileItem.encInfo}
fileItem={fileItem}
setMetadata={handleFileMetadata}
onRemove={handleRemoveUpload}
/>
))}
</UploadBoardContent>
</Scroll>
)}
</UploadBoard>
)}
<Overlay
open={dropZoneVisible}
backdrop={<OverlayBackdrop />}
style={{ pointerEvents: 'none' }}
>
<OverlayCenter>
<Dialog variant="Primary">
<Box
direction="Column"
justifyContent="Center"
alignItems="Center"
gap="500"
style={{ padding: toRem(60) }}
>
<Icon size="600" src={Icons.File} />
<Text size="H4" align="Center">
{t('Room.drop_files', { name: room?.name || 'Room' })}
</Text>
<Text align="Center">{t('Room.drag_drop_desc')}</Text>
</Box>
</Dialog>
</OverlayCenter>
</Overlay>
{autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
<RoomMentionAutocomplete
roomId={roomId}
editor={editor}
query={autocompleteQuery}
requestClose={handleCloseAutocomplete}
/>
)}
{autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
<UserMentionAutocomplete
room={room}
editor={editor}
query={autocompleteQuery}
requestClose={handleCloseAutocomplete}
/>
)}
{autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
<EmoticonAutocomplete
imagePackRooms={imagePackRooms}
editor={editor}
query={autocompleteQuery}
requestClose={handleCloseAutocomplete}
/>
)}
{autocompleteQuery?.prefix === AutocompletePrefix.Command && (
<CommandAutocomplete
room={room}
editor={editor}
query={autocompleteQuery}
requestClose={handleCloseAutocomplete}
/>
)}
<CustomEditor
editableName="RoomInput"
editor={editor}
placeholder={t(placeholderKey)}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onPaste={handlePaste}
top={
replyDraft && (
<div>
<Box
alignItems="Center"
gap="300"
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
<IconButton
onClick={() => setReplyDraft(undefined)}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.Cross} size="50" />
</IconButton>
<Box direction="Row" gap="200" alignItems="Center">
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
<ReplyLayout
userColor={replyUsernameColor}
username={
<Text size="T300" truncate>
<b>
{getMemberDisplayName(room, replyDraft.userId) ??
getMxIdLocalPart(replyDraft.userId) ??
replyDraft.userId}
</b>
</Text>
}
>
<Text size="T300" truncate>
{trimReplyFromBody(replyDraft.body)}
</Text>
</ReplyLayout>
</Box>
</Box>
</div>
)
}
bottom={
<Box
alignItems="Center"
gap="200"
style={{ padding: `${toRem(2)} ${toRem(8)} ${toRem(4)}` }}
>
{plusButton}
<Box grow="Yes" />
{emojiButton}
{sendButton}
</Box>
}
/>
</div>
);
}
);