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>
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user