refactor(room): fold message editing and file attachments into the composer, removing the floating MessageEditor and UploadBoard
This commit is contained in:
parent
180021b9ad
commit
abae93170a
18 changed files with 1062 additions and 1116 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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>
|
||||
));
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from './UploadBoard';
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,2 @@
|
|||
export * from './UploadCard';
|
||||
export * from './UploadCardRenderer';
|
||||
export * from './CompactUploadCardRenderer';
|
||||
|
|
|
|||
187
src/app/features/room/ComposerAttachments.css.ts
Normal file
187
src/app/features/room/ComposerAttachments.css.ts
Normal 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,
|
||||
});
|
||||
213
src/app/features/room/ComposerAttachments.tsx
Normal file
213
src/app/features/room/ComposerAttachments.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue