Skip to content
This repository was archived by the owner on Mar 10, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
290 changes: 235 additions & 55 deletions src/app/features/room/RoomInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,13 @@ import {
IconButton,
Icons,
Line,
Menu,
MenuItem,
Overlay,
OverlayBackdrop,
OverlayCenter,
PopOut,
RectCords,
Scroll,
Text,
toRem,
Expand Down Expand Up @@ -123,13 +126,30 @@ import { useImagePackRooms } from '$hooks/useImagePackRooms';
import { useComposingCheck } from '$hooks/useComposingCheck';
import { useSableCosmetics } from '$hooks/useSableCosmetics';
import { createLogger } from '$utils/debug';
import { CommandAutocomplete } from './CommandAutocomplete';
import FocusTrap from 'focus-trap-react';
import { useQueryClient } from '@tanstack/react-query';
import {
delayedEventsSupportedAtom,
roomIdToScheduledTimeAtomFamily,
roomIdToEditingScheduledDelayIdAtomFamily,
} from '$state/scheduledMessages';
import {
sendDelayedMessage,
sendDelayedMessageE2EE,
computeDelayMs,
cancelDelayedEvent,
} from '$utils/delayedEvents';
import { timeHourMinute, timeDayMonthYear } from '$utils/time';
import { stopPropagation } from '$utils/keyboard';
import { SchedulePickerDialog } from './schedule-send';
import * as css from './schedule-send/SchedulePickerDialog.css';
import {
getAudioMsgContent,
getFileMsgContent,
getImageMsgContent,
getVideoMsgContent,
} from './msgContent';
import { CommandAutocomplete } from './CommandAutocomplete';

const getReplyContent = (replyDraft: IReplyDraft | undefined): IEventRelation => {
if (!replyDraft) return {};
Expand Down Expand Up @@ -242,6 +262,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(

const isComposing = useComposingCheck();

const queryClient = useQueryClient();
const delayedEventsSupported = useAtomValue(delayedEventsSupportedAtom);
const [scheduledTime, setScheduledTime] = useAtom(roomIdToScheduledTimeAtomFamily(roomId));
const [editingScheduledDelayId, setEditingScheduledDelayId] = useAtom(
roomIdToEditingScheduledDelayIdAtomFamily(roomId)
);
const [scheduleMenuAnchor, setScheduleMenuAnchor] = useState<RectCords>();
const [showSchedulePicker, setShowSchedulePicker] = useState(false);
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const isEncrypted = room.hasEncryptionStateEvent();

useElementSizeObserver(
useCallback(() => document.body, []),
useCallback((width) => setHideStickerBtn(width < 500), [])
Expand Down Expand Up @@ -428,21 +459,70 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (replyDraft) {
content['m.relates_to'] = getReplyContent(replyDraft);
}
if (replyDraft) {
setReplyDraft(undefined);
}
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ['delayedEvents', roomId] });

resetEditor(editor);
resetEditorHistory(editor);
setInputKey((prev) => prev + 1);
sendTypingStatus(false);
const resetInput = () => {
resetEditor(editor);
resetEditorHistory(editor);
setInputKey((prev) => prev + 1);
setReplyDraft(undefined);
sendTypingStatus(false);
};

try {
await mx.sendMessage(roomId, content as any);
} catch (error) {
log.error('failed to send message', { roomId }, error);
if (scheduledTime) {
try {
const delayMs = computeDelayMs(scheduledTime);
if (editingScheduledDelayId) {
await cancelDelayedEvent(mx, editingScheduledDelayId);
}
if (isEncrypted) {
await sendDelayedMessageE2EE(mx, roomId, room, content, delayMs);
} else {
await sendDelayedMessage(mx, roomId, content, delayMs);
}
invalidate();
setEditingScheduledDelayId(null);
setScheduledTime(null);
resetInput();
} catch {
// Network/server error — leave editor and scheduled state intact for retry
}
} else if (editingScheduledDelayId) {
try {
await cancelDelayedEvent(mx, editingScheduledDelayId);
mx.sendMessage(roomId, content as any);
invalidate();
setEditingScheduledDelayId(null);
resetInput();
} catch {
// Cancel failed — leave state intact for retry
}
} else {
try {
await mx.sendMessage(roomId, content as any);
} catch (error) {
log.error('failed to send message', { roomId }, error);
}
resetInput();
}
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
}, [
mx,
roomId,
room,
editor,
replyDraft,
sendTypingStatus,
setReplyDraft,
isMarkdown,
commands,
scheduledTime,
setScheduledTime,
isEncrypted,
queryClient,
editingScheduledDelayId,
setEditingScheduledDelayId,
]);

const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => {
Expand Down Expand Up @@ -617,43 +697,73 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
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"
<>
{scheduledTime && (
<div>
<Box
alignItems="Center"
gap="300"
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
<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 style={{ fontFamily: replyUsernameFont }}>
<b>
{getMemberDisplayName(room, replyDraft.userId, nicknames) ??
getMxIdLocalPart(replyDraft.userId) ??
replyDraft.userId}
</b>
</Text>
}
<IconButton
onClick={() => {
setScheduledTime(null);
setEditingScheduledDelayId(null);
}}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Text size="T300" truncate>
{replyBodyJSX}
<Icon src={Icons.Cross} size="50" />
</IconButton>
<Box direction="Row" gap="200" alignItems="Center">
<Icon size="100" src={Icons.Clock} />
<Text size="T300">
Scheduled for {timeDayMonthYear(scheduledTime.getTime())} at{' '}
{timeHourMinute(scheduledTime.getTime(), hour24Clock)}
</Text>
</ReplyLayout>
</Box>
</Box>
</Box>
</div>
)
</div>
)}
{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 style={{ fontFamily: replyUsernameFont }}>
<b>
{getMemberDisplayName(room, replyDraft.userId, nicknames) ??
getMxIdLocalPart(replyDraft.userId) ??
replyDraft.userId}
</b>
</Text>
}
>
<Text size="T300" truncate>
{replyBodyJSX}
</Text>
</ReplyLayout>
</Box>
</Box>
</div>
)}
</>
}
before={
<IconButton
Expand Down Expand Up @@ -742,15 +852,74 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</PopOut>
)}
</UseStateProvider>
<IconButton
onClick={submit}
onMouseDown={(e: MouseEvent) => e.preventDefault()}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.Send} />
</IconButton>
<PopOut
anchor={scheduleMenuAnchor}
position="Top"
align="End"
offset={5}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setScheduleMenuAnchor(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
size="300"
radii="300"
onClick={() => {
setScheduleMenuAnchor(undefined);
submit();
}}
before={<Icon size="100" src={Icons.Send} />}
>
<Text size="B300">Send Now</Text>
</MenuItem>
<MenuItem
size="300"
radii="300"
onClick={() => {
setScheduleMenuAnchor(undefined);
setShowSchedulePicker(true);
}}
before={<Icon size="100" src={Icons.Clock} />}
>
<Text size="B300">Schedule Send</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
/>
<Box display="Flex" alignItems="Center">
<IconButton
onClick={submit}
onMouseDown={(e: MouseEvent) => e.preventDefault()}
variant={scheduledTime ? 'Primary' : 'SurfaceVariant'}
size="300"
radii="0"
className={delayedEventsSupported ? css.SplitSendButton : undefined}
>
<Icon src={scheduledTime ? Icons.Clock : Icons.Send} />
</IconButton>
{delayedEventsSupported && (
<IconButton
onClick={(evt: MouseEvent<HTMLButtonElement>) => {
setScheduleMenuAnchor(evt.currentTarget.getBoundingClientRect());
}}
variant={scheduledTime ? 'Primary' : 'SurfaceVariant'}
size="300"
radii="0"
className={css.SplitChevronButton}
>
<Icon size="50" src={Icons.ChevronBottom} />
</IconButton>
)}
</Box>
</>
}
bottom={
Expand All @@ -762,6 +931,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
)
}
/>
{showSchedulePicker && (
<SchedulePickerDialog
initialTime={scheduledTime?.getTime()}
showEncryptionWarning={isEncrypted}
onCancel={() => setShowSchedulePicker(false)}
onSubmit={(date) => {
setScheduledTime(date);
setShowSchedulePicker(false);
}}
/>
)}
</div>
);
}
Expand Down
Loading
Loading