Skip to content
Open
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
216 changes: 202 additions & 14 deletions src/app/features/room/RoomInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import React, {
useRef,
useState,
} from 'react';
import { useAtom, useAtomValue } from 'jotai';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { isKeyHotkey } from 'is-hotkey';
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
import { EventType, IContent, MatrixError, MsgType, RelationType, Room } from 'matrix-js-sdk';
import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/@types/events';
import { ReactEditor } from 'slate-react';
import { Transforms, Editor } from 'slate';
import {
Expand All @@ -19,15 +20,21 @@ import {
IconButton,
Icons,
Line,
Menu,
MenuItem,
Overlay,
OverlayBackdrop,
OverlayCenter,
PopOut,
RectCords,
Scroll,
Text,
color,
config,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useQueryClient } from '@tanstack/react-query';

import { useMatrixClient } from '../../hooks/useMatrixClient';
import {
Expand Down Expand Up @@ -117,6 +124,12 @@ import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useComposingCheck } from '../../hooks/useComposingCheck';
import { delayedEventsSupportedAtom, roomIdToScheduledTimeAtomFamily, roomIdToEditingScheduledDelayIdAtomFamily, serverMaxDelayMsAtom } from '../../state/scheduledMessages';
import { sendDelayedMessage, sendDelayedMessageE2EE, computeDelayMs, cancelDelayedEvent } from '../../utils/delayedEvents';
import { SchedulePickerDialog } from './schedule-send';
import * as css from './schedule-send/SchedulePickerDialog.css';
import { timeHourMinute, timeDayMonthYear } from '../../utils/time';
import { stopPropagation } from '../../utils/keyboard';

interface RoomInputProps {
editor: Editor;
Expand Down Expand Up @@ -220,6 +233,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 setServerMaxDelayMs = useSetAtom(serverMaxDelayMsAtom);
const [scheduleMenuAnchor, setScheduleMenuAnchor] = useState<RectCords>();
const [showSchedulePicker, setShowSchedulePicker] = useState(false);
const [sendError, setSendError] = useState<string>();
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const isEncrypted = room.hasEncryptionStateEvent();

useElementSizeObserver(
useCallback(() => document.body, []),
useCallback((width) => setHideStickerBtn(width < 500), [])
Expand Down Expand Up @@ -296,7 +320,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
contents.forEach((content) => mx.sendMessage(roomId, content as any));
};

const submit = useCallback(() => {
const submit = useCallback(async () => {
uploadBoardHandlers.current?.handleSend();

const commandName = getBeginCommand(editor);
Expand Down Expand Up @@ -372,12 +396,62 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
content['m.relates_to'].is_falling_back = false;
}
}
mx.sendMessage(roomId, content as any);
resetEditor(editor);
resetEditorHistory(editor);
setReplyDraft(undefined);
sendTypingStatus(false);
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ['delayedEvents', roomId] });

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

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);
setSendError(undefined);
resetInput();
} catch (e: unknown) {
if (
e instanceof MatrixError &&
e.data?.['org.matrix.msc4140.errcode'] === 'M_MAX_DELAY_EXCEEDED'
) {
const serverLimit = e.data['org.matrix.msc4140.max_delay'];
if (typeof serverLimit === 'number') {
setServerMaxDelayMs(serverLimit);
}
setSendError('Scheduled time exceeds the maximum delay allowed by this server. Please choose an earlier time.');
} else {
setSendError('Failed to schedule message. Please try again.');
}
// Network/server error — leave editor and scheduled state intact for retry
}
} else if (editingScheduledDelayId) {
try {
await cancelDelayedEvent(mx, editingScheduledDelayId);
mx.sendMessage(roomId, content as RoomMessageEventContent);
invalidate();
setEditingScheduledDelayId(null);
resetInput();
} catch {
// Cancel failed — leave state intact for retry
}
} else {
mx.sendMessage(roomId, content as any);
resetInput();
}
}, [mx, roomId, room, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, scheduledTime, setScheduledTime, isEncrypted, queryClient, editingScheduledDelayId, setEditingScheduledDelayId, setServerMaxDelayMs, setSendError]);

const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => {
Expand Down Expand Up @@ -544,7 +618,44 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
onKeyUp={handleKeyUp}
onPaste={handlePaste}
top={
replyDraft && (
<>
{scheduledTime && (
<div>
<Box
alignItems="Center"
gap="300"
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
<IconButton
onClick={() => {
setScheduledTime(null);
setEditingScheduledDelayId(null);
setSendError(undefined);
}}
variant="SurfaceVariant"
size="300"
radii="300"
>
<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>
</Box>
</Box>
</div>
)}
{sendError && (
<Box style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}>
<Text style={{ color: color.Critical.Main }} size="T300">
{sendError}
</Text>
</Box>
)}
{replyDraft && (
<div>
<Box
alignItems="Center"
Expand Down Expand Up @@ -580,7 +691,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</Box>
</Box>
</div>
)
)}
</>
}
before={
<IconButton
Expand Down Expand Up @@ -669,9 +781,73 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</PopOut>
)}
</UseStateProvider>
<IconButton onClick={submit} 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}
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: React.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 @@ -683,6 +859,18 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
)
}
/>
{showSchedulePicker && (
<SchedulePickerDialog
initialTime={scheduledTime?.getTime()}
showEncryptionWarning={isEncrypted}
onCancel={() => setShowSchedulePicker(false)}
onSubmit={(date) => {
setScheduledTime(date);
setSendError(undefined);
setShowSchedulePicker(false);
}}
/>
)}
</div>
);
}
Expand Down
22 changes: 21 additions & 1 deletion src/app/features/room/RoomView.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import React, { useCallback, useRef } from 'react';
import { useAtomValue } from 'jotai';
import { Transforms } from 'slate';
import { Box, Text, config } from 'folds';
import { EventType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
Expand All @@ -7,7 +9,7 @@ import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useEditor } from '../../components/editor';
import { useEditor, resetEditor } from '../../components/editor';
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
import { RoomTimeline } from './RoomTimeline';
import { RoomViewTyping } from './RoomViewTyping';
Expand All @@ -22,6 +24,9 @@ import { settingsAtom } from '../../state/settings';
import { useSetting } from '../../state/hooks/settings';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { ScheduledMessagesList } from './schedule-send';
import { useDelayedEventsSupport } from '../../hooks/useDelayedEventsSupport';
import { delayedEventsSupportedAtom } from '../../state/scheduledMessages';

const FN_KEYS_REGEX = /^F\d+$/;
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
Expand Down Expand Up @@ -74,6 +79,18 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
const permissions = useRoomPermissions(creators, powerLevels);
const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());

useDelayedEventsSupport();
const delayedEventsSupported = useAtomValue(delayedEventsSupportedAtom);

const handleEditMessage = useCallback(
(body: string) => {
resetEditor(editor);
if (body) Transforms.insertText(editor, body);
ReactEditor.focus(editor);
},
[editor]
);

useKeyDown(
window,
useCallback(
Expand Down Expand Up @@ -105,6 +122,9 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
<RoomViewTyping room={room} />
</Box>
<Box shrink="No" direction="Column">
{canMessage && delayedEventsSupported && (
<ScheduledMessagesList room={room} onEditMessage={handleEditMessage} />
)}
<div style={{ padding: `0 ${config.space.S400}` }}>
{tombstoneEvent ? (
<RoomTombstone
Expand Down
18 changes: 18 additions & 0 deletions src/app/features/room/schedule-send/SchedulePickerDialog.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';

export const SchedulePickerContent = style({
padding: config.space.S400,
minWidth: toRem(300),
});

export const SplitSendButton = style({
borderRadius: `${config.radii.R300} 0 0 ${config.radii.R300}`,
});

export const SplitChevronButton = style({
borderRadius: `0 ${config.radii.R300} ${config.radii.R300} 0`,
borderLeft: '1px solid currentColor',
opacity: 0.7,
paddingInline: toRem(2),
});
Loading
Loading