diff --git a/src/app/components/message/layout/Bubble.css.ts b/src/app/components/message/layout/Bubble.css.ts
index c5c2d0a3..01b60b6d 100644
--- a/src/app/components/message/layout/Bubble.css.ts
+++ b/src/app/components/message/layout/Bubble.css.ts
@@ -85,13 +85,6 @@ export const MediaPlain = style({
maxWidth: '100%',
});
-// Editing: the body is the composer card itself (wrapped in ChatComposer by the
-// caller) — full width, no bubble background of our own.
-export const EditContent = style({
- width: '100%',
- minWidth: 0,
-});
-
// Tap-rail open (or context menu / emoji board open) → the message reads as
// «selected», the same affordance long-press gives. A soft brand ring rather
// than a fill so it works over both the bubble and bare peer text / media.
diff --git a/src/app/components/message/layout/Bubble.tsx b/src/app/components/message/layout/Bubble.tsx
index 43f3c681..2866c280 100644
--- a/src/app/components/message/layout/Bubble.tsx
+++ b/src/app/components/message/layout/Bubble.tsx
@@ -19,9 +19,6 @@ export type BubbleLayoutProps = {
// media AND for the tail of a same-minute series (grouped messages). A single
// text message keeps its timestamp on the side.
timeBelow?: boolean;
- // While editing, the body IS a composer card — drop the bubble chrome so it
- // reads as one box, and skip the timestamp.
- editing?: boolean;
// Reactions chip-row, floats under the message on the page background.
reactions?: ReactNode;
threadSummary?: ReactNode;
@@ -45,7 +42,6 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
selected,
mediaMode,
timeBelow,
- editing,
reactions,
threadSummary,
readStatus,
@@ -63,10 +59,7 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
);
let body: ReactNode;
- if (editing) {
- // The child is already a composer card; render it full width, no chrome.
- body =
{children}
;
- } else if (mediaMode || timeBelow) {
+ if (mediaMode || timeBelow) {
// Media, and the tail of a same-minute series: timestamp BELOW the content,
// aligned to the message side by the Row (own → right, peer → left).
body = (
diff --git a/src/app/components/upload-board/UploadBoard.css.ts b/src/app/components/upload-board/UploadBoard.css.ts
deleted file mode 100644
index 80c1b264..00000000
--- a/src/app/components/upload-board/UploadBoard.css.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { style } from '@vanilla-extract/css';
-import { DefaultReset, color, config, toRem } from 'folds';
-
-export const UploadBoardBase = style([
- DefaultReset,
- {
- position: 'relative',
- pointerEvents: 'none',
- },
-]);
-
-export const UploadBoardContainer = style([
- DefaultReset,
- {
- position: 'absolute',
- bottom: config.space.S200,
- left: 0,
- right: 0,
- zIndex: config.zIndex.Max,
- },
-]);
-
-export const UploadBoard = style({
- maxWidth: toRem(400),
- width: '100%',
- maxHeight: toRem(450),
- height: '100%',
- backgroundColor: color.Surface.Container,
- color: color.Surface.OnContainer,
- borderRadius: config.radii.R400,
- boxShadow: config.shadow.E200,
- border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
- overflow: 'hidden',
- pointerEvents: 'all',
-});
-
-export const UploadBoardHeaderContent = style({
- height: '100%',
- padding: `0 ${config.space.S200}`,
-});
-
-export const UploadBoardContent = style({
- padding: config.space.S200,
- paddingBottom: 0,
- paddingRight: 0,
-});
diff --git a/src/app/components/upload-board/UploadBoard.tsx b/src/app/components/upload-board/UploadBoard.tsx
deleted file mode 100644
index 42f3899f..00000000
--- a/src/app/components/upload-board/UploadBoard.tsx
+++ /dev/null
@@ -1,145 +0,0 @@
-import React, { MutableRefObject, ReactNode, useImperativeHandle, useRef } from 'react';
-import { Badge, Box, Chip, Header, Icon, Icons, Spinner, Text, as, percent } from 'folds';
-import classNames from 'classnames';
-import { useAtomValue } from 'jotai';
-
-import * as css from './UploadBoard.css';
-import { TUploadFamilyObserverAtom, Upload, UploadStatus, UploadSuccess } from '../../state/upload';
-
-type UploadBoardProps = {
- header: ReactNode;
-};
-export const UploadBoard = as<'div', UploadBoardProps>(({ header, children, ...props }, ref) => (
-
-
-
-
- {children}
-
-
- {header}
-
-
-
-
-));
-
-export type UploadBoardImperativeHandlers = { handleSend: () => Promise };
-
-type UploadBoardHeaderProps = {
- open: boolean;
- onToggle: () => void;
- uploadFamilyObserverAtom: TUploadFamilyObserverAtom;
- onCancel: (uploads: Upload[]) => void;
- onSend: (uploads: UploadSuccess[]) => Promise;
- imperativeHandlerRef: MutableRefObject;
-};
-
-export function UploadBoardHeader({
- open,
- onToggle,
- uploadFamilyObserverAtom,
- onCancel,
- onSend,
- imperativeHandlerRef,
-}: UploadBoardHeaderProps) {
- const sendingRef = useRef(false);
- const uploads = useAtomValue(uploadFamilyObserverAtom);
-
- const isSuccess = uploads.every((upload) => upload.status === UploadStatus.Success);
- const isError = uploads.some((upload) => upload.status === UploadStatus.Error);
- const progress = uploads.reduce(
- (acc, upload) => {
- acc.total += upload.file.size;
- if (upload.status === UploadStatus.Loading) {
- acc.loaded += upload.progress.loaded;
- }
- if (upload.status === UploadStatus.Success) {
- acc.loaded += upload.file.size;
- }
- return acc;
- },
- { loaded: 0, total: 0 }
- );
-
- const handleSend = async () => {
- if (sendingRef.current) return;
- sendingRef.current = true;
- await onSend(
- uploads.filter((upload) => upload.status === UploadStatus.Success) as UploadSuccess[]
- );
- sendingRef.current = false;
- };
-
- useImperativeHandle(imperativeHandlerRef, () => ({
- handleSend,
- }));
- const handleCancel = () => onCancel(uploads);
-
- return (
-
-
-
- Files
-
-
- {isSuccess && (
- }
- >
- Send
-
- )}
- {isError && !open && (
-
- Upload Failed
-
- )}
- {!isSuccess && !isError && !open && (
- <>
-
- {Math.round(percent(0, progress.total, progress.loaded))}%
-
-
- >
- )}
- {!isSuccess && open && (
- }
- >
- {uploads.length === 1 ? 'Remove' : 'Remove All'}
-
- )}
-
-
- );
-}
-
-export const UploadBoardContent = as<'div'>(({ className, children, ...props }, ref) => (
-
- {children}
-
-));
diff --git a/src/app/components/upload-board/index.ts b/src/app/components/upload-board/index.ts
deleted file mode 100644
index 24ae780c..00000000
--- a/src/app/components/upload-board/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './UploadBoard';
diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx
deleted file mode 100644
index f5bc68e0..00000000
--- a/src/app/components/upload-card/UploadCardRenderer.tsx
+++ /dev/null
@@ -1,216 +0,0 @@
-import React, { ReactNode, useEffect } from 'react';
-import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
-import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
-import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
-import { useMatrixClient } from '../../hooks/useMatrixClient';
-import { TUploadContent } from '../../utils/matrix';
-import { bytesToSize, getFileTypeIcon } from '../../utils/common';
-import {
- roomUploadAtomFamily,
- TUploadItem,
- TUploadMetadata,
-} from '../../state/room/roomInputDrafts';
-import { useObjectURL } from '../../hooks/useObjectURL';
-import { useMediaConfig } from '../../hooks/useMediaConfig';
-
-type PreviewImageProps = {
- fileItem: TUploadItem;
-};
-function PreviewImage({ fileItem }: PreviewImageProps) {
- const { originalFile, metadata } = fileItem;
- const fileUrl = useObjectURL(originalFile);
-
- return (
-
- );
-}
-
-type PreviewVideoProps = {
- fileItem: TUploadItem;
-};
-function PreviewVideo({ fileItem }: PreviewVideoProps) {
- const { originalFile, metadata } = fileItem;
- const fileUrl = useObjectURL(originalFile);
-
- return (
- // eslint-disable-next-line jsx-a11y/media-has-caption
-
- );
-}
-
-type MediaPreviewProps = {
- fileItem: TUploadItem;
- onSpoiler: (marked: boolean) => void;
- children: ReactNode;
-};
-function MediaPreview({ fileItem, onSpoiler, children }: MediaPreviewProps) {
- const { originalFile, metadata } = fileItem;
- const fileUrl = useObjectURL(originalFile);
-
- return fileUrl ? (
-
- {children}
-
- }
- onClick={() => onSpoiler(!metadata.markedAsSpoiler)}
- >
- Spoiler
-
-
-
- ) : null;
-}
-
-type UploadCardRendererProps = {
- isEncrypted?: boolean;
- fileItem: TUploadItem;
- setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
- onRemove: (file: TUploadContent) => void;
- onComplete?: (upload: UploadSuccess) => void;
-};
-export function UploadCardRenderer({
- isEncrypted,
- fileItem,
- setMetadata,
- onRemove,
- onComplete,
-}: UploadCardRendererProps) {
- const mx = useMatrixClient();
- const mediaConfig = useMediaConfig();
- const allowSize = mediaConfig['m.upload.size'] || Infinity;
-
- const uploadAtom = roomUploadAtomFamily(fileItem.file);
- const { metadata } = fileItem;
- const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
- const { file } = upload;
- const fileSizeExceeded = file.size >= allowSize;
-
- if (upload.status === UploadStatus.Idle && !fileSizeExceeded) {
- startUpload();
- }
-
- const handleSpoiler = (marked: boolean) => {
- setMetadata(fileItem, { ...metadata, markedAsSpoiler: marked });
- };
-
- const removeUpload = () => {
- cancelUpload();
- onRemove(file);
- };
-
- useEffect(() => {
- if (upload.status === UploadStatus.Success) {
- onComplete?.(upload);
- }
- }, [upload, onComplete]);
-
- return (
- }
- after={
- <>
- {upload.status === UploadStatus.Error && (
-
- Retry
-
- )}
-
-
-
- >
- }
- bottom={
- <>
- {fileItem.originalFile.type.startsWith('image') && (
-
-
-
- )}
- {fileItem.originalFile.type.startsWith('video') && (
-
-
-
- )}
- {upload.status === UploadStatus.Idle && !fileSizeExceeded && (
-
- )}
- {upload.status === UploadStatus.Loading && (
-
- )}
- {upload.status === UploadStatus.Error && (
-
- {upload.error.message}
-
- )}
- {upload.status === UploadStatus.Idle && fileSizeExceeded && (
-
-
- The file size exceeds the limit. Maximum allowed size is{' '}
- {bytesToSize(allowSize)} , but the uploaded file is{' '}
- {bytesToSize(file.size)} .
-
-
- )}
- >
- }
- >
-
- {file.name}
-
- {upload.status === UploadStatus.Success && (
-
- )}
-
- );
-}
diff --git a/src/app/components/upload-card/index.ts b/src/app/components/upload-card/index.ts
index e014ab8a..94dc1498 100644
--- a/src/app/components/upload-card/index.ts
+++ b/src/app/components/upload-card/index.ts
@@ -1,3 +1,2 @@
export * from './UploadCard';
-export * from './UploadCardRenderer';
export * from './CompactUploadCardRenderer';
diff --git a/src/app/features/room/ComposerAttachments.css.ts b/src/app/features/room/ComposerAttachments.css.ts
new file mode 100644
index 00000000..439e883a
--- /dev/null
+++ b/src/app/features/room/ComposerAttachments.css.ts
@@ -0,0 +1,187 @@
+import { globalStyle, style } from '@vanilla-extract/css';
+import { color, config, toRem } from 'folds';
+
+// In-composer attachment strip — replaces the old floating UploadBoard
+// window. Attached media render as square thumbnails and other files as
+// compact pills, all on ONE horizontally-scrollable row inside the
+// composer card (the same `top` slot the reply/edit banners use) —
+// Telegram-style: the attachment is a record in the input form, and the
+// composer's own send button ships it.
+export const Strip = style({
+ display: 'flex',
+ alignItems: 'center',
+ gap: config.space.S200,
+ overflowX: 'auto',
+ overflowY: 'hidden',
+ padding: `${config.space.S200} ${config.space.S300} 0`,
+ scrollbarWidth: 'none',
+});
+
+globalStyle(`${Strip}::-webkit-scrollbar`, {
+ display: 'none',
+});
+
+const TILE_PX = 76;
+
+// Square media thumbnail (image / video).
+export const MediaTile = style({
+ position: 'relative',
+ width: toRem(TILE_PX),
+ height: toRem(TILE_PX),
+ flexShrink: 0,
+ borderRadius: toRem(12),
+ overflow: 'hidden',
+ backgroundColor: color.SurfaceVariant.Container,
+});
+
+export const MediaTileContent = style({
+ width: '100%',
+ height: '100%',
+ objectFit: 'cover',
+ display: 'block',
+});
+
+export const MediaTileSpoilered = style({
+ filter: 'blur(20px)',
+});
+
+// Non-media file pill: type icon + name/size column.
+export const FileTile = style({
+ position: 'relative',
+ display: 'flex',
+ alignItems: 'center',
+ gap: config.space.S200,
+ height: toRem(TILE_PX),
+ maxWidth: toRem(220),
+ flexShrink: 0,
+ padding: `0 ${config.space.S300}`,
+ // Room for the floating remove badge so it doesn't sit on the text.
+ paddingRight: toRem(28),
+ borderRadius: toRem(12),
+ backgroundColor: color.SurfaceVariant.Container,
+});
+
+export const FileTileText = style({
+ minWidth: 0,
+ display: 'flex',
+ flexDirection: 'column',
+ gap: toRem(2),
+});
+
+export const FileTileName = style({
+ fontSize: toRem(12.5),
+ lineHeight: toRem(16),
+ fontWeight: 600,
+ color: color.SurfaceVariant.OnContainer,
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+});
+
+export const FileTileMeta = style({
+ fontSize: toRem(11),
+ lineHeight: toRem(14),
+ color: color.SurfaceVariant.OnContainer,
+ opacity: 0.6,
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ fontVariantNumeric: 'tabular-nums',
+});
+
+export const FileTileMetaCritical = style({
+ color: color.Critical.Main,
+ opacity: 1,
+});
+
+// Floating × — top-right corner of every tile. The visible badge stays
+// 20px; the transparent ::after inflates the hit area to ~36px for
+// thumbs (clipped by the tile's overflow:hidden at the outer edges, so
+// the growth is effectively inward).
+export const RemoveBtn = style({
+ position: 'absolute',
+ top: toRem(4),
+ right: toRem(4),
+ zIndex: 2,
+ width: toRem(20),
+ height: toRem(20),
+ borderRadius: '50%',
+ border: 'none',
+ padding: 0,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ cursor: 'pointer',
+ backgroundColor: 'rgba(0, 0, 0, 0.6)',
+ color: '#fff',
+ selectors: {
+ '&::after': {
+ content: '""',
+ position: 'absolute',
+ inset: toRem(-8),
+ },
+ },
+});
+
+// Spoiler toggle — bottom-left of media tiles; violet when armed. Same
+// inflated hit area as RemoveBtn.
+export const SpoilerBtn = style({
+ position: 'absolute',
+ bottom: toRem(4),
+ left: toRem(4),
+ zIndex: 2,
+ width: toRem(22),
+ height: toRem(22),
+ borderRadius: '50%',
+ border: 'none',
+ padding: 0,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ cursor: 'pointer',
+ backgroundColor: 'rgba(0, 0, 0, 0.6)',
+ color: '#fff',
+ selectors: {
+ '&[aria-pressed="true"]': {
+ backgroundColor: color.Primary.Main,
+ color: color.Primary.OnMain,
+ },
+ '&::after': {
+ content: '""',
+ position: 'absolute',
+ inset: toRem(-7),
+ },
+ },
+});
+
+// Upload progress / error overlay over a media tile.
+export const TileOverlay = style({
+ position: 'absolute',
+ inset: 0,
+ zIndex: 1,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: 'rgba(0, 0, 0, 0.45)',
+ color: '#fff',
+ fontSize: toRem(12),
+ fontWeight: 600,
+ fontVariantNumeric: 'tabular-nums',
+});
+
+// Failed / oversized tile — critical rim; the retry button lives in the
+// overlay (media) or the meta line (files).
+export const TileError = style({
+ boxShadow: `inset 0 0 0 ${toRem(1.5)} ${color.Critical.Main}`,
+});
+
+export const RetryBtn = style({
+ border: 'none',
+ padding: `${toRem(2)} ${toRem(8)}`,
+ borderRadius: toRem(99),
+ cursor: 'pointer',
+ backgroundColor: color.Critical.Main,
+ color: color.Critical.OnMain,
+ fontSize: toRem(11),
+ fontWeight: 600,
+});
diff --git a/src/app/features/room/ComposerAttachments.tsx b/src/app/features/room/ComposerAttachments.tsx
new file mode 100644
index 00000000..c6210bdd
--- /dev/null
+++ b/src/app/features/room/ComposerAttachments.tsx
@@ -0,0 +1,213 @@
+import React, { useEffect } from 'react';
+import classNames from 'classnames';
+import { Icon, Icons, Text, percent } from 'folds';
+import { useTranslation } from 'react-i18next';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useObjectURL } from '../../hooks/useObjectURL';
+import { useMediaConfig } from '../../hooks/useMediaConfig';
+import { UploadStatus, useBindUploadAtom } from '../../state/upload';
+import {
+ roomUploadAtomFamily,
+ TUploadItem,
+ TUploadMetadata,
+} from '../../state/room/roomInputDrafts';
+import { TUploadContent } from '../../utils/matrix';
+import { bytesToSize, getFileTypeIcon } from '../../utils/common';
+import * as css from './ComposerAttachments.css';
+
+// In-composer attachment strip — the record of attached media INSIDE the
+// input form (Telegram-style), replacing the old floating UploadBoard
+// window. Uploads start immediately on attach (same as before); the
+// composer's send button ships every finished upload together with the
+// text. Per item: × to remove, spoiler toggle on media, progress overlay
+// while uploading, critical rim + retry on failure.
+
+type AttachmentItemProps = {
+ fileItem: TUploadItem;
+ isEncrypted?: boolean;
+ setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
+ onRemove: (file: TUploadContent) => void;
+};
+
+function AttachmentItem({ fileItem, isEncrypted, setMetadata, onRemove }: AttachmentItemProps) {
+ const { t } = useTranslation();
+ const mx = useMatrixClient();
+ const mediaConfig = useMediaConfig();
+ const allowSize = mediaConfig['m.upload.size'] || Infinity;
+
+ const uploadAtom = roomUploadAtomFamily(fileItem.file);
+ const { metadata, originalFile } = fileItem;
+ const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
+ const { file } = upload;
+ const fileSizeExceeded = file.size >= allowSize;
+
+ // Auto-start on attach — same behaviour the old UploadCardRenderer had.
+ useEffect(() => {
+ if (upload.status === UploadStatus.Idle && !fileSizeExceeded) {
+ startUpload();
+ }
+ }, [upload.status, fileSizeExceeded, startUpload]);
+
+ const previewUrl = useObjectURL(
+ originalFile.type.startsWith('image') || originalFile.type.startsWith('video')
+ ? originalFile
+ : undefined
+ );
+
+ const handleRemove = () => {
+ cancelUpload();
+ onRemove(file);
+ };
+ const handleSpoiler = () => {
+ setMetadata(fileItem, { ...metadata, markedAsSpoiler: !metadata.markedAsSpoiler });
+ };
+
+ const loading = upload.status === UploadStatus.Loading;
+ const failed = upload.status === UploadStatus.Error;
+ const progressPercent = loading
+ ? Math.round(percent(0, file.size || 1, upload.progress.loaded))
+ : 0;
+
+ const removeBtn = (
+
+
+
+ );
+
+ if (previewUrl) {
+ const isVideo = originalFile.type.startsWith('video');
+ return (
+
+ {isVideo ? (
+ // eslint-disable-next-line jsx-a11y/media-has-caption
+
+ ) : (
+
+ )}
+ {loading &&
{progressPercent}%
}
+ {(failed || fileSizeExceeded) && (
+
+ {failed ? (
+
+ {t('Room.upload_retry')}
+
+ ) : (
+
+ {t('Room.upload_too_large')}
+
+ )}
+
+ )}
+ {removeBtn}
+ {!fileSizeExceeded && (
+
+
+
+ )}
+
+ );
+ }
+
+ let meta: React.ReactNode = bytesToSize(file.size);
+ if (loading) meta = `${progressPercent}%`;
+ else if (fileSizeExceeded) meta = t('Room.upload_too_large');
+ else if (failed) {
+ meta = (
+
+ {t('Room.upload_retry')}
+
+ );
+ }
+
+ return (
+
+
+
+ {file.name}
+
+ {meta}
+
+
+ {removeBtn}
+
+ );
+}
+
+type ComposerAttachmentsProps = {
+ selectedFiles: TUploadItem[];
+ setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
+ onRemove: (file: TUploadContent) => void;
+};
+
+export function ComposerAttachments({
+ selectedFiles,
+ setMetadata,
+ onRemove,
+}: ComposerAttachmentsProps) {
+ if (selectedFiles.length === 0) return null;
+ return (
+
+ {selectedFiles.map((fileItem, index) => (
+
+ ))}
+
+ );
+}
diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx
index c58335a6..3d06df08 100644
--- a/src/app/features/room/RoomInput.tsx
+++ b/src/app/features/room/RoomInput.tsx
@@ -9,13 +9,24 @@ import React, {
useRef,
useState,
} from 'react';
-import { useAtom, useAtomValue, useSetAtom } from 'jotai';
+import { useAtom, useAtomValue, useSetAtom, useStore } from 'jotai';
import { isKeyHotkey } from 'is-hotkey';
+import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next';
-import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
+import {
+ EventStatus,
+ EventType,
+ IContent,
+ IMentions,
+ MatrixEvent,
+ MsgType,
+ RelationType,
+ Room,
+ RoomEvent,
+} from 'matrix-js-sdk';
import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/types';
import { ReactEditor } from 'slate-react';
-import { Transforms, Editor } from 'slate';
+import { Transforms, Editor, Descendant } from 'slate';
import {
Box,
Dialog,
@@ -26,7 +37,6 @@ import {
OverlayBackdrop,
OverlayCenter,
PopOut,
- Scroll,
Text,
color,
config,
@@ -56,6 +66,8 @@ import {
getBeginCommand,
trimCommand,
getMentions,
+ htmlToEditorInput,
+ plainToEditorInput,
} from '../../components/editor';
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
import { UseStateProvider } from '../../components/UseStateProvider';
@@ -68,6 +80,7 @@ import {
mxcUrlToHttp,
} from '../../utils/matrix';
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
+import { useAlive } from '../../hooks/useAlive';
import { useFilePicker } from '../../hooks/useFilePicker';
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
import { useFileDropZone } from '../../hooks/useFileDrop';
@@ -75,18 +88,13 @@ import {
TUploadItem,
TUploadMetadata,
draftKey,
+ roomIdToEditDraftAtomFamily,
roomIdToMsgDraftAtomFamily,
roomIdToReplyDraftAtomFamily,
roomIdToUploadItemsAtomFamily,
roomUploadAtomFamily,
} from '../../state/room/roomInputDrafts';
-import { UploadCardRenderer } from '../../components/upload-card';
-import {
- UploadBoard,
- UploadBoardContent,
- UploadBoardHeader,
- UploadBoardImperativeHandlers,
-} from '../../components/upload-board';
+import { ComposerAttachments } from './ComposerAttachments';
import {
Upload,
UploadStatus,
@@ -94,6 +102,7 @@ import {
createUploadFamilyObserverAtom,
} from '../../state/upload';
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
+import { stopPropagation } from '../../utils/keyboard';
import { safeFile } from '../../utils/mimeTypes';
import { fulfilledPromiseSettledResult } from '../../utils/common';
import { useSetting } from '../../state/hooks/settings';
@@ -109,7 +118,13 @@ import { VoiceRecording, VoiceRecordingResult } from '../../utils/voiceRecording
import { VoiceRecorder } from './VoiceRecorderForm';
import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room';
-import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room';
+import {
+ getEditedEvent,
+ getMemberDisplayName,
+ getMentionContent,
+ trimReplyFromBody,
+ trimReplyFromFormattedBody,
+} from '../../utils/room';
import { CommandAutocomplete } from './CommandAutocomplete';
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
import { mobileOrTablet } from '../../utils/user-agent';
@@ -264,15 +279,19 @@ export const RoomInput = forwardRef(
}, [voiceDisabledBy]);
const emojiBtnRef = useRef(null);
const screenSize = useScreenSizeContext();
- // On native / narrow screens the emoji-sticker board is docked inline at the
- // top of the composer instead of floating as a pop-out.
- const dockEmojiBoard = mobileOrTablet() || screenSize === ScreenSize.Mobile;
+ // On native / narrow screens the emoji-sticker board opens as a CENTERED
+ // overlay window — the same presentation the message rail's reaction
+ // picker uses (an anchored PopOut drifts off the small viewport, and the
+ // old in-composer dock crowded the input). Desktop keeps the anchored
+ // pop-out by the emoji button.
+ const centerEmojiBoard = mobileOrTablet() || screenSize === ScreenSize.Mobile;
const [emojiBoardTab, setEmojiBoardTab] = useState(undefined);
- // Crossing the dock/pop-out breakpoint remounts the trigger; drop any open
- // dock state so the board doesn't silently re-open after a resize round-trip.
+ // Crossing the center/pop-out breakpoint remounts the trigger; drop any
+ // open state so the board doesn't silently re-open after a resize
+ // round-trip.
useEffect(() => {
setEmojiBoardTab(undefined);
- }, [dockEmojiBoard]);
+ }, [centerEmojiBoard]);
const roomToParents = useAtomValue(roomToParentsAtom);
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
@@ -283,6 +302,7 @@ export const RoomInput = forwardRef(
const inputDraftKey = draftKey(roomId, threadId);
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(inputDraftKey));
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(inputDraftKey));
+ const [editDraft, setEditDraft] = useAtom(roomIdToEditDraftAtomFamily(inputDraftKey));
const replyUserID = replyDraft?.userId;
const powerLevelTags = usePowerLevelTags(room, powerLevels);
@@ -301,13 +321,26 @@ export const RoomInput = forwardRef(
: 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)
+ // Memoised on the file list — a fresh derived atom every render would
+ // churn submit()'s identity (it sits in submit's deps) and with it every
+ // downstream useCallback.
+ const uploadFamilyObserverAtom = useMemo(
+ () =>
+ createUploadFamilyObserverAtom(
+ roomUploadAtomFamily,
+ selectedFiles.map((f) => f.file)
+ ),
+ [selectedFiles]
);
- const uploadBoardHandlers = useRef();
+ // Imperative store handle — submit() reads the live upload states once
+ // at send time instead of subscribing the whole composer to every
+ // (throttled) progress tick. The attachment strip's per-item components
+ // are the only progress subscribers.
+ const jotaiStore = useStore();
+ // Re-entrancy guard for the attachment send — mirrors the old
+ // UploadBoardHeader's sendingRef.
+ const sendingAttachmentsRef = useRef(false);
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
@@ -319,13 +352,13 @@ export const RoomInput = forwardRef(
// 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 alive = useAlive();
const handleFiles = useCallback(
async (files: File[]) => {
// Text-only composer (AI new-chat landing) ignores every file vector —
// picker, paste and drop all funnel through here.
if (textOnly) return;
- setUploadBoard(true);
const safeFiles = files.map(safeFile);
const fileItems: TUploadItem[] = [];
@@ -375,15 +408,151 @@ export const RoomInput = forwardRef(
Transforms.insertFragment(editor, msgDraft);
}, [editor, msgDraft]);
+ // ── Edit-in-composer (Telegram-style) ─────────────────────────────
+ // «Edit» on an own message sets the per-scope edit draft; this block
+ // loads the message body into THIS composer, shows the banner (in the
+ // `top` slot below) and routes submit() through the m.replace path.
+ // The user's unsent draft is stashed on entry and restored when the
+ // edit ends (cancel or save) — same behaviour as Telegram.
+
+ // Reads the target's CURRENT body (following any prior m.replace
+ // aggregation) — mirror of the old MessageEditor's
+ // getPrevBodyAndFormattedBody.
+ const getEditTargetContent = useCallback(
+ (
+ eventId: string
+ ): {
+ body: string | undefined;
+ customHtml: string | undefined;
+ mentions: IMentions | undefined;
+ msgtype: string | undefined;
+ threadRootId: string | undefined;
+ } => {
+ const mEvent = room.findEventById(eventId);
+ if (!mEvent) {
+ return {
+ body: undefined,
+ customHtml: undefined,
+ mentions: undefined,
+ msgtype: undefined,
+ threadRootId: undefined,
+ };
+ }
+ const editedEvent = getEditedEvent(eventId, mEvent, room.getUnfilteredTimelineSet());
+ const content: IContent = editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
+ const { body, formatted_body: customHtml }: Record = content;
+ return {
+ body: typeof body === 'string' ? body : undefined,
+ customHtml: typeof customHtml === 'string' ? customHtml : undefined,
+ mentions: content['m.mentions'],
+ msgtype: mEvent.getContent().msgtype,
+ threadRootId: mEvent.threadRootId,
+ };
+ },
+ [room]
+ );
+
+ // Unsent text stashed while the editor hosts the edit body.
+ const preEditStashRef = useRef(null);
+ // A failed m.replace send re-enters edit mode with the USER'S edited
+ // text (set by submitEdit's .catch, consumed by the edit-load effect)
+ // instead of re-loading the target's old body — the edit banner
+ // re-opening with their text intact IS the failure feedback.
+ const failedEditRef = useRef<{ eventId: string; children: Descendant[] } | null>(null);
+ // Tracks which eventId the editor currently holds, so re-renders with
+ // the same draft don't re-load, while retargeting (edit A → edit B)
+ // and exit both do.
+ const loadedEditIdRef = useRef();
+
+ useEffect(() => {
+ const evtId = editDraft?.eventId;
+ if (evtId === loadedEditIdRef.current) return;
+ const wasEditing = loadedEditIdRef.current !== undefined;
+ loadedEditIdRef.current = evtId;
+
+ if (!evtId) {
+ // Edit ended (cancel / save / external clear) — restore the stash.
+ resetEditor(editor);
+ resetEditorHistory(editor);
+ const stash = preEditStashRef.current;
+ preEditStashRef.current = null;
+ if (stash && stash.length > 0) {
+ Transforms.insertFragment(editor, stash);
+ }
+ if (!mobileOrTablet()) ReactEditor.focus(editor);
+ return;
+ }
+
+ // Entering edit (stash once) or retargeting (keep the original stash).
+ if (!wasEditing && !isEmptyEditor(editor)) {
+ preEditStashRef.current = JSON.parse(JSON.stringify(editor.children));
+ }
+ const failed = failedEditRef.current;
+ failedEditRef.current = null;
+ const initialValue =
+ failed && failed.eventId === evtId
+ ? failed.children
+ : (() => {
+ const { body, customHtml } = getEditTargetContent(evtId);
+ return typeof customHtml === 'string'
+ ? htmlToEditorInput(customHtml, isMarkdown)
+ : plainToEditorInput(typeof body === 'string' ? body : '', isMarkdown);
+ })();
+ resetEditor(editor);
+ resetEditorHistory(editor);
+ Transforms.insertFragment(editor, initialValue);
+ if (!mobileOrTablet()) ReactEditor.focus(editor);
+ // isMarkdown intentionally NOT a dep — toggling the setting mid-edit
+ // must not blow away the user's in-progress changes.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [editDraft, editor, getEditTargetContent]);
+
+ const editDraftRef = useRef(editDraft);
+ editDraftRef.current = editDraft;
+
+ // Editing a still-sending message: the draft stores the local-echo
+ // '~txn' id, and the remote echo DELETES that id from the timeline set
+ // (EventTimelineSet.replaceEventId) — without retargeting, submit would
+ // build an m.replace against a dead id (and the SDK throws on a '~'
+ // relation under Chronological pending ordering). Follow the swap and
+ // keep the user's in-progress text (bump loadedEditIdRef FIRST so the
+ // edit-load effect doesn't reload the body over their changes).
+ useEffect(() => {
+ const onLocalEchoUpdated = (event: MatrixEvent, _room: Room, oldEventId?: string): void => {
+ const draft = editDraftRef.current;
+ if (!draft) return;
+ // The edit TARGET failed to send: an m.replace can never apply to
+ // an event that never reached the server, so Save would stay a
+ // silent no-op forever ('~' guard in submitEdit). Cancel the edit
+ // (restoring the stash) — the failed message keeps its own
+ // retry/delete affordances in the timeline.
+ if (event.status === EventStatus.NOT_SENT && event.getId() === draft.eventId) {
+ setEditDraft(undefined);
+ return;
+ }
+ if (!oldEventId || oldEventId !== draft.eventId) return;
+ const newId = event.getId();
+ if (!newId || newId === oldEventId) return;
+ loadedEditIdRef.current = newId;
+ setEditDraft({ eventId: newId });
+ };
+ room.on(RoomEvent.LocalEchoUpdated, onLocalEchoUpdated);
+ return () => {
+ room.off(RoomEvent.LocalEchoUpdated, onLocalEchoUpdated);
+ };
+ }, [room, setEditDraft]);
+
+ const editTarget = editDraft ? getEditTargetContent(editDraft.eventId) : undefined;
+
// Drain the global share-target hand-off into THIS chat. The system
// share-sheet flow doesn't open a picker — it just lights up the
// ShareTargetStrip banner and lets the user pick a chat by navigating
// normally. The first RoomInput that mounts (or, if the user was
// already inside a chat when the share arrived, the current RoomInput
// re-running this effect with a non-null `pendingShare`) consumes the
- // payload: files into the upload board, text into the composer. The
- // user can still bail by tapping the [×] on each upload card before
- // pressing Send.
+ // payload: files into the composer's attachment strip, text into the
+ // editor. The user can still bail by tapping the [×] on each
+ // attachment before pressing Send.
//
// Declared AFTER the msgDraft restore so the share text appends to
// any saved draft instead of getting overwritten by it.
@@ -391,8 +560,17 @@ export const RoomInput = forwardRef(
// Thread composers (threadId set) deliberately skip — sharing into a
// thread isn't a flow users ask for, and would silently consume the
// share leaving the main composer empty.
+ //
+ // Edit mode also defers: the editor currently holds the EDIT body, and
+ // inserting the shared text there would pollute the m.replace (or lose
+ // the share on cancel). The payload stays in pendingShareAtom (the
+ // strip banner stays up); `editDraft` in the deps re-runs the drain
+ // when the edit ends — by then the edit-end effect above (declared
+ // earlier, runs first in the same commit) has restored the user's
+ // stashed draft, so the share appends to their real draft as designed.
useEffect(() => {
if (threadId) return;
+ if (editDraft) return;
if (!pendingShare) return;
// Clear first so a re-render mid-handleFiles can't queue another
// run for the same payload.
@@ -405,11 +583,21 @@ export const RoomInput = forwardRef(
if (text) {
Transforms.insertText(editor, text);
}
- }, [threadId, pendingShare, setPendingShare, handleFiles, editor]);
+ }, [threadId, editDraft, pendingShare, setPendingShare, handleFiles, editor]);
useEffect(
() => () => {
- if (!isEmptyEditor(editor)) {
+ if (loadedEditIdRef.current !== undefined) {
+ // Unmounted mid-edit (navigation, thread drawer opening over the
+ // main composer): drop the edit — the editor holds the EDIT body,
+ // which must not be saved as the user's message draft. Persist
+ // the pre-edit stash instead so their unsent text survives.
+ setEditDraft(undefined);
+ loadedEditIdRef.current = undefined;
+ const stash = preEditStashRef.current;
+ preEditStashRef.current = null;
+ setMsgDraft(stash && stash.length > 0 ? stash : []);
+ } else if (!isEmptyEditor(editor)) {
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
setMsgDraft(parsedDraft);
} else {
@@ -423,7 +611,7 @@ export const RoomInput = forwardRef(
// 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]
+ [roomId, threadId, editor, setMsgDraft, setEditDraft]
);
const handleFileMetadata = useCallback(
@@ -449,37 +637,49 @@ export const RoomInput = forwardRef(
[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 handleCancelUpload = useCallback(
+ (uploads: Upload[]) => {
+ uploads.forEach((upload) => {
+ if (upload.status === UploadStatus.Loading) {
+ mx.cancelUpload(upload.promise);
+ }
+ });
+ handleRemoveUpload(uploads.map((upload) => upload.file));
+ },
+ [mx, handleRemoveUpload]
+ );
- 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');
+ // useCallback (not a plain function) — submit() depends on it.
+ const handleSendUpload = useCallback(
+ 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 RoomMessageEventContent)
- );
- };
+ 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);
+ });
+ // Settle the content builds BEFORE clearing the strip: an item
+ // whose build failed (image decode, thumbnailing) stays visible
+ // for another Send instead of silently vanishing unsent.
+ const settled = await Promise.allSettled(contentsPromises);
+ const builtUploads = uploads.filter((_, i) => settled[i].status === 'fulfilled');
+ handleCancelUpload(builtUploads);
+ const contents = fulfilledPromiseSettledResult(settled);
+ contents.forEach((content) =>
+ mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent)
+ );
+ },
+ [mx, roomId, threadId, selectedFiles, handleCancelUpload]
+ );
const voiceBlockedName = useCallback(
() =>
@@ -559,8 +759,163 @@ export const RoomInput = forwardRef(
[voiceDisabledBy, voiceBlockedName, room, mx, roomId, threadId, t]
);
+ // Edit submit — builds the m.replace content the way the old
+ // MessageEditor did: `m.new_content` carries the real body, the
+ // top-level body gets the `* ` fallback prefix, mentions merge the
+ // previous revision's users. No-op edits (unchanged / emptied text)
+ // just exit edit mode. threadRootId comes from the TARGET event so a
+ // thread message edited from the drawer keeps its thread relation
+ // (SDK sets the local-echo `.thread` pointer; m.replace itself owns
+ // `m.relates_to`, spec-correct — see the old MessageEditor notes).
+ const submitEdit = useCallback(() => {
+ if (!editDraft) return;
+ const { eventId } = editDraft;
+
+ // Still-sending local echo: a '~txn' relation makes the SDK throw
+ // synchronously (getPendingEvents under Chronological ordering) —
+ // stay in edit mode and wait for the LocalEchoUpdated retarget above
+ // to swap in the real id (usually a beat later).
+ if (eventId.startsWith('~')) return;
+ // Target vanished (redacted / not in the timeline set any more):
+ // there is nothing to replace — cancel the edit, restoring the stash.
+ if (!room.findEventById(eventId)) {
+ setEditDraft(undefined);
+ return;
+ }
+
+ const target = getEditTargetContent(eventId);
+
+ const plainText = toPlainText(editor.children, isMarkdown).trim();
+ const customHtml = trimCustomHtml(
+ toMatrixCustomHTML(editor.children, {
+ allowTextFormatting: true,
+ allowBlockMarkdown: isMarkdown,
+ allowInlineMarkdown: isMarkdown,
+ })
+ );
+
+ const exitEdit = () => {
+ setEditDraft(undefined);
+ sendTypingStatus(false);
+ };
+
+ if (plainText === '') {
+ // Emptied-out edit: do nothing (deleting is the Delete action's job).
+ exitEdit();
+ return;
+ }
+ if (target.body) {
+ const unchangedHtml =
+ target.customHtml && trimReplyFromFormattedBody(target.customHtml) === customHtml;
+ const unchangedPlain =
+ !target.customHtml &&
+ target.body === plainText &&
+ customHtmlEqualsPlainText(customHtml, plainText);
+ if (unchangedHtml || unchangedPlain) {
+ exitEdit();
+ return;
+ }
+ }
+
+ const newContent: IContent = {
+ msgtype: target.msgtype,
+ body: plainText,
+ };
+
+ // Spec (event replacements / m.mentions): `m.new_content` carries the
+ // FULL resolved mention set of the revision, while the TOP-LEVEL
+ // m.mentions must list only the mentions ADDED by this edit — anyone
+ // already mentioned was pinged by the original and must not be
+ // re-notified on every revision.
+ const mentionData = getMentions(mx, roomId, editor);
+ const prevUserIds = new Set(target.mentions?.user_ids ?? []);
+ const prevRoom = target.mentions?.room === true;
+ const addedUsers = Array.from(mentionData.users).filter((u) => !prevUserIds.has(u));
+ const fullUsers = new Set(mentionData.users);
+ prevUserIds.forEach((u) => fullUsers.add(u));
+ newContent['m.mentions'] = getMentionContent(
+ Array.from(fullUsers),
+ mentionData.room || prevRoom
+ );
+
+ if (!customHtmlEqualsPlainText(customHtml, plainText)) {
+ newContent.format = 'org.matrix.custom.html';
+ newContent.formatted_body = customHtml;
+ }
+
+ const content: IContent = {
+ ...newContent,
+ 'm.mentions': getMentionContent(addedUsers, mentionData.room && !prevRoom),
+ body: `* ${plainText}`,
+ 'm.new_content': newContent,
+ 'm.relates_to': {
+ event_id: eventId,
+ rel_type: RelationType.Replace,
+ },
+ };
+ // The `* ` fallback prefix applies to the formatted fallback too
+ // (mirrors element-web's edit content shape).
+ if (typeof newContent.formatted_body === 'string') {
+ content.formatted_body = `* ${newContent.formatted_body}`;
+ }
+
+ // Optimistic exit (Telegram-style: banner closes immediately). The
+ // failure leg re-enters edit mode with the user's text via
+ // failedEditRef — unless they have already started another edit by
+ // the time the send settles, or this composer is long gone.
+ const editedChildren: Descendant[] = JSON.parse(JSON.stringify(editor.children));
+ mx.sendMessage(roomId, target.threadRootId ?? null, content as RoomMessageEventContent).catch(
+ () => {
+ if (!alive()) return;
+ if (editDraftRef.current) return;
+ failedEditRef.current = { eventId, children: editedChildren };
+ setEditDraft({ eventId });
+ }
+ );
+ exitEdit();
+ }, [
+ mx,
+ room,
+ roomId,
+ editor,
+ editDraft,
+ getEditTargetContent,
+ isMarkdown,
+ setEditDraft,
+ sendTypingStatus,
+ alive,
+ ]);
+
const submit = useCallback(() => {
- uploadBoardHandlers.current?.handleSend();
+ // Edit mode owns the send path entirely — uploads/replies/commands
+ // don't apply to an m.replace.
+ if (editDraft) {
+ submitEdit();
+ return;
+ }
+
+ // Ship every FINISHED upload from the attachment strip. Still-running
+ // and failed ones stay in the strip (the user retries or sends again
+ // once they finish) — same semantics the old UploadBoard's Send had.
+ // Read the live states from the store so the composer doesn't
+ // subscribe to per-tick progress.
+ //
+ // The text send below CHAINS on the drain: handleSendUpload builds
+ // media contents asynchronously, and queueing the text in this tick
+ // would land the caption ABOVE the photos for everyone.
+ const uploads = jotaiStore.get(uploadFamilyObserverAtom);
+ const readyUploads = uploads.filter(
+ (upload): upload is UploadSuccess => upload.status === UploadStatus.Success
+ );
+ let attachmentsDrain: Promise = Promise.resolve();
+ if (readyUploads.length > 0 && !sendingAttachmentsRef.current) {
+ sendingAttachmentsRef.current = true;
+ attachmentsDrain = handleSendUpload(readyUploads)
+ .catch(() => undefined)
+ .finally(() => {
+ sendingAttachmentsRef.current = false;
+ });
+ }
const commandName = getBeginCommand(editor);
let plainText = toPlainText(editor.children, isMarkdown).trim();
@@ -635,7 +990,12 @@ export const RoomInput = forwardRef(
content['m.relates_to'].is_falling_back = false;
}
}
- const pending = mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent);
+ // Queue the text AFTER the attachment drain so a caption lands below
+ // its media. The composer resets immediately regardless — the user's
+ // send gesture already happened.
+ const pending = attachmentsDrain.then(() =>
+ mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent)
+ );
if (onSend) {
pending.then((res) => onSend(res.event_id)).catch(() => undefined);
}
@@ -650,10 +1010,15 @@ export const RoomInput = forwardRef(
onSend,
editor,
replyDraft,
+ editDraft,
+ submitEdit,
sendTypingStatus,
setReplyDraft,
isMarkdown,
commands,
+ jotaiStore,
+ uploadFamilyObserverAtom,
+ handleSendUpload,
]);
const handleKeyDown: KeyboardEventHandler = useCallback(
@@ -671,10 +1036,24 @@ export const RoomInput = forwardRef(
setAutocompleteQuery(undefined);
return;
}
+ // Edit first: Esc cancels the edit (restoring the stashed draft);
+ // a second Esc then clears the reply preview as before.
+ if (editDraft) {
+ setEditDraft(undefined);
+ return;
+ }
setReplyDraft(undefined);
}
},
- [submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing]
+ [
+ submit,
+ setReplyDraft,
+ editDraft,
+ setEditDraft,
+ enterForNewline,
+ autocompleteQuery,
+ isComposing,
+ ]
);
const handleKeyUp: KeyboardEventHandler = useCallback(
@@ -755,25 +1134,38 @@ export const RoomInput = forwardRef(
);
- // The native dock renders the board in the composer's top slot, so its
- // open/close state lives here (only read on native). Desktop keeps its
- // state isolated inside the UseStateProvider below so opening the pop-out
- // doesn't re-render the whole composer.
- const dockedEmojiBoard = dockEmojiBoard && emojiBoardTab !== undefined && (
- setEmojiBoardTab(undefined)}
- />
+ // Mobile / native: the board opens as a centered overlay WINDOW — same
+ // presentation as the message rail's reaction picker — so its open
+ // state lives here. Desktop keeps its state isolated inside the
+ // UseStateProvider below so opening the pop-out doesn't re-render the
+ // whole composer.
+ const centeredEmojiBoard = centerEmojiBoard && emojiBoardTab !== undefined && (
+ }>
+
+ setEmojiBoardTab(undefined),
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+ setEmojiBoardTab(undefined)}
+ />
+
+
+
);
- const emojiButton = dockEmojiBoard ? (
+ const emojiButton = centerEmojiBoard ? (
(
return (
- {selectedFiles.length > 0 && (
-
setUploadBoard(!uploadBoard)}
- uploadFamilyObserverAtom={uploadFamilyObserverAtom}
- onSend={handleSendUpload}
- imperativeHandlerRef={uploadBoardHandlers}
- onCancel={handleCancelUpload}
- />
- }
- >
- {uploadBoard && (
-
-
- {Array.from(selectedFiles)
- .reverse()
- .map((fileItem, index) => (
-
- ))}
-
-
- )}
-
- )}
}
@@ -975,7 +1334,16 @@ export const RoomInput = forwardRef
(
}
top={
<>
- {dockedEmojiBoard}
+ {centeredEmojiBoard}
+ {/* Attachment strip — the in-form record of attached media
+ (replaces the old floating UploadBoard window). Visible in
+ edit mode too: the m.replace send ignores it, and the
+ attachments are still there when the edit ends. */}
+
{voiceError && (
(
)}
- {replyDraft && (
+ {/* Edit banner — Telegram-style: pencil + «Editing» + the
+ original text, with [×] to cancel (Esc does the same).
+ Same chrome slot/structure as the reply preview below. */}
+ {editDraft && (
+
+
+ setEditDraft(undefined)}
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ aria-label={t('Room.editing_cancel')}
+ >
+
+
+
+
+
+ {t('Room.editing_message')}
+
+ }
+ >
+
+ {editTarget?.body ? trimReplyFromBody(editTarget.body) : ''}
+
+
+
+
+
+ )}
+ {/* Reply preview hides while editing (the edit owns the send);
+ the reply draft itself is preserved and returns after. */}
+ {replyDraft && !editDraft && (
(
after={
singleRow && !voiceMode ? (
<>
- {!textOnly && voiceSupported && voiceDisabledBy === undefined && micButton}
+ {/* Mic hides while editing — a recording would ship a NEW
+ message under a pending edit banner. */}
+ {!textOnly &&
+ !editDraft &&
+ voiceSupported &&
+ voiceDisabledBy === undefined &&
+ micButton}
{!textOnly && emojiButton}
{sendButton}
>
@@ -1059,7 +1476,13 @@ export const RoomInput = forwardRef(
>
{!textOnly && plusButton}
- {!textOnly && voiceSupported && voiceDisabledBy === undefined && micButton}
+ {/* Mic hides while editing — a recording would ship a NEW
+ message under a pending edit banner. */}
+ {!textOnly &&
+ !editDraft &&
+ voiceSupported &&
+ voiceDisabledBy === undefined &&
+ micButton}
{!textOnly && emojiButton}
{sendButton}
diff --git a/src/app/features/room/RoomTimeline.css.ts b/src/app/features/room/RoomTimeline.css.ts
index c428f470..a25a8553 100644
--- a/src/app/features/room/RoomTimeline.css.ts
+++ b/src/app/features/room/RoomTimeline.css.ts
@@ -1,5 +1,4 @@
import { globalStyle, keyframes, style } from '@vanilla-extract/css';
-import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds';
import {
VOJO_BUBBLE_BAND_PX,
@@ -101,31 +100,6 @@ export const TimelineScroll = style({
scrollbarColor: `${color.SurfaceVariant.ContainerLine} transparent`,
});
-export const TimelineFloat = recipe({
- base: [
- DefaultReset,
- {
- position: 'absolute',
- left: '50%',
- transform: 'translateX(-50%)',
- zIndex: 1,
- minWidth: 'max-content',
- },
- ],
- variants: {
- position: {
- Top: {
- top: config.space.S400,
- },
- },
- },
- defaultVariants: {
- position: 'Top',
- },
-});
-
-export type TimelineFloatVariants = RecipeVariants
;
-
// "Jump to latest" FAB. Bottom-right, circular, lavender brand accent.
// `data-hidden` encodes visibility (state inline-styled would clobber the
// `:active` press feedback). Inline `bottom` at the use site offsets for
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index bf50b01a..658a4938 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -22,12 +22,11 @@ import {
RoomEventHandlerMap,
} from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
-import classNames from 'classnames';
import { Editor } from 'slate';
import { DEFAULT_EXPIRE_DURATION, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc';
import to from 'await-to-js';
import { useAtomValue, useSetAtom } from 'jotai';
-import { Box, Chip, Icon, Icons, Text, as, config } from 'folds';
+import { Box, Icon, Icons, Text, config } from 'folds';
import { isKeyHotkey } from 'is-hotkey';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { useTranslation } from 'react-i18next';
@@ -96,9 +95,14 @@ import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResize
import * as css from './RoomTimeline.css';
import { inSameDay, minuteDifference, timeDayMonYear, today, yesterday } from '../../utils/time';
import { isEmptyEditor } from '../../components/editor';
-import { draftKey, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
+import {
+ draftKey,
+ roomIdToEditDraftAtomFamily,
+ roomIdToReplyDraftAtomFamily,
+} from '../../state/room/roomInputDrafts';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
+import { useStateEvent } from '../../hooks/useStateEvent';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
import { RenderMessageContent } from '../../components/RenderMessageContent';
@@ -127,19 +131,6 @@ import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { RoomTimelineTyping } from './RoomTimelineTyping';
import { MessageErrorBoundary } from './MessageErrorBoundary';
-const TimelineFloat = as<'div', css.TimelineFloatVariants>(
- ({ position, className, ...props }, ref) => (
-
- )
-);
-
export const getLiveTimeline = (room: Room): EventTimeline =>
room.getUnfilteredTimelineSet().getLiveTimeline();
@@ -593,6 +584,9 @@ export function RoomTimeline({
// / DM / legacy timeline). Drawer composer manages its own per-thread
// reply draft via DraftKey([roomId, rootId]) inside RoomInput.
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(draftKey(room.roomId)));
+ // Edit-draft setter — same MAIN-composer scope. «Edit» loads the message
+ // into the composer (RoomInput's edit banner) instead of an in-timeline form.
+ const setEditDraft = useSetAtom(roomIdToEditDraftAtomFamily(draftKey(room.roomId)));
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
@@ -613,6 +607,14 @@ export function RoomTimeline({
const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
+ // Mirror of RoomView's composer mount condition (tombstone / canMessage /
+ // thread drawer). Editing happens IN the composer now, so the Edit
+ // affordance must hide whenever the main composer is unmounted —
+ // otherwise the tap silently writes an edit draft no one consumes, which
+ // then hijacks the composer if it mounts later (e.g. permission restored).
+ const canMessage = permissions.event(MessageEvent.RoomMessage, mx.getSafeUserId());
+ const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
+ const mainComposerSuspended = threadDrawerOpen || !canMessage || !!tombstoneEvent;
const roomToParents = useAtomValue(roomToParentsAtom);
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
@@ -735,21 +737,20 @@ export function RoomTimeline({
return () => scrollEl.removeEventListener('scroll', onScroll);
}, [getScrollElement, setOpenMessageId]);
- const { getItems, scrollToItem, scrollToElement, observeBackAnchor, observeFrontAnchor } =
- useVirtualPaginator({
- count: eventsLength,
- limit: PAGINATION_LIMIT,
- range: timeline.range,
- onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
- getScrollElement,
- getItemElement: useCallback(
- (index: number) =>
- (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
- undefined,
- []
- ),
- onEnd: handleTimelinePagination,
- });
+ const { getItems, scrollToItem, observeBackAnchor, observeFrontAnchor } = useVirtualPaginator({
+ count: eventsLength,
+ limit: PAGINATION_LIMIT,
+ range: timeline.range,
+ onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
+ getScrollElement,
+ getItemElement: useCallback(
+ (index: number) =>
+ (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
+ undefined,
+ []
+ ),
+ onEnd: handleTimelinePagination,
+ });
const loadEventTimeline = useEventTimelineLoader(
mx,
@@ -947,7 +948,6 @@ export function RoomTimeline({
);
const {
- editId,
handleEdit,
handleOpenReply,
handleUserClick,
@@ -957,8 +957,9 @@ export function RoomTimeline({
} = useMessageInteractionHandlers({
room,
editor,
- composerSuspended: threadDrawerOpen,
+ composerSuspended: mainComposerSuspended,
setReplyDraft,
+ setEditDraft,
onOpenEvent: handleOpenEvent,
channelsMode,
isBridged,
@@ -1263,22 +1264,6 @@ export function RoomTimeline({
}
}, [unread]);
- // scroll out of view msg editor in view.
- useEffect(() => {
- if (editId) {
- const editMsgElement =
- (scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ??
- undefined;
- if (editMsgElement) {
- scrollToElement(editMsgElement, {
- align: 'center',
- behavior: 'smooth',
- stopInView: true,
- });
- }
- }
- }, [scrollToElement, editId]);
-
const handleJumpToLatest = () => {
if (eventId) {
navigateRoom(room.roomId, undefined, { replace: true });
@@ -1288,17 +1273,6 @@ export function RoomTimeline({
scrollToBottomRef.current.smooth = false;
};
- const handleJumpToUnread = () => {
- if (unreadInfo?.readUptoEventId) {
- setTimeline(getEmptyTimeline());
- loadEventTimeline(unreadInfo.readUptoEventId);
- }
- };
-
- const handleMarkAsRead = () => {
- markAsRead(mx, room.roomId, hideActivity);
- };
-
const { t } = useTranslation();
// Sticky day capsules (every room class — 1:1 bubble, group, channel). Each
@@ -1697,7 +1671,6 @@ export function RoomTimeline({
collapse={collapse}
railHidden={railHidden}
highlight={highlighted}
- edit={editId === mEventId}
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
@@ -1707,7 +1680,7 @@ export function RoomTimeline({
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
- onEditId={handleEdit}
+ onEditId={mainComposerSuspended ? undefined : handleEdit}
reply={
replyEventId && (
{/* Day dates are the inline capsules themselves (every room class), made
sticky via real CSS `position: sticky` (engaged by the effect above) —
no separate floating pill. */}
- {unreadFloatShown && (
-
- }
- onClick={handleJumpToUnread}
- >
- {t('Room.jump_to_unread')}
-
-
- }
- onClick={handleMarkAsRead}
- >
- {t('Room.mark_as_read')}
-
-
- )}
- }>
-
-
-
-
-
-
-
-
+ {/* EventReaders owns its Overlay + FocusTrap (self-contained Vojo card). */}
+ {open && }
}
@@ -809,7 +793,6 @@ export type MessageProps = {
// by the channel layout.
railHidden?: boolean;
highlight: boolean;
- edit?: boolean;
canDelete?: boolean;
canSendReaction?: boolean;
canPinEvent?: boolean;
@@ -930,7 +913,6 @@ const MessageInner = as<'div', MessageProps>(
// but unused by the bubble/channel layouts. Pending the stream-rail cleanup.
railHidden: _railHidden,
highlight,
- edit,
canDelete,
canSendReaction,
canPinEvent,
@@ -1010,7 +992,7 @@ const MessageInner = as<'div', MessageProps>(
if (id) setOpenMessageId((prev) => (prev === id ? null : id));
};
const handleBubbleToggle: MouseEventHandler = (evt) => {
- if (!isBubble || edit) return;
+ if (!isBubble) return;
// Don't hijack a text selection, nor taps on interactive children (links,
// buttons, media players, the waveform slider, the action rail, reaction
// chips) — those run their own action and must not also toggle the rail.
@@ -1030,7 +1012,7 @@ const MessageInner = as<'div', MessageProps>(
// holds focus (not a child link/button), so Enter on a focused link still
// follows it. Replaces the focus-within reveal the hover rail used to give.
const handleBubbleKeyDown: KeyboardEventHandler = (evt) => {
- if (!isBubble || edit) return;
+ if (!isBubble) return;
if (evt.target !== evt.currentTarget) return;
if (evt.key === 'Enter' || evt.key === ' ') {
evt.preventDefault();
@@ -1048,20 +1030,19 @@ const MessageInner = as<'div', MessageProps>(
// a local useState here sidesteps the commit↔effect race where
// decryption fires between render and listener attach.
const isMediaMessage = msgType === MsgType.Image || msgType === MsgType.Video;
- const mediaMode = isMediaMessage && !edit;
+ const mediaMode = isMediaMessage;
// Voice notes are self-chromed cards — VoiceContent draws its own avatar +
// bubble. Collapse the asymmetric Stream bubble (DM) and drop the channel
// avatar (group) so a voice note renders identically for own/other, the only
// difference being the bubble fill. See docs/plans/voice_messages.md §5.
const isVoiceMessage = msgType === MsgType.Audio && isVoiceMessageContent(mEvent.getContent());
- const voiceMode = isVoiceMessage && !edit;
+ const voiceMode = isVoiceMessage;
if (msgType === MsgType.Image || msgType === MsgType.Video || msgType === MsgType.File) {
logMedia('Message', {
eventId: mEvent.getId(),
msgType,
isMediaMessage,
- edit,
mediaMode,
isMobile,
screenSize,
@@ -1082,29 +1063,12 @@ const MessageInner = as<'div', MessageProps>(
const msgContentJSX = (
{reply}
- {edit && onEditId ? (
- // Edit form styled as the main composer — a single rounded card
- // (`ChatComposer` paints the inner Editor with Surface.Container + 32px
- // radius). The bubble drops its own chrome while editing (see
- // BubbleLayout `editing`) so it's ONE box, not a bubble inside a bubble.
-
- onEditId()}
- />
-
- ) : (
- children
- )}
+ {children}
);
const handleContextMenu: MouseEventHandler = (evt) => {
- if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return;
+ if (evt.altKey || !window.getSelection()?.isCollapsed) return;
const tag = (evt.target as Element).tagName;
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
evt.preventDefault();
@@ -1153,8 +1117,7 @@ const MessageInner = as<'div', MessageProps>(
// bar can render in two places: as a row-anchored overlay for channel/stream
// rows, and — for the bubble layout — passed into BubbleLayout so it floats
// centred just above the bubble itself.
- const railVisible =
- !edit && ((isBubble ? railOpen : hover) || !!menuAnchor || !!emojiBoardAnchor);
+ const railVisible = (isBubble ? railOpen : hover) || !!menuAnchor || !!emojiBoardAnchor;
// The reaction emoji board, shared by the desktop anchored PopOut and the
// mobile centred Overlay (an anchored PopOut drifts off-screen on the small
// native viewport — `съезжает` — so mobile centres it instead).
@@ -1423,7 +1386,7 @@ const MessageInner = as<'div', MessageProps>(
)}
{/* Mobile: the reaction emoji board renders as a centred Overlay (an
anchored PopOut drifts off the small native viewport). */}
- {isMobile && canSendReaction && !edit && !!emojiBoardAnchor && (
+ {isMobile && canSendReaction && !!emojiBoardAnchor && (
}>
(
// `collapsed`.
collapsed={collapse}
selected={bubbleSelected}
- // While editing, the body IS a composer card — drop the bubble chrome
- // so it reads as one box, not a composer nested in a bubble.
- editing={!!edit}
// Grouped same-minute series → timestamp below the bubble; singles keep
// it on the side (RoomTimeline computes timeBelow). Media is always below.
timeBelow={timeBelow}
@@ -1505,7 +1465,7 @@ const MessageInner = as<'div', MessageProps>(
// Read/delivery status under the user's LAST own message only — at
// the live end of the timeline (RoomTimeline computes isLatestOwn).
readStatus={
- isOwnMessage && isLatestOwn && !edit ? (
+ isOwnMessage && isLatestOwn ? (
) : undefined
}
@@ -1602,7 +1562,6 @@ function areMessagePropsEqual(
prev.collapse === next.collapse &&
prev.railHidden === next.railHidden &&
prev.highlight === next.highlight &&
- prev.edit === next.edit &&
prev.canDelete === next.canDelete &&
prev.canSendReaction === next.canSendReaction &&
prev.canPinEvent === next.canPinEvent &&
diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx
deleted file mode 100644
index 98793c2d..00000000
--- a/src/app/features/room/message/MessageEditor.tsx
+++ /dev/null
@@ -1,379 +0,0 @@
-import React, {
- KeyboardEventHandler,
- MouseEventHandler,
- useCallback,
- useEffect,
- useState,
-} from 'react';
-import {
- Box,
- Chip,
- Icon,
- IconButton,
- Icons,
- Line,
- PopOut,
- RectCords,
- Spinner,
- Text,
- as,
- config,
-} from 'folds';
-import { Editor, Transforms } from 'slate';
-import { ReactEditor } from 'slate-react';
-import { IContent, IMentions, MatrixEvent, RelationType, Room } from 'matrix-js-sdk';
-import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/types';
-import { isKeyHotkey } from 'is-hotkey';
-import {
- AUTOCOMPLETE_PREFIXES,
- AutocompletePrefix,
- AutocompleteQuery,
- CustomEditor,
- EmoticonAutocomplete,
- RoomMentionAutocomplete,
- Toolbar,
- UserMentionAutocomplete,
- createEmoticonElement,
- customHtmlEqualsPlainText,
- getAutocompleteQuery,
- getPrevWorldRange,
- htmlToEditorInput,
- moveCursor,
- plainToEditorInput,
- toMatrixCustomHTML,
- toPlainText,
- trimCustomHtml,
- useEditor,
- getMentions,
-} from '../../../components/editor';
-import { useSetting } from '../../../state/hooks/settings';
-import { settingsAtom } from '../../../state/settings';
-import { UseStateProvider } from '../../../components/UseStateProvider';
-import { EmojiBoard } from '../../../components/emoji-board';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room';
-import { mobileOrTablet } from '../../../utils/user-agent';
-import { useComposingCheck } from '../../../hooks/useComposingCheck';
-
-type MessageEditorProps = {
- roomId: string;
- room: Room;
- mEvent: MatrixEvent;
- imagePackRooms?: Room[];
- onCancel: () => void;
-};
-export const MessageEditor = as<'div', MessageEditorProps>(
- ({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
- const mx = useMatrixClient();
- const editor = useEditor();
- const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
- const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
- // The edit toolbar starts collapsed (the `editorToolbar` opt-in setting was
- // removed — it only ever gated this one initial-open flag).
- const [toolbar, setToolbar] = useState(false);
- const isComposing = useComposingCheck();
-
- const [autocompleteQuery, setAutocompleteQuery] =
- useState>();
-
- const getPrevBodyAndFormattedBody = useCallback((): [
- string | undefined,
- string | undefined,
- IMentions | undefined
- ] => {
- const evtId = mEvent.getId();
- if (!evtId) return [undefined, undefined, undefined];
- const evtTimeline = room.getTimelineForEvent(evtId);
- const editedEvent =
- evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet());
-
- const content: IContent = editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
- const { body, formatted_body: customHtml }: Record = content;
-
- const mMentions: IMentions | undefined = content['m.mentions'];
-
- return [
- typeof body === 'string' ? body : undefined,
- typeof customHtml === 'string' ? customHtml : undefined,
- mMentions,
- ];
- }, [room, mEvent]);
-
- const [saveState, save] = useAsyncCallback(
- useCallback(async () => {
- const plainText = toPlainText(editor.children, isMarkdown).trim();
- const customHtml = trimCustomHtml(
- toMatrixCustomHTML(editor.children, {
- allowTextFormatting: true,
- allowBlockMarkdown: isMarkdown,
- allowInlineMarkdown: isMarkdown,
- })
- );
-
- const [prevBody, prevCustomHtml, prevMentions] = getPrevBodyAndFormattedBody();
-
- if (plainText === '') return undefined;
- if (prevBody) {
- if (prevCustomHtml && trimReplyFromFormattedBody(prevCustomHtml) === customHtml) {
- return undefined;
- }
- if (
- !prevCustomHtml &&
- prevBody === plainText &&
- customHtmlEqualsPlainText(customHtml, plainText)
- ) {
- return undefined;
- }
- }
-
- const newContent: IContent = {
- msgtype: mEvent.getContent().msgtype,
- body: plainText,
- };
-
- const mentionData = getMentions(mx, roomId, editor);
-
- prevMentions?.user_ids?.forEach((prevMentionId) => {
- mentionData.users.add(prevMentionId);
- });
-
- const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room);
- newContent['m.mentions'] = mMentions;
-
- if (!customHtmlEqualsPlainText(customHtml, plainText)) {
- newContent.format = 'org.matrix.custom.html';
- newContent.formatted_body = customHtml;
- }
-
- const content: IContent = {
- ...newContent,
- body: `* ${plainText}`,
- 'm.new_content': newContent,
- 'm.relates_to': {
- event_id: mEvent.getId(),
- rel_type: RelationType.Replace,
- },
- };
-
- // Pass threadRootId so the local-echo MatrixEvent gets its
- // `.thread` pointer set via `localEvent.setThread(thread)`
- // (client.js:1872-1875) and `Thread.onEcho` re-emits
- // `ThreadEvent.Update` post-send, which the drawer subscribes
- // to. SDK `addThreadRelationIfNeeded` (client.js:1810-1820)
- // doesn't inject `m.thread` because m.replace already owns
- // `m.relates_to.rel_type` — spec-correct, edit aggregation
- // chains via the original event's relation. Element-web's
- // EditMessageComposer.tsx:349-351 uses the same pattern.
- // M4a-followup: own-send local-echo of m.replace doesn't
- // aggregate into `thread.timelineSet.relations` (canContain
- // rejects against unfilteredTimelineSet at
- // event-timeline-set.js:786-803, and the thread-side path runs
- // only on remote echo). User sees the new body only after
- // /sync round-trip — same lag as M4 receipts. Optimistic
- // local-replace via `mEvent.makeReplaced(localEvent)` is
- // tracked for follow-up.
- return mx.sendMessage(
- roomId,
- mEvent.threadRootId ?? null,
- content as RoomMessageEventContent
- );
- }, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody])
- );
-
- const handleSave = useCallback(() => {
- if (saveState.status !== AsyncStatus.Loading) {
- save();
- }
- }, [saveState, save]);
-
- const handleKeyDown: KeyboardEventHandler = useCallback(
- (evt) => {
- if (
- (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
- !isComposing(evt)
- ) {
- evt.preventDefault();
- handleSave();
- }
- if (isKeyHotkey('escape', evt)) {
- evt.preventDefault();
- onCancel();
- }
- },
- [onCancel, handleSave, enterForNewline, isComposing]
- );
-
- const handleKeyUp: KeyboardEventHandler = useCallback(
- (evt) => {
- if (isKeyHotkey('escape', evt)) {
- evt.preventDefault();
- return;
- }
-
- const prevWordRange = getPrevWorldRange(editor);
- const query = prevWordRange
- ? getAutocompleteQuery(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
- : undefined;
- setAutocompleteQuery(query);
- },
- [editor]
- );
-
- const handleCloseAutocomplete = useCallback(() => {
- ReactEditor.focus(editor);
- setAutocompleteQuery(undefined);
- }, [editor]);
-
- const handleEmoticonSelect = (key: string, shortcode: string) => {
- editor.insertNode(createEmoticonElement(key, shortcode));
- moveCursor(editor);
- };
-
- useEffect(() => {
- const [body, customHtml] = getPrevBodyAndFormattedBody();
-
- const initialValue =
- typeof customHtml === 'string'
- ? htmlToEditorInput(customHtml, isMarkdown)
- : plainToEditorInput(typeof body === 'string' ? body : '', isMarkdown);
-
- Transforms.select(editor, {
- anchor: Editor.start(editor, []),
- focus: Editor.end(editor, []),
- });
-
- editor.insertFragment(initialValue);
- if (!mobileOrTablet()) ReactEditor.focus(editor);
- }, [editor, getPrevBodyAndFormattedBody, isMarkdown]);
-
- useEffect(() => {
- if (saveState.status === AsyncStatus.Success) {
- onCancel();
- }
- }, [saveState, onCancel]);
-
- return (
-
- {autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
-
- )}
- {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
-
- )}
- {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
-
- )}
-
-
-
-
- ) : undefined
- }
- >
- Save
-
-
- Cancel
-
-
-
- setToolbar(!toolbar)}
- >
-
-
-
- {(anchor: RectCords | undefined, setAnchor) => (
- {
- setAnchor((v) => {
- if (v) {
- if (!mobileOrTablet()) ReactEditor.focus(editor);
- return undefined;
- }
- return v;
- });
- }}
- />
- }
- >
-
- setAnchor(
- evt.currentTarget.getBoundingClientRect()
- )) as MouseEventHandler
- }
- variant="SurfaceVariant"
- size="300"
- radii="300"
- >
-
-
-
- )}
-
-
-
- {toolbar && (
-
-
-
-
- )}
- >
- }
- />
-
- );
- }
-);
diff --git a/src/app/features/room/message/useMessageInteractionHandlers.ts b/src/app/features/room/message/useMessageInteractionHandlers.ts
index b96d4e76..73e64e4c 100644
--- a/src/app/features/room/message/useMessageInteractionHandlers.ts
+++ b/src/app/features/room/message/useMessageInteractionHandlers.ts
@@ -1,4 +1,4 @@
-import { MouseEvent, MouseEventHandler, useCallback, useState } from 'react';
+import { MouseEvent, MouseEventHandler, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Editor } from 'slate';
import { ReactEditor } from 'slate-react';
@@ -16,7 +16,7 @@ import {
getReactionContent,
} from '../../../utils/room';
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../../utils/matrix';
-import { IReplyDraft } from '../../../state/room/roomInputDrafts';
+import { IEditDraft, IReplyDraft } from '../../../state/room/roomInputDrafts';
import { getChannelsThreadPath } from '../../../pages/pathUtils';
export type UseMessageInteractionHandlersOptions = {
@@ -26,16 +26,21 @@ export type UseMessageInteractionHandlersOptions = {
// insertion + reply-draft focus target this instance.
editor: Editor;
// True when the composer associated with `editor` is currently
- // unmounted/hidden. Main timeline passes `threadDrawerOpen` (drawer
- // open hides main composer); drawer passes `false`. When suspended,
- // the username-click mention insert no-ops to avoid writing into a
- // hidden Slate instance the user can't see.
+ // unmounted/hidden. Main timeline passes `mainComposerSuspended`
+ // (drawer open / no post permission / tombstoned room); drawer passes
+ // `!canMessage`. When suspended, the username-click mention insert,
+ // edit and reply-draft writes all no-op to avoid writing into a hidden
+ // Slate instance / drafts the user can't see or clear.
composerSuspended: boolean;
// Reply-draft setter targeting the right composer scope. Caller
// subscribes via `useSetAtom(roomIdToReplyDraftAtomFamily(...))` —
// main timeline scopes to `[roomId, 'main']`, drawer scopes to
// `[roomId, rootId]`.
setReplyDraft: (draft: IReplyDraft | undefined) => void;
+ // Edit-draft setter for the same composer scope — «Edit» on a message
+ // loads it into the composer (Telegram-style) instead of opening an
+ // in-timeline edit form. See roomInputDrafts.ts::IEditDraft.
+ setEditDraft: (draft: IEditDraft | undefined) => void;
// Caller-specific scroll-to-event for reply-chip clicks. Main timeline
// scrolls via virtual paginator + setFocusItem; drawer scrolls via DOM
// scrollIntoView on the matching `data-message-id` node.
@@ -51,7 +56,6 @@ export type UseMessageInteractionHandlersOptions = {
};
export type MessageInteractionHandlers = {
- editId: string | undefined;
handleEdit: (editEvtId?: string) => void;
handleOpenReply: MouseEventHandler;
handleUserClick: MouseEventHandler;
@@ -61,15 +65,17 @@ export type MessageInteractionHandlers = {
};
// Wiring layer for `` event-handler props, shared between
-// `RoomTimeline` (main column) and `ThreadDrawer` (thread column). Each
-// hook instance owns its own `editId` — editing in the drawer doesn't
-// flip a row in the main timeline into edit mode and vice versa, which
-// matches the user's mental model of two independent conversations.
+// `RoomTimeline` (main column) and `ThreadDrawer` (thread column). Edit
+// state lives in the per-(roomId, threadKey) edit-draft atom (the caller
+// passes the scoped setter) — editing in the drawer doesn't put a main
+// timeline row into edit mode and vice versa, which matches the user's
+// mental model of two independent conversations.
export function useMessageInteractionHandlers({
room,
editor,
composerSuspended,
setReplyDraft,
+ setEditDraft,
onOpenEvent,
channelsMode,
isBridged,
@@ -81,8 +87,6 @@ export function useMessageInteractionHandlers({
const space = useSpaceOptionally();
const openUserRoomProfile = useOpenUserRoomProfile();
- const [editId, setEditId] = useState();
-
const handleOpenReply: MouseEventHandler = useCallback(
(evt) => {
const targetId = evt.currentTarget.getAttribute('data-event-id');
@@ -171,6 +175,11 @@ export function useMessageInteractionHandlers({
navigate(getChannelsThreadPath(decodedSpace, decodedRoom, replyId));
return;
}
+ // Same orphan-draft guard as handleEdit below: while this scope's
+ // composer is hidden the reply banner could neither render nor be
+ // cleared. The thread-drawer navigation above is composer-independent
+ // and stays available.
+ if (composerSuspended) return;
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
const { body, formatted_body: formattedBody } = content;
@@ -189,7 +198,17 @@ export function useMessageInteractionHandlers({
setTimeout(() => ReactEditor.focus(editor), 100);
}
},
- [room, setReplyDraft, editor, channelsMode, isBridged, navigate, spaceIdOrAlias, roomIdOrAlias]
+ [
+ room,
+ setReplyDraft,
+ editor,
+ channelsMode,
+ isBridged,
+ navigate,
+ spaceIdOrAlias,
+ roomIdOrAlias,
+ composerSuspended,
+ ]
);
const handleReactionToggle = useCallback(
@@ -219,19 +238,21 @@ export function useMessageInteractionHandlers({
);
const handleEdit = useCallback(
+ // Param stays optional because callers pass `mEvent.getId()` which is
+ // `string | undefined`; an id-less call is simply ignored (the old
+ // no-arg «cancel» semantics died with the in-timeline MessageEditor).
(editEvtId?: string) => {
- if (editEvtId) {
- setEditId(editEvtId);
- return;
- }
- setEditId(undefined);
- ReactEditor.focus(editor);
+ if (!editEvtId) return;
+ // The composer for this scope loads the message + shows the edit
+ // banner (RoomInput's edit-draft effect). No-op while the composer
+ // is unmounted (callers also hide the Edit affordance then).
+ if (composerSuspended) return;
+ setEditDraft({ eventId: editEvtId });
},
- [editor]
+ [setEditDraft, composerSuspended]
);
return {
- editId,
handleEdit,
handleOpenReply,
handleUserClick,
diff --git a/src/app/state/room/roomInputDrafts.ts b/src/app/state/room/roomInputDrafts.ts
index 93c9f523..0361371a 100644
--- a/src/app/state/room/roomInputDrafts.ts
+++ b/src/app/state/room/roomInputDrafts.ts
@@ -74,3 +74,20 @@ export const roomIdToReplyDraftAtomFamily = atomFamily createReplyDraftAtom(),
draftKeyEqual
);
+
+// Edit-in-composer draft (Telegram-style): set when the user picks «Edit» on
+// an own message — the composer for the SAME (roomId, threadKey) scope loads
+// the message body, shows the edit banner and sends an m.replace on submit.
+// Only the target eventId lives here; the body is re-read from the room at
+// load/submit time so the edit always chains off the LATEST replacement.
+// Scoped per draft key like the reply draft: the main composer and each
+// thread-drawer composer keep independent edit states.
+export type IEditDraft = {
+ eventId: string;
+};
+const createEditDraftAtom = () => atom(undefined);
+export type TEditDraftAtom = ReturnType;
+export const roomIdToEditDraftAtomFamily = atomFamily(
+ () => createEditDraftAtom(),
+ draftKeyEqual
+);