feat: bookmarks, message scheduling, image compression, room insights
P3-1: Message Bookmarks — right-click any message to bookmark; saved to io.lotus.bookmarks account data (max 500, syncs across devices); star icon in sidebar opens BookmarksPanel with filter, Jump-to-message, and remove buttons; reactive to AccountData events P3-2: Message Scheduling (MSC4140) — clock button next to send opens ScheduleMessageModal with datetime-local picker; validates ≥1 min future; calls PUT org.matrix.msc4140 delayed event API; collapsible ScheduledMessagesTray above composer lists pending messages with cancel; local Jotai atom tracks scheduled messages per room P3-3: File Upload Compression — opt-in checkbox per JPEG/PNG file ≥200KB in upload preview; canvas API compresses at 0.82 quality; shows before/ after size estimate; compressed blob used in upload when checked P3-7: Room Insights — new Insights tab in room settings; top 5 active members (bar chart), top 5 reactions (chips), media breakdown (4 tiles), 24-hour activity heatmap (CSS bar chart); all from local cache only with disclaimer banner; never the first tab shown Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ 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 { ReactEditor } from 'slate-react';
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
getMxIdLocalPart,
|
||||
mxcUrlToHttp,
|
||||
} from '../../utils/matrix';
|
||||
import { compressImage } from '../../utils/imageCompression';
|
||||
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
|
||||
import { useFilePicker } from '../../hooks/useFilePicker';
|
||||
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
|
||||
@@ -124,6 +125,9 @@ import { useComposingCheck } from '../../hooks/useComposingCheck';
|
||||
import { VoiceMessageRecorder } from '../../components/VoiceMessageRecorder';
|
||||
import { PollCreator } from './PollCreator';
|
||||
import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus';
|
||||
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
||||
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
|
||||
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
||||
|
||||
const GifPicker = React.lazy(() =>
|
||||
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
|
||||
@@ -167,6 +171,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
setCharCount(0);
|
||||
}, [roomId]);
|
||||
const [pollOpen, setPollOpen] = useState(false);
|
||||
const [scheduleOpen, setScheduleOpen] = useState(false);
|
||||
const [scheduleContent, setScheduleContent] = useState<IContent | null>(null);
|
||||
const setScheduledMessages = useSetAtom(scheduledMessagesAtom);
|
||||
|
||||
const alive = useAlive();
|
||||
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
|
||||
@@ -401,16 +408,55 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
||||
if (!fileItem) throw new Error('Broken upload');
|
||||
|
||||
// Resolve the MXC URL to use — may be overridden if compression is enabled
|
||||
let mxc = upload.mxc;
|
||||
|
||||
if (
|
||||
fileItem.metadata.compressImage &&
|
||||
fileItem.originalFile.type.startsWith('image') &&
|
||||
(fileItem.originalFile.type === 'image/jpeg' ||
|
||||
fileItem.originalFile.type === 'image/png')
|
||||
) {
|
||||
// Use the cached compression result if available, otherwise compute it now
|
||||
let compressionResult = fileItem.metadata.compressionResult;
|
||||
if (compressionResult === undefined) {
|
||||
compressionResult = await compressImage(fileItem.originalFile as File);
|
||||
}
|
||||
|
||||
if (compressionResult) {
|
||||
const originalFile = fileItem.originalFile as File;
|
||||
const compressedFile = new File([compressionResult.blob], originalFile.name, {
|
||||
type: 'image/jpeg',
|
||||
});
|
||||
const uploadRes = await mx.uploadContent(compressedFile, {
|
||||
name: originalFile.name,
|
||||
type: 'image/jpeg',
|
||||
});
|
||||
const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
|
||||
if (compressedMxc) {
|
||||
mxc = compressedMxc;
|
||||
// Build a synthetic fileItem that refers to the compressed file so
|
||||
// getImageMsgContent picks up the correct dimensions and type.
|
||||
const compressedItem = {
|
||||
...fileItem,
|
||||
file: compressedFile,
|
||||
originalFile: compressedFile,
|
||||
};
|
||||
return getImageMsgContent(mx, compressedItem, mxc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fileItem.file.type.startsWith('image')) {
|
||||
return getImageMsgContent(mx, fileItem, upload.mxc);
|
||||
return getImageMsgContent(mx, fileItem, mxc);
|
||||
}
|
||||
if (fileItem.file.type.startsWith('video')) {
|
||||
return getVideoMsgContent(mx, fileItem, upload.mxc);
|
||||
return getVideoMsgContent(mx, fileItem, mxc);
|
||||
}
|
||||
if (fileItem.file.type.startsWith('audio')) {
|
||||
return getAudioMsgContent(fileItem, upload.mxc);
|
||||
return getAudioMsgContent(fileItem, mxc);
|
||||
}
|
||||
return getFileMsgContent(fileItem, upload.mxc);
|
||||
return getFileMsgContent(fileItem, mxc);
|
||||
});
|
||||
handleCancelUpload(uploads);
|
||||
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
||||
@@ -501,6 +547,80 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
sendTypingStatus(false);
|
||||
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
|
||||
|
||||
/**
|
||||
* Build a text message content object from the current editor state.
|
||||
* Returns null if the editor is empty or the input is a command.
|
||||
*/
|
||||
const buildCurrentTextContent = useCallback((): IContent | null => {
|
||||
const commandName = getBeginCommand(editor);
|
||||
// Don't schedule commands
|
||||
if (commandName) return null;
|
||||
|
||||
const plainText = toPlainText(editor.children, isMarkdown).trim();
|
||||
const customHtml = trimCustomHtml(
|
||||
toMatrixCustomHTML(editor.children, {
|
||||
allowTextFormatting: true,
|
||||
allowBlockMarkdown: isMarkdown,
|
||||
allowInlineMarkdown: isMarkdown,
|
||||
}),
|
||||
);
|
||||
if (plainText === '') return null;
|
||||
|
||||
const body = plainText;
|
||||
const formattedBody = customHtml;
|
||||
const mentionData = getMentions(mx, roomId, editor);
|
||||
|
||||
const content: IContent = {
|
||||
msgtype: MsgType.Text,
|
||||
body,
|
||||
};
|
||||
|
||||
if (replyDraft && replyDraft.userId !== mx.getUserId()) {
|
||||
mentionData.users.add(replyDraft.userId);
|
||||
}
|
||||
content['m.mentions'] = getMentionContent(Array.from(mentionData.users), mentionData.room);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}, [editor, isMarkdown, mx, roomId, replyDraft]);
|
||||
|
||||
const handleScheduleClick = useCallback(() => {
|
||||
const content = buildCurrentTextContent();
|
||||
if (!content) return;
|
||||
setScheduleContent(content);
|
||||
setScheduleOpen(true);
|
||||
}, [buildCurrentTextContent]);
|
||||
|
||||
const handleScheduled = useCallback(
|
||||
(delayId: string, sendAt: number, content: IContent) => {
|
||||
setScheduledMessages((prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = next.get(roomId) ?? [];
|
||||
next.set(roomId, [...current, { delayId, roomId, content, sendAt }]);
|
||||
return next;
|
||||
});
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
localStorage.removeItem(`draft-msg-${roomId}`);
|
||||
setReplyDraft(undefined);
|
||||
sendTypingStatus(false);
|
||||
},
|
||||
[setScheduledMessages, roomId, editor, setReplyDraft, sendTypingStatus],
|
||||
);
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||
(evt) => {
|
||||
if (
|
||||
@@ -750,6 +870,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<ScheduledMessagesTray roomId={roomId} />
|
||||
<CustomEditor
|
||||
editableName="RoomInput"
|
||||
editor={editor}
|
||||
@@ -1019,6 +1140,16 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
{charCount}
|
||||
</Text>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={handleScheduleClick}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label="Schedule message"
|
||||
title="Schedule message"
|
||||
>
|
||||
<Icon src={Icons.Clock} size="100" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={submit}
|
||||
variant="SurfaceVariant"
|
||||
@@ -1040,6 +1171,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
}
|
||||
/>
|
||||
{pollOpen && <PollCreator room={room} roomId={roomId} onClose={() => setPollOpen(false)} />}
|
||||
{scheduleOpen && scheduleContent && (
|
||||
<ScheduleMessageModal
|
||||
roomId={roomId}
|
||||
content={scheduleContent}
|
||||
onScheduled={handleScheduled}
|
||||
onClose={() => {
|
||||
setScheduleOpen(false);
|
||||
setScheduleContent(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Spinner,
|
||||
Text,
|
||||
color,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { IContent } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { scheduleMessage } from '../../utils/scheduledMessages';
|
||||
|
||||
interface ScheduleMessageModalProps {
|
||||
roomId: string;
|
||||
content: IContent;
|
||||
onScheduled: (delayId: string, sendAt: number, content: IContent) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatRelativeTime(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
if (hours > 0 && minutes > 0) return `in ${hours}h ${minutes}m`;
|
||||
if (hours > 0) return `in ${hours}h`;
|
||||
if (minutes > 0) return `in ${minutes}m`;
|
||||
return 'in less than a minute';
|
||||
}
|
||||
|
||||
function formatSendAt(sendAt: Date): string {
|
||||
const now = new Date();
|
||||
const isToday =
|
||||
sendAt.getFullYear() === now.getFullYear() &&
|
||||
sendAt.getMonth() === now.getMonth() &&
|
||||
sendAt.getDate() === now.getDate();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const isTomorrow =
|
||||
sendAt.getFullYear() === tomorrow.getFullYear() &&
|
||||
sendAt.getMonth() === tomorrow.getMonth() &&
|
||||
sendAt.getDate() === tomorrow.getDate();
|
||||
|
||||
const timeStr = sendAt.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
if (isToday) return `Today at ${timeStr}`;
|
||||
if (isTomorrow) return `Tomorrow at ${timeStr}`;
|
||||
return `${sendAt.toLocaleDateString()} at ${timeStr}`;
|
||||
}
|
||||
|
||||
function toLocalDatetimeValue(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return (
|
||||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
||||
`T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
);
|
||||
}
|
||||
|
||||
export function ScheduleMessageModal({
|
||||
roomId,
|
||||
content,
|
||||
onScheduled,
|
||||
onClose,
|
||||
}: ScheduleMessageModalProps) {
|
||||
const mx = useMatrixClient();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Default: 1 hour from now, rounded to nearest 5 minutes
|
||||
const defaultDate = () => {
|
||||
const d = new Date(Date.now() + 60 * 60 * 1000);
|
||||
d.setSeconds(0, 0);
|
||||
d.setMinutes(Math.ceil(d.getMinutes() / 5) * 5);
|
||||
return d;
|
||||
};
|
||||
|
||||
const [datetimeValue, setDatetimeValue] = useState<string>(() =>
|
||||
toLocalDatetimeValue(defaultDate()),
|
||||
);
|
||||
|
||||
const [preview, setPreview] = useState<{ label: string; relative: string } | null>(null);
|
||||
|
||||
const updatePreview = useCallback((value: string) => {
|
||||
if (!value) {
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
const sendAt = new Date(value);
|
||||
const now = Date.now();
|
||||
const diffMs = sendAt.getTime() - now;
|
||||
if (Number.isNaN(sendAt.getTime()) || diffMs < 60_000) {
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
setPreview({ label: formatSendAt(sendAt), relative: formatRelativeTime(diffMs) });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
updatePreview(datetimeValue);
|
||||
}, [datetimeValue, updatePreview]);
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
|
||||
e.preventDefault();
|
||||
if (submitting) return;
|
||||
|
||||
if (!datetimeValue) {
|
||||
setError('Please select a date and time.');
|
||||
return;
|
||||
}
|
||||
const sendAt = new Date(datetimeValue);
|
||||
if (Number.isNaN(sendAt.getTime())) {
|
||||
setError('Invalid date/time.');
|
||||
return;
|
||||
}
|
||||
const diffMs = sendAt.getTime() - Date.now();
|
||||
if (diffMs < 60_000) {
|
||||
setError('Scheduled time must be at least 1 minute in the future.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const delayId = await scheduleMessage(mx, roomId, content, sendAt.getTime());
|
||||
onScheduled(delayId, sendAt.getTime(), content);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to schedule message.');
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: onClose,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
as="form"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="schedule-message-title"
|
||||
onSubmit={handleSubmit}
|
||||
direction="Column"
|
||||
style={{
|
||||
background: color.Surface.Container,
|
||||
borderRadius: config.radii.R400,
|
||||
boxShadow: color.Other.Shadow,
|
||||
width: '100vw',
|
||||
maxWidth: 400,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Header
|
||||
variant="Surface"
|
||||
size="500"
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Icon src={Icons.Clock} size="100" />
|
||||
<Text id="schedule-message-title" size="H4">
|
||||
Schedule Message
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
{/* Body */}
|
||||
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
|
||||
{/* Message preview */}
|
||||
{typeof content.body === 'string' && content.body.trim() !== '' && (
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{
|
||||
background: color.SurfaceVariant.Container,
|
||||
borderRadius: config.radii.R300,
|
||||
padding: config.space.S200,
|
||||
}}
|
||||
>
|
||||
<Text size="L400">Message</Text>
|
||||
<Text
|
||||
size="T300"
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
{content.body as string}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Datetime picker */}
|
||||
<Box direction="Column" gap="100">
|
||||
<Text as="label" htmlFor="schedule-datetime" size="L400">
|
||||
Send at
|
||||
</Text>
|
||||
<input
|
||||
id="schedule-datetime"
|
||||
type="datetime-local"
|
||||
value={datetimeValue}
|
||||
onChange={(e) => setDatetimeValue(e.target.value)}
|
||||
style={{
|
||||
background: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
borderRadius: config.radii.R300,
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
fontSize: '0.875rem',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Preview */}
|
||||
{preview ? (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T300" style={{ opacity: 0.7 }}>
|
||||
{preview.label}
|
||||
</Text>
|
||||
<Text size="T200" style={{ opacity: 0.5 }}>
|
||||
({preview.relative})
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
datetimeValue && (
|
||||
<Text size="T200" style={{ color: 'var(--tc-danger-normal)' }}>
|
||||
Must be at least 1 minute in the future
|
||||
</Text>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<Text size="T300" style={{ color: 'var(--tc-danger-normal)' }}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Box
|
||||
gap="300"
|
||||
justifyContent="End"
|
||||
style={{
|
||||
padding: `${config.space.S200} ${config.space.S400} ${config.space.S400}`,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="Secondary"
|
||||
fill="None"
|
||||
radii="300"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
<Text size="B400">Cancel</Text>
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="Primary"
|
||||
radii="300"
|
||||
disabled={submitting || !preview}
|
||||
before={submitting ? <Spinner variant="Primary" size="100" /> : undefined}
|
||||
>
|
||||
<Text size="B400">Schedule</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { Box, Icon, IconButton, Icons, Text, color, config } from 'folds';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages';
|
||||
import { cancelScheduledMessage } from '../../utils/scheduledMessages';
|
||||
|
||||
interface ScheduledMessagesTrayProps {
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
function formatSendAt(sendAt: number): string {
|
||||
const date = new Date(sendAt);
|
||||
const now = new Date();
|
||||
const isToday =
|
||||
date.getFullYear() === now.getFullYear() &&
|
||||
date.getMonth() === now.getMonth() &&
|
||||
date.getDate() === now.getDate();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const isTomorrow =
|
||||
date.getFullYear() === tomorrow.getFullYear() &&
|
||||
date.getMonth() === tomorrow.getMonth() &&
|
||||
date.getDate() === tomorrow.getDate();
|
||||
const timeStr = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
if (isToday) return `Today ${timeStr}`;
|
||||
if (isTomorrow) return `Tomorrow ${timeStr}`;
|
||||
return `${date.toLocaleDateString()} ${timeStr}`;
|
||||
}
|
||||
|
||||
export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
const mx = useMatrixClient();
|
||||
const [scheduledMessages, setScheduledMessages] = useAtom(scheduledMessagesAtom);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [cancelling, setCancelling] = useState<Set<string>>(new Set());
|
||||
|
||||
const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]);
|
||||
|
||||
// Remove scheduled messages whose time has passed
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) return undefined;
|
||||
|
||||
const nearestSendAt = Math.min(...messages.map((m) => m.sendAt));
|
||||
const delay = nearestSendAt - Date.now();
|
||||
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
const now = Date.now();
|
||||
setScheduledMessages((prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = next.get(roomId) ?? [];
|
||||
const remaining = current.filter((m) => m.sendAt > now);
|
||||
if (remaining.length === 0) {
|
||||
next.delete(roomId);
|
||||
} else {
|
||||
next.set(roomId, remaining);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
Math.max(0, delay) + 2000,
|
||||
); // 2s grace after scheduled time
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [messages, roomId, setScheduledMessages]);
|
||||
|
||||
const handleCancel = useCallback(
|
||||
async (msg: ScheduledMessage) => {
|
||||
if (cancelling.has(msg.delayId)) return;
|
||||
setCancelling((prev) => new Set(prev).add(msg.delayId));
|
||||
try {
|
||||
await cancelScheduledMessage(mx, msg.delayId);
|
||||
} catch {
|
||||
// If cancellation fails on the server, still remove locally
|
||||
// since the user intends to remove it
|
||||
} finally {
|
||||
setScheduledMessages((prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = next.get(roomId) ?? [];
|
||||
const remaining = current.filter((m) => m.delayId !== msg.delayId);
|
||||
if (remaining.length === 0) {
|
||||
next.delete(roomId);
|
||||
} else {
|
||||
next.set(roomId, remaining);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setCancelling((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(msg.delayId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[mx, roomId, cancelling, setScheduledMessages],
|
||||
);
|
||||
|
||||
if (messages.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
direction="Column"
|
||||
style={{
|
||||
borderBottom: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
background: color.SurfaceVariant.Container,
|
||||
}}
|
||||
>
|
||||
{/* Tray header */}
|
||||
<Box
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{
|
||||
padding: `${config.space.S100} ${config.space.S300}`,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
as="button"
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${messages.length} scheduled message${messages.length !== 1 ? 's' : ''}`}
|
||||
>
|
||||
<Icon src={Icons.Clock} size="50" />
|
||||
<Text size="T200" style={{ flex: 1, fontWeight: 600 }}>
|
||||
{messages.length} scheduled message{messages.length !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
<Icon src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} size="50" />
|
||||
</Box>
|
||||
|
||||
{/* Tray items */}
|
||||
{expanded && (
|
||||
<Box direction="Column">
|
||||
{messages.map((msg) => (
|
||||
<Box
|
||||
key={msg.delayId}
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{
|
||||
padding: `${config.space.S100} ${config.space.S300}`,
|
||||
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="T200"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
{typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)'}
|
||||
</Text>
|
||||
<Text size="T200" style={{ opacity: 0.6, whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{formatSendAt(msg.sendAt)}
|
||||
</Text>
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="SurfaceVariant"
|
||||
aria-label="Cancel scheduled message"
|
||||
disabled={cancelling.has(msg.delayId)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCancel(msg);
|
||||
}}
|
||||
>
|
||||
<Icon src={Icons.Cross} size="50" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -79,6 +79,7 @@ import { PowerIcon } from '../../../components/power';
|
||||
import colorMXID from '../../../../util/colorMXID';
|
||||
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
||||
import { ForwardMessageDialog } from './ForwardMessageDialog';
|
||||
import { useBookmarks } from '../../../hooks/useBookmarks';
|
||||
|
||||
// Delivery status indicator for own messages
|
||||
function DeliveryStatus({
|
||||
@@ -792,6 +793,7 @@ export const Message = React.memo(
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<RectCords>();
|
||||
const [forwardOpen, setForwardOpen] = useState(false);
|
||||
const { addBookmark, removeBookmark, isBookmarked } = useBookmarks();
|
||||
|
||||
const senderDisplayName =
|
||||
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
|
||||
@@ -1128,6 +1130,48 @@ export const Message = React.memo(
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{!mEvent.isRedacted() && mEvent.getId() && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={
|
||||
<Icon
|
||||
size="100"
|
||||
src={Icons.Star}
|
||||
filled={isBookmarked(mEvent.getId()!)}
|
||||
/>
|
||||
}
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
const eventId = mEvent.getId()!;
|
||||
if (isBookmarked(eventId)) {
|
||||
removeBookmark(eventId);
|
||||
} else {
|
||||
const content = mEvent.getContent();
|
||||
const body: string =
|
||||
(content?.body as string | undefined) ?? '';
|
||||
addBookmark({
|
||||
roomId: room.roomId,
|
||||
eventId,
|
||||
savedAt: Date.now(),
|
||||
previewText: body.slice(0, 120),
|
||||
roomName: room.name,
|
||||
});
|
||||
}
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className={css.MessageMenuItemText}
|
||||
as="span"
|
||||
size="T300"
|
||||
truncate
|
||||
>
|
||||
{isBookmarked(mEvent.getId()!)
|
||||
? 'Remove Bookmark'
|
||||
: 'Bookmark Message'}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{!isThreadedMessage && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
|
||||
Reference in New Issue
Block a user