refactor(room): fold message editing and file attachments into the composer, removing the floating MessageEditor and UploadBoard

This commit is contained in:
heaven 2026-06-15 02:57:32 +03:00
parent 180021b9ad
commit abae93170a
18 changed files with 1062 additions and 1116 deletions

View file

@ -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.

View file

@ -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 = <div className={css.EditContent}>{children}</div>;
} 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 = (

View file

@ -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,
});

View file

@ -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) => (
<Box className={css.UploadBoardBase} {...props} ref={ref}>
<Box className={css.UploadBoardContainer} justifyContent="End">
<Box className={classNames(css.UploadBoard)} direction="Column">
<Box grow="Yes" direction="Column">
{children}
</Box>
<Box direction="Column" shrink="No">
{header}
</Box>
</Box>
</Box>
</Box>
));
export type UploadBoardImperativeHandlers = { handleSend: () => Promise<void> };
type UploadBoardHeaderProps = {
open: boolean;
onToggle: () => void;
uploadFamilyObserverAtom: TUploadFamilyObserverAtom;
onCancel: (uploads: Upload[]) => void;
onSend: (uploads: UploadSuccess[]) => Promise<void>;
imperativeHandlerRef: MutableRefObject<UploadBoardImperativeHandlers | undefined>;
};
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 (
<Header size="400">
<Box
as="button"
style={{ cursor: 'pointer' }}
onClick={onToggle}
className={css.UploadBoardHeaderContent}
alignItems="Center"
grow="Yes"
gap="100"
>
<Icon src={open ? Icons.ChevronTop : Icons.ChevronRight} size="50" />
<Text size="H6">Files</Text>
</Box>
<Box className={css.UploadBoardHeaderContent} alignItems="Center" gap="100">
{isSuccess && (
<Chip
as="button"
onClick={handleSend}
variant="Primary"
radii="Pill"
outlined
after={<Icon src={Icons.Send} size="50" filled />}
>
<Text size="B300">Send</Text>
</Chip>
)}
{isError && !open && (
<Badge variant="Critical" fill="Solid" radii="300">
<Text size="L400">Upload Failed</Text>
</Badge>
)}
{!isSuccess && !isError && !open && (
<>
<Badge variant="Secondary" fill="Solid" radii="Pill">
<Text size="L400">{Math.round(percent(0, progress.total, progress.loaded))}%</Text>
</Badge>
<Spinner variant="Secondary" size="200" />
</>
)}
{!isSuccess && open && (
<Chip
as="button"
onClick={handleCancel}
variant="SurfaceVariant"
radii="Pill"
after={<Icon src={Icons.Cross} size="50" />}
>
<Text size="B300">{uploads.length === 1 ? 'Remove' : 'Remove All'}</Text>
</Chip>
)}
</Box>
</Header>
);
}
export const UploadBoardContent = as<'div'>(({ className, children, ...props }, ref) => (
<Box
className={classNames(css.UploadBoardContent, className)}
direction="Column"
gap="200"
{...props}
ref={ref}
>
{children}
</Box>
));

View file

@ -1 +0,0 @@
export * from './UploadBoard';

View file

@ -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 (
<img
style={{
objectFit: 'contain',
width: '100%',
height: toRem(152),
filter: metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
}}
alt={originalFile.name}
src={fileUrl}
/>
);
}
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
<video
style={{
objectFit: 'contain',
width: '100%',
height: toRem(152),
filter: metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
}}
src={fileUrl}
/>
);
}
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 ? (
<Box
style={{
borderRadius: config.radii.R300,
overflow: 'hidden',
backgroundColor: 'black',
position: 'relative',
}}
>
{children}
<Box
justifyContent="End"
style={{
position: 'absolute',
bottom: config.space.S100,
left: config.space.S100,
right: config.space.S100,
}}
>
<Chip
variant={metadata.markedAsSpoiler ? 'Warning' : 'Secondary'}
fill="Soft"
radii="Pill"
aria-pressed={metadata.markedAsSpoiler}
before={<Icon src={Icons.EyeBlind} size="50" />}
onClick={() => onSpoiler(!metadata.markedAsSpoiler)}
>
<Text size="B300">Spoiler</Text>
</Chip>
</Box>
</Box>
) : 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 (
<UploadCard
radii="300"
before={<Icon src={getFileTypeIcon(Icons, file.type)} />}
after={
<>
{upload.status === UploadStatus.Error && (
<Chip
as="button"
onClick={startUpload}
aria-label="Retry Upload"
variant="Critical"
radii="Pill"
outlined
>
<Text size="B300">Retry</Text>
</Chip>
)}
<IconButton
onClick={removeUpload}
aria-label="Cancel Upload"
variant="SurfaceVariant"
radii="Pill"
size="300"
>
<Icon src={Icons.Cross} size="200" />
</IconButton>
</>
}
bottom={
<>
{fileItem.originalFile.type.startsWith('image') && (
<MediaPreview fileItem={fileItem} onSpoiler={handleSpoiler}>
<PreviewImage fileItem={fileItem} />
</MediaPreview>
)}
{fileItem.originalFile.type.startsWith('video') && (
<MediaPreview fileItem={fileItem} onSpoiler={handleSpoiler}>
<PreviewVideo fileItem={fileItem} />
</MediaPreview>
)}
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
)}
{upload.status === UploadStatus.Loading && (
<UploadCardProgress sentBytes={upload.progress.loaded} totalBytes={file.size} />
)}
{upload.status === UploadStatus.Error && (
<UploadCardError>
<Text size="T200">{upload.error.message}</Text>
</UploadCardError>
)}
{upload.status === UploadStatus.Idle && fileSizeExceeded && (
<UploadCardError>
<Text size="T200">
The file size exceeds the limit. Maximum allowed size is{' '}
<b>{bytesToSize(allowSize)}</b>, but the uploaded file is{' '}
<b>{bytesToSize(file.size)}</b>.
</Text>
</UploadCardError>
)}
</>
}
>
<Text size="H6" truncate>
{file.name}
</Text>
{upload.status === UploadStatus.Success && (
<Icon style={{ color: color.Success.Main }} src={Icons.Check} size="100" />
)}
</UploadCard>
);
}

View file

@ -1,3 +1,2 @@
export * from './UploadCard';
export * from './UploadCardRenderer';
export * from './CompactUploadCardRenderer';

View file

@ -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,
});

View file

@ -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 = (
<button
type="button"
className={css.RemoveBtn}
onClick={handleRemove}
aria-label={t('Room.upload_cancel_label')}
>
<Icon src={Icons.Cross} size="50" />
</button>
);
if (previewUrl) {
const isVideo = originalFile.type.startsWith('video');
return (
<div
className={classNames(css.MediaTile, (failed || fileSizeExceeded) && css.TileError)}
title={file.name}
>
{isVideo ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video
className={classNames(
css.MediaTileContent,
metadata.markedAsSpoiler && css.MediaTileSpoilered
)}
src={previewUrl}
/>
) : (
<img
className={classNames(
css.MediaTileContent,
metadata.markedAsSpoiler && css.MediaTileSpoilered
)}
src={previewUrl}
alt={file.name}
/>
)}
{loading && <div className={css.TileOverlay}>{progressPercent}%</div>}
{(failed || fileSizeExceeded) && (
<div className={css.TileOverlay}>
{failed ? (
<button
type="button"
className={css.RetryBtn}
onClick={startUpload}
aria-label={t('Room.upload_retry_label')}
>
{t('Room.upload_retry')}
</button>
) : (
<Text size="T200" align="Center">
{t('Room.upload_too_large')}
</Text>
)}
</div>
)}
{removeBtn}
{!fileSizeExceeded && (
<button
type="button"
className={css.SpoilerBtn}
onClick={handleSpoiler}
aria-pressed={metadata.markedAsSpoiler}
aria-label={t('Room.upload_spoiler')}
>
<Icon src={Icons.EyeBlind} size="50" />
</button>
)}
</div>
);
}
let meta: React.ReactNode = bytesToSize(file.size);
if (loading) meta = `${progressPercent}%`;
else if (fileSizeExceeded) meta = t('Room.upload_too_large');
else if (failed) {
meta = (
<button
type="button"
className={css.RetryBtn}
onClick={startUpload}
aria-label={t('Room.upload_retry_label')}
>
{t('Room.upload_retry')}
</button>
);
}
return (
<div
className={classNames(css.FileTile, (failed || fileSizeExceeded) && css.TileError)}
title={file.name}
>
<Icon src={getFileTypeIcon(Icons, file.type)} size="200" />
<div className={css.FileTileText}>
<span className={css.FileTileName}>{file.name}</span>
<span
className={classNames(
css.FileTileMeta,
(failed || fileSizeExceeded) && css.FileTileMetaCritical
)}
>
{meta}
</span>
</div>
{removeBtn}
</div>
);
}
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 (
<div className={css.Strip}>
{selectedFiles.map((fileItem, index) => (
<AttachmentItem
// Index keys are safe here: per-item upload state lives in the
// jotai family keyed by the FILE OBJECT, so a remount after a
// mid-list removal re-binds to the same atom. (Name+size would
// collide when the same file is attached twice.)
// eslint-disable-next-line react/no-array-index-key
key={index}
fileItem={fileItem}
// E2EE rooms upload the pre-encrypted blob — hide the filename
// from the upload endpoint, same as the old per-card renderer.
isEncrypted={!!fileItem.encInfo}
setMetadata={setMetadata}
onRemove={onRemove}
/>
))}
</div>
);
}

View file

@ -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<HTMLDivElement, RoomInputProps>(
}, [voiceDisabledBy]);
const emojiBtnRef = useRef<HTMLButtonElement>(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<EmojiBoardTab | undefined>(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<HTMLDivElement, RoomInputProps>(
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<HTMLDivElement, RoomInputProps>(
: 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<UploadBoardImperativeHandlers>();
// 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<HTMLDivElement, RoomInputProps>(
// 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<HTMLDivElement, RoomInputProps>(
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<string, unknown> = 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<Descendant[] | null>(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<string>();
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<HTMLDivElement, RoomInputProps>(
// 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<HTMLDivElement, RoomInputProps>(
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<HTMLDivElement, RoomInputProps>(
// 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<HTMLDivElement, RoomInputProps>(
[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<HTMLDivElement, RoomInputProps>(
[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<unknown> = 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<HTMLDivElement, RoomInputProps>(
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<HTMLDivElement, RoomInputProps>(
onSend,
editor,
replyDraft,
editDraft,
submitEdit,
sendTypingStatus,
setReplyDraft,
isMarkdown,
commands,
jotaiStore,
uploadFamilyObserverAtom,
handleSendUpload,
]);
const handleKeyDown: KeyboardEventHandler = useCallback(
@ -671,10 +1036,24 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
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<HTMLDivElement, RoomInputProps>(
</IconButton>
);
// 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 && (
<EmojiBoard
tab={emojiBoardTab}
onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
dock
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
requestClose={() => 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 && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: () => setEmojiBoardTab(undefined),
escapeDeactivates: stopPropagation,
}}
>
<EmojiBoard
tab={emojiBoardTab}
onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
requestClose={() => setEmojiBoardTab(undefined)}
/>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
const emojiButton = dockEmojiBoard ? (
const emojiButton = centerEmojiBoard ? (
<IconButton
ref={emojiBtnRef}
aria-pressed={!!emojiBoardTab}
@ -873,39 +1265,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
return (
<div ref={ref}>
{selectedFiles.length > 0 && (
<UploadBoard
header={
<UploadBoardHeader
open={uploadBoard}
onToggle={() => setUploadBoard(!uploadBoard)}
uploadFamilyObserverAtom={uploadFamilyObserverAtom}
onSend={handleSendUpload}
imperativeHandlerRef={uploadBoardHandlers}
onCancel={handleCancelUpload}
/>
}
>
{uploadBoard && (
<Scroll size="300" hideTrack visibility="Hover">
<UploadBoardContent>
{Array.from(selectedFiles)
.reverse()
.map((fileItem, index) => (
<UploadCardRenderer
// eslint-disable-next-line react/no-array-index-key
key={index}
isEncrypted={!!fileItem.encInfo}
fileItem={fileItem}
setMetadata={handleFileMetadata}
onRemove={handleRemoveUpload}
/>
))}
</UploadBoardContent>
</Scroll>
)}
</UploadBoard>
)}
<Overlay
open={dropZoneVisible && !textOnly}
backdrop={<OverlayBackdrop />}
@ -975,7 +1334,16 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}
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. */}
<ComposerAttachments
selectedFiles={selectedFiles}
setMetadata={handleFileMetadata}
onRemove={handleRemoveUpload}
/>
{voiceError && (
<Box
alignItems="Center"
@ -996,7 +1364,50 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</Text>
</Box>
)}
{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 && (
<div>
<Box
alignItems="Center"
gap="300"
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
<IconButton
onClick={() => setEditDraft(undefined)}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label={t('Room.editing_cancel')}
>
<Icon src={Icons.Cross} size="50" />
</IconButton>
<Box direction="Row" gap="200" alignItems="Center">
<Icon
src={Icons.Pencil}
size="50"
style={{ color: color.Primary.Main, flexShrink: 0 }}
/>
<ReplyLayout
userColor={color.Primary.Main}
username={
<Text size="T300" truncate>
<b>{t('Room.editing_message')}</b>
</Text>
}
>
<Text size="T300" truncate>
{editTarget?.body ? trimReplyFromBody(editTarget.body) : ''}
</Text>
</ReplyLayout>
</Box>
</Box>
</div>
)}
{/* Reply preview hides while editing (the edit owns the send);
the reply draft itself is preserved and returns after. */}
{replyDraft && !editDraft && (
<div>
<Box
alignItems="Center"
@ -1044,7 +1455,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
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<HTMLDivElement, RoomInputProps>(
>
{!textOnly && plusButton}
<Box grow="Yes" />
{!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}
</Box>

View file

@ -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<typeof TimelineFloat>;
// "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

View file

@ -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) => (
<Box
className={classNames(css.TimelineFloat({ position }), className)}
justifyContent="Center"
alignItems="Center"
gap="200"
{...props}
ref={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 && (
<Reply
@ -1830,7 +1803,6 @@ export function RoomTimeline({
collapse={collapse}
railHidden={railHidden}
highlight={highlighted}
edit={editId === mEventId}
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
@ -1840,7 +1812,7 @@ export function RoomTimeline({
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
onEditId={handleEdit}
onEditId={mainComposerSuspended ? undefined : handleEdit}
reply={
replyEventId && (
<Reply
@ -2671,36 +2643,11 @@ export function RoomTimeline({
return eventJSX;
};
const unreadFloatShown = !!unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline;
return (
<Box grow="Yes" style={{ position: 'relative' }}>
{/* 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 && (
<TimelineFloat position="Top">
<Chip
variant="Primary"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.MessageUnread} />}
onClick={handleJumpToUnread}
>
<Text size="L400">{t('Room.jump_to_unread')}</Text>
</Chip>
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.CheckTwice} />}
onClick={handleMarkAsRead}
>
<Text size="L400">{t('Room.mark_as_read')}</Text>
</Chip>
</TimelineFloat>
)}
<div ref={scrollRef} className={css.TimelineScroll}>
<Box
direction="Column"

View file

@ -15,10 +15,11 @@ import {
// surface mirror the same curvature. The thread-drawer composer in
// `ThreadDrawer.tsx` also wraps `RoomInput` with this class
// (`${ThreadComposer} ${ChatComposer}`), so it inherits both the dark
// card chrome and the compact two-row geometry. The message-edit overlay
// and `Editor.preview.tsx` mount `CustomEditor` directly without the
// `ChatComposer` wrap, so they keep the folds-default pill R400 +
// SurfaceVariant fill. The touch-hover gate at the bottom of this file
// card chrome and the compact two-row geometry. `Editor.preview.tsx`
// mounts `CustomEditor` directly without the `ChatComposer` wrap, so it
// keeps the folds-default pill R400 + SurfaceVariant fill. (Message
// editing happens IN the composer now — the old in-timeline edit overlay
// is gone.) The touch-hover gate at the bottom of this file
// also covers `RoomTombstone` / `RoomInputPlaceholder` since they share
// the wrap, which is intentional: their action buttons benefit from the
// same Android-WebView stuck-:hover suppression.

View file

@ -71,7 +71,11 @@ import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { draftKey, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
import {
draftKey,
roomIdToEditDraftAtomFamily,
roomIdToReplyDraftAtomFamily,
} from '../../state/room/roomInputDrafts';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import {
@ -383,6 +387,9 @@ export function ThreadDrawer({
// `draftKey(roomId, threadId)` (see `roomInputDrafts.ts:34-37`) so
// the chip surfaces inside the drawer composer.
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(draftKey(room.roomId, rootId)));
// Edit-draft scope: same tuple key — «Edit» on a thread row loads it into
// the DRAWER composer (its RoomInput subscribes to this slot).
const setEditDraft = useSetAtom(roomIdToEditDraftAtomFamily(draftKey(room.roomId, rootId)));
// Reply-chip click scrolls within the drawer to the matching
// `data-message-id` row. If the target lives in the main timeline
@ -397,7 +404,6 @@ export function ThreadDrawer({
}, []);
const {
editId,
handleEdit,
handleOpenReply,
handleUserClick,
@ -417,6 +423,7 @@ export function ThreadDrawer({
// bail before touching the unmounted editor.
composerSuspended: !canMessage,
setReplyDraft,
setEditDraft,
onOpenEvent: handleScrollToDrawerEvent,
channelsMode,
isBridged,
@ -1136,7 +1143,6 @@ export function ThreadDrawer({
mEvent={mEvent}
collapse={false}
highlight={false}
edit={editId === eventId}
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
@ -1146,7 +1152,7 @@ export function ThreadDrawer({
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
onEditId={handleEdit}
onEditId={canMessage ? handleEdit : undefined}
reply={
showReplyChip && (
<Reply

View file

@ -58,7 +58,6 @@ import {
UsernameBold,
} from '../../../components/message';
import { StreamMediaContext } from '../../../components/RenderMessageContent';
import { ChatComposer } from '../RoomView.css';
import { logMedia } from '../../../components/message/attachment/streamMediaDebug';
import { canEditEvent, getEventEdits, getMemberDisplayName } from '../../../utils/room';
import { getMxIdLocalPart } from '../../../utils/matrix';
@ -73,7 +72,6 @@ import { TextViewer } from '../../../components/text-viewer';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { EmojiBoard } from '../../../components/emoji-board';
import { ReactionViewer } from '../reaction-viewer';
import { MessageEditor } from './MessageEditor';
import { copyToClipboard } from '../../../utils/dom';
import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
@ -343,22 +341,8 @@ export const MessageReadReceiptItem = as<
return (
<>
<Overlay open={open} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="300">
<EventReaders room={room} eventId={eventId} requestClose={handleClose} />
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
{/* EventReaders owns its Overlay + FocusTrap (self-contained Vojo card). */}
{open && <EventReaders room={room} eventId={eventId} requestClose={handleClose} />}
<MenuItem
size="300"
after={<Icon size="100" src={RailIcons.Receipts} />}
@ -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<HTMLDivElement> = (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<HTMLDivElement> = (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 = (
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%', width: '100%' }}>
{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.
<div className={ChatComposer} style={{ width: '100%' }}>
<MessageEditor
style={{ maxWidth: '100%', width: '100%' }}
roomId={room.roomId}
room={room}
mEvent={mEvent}
imagePackRooms={imagePackRooms}
onCancel={() => onEditId()}
/>
</div>
) : (
children
)}
{children}
</Box>
);
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (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 && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
@ -1493,9 +1456,6 @@ const MessageInner = as<'div', MessageProps>(
// `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 ? (
<DmReadStatusLine room={room} mEvent={mEvent} hideReadReceipts={hideReadReceipts} />
) : 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 &&

View file

@ -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<AutocompleteQuery<AutocompletePrefix>>();
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<string, unknown> = 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<AutocompletePrefix>(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 (
<div {...props} ref={ref}>
{autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
<RoomMentionAutocomplete
roomId={roomId}
editor={editor}
query={autocompleteQuery}
requestClose={handleCloseAutocomplete}
/>
)}
{autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
<UserMentionAutocomplete
room={room}
editor={editor}
query={autocompleteQuery}
requestClose={handleCloseAutocomplete}
/>
)}
{autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
<EmoticonAutocomplete
imagePackRooms={imagePackRooms || []}
editor={editor}
query={autocompleteQuery}
requestClose={handleCloseAutocomplete}
/>
)}
<CustomEditor
editor={editor}
placeholder="Edit message..."
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
bottom={
<>
<Box
style={{ padding: config.space.S200, paddingTop: 0 }}
alignItems="End"
justifyContent="SpaceBetween"
gap="100"
>
<Box gap="Inherit">
<Chip
onClick={handleSave}
variant="Primary"
radii="Pill"
disabled={saveState.status === AsyncStatus.Loading}
outlined
before={
saveState.status === AsyncStatus.Loading ? (
<Spinner variant="Primary" fill="Soft" size="100" />
) : undefined
}
>
<Text size="B300">Save</Text>
</Chip>
<Chip onClick={onCancel} variant="SurfaceVariant" radii="Pill">
<Text size="B300">Cancel</Text>
</Chip>
</Box>
<Box gap="Inherit">
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
onClick={() => setToolbar(!toolbar)}
>
<Icon size="400" src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
</IconButton>
<UseStateProvider initial={undefined}>
{(anchor: RectCords | undefined, setAnchor) => (
<PopOut
anchor={anchor}
alignOffset={-8}
position="Top"
align="End"
content={
<EmojiBoard
imagePackRooms={imagePackRooms ?? []}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
requestClose={() => {
setAnchor((v) => {
if (v) {
if (!mobileOrTablet()) ReactEditor.focus(editor);
return undefined;
}
return v;
});
}}
/>
}
>
<IconButton
aria-pressed={anchor !== undefined}
onClick={
((evt) =>
setAnchor(
evt.currentTarget.getBoundingClientRect()
)) as MouseEventHandler<HTMLButtonElement>
}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon size="400" src={Icons.Smile} filled={anchor !== undefined} />
</IconButton>
</PopOut>
)}
</UseStateProvider>
</Box>
</Box>
{toolbar && (
<div>
<Line variant="SurfaceVariant" size="300" />
<Toolbar />
</div>
)}
</>
}
/>
</div>
);
}
);

View file

@ -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<HTMLButtonElement>;
@ -61,15 +65,17 @@ export type MessageInteractionHandlers = {
};
// Wiring layer for `<Message>` 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<string>();
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,

View file

@ -74,3 +74,20 @@ export const roomIdToReplyDraftAtomFamily = atomFamily<DraftKey, TReplyDraftAtom
() => 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<IEditDraft | undefined>(undefined);
export type TEditDraftAtom = ReturnType<typeof createEditDraftAtom>;
export const roomIdToEditDraftAtomFamily = atomFamily<DraftKey, TEditDraftAtom>(
() => createEditDraftAtom(),
draftKeyEqual
);