Send edited messages support

Co-authored-by: Fedor Indutnyy <indutny@signal.org>
This commit is contained in:
Josh Perez 2023-04-20 12:31:59 -04:00 committed by GitHub
parent d380817a44
commit 1f2cde6d04
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
79 changed files with 2507 additions and 1175 deletions

View file

@ -34,6 +34,7 @@ export default {
const useProps = (overrideProps: Partial<Props> = {}): Props => ({
addAttachment: action('addAttachment'),
conversationId: '123',
discardEditMessage: action('discardEditMessage'),
focusCounter: 0,
sendCounter: 0,
i18n,
@ -47,6 +48,7 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
? overrideProps.isFormattingEnabled
: true,
messageCompositionId: '456',
sendEditedMessage: action('sendEditedMessage'),
sendMultiMediaMessage: action('sendMultiMediaMessage'),
processAttachments: action('processAttachments'),
removeAttachment: action('removeAttachment'),

View file

@ -4,6 +4,8 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { get } from 'lodash';
import classNames from 'classnames';
import type { ReadonlyDeep } from 'type-fest';
import type { DraftBodyRanges } from '../types/BodyRange';
import type { LocalizerType, ThemeType } from '../types/Util';
import type { ErrorDialogAudioRecorderType } from '../types/AudioRecorder';
@ -64,6 +66,7 @@ import { useEscapeHandling } from '../hooks/useEscapeHandling';
import type { SmartCompositionRecordingProps } from '../state/smart/CompositionRecording';
import SelectModeActions from './conversation/SelectModeActions';
import type { ShowToastAction } from '../state/ducks/toast';
import type { DraftEditMessageType } from '../model-types.d';
export type OwnProps = Readonly<{
acceptedMessageRequest?: boolean;
@ -82,6 +85,8 @@ export type OwnProps = Readonly<{
onRecordingComplete: (rec: InMemoryAttachmentDraftType) => unknown
) => unknown;
conversationId: string;
discardEditMessage: (id: string) => unknown;
draftEditMessage?: DraftEditMessageType;
uuid?: string;
draftAttachments: ReadonlyArray<AttachmentDraftType>;
errorDialogAudioRecorderType?: ErrorDialogAudioRecorderType;
@ -117,6 +122,16 @@ export type OwnProps = Readonly<{
id: string,
opts: { packId: string; stickerId: number }
): unknown;
sendEditedMessage(
conversationId: string,
options: {
bodyRanges?: DraftBodyRanges;
message?: string;
quoteAuthorUuid?: string;
quoteSentAt?: number;
targetMessageId: string;
}
): unknown;
sendMultiMediaMessage(
conversationId: string,
options: {
@ -128,10 +143,15 @@ export type OwnProps = Readonly<{
}
): unknown;
quotedMessageId?: string;
quotedMessageProps?: Omit<
QuoteProps,
'i18n' | 'onClick' | 'onClose' | 'withContentAbove'
quotedMessageProps?: ReadonlyDeep<
Omit<
QuoteProps,
'i18n' | 'onClick' | 'onClose' | 'withContentAbove' | 'isCompose'
>
>;
quotedMessageAuthorUuid?: string;
quotedMessageSentAt?: number;
removeAttachment: (conversationId: string, filePath: string) => unknown;
scrollToMessage: (conversationId: string, messageId: string) => unknown;
setComposerFocus: (conversationId: string) => unknown;
@ -196,6 +216,8 @@ export function CompositionArea({
// Base props
addAttachment,
conversationId,
discardEditMessage,
draftEditMessage,
focusCounter,
i18n,
imageToBlurHash,
@ -206,6 +228,7 @@ export function CompositionArea({
pushPanelForConversation,
processAttachments,
removeAttachment,
sendEditedMessage,
sendMultiMediaMessage,
setComposerFocus,
setQuoteByMessageId,
@ -224,6 +247,8 @@ export function CompositionArea({
// Quote
quotedMessageId,
quotedMessageProps,
quotedMessageAuthorUuid,
quotedMessageSentAt,
scrollToMessage,
// MediaQualitySelector
setMediaQualitySetting,
@ -308,18 +333,42 @@ export function CompositionArea({
}
}, [inputApiRef, setLarge]);
const draftEditMessageBody = draftEditMessage?.body;
const editedMessageId = draftEditMessage?.targetMessageId;
const handleSubmit = useCallback(
(message: string, bodyRanges: DraftBodyRanges, timestamp: number) => {
emojiButtonRef.current?.close();
sendMultiMediaMessage(conversationId, {
draftAttachments,
bodyRanges,
message,
timestamp,
});
if (editedMessageId) {
sendEditedMessage(conversationId, {
bodyRanges,
message,
// sent timestamp for the quote
quoteSentAt: quotedMessageSentAt,
quoteAuthorUuid: quotedMessageAuthorUuid,
targetMessageId: editedMessageId,
});
} else {
sendMultiMediaMessage(conversationId, {
draftAttachments,
bodyRanges,
message,
timestamp,
});
}
setLarge(false);
},
[conversationId, draftAttachments, sendMultiMediaMessage, setLarge]
[
conversationId,
draftAttachments,
editedMessageId,
quotedMessageSentAt,
quotedMessageAuthorUuid,
sendEditedMessage,
sendMultiMediaMessage,
setLarge,
]
);
const launchAttachmentPicker = useCallback(() => {
@ -414,11 +463,35 @@ export function CompositionArea({
inputApiRef.current?.setContents(draftText, draftBodyRanges, true);
}, [conversationId, draftBodyRanges, draftText, previousConversationId]);
// We want to reset the state of Quill only if:
//
// - Our other device edits the message (edit history length would change)
// - User begins editing another message.
const editHistoryLength = draftEditMessage?.editHistoryLength;
const hasEditHistoryChanged =
usePrevious(editHistoryLength, editHistoryLength) !== editHistoryLength;
const hasEditedMessageChanged =
usePrevious(editedMessageId, editedMessageId) !== editedMessageId;
const hasEditDraftChanged = hasEditHistoryChanged || hasEditedMessageChanged;
useEffect(() => {
if (!hasEditDraftChanged) {
return;
}
inputApiRef.current?.setContents(
draftEditMessageBody ?? '',
draftBodyRanges,
true
);
}, [draftBodyRanges, draftEditMessageBody, hasEditDraftChanged]);
const handleToggleLarge = useCallback(() => {
setLarge(l => !l);
}, [setLarge]);
const shouldShowMicrophone = !large && !draftAttachments.length && !draftText;
const shouldShowMicrophone =
!large && !draftAttachments.length && !draftText && !draftEditMessage;
const showMediaQualitySelector = draftAttachments.some(isImageAttachment);
@ -460,9 +533,29 @@ export function CompositionArea({
</div>
) : null;
const editMessageFragment = draftEditMessage ? (
<>
{large && <div className="CompositionArea__placeholder" />}
<div className="CompositionArea__button-cell">
<button
aria-label={i18n('icu:CompositionArea__edit-action--discard')}
className="CompositionArea__edit-button CompositionArea__edit-button--discard"
onClick={() => discardEditMessage(conversationId)}
type="button"
/>
<button
aria-label={i18n('icu:CompositionArea__edit-action--send')}
className="CompositionArea__edit-button CompositionArea__edit-button--accept"
onClick={() => inputApiRef.current?.submit()}
type="button"
/>
</div>
</>
) : null;
const isRecording = recordingState === RecordingState.Recording;
const attButton =
linkPreviewResult || isRecording ? undefined : (
draftEditMessage || linkPreviewResult || isRecording ? undefined : (
<div className="CompositionArea__button-cell">
<button
type="button"
@ -473,7 +566,7 @@ export function CompositionArea({
</div>
);
const sendButtonFragment = (
const sendButtonFragment = !draftEditMessage ? (
<>
<div className="CompositionArea__placeholder" />
<div className="CompositionArea__button-cell">
@ -485,35 +578,36 @@ export function CompositionArea({
/>
</div>
</>
);
) : null;
const stickerButtonPlacement = large ? 'top-start' : 'top-end';
const stickerButtonFragment = withStickers ? (
<div className="CompositionArea__button-cell">
<StickerButton
i18n={i18n}
knownPacks={knownPacks}
receivedPacks={receivedPacks}
installedPack={installedPack}
installedPacks={installedPacks}
blessedPacks={blessedPacks}
recentStickers={recentStickers}
clearInstalledStickerPack={clearInstalledStickerPack}
onClickAddPack={() =>
pushPanelForConversation({
type: PanelType.StickerManager,
})
}
onPickSticker={(packId, stickerId) =>
sendStickerMessage(conversationId, { packId, stickerId })
}
clearShowIntroduction={clearShowIntroduction}
showPickerHint={showPickerHint}
clearShowPickerHint={clearShowPickerHint}
position={stickerButtonPlacement}
/>
</div>
) : null;
const stickerButtonFragment =
!draftEditMessage && withStickers ? (
<div className="CompositionArea__button-cell">
<StickerButton
i18n={i18n}
knownPacks={knownPacks}
receivedPacks={receivedPacks}
installedPack={installedPack}
installedPacks={installedPacks}
blessedPacks={blessedPacks}
recentStickers={recentStickers}
clearInstalledStickerPack={clearInstalledStickerPack}
onClickAddPack={() =>
pushPanelForConversation({
type: PanelType.StickerManager,
})
}
onPickSticker={(packId, stickerId) =>
sendStickerMessage(conversationId, { packId, stickerId })
}
clearShowIntroduction={clearShowIntroduction}
showPickerHint={showPickerHint}
clearShowPickerHint={clearShowPickerHint}
position={stickerButtonPlacement}
/>
</div>
) : null;
// Listen for cmd/ctrl-shift-x to toggle large composition mode
useEffect(() => {
@ -548,7 +642,16 @@ export function CompositionArea({
if (quotedMessageId) {
setQuoteByMessageId(conversationId, undefined);
}
}, [conversationId, quotedMessageId, setQuoteByMessageId]);
if (draftEditMessage) {
discardEditMessage(conversationId);
}
}, [
conversationId,
discardEditMessage,
draftEditMessage,
quotedMessageId,
setQuoteByMessageId,
]);
useEscapeHandling(clearQuote);
@ -752,13 +855,17 @@ export function CompositionArea({
'CompositionArea__row--column'
)}
>
{quotedMessageId && quotedMessageProps && (
{quotedMessageProps && (
<div className="quote-wrapper">
<Quote
isCompose
{...quotedMessageProps}
i18n={i18n}
onClick={() => scrollToMessage(conversationId, quotedMessageId)}
onClick={
quotedMessageId
? () => scrollToMessage(conversationId, quotedMessageId)
: undefined
}
onClose={() => {
setQuoteByMessageId(conversationId, undefined);
}}
@ -801,6 +908,7 @@ export function CompositionArea({
conversationId={conversationId}
disabled={isDisabled}
draftBodyRanges={draftBodyRanges}
draftEditMessage={draftEditMessage}
draftText={draftText}
getPreferredBadge={getPreferredBadge}
getQuotedMessage={getQuotedMessage}
@ -827,6 +935,7 @@ export function CompositionArea({
<>
{stickerButtonFragment}
{!dirty ? micButtonFragment : null}
{editMessageFragment}
{attButton}
</>
) : null}
@ -842,6 +951,7 @@ export function CompositionArea({
{stickerButtonFragment}
{attButton}
{!dirty ? micButtonFragment : null}
{editMessageFragment}
{dirty || !shouldShowMicrophone ? sendButtonFragment : null}
</div>
) : null}

View file

@ -37,6 +37,7 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
? overrideProps.isFormattingEnabled
: true,
large: boolean('large', overrideProps.large || false),
onCloseLinkPreview: action('onCloseLinkPreview'),
onEditorStateChange: action('onEditorStateChange'),
onPickEmoji: action('onPickEmoji'),
onSubmit: action('onSubmit'),

View file

@ -51,6 +51,7 @@ import * as log from '../logging/log';
import { useRefMerger } from '../hooks/useRefMerger';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
import type { DraftEditMessageType } from '../model-types.d';
import { usePrevious } from '../hooks/usePrevious';
Quill.register('formats/emoji', EmojiBlot);
@ -85,6 +86,7 @@ export type Props = Readonly<{
conversationId?: string;
i18n: LocalizerType;
disabled?: boolean;
draftEditMessage?: DraftEditMessageType;
getPreferredBadge: PreferredBadgeSelectorType;
large?: boolean;
inputApi?: React.MutableRefObject<InputApi | undefined>;
@ -132,6 +134,7 @@ export function CompositionInput(props: Props): React.ReactElement {
conversationId,
disabled,
draftBodyRanges,
draftEditMessage,
draftText,
getPreferredBadge,
getQuotedMessage,
@ -782,6 +785,21 @@ export function CompositionInput(props: Props): React.ReactElement {
data-testid="CompositionInput"
data-enabled={disabled ? 'false' : 'true'}
>
{draftEditMessage && (
<div className={getClassName('__editing-message')}>
{i18n('icu:CompositionInput__editing-message')}
</div>
)}
{draftEditMessage?.attachmentThumbnail && (
<div className={getClassName('__editing-message__attachment')}>
<img
alt={i18n('icu:stagedImageAttachment', {
path: draftEditMessage.attachmentThumbnail,
})}
src={draftEditMessage.attachmentThumbnail}
/>
</div>
)}
{conversationId && linkPreviewLoading && linkPreviewResult && (
<StagedLinkPreview
{...linkPreviewResult}

View file

@ -47,6 +47,7 @@ const MESSAGE_DEFAULT_PROPS = {
openGiftBadge: shouldNeverBeCalled,
openLink: shouldNeverBeCalled,
previews: [],
retryMessageSend: shouldNeverBeCalled,
pushPanelForConversation: shouldNeverBeCalled,
renderAudioAttachment: () => <div />,
renderingContext: 'EditHistoryMessagesModal',
@ -99,8 +100,10 @@ export function EditHistoryMessagesModal({
hasXButton
i18n={i18n}
modalName="EditHistoryMessagesModal"
moduleClassName="EditHistoryMessagesModal"
onClose={closeEditHistoryModal}
title={i18n('icu:EditHistoryMessagesModal__title')}
noTransform
>
<div ref={containerElementRef}>
{editHistoryMessages.map(messageAttributes => {

View file

@ -36,6 +36,7 @@ type PropsType = {
};
export type ModalPropsType = PropsType & {
noTransform?: boolean;
noMouseClose?: boolean;
theme?: Theme;
};
@ -57,15 +58,31 @@ export function Modal({
useFocusTrap,
hasHeaderDivider = false,
hasFooterDivider = false,
noTransform = false,
padded = true,
}: Readonly<ModalPropsType>): JSX.Element | null {
const { close, isClosed, modalStyles, overlayStyles } = useAnimated(onClose, {
getFrom: () => ({ opacity: 0, transform: 'translateY(48px)' }),
getTo: isOpen =>
isOpen
? { opacity: 1, transform: 'translateY(0px)' }
: { opacity: 0, transform: 'translateY(48px)' },
});
const { close, isClosed, modalStyles, overlayStyles } = useAnimated(
onClose,
// `background-position: fixed` cannot properly detect the viewport when
// the parent element has `transform: translate*`. Even though it requires
// layout recalculation - use `margin-top` if asked by the embedder.
noTransform
? {
getFrom: () => ({ opacity: 0, marginTop: '48px' }),
getTo: isOpen =>
isOpen
? { opacity: 1, marginTop: '0px' }
: { opacity: 0, marginTop: '48px' },
}
: {
getFrom: () => ({ opacity: 0, transform: 'translateY(48px)' }),
getTo: isOpen =>
isOpen
? { opacity: 1, transform: 'translateY(0px)' }
: { opacity: 0, transform: 'translateY(48px)' },
}
);
useEffect(() => {
if (!isClosed) {

View file

@ -59,6 +59,7 @@ const MESSAGE_DEFAULT_PROPS = {
openGiftBadge: shouldNeverBeCalled,
openLink: shouldNeverBeCalled,
previews: [],
retryMessageSend: shouldNeverBeCalled,
pushPanelForConversation: shouldNeverBeCalled,
renderAudioAttachment: () => <div />,
saveAttachment: shouldNeverBeCalled,
@ -240,6 +241,7 @@ export function StoryViewsNRepliesModal({
isFormattingEnabled={isFormattingEnabled}
isFormattingSpoilersEnabled={isFormattingSpoilersEnabled}
moduleClassName="StoryViewsNRepliesModal__input"
onCloseLinkPreview={noop}
onEditorStateChange={({ messageText }) => {
setMessageBodyText(messageText);
}}

View file

@ -26,6 +26,8 @@ function getToast(toastType: ToastType): AnyToast {
return { toastType: ToastType.Blocked };
case ToastType.BlockedGroup:
return { toastType: ToastType.BlockedGroup };
case ToastType.CannotEditMessage:
return { toastType: ToastType.CannotEditMessage };
case ToastType.CannotForwardEmptyMessage:
return { toastType: ToastType.CannotForwardEmptyMessage };
case ToastType.CannotMixMultiAndNonMultiAttachments:

View file

@ -68,6 +68,14 @@ export function ToastManager({
return <Toast onClose={hideToast}>{i18n('icu:unblockGroupToSend')}</Toast>;
}
if (toastType === ToastType.CannotEditMessage) {
return (
<Toast onClose={hideToast}>
{i18n('icu:ToastManager__CannotEditMessage')}
</Toast>
);
}
if (toastType === ToastType.CannotForwardEmptyMessage) {
return (
<Toast onClose={hideToast}>

View file

@ -99,6 +99,7 @@ import { RenderLocation } from './MessageTextRenderer';
const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 16;
const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18;
const GUESS_METADATA_WIDTH_EDITED_SIZE = 40;
const GUESS_METADATA_WIDTH_OUTGOING_SIZE: Record<MessageStatusType, number> = {
delivered: 24,
error: 24,
@ -314,6 +315,7 @@ export type PropsActions = {
showConversation: ShowConversationType;
openGiftBadge: (messageId: string) => void;
pushPanelForConversation: PushPanelForConversationActionType;
retryMessageSend: (messageId: string) => unknown;
showContactModal: (contactId: string, conversationId?: string) => void;
showSpoiler: (messageId: string) => void;
@ -617,10 +619,14 @@ export class Message extends React.PureComponent<Props, State> {
* because it can reduce layout jumpiness.
*/
private guessMetadataWidth(): number {
const { direction, expirationLength, status } = this.props;
const { direction, expirationLength, status, isEditedMessage } = this.props;
let result = GUESS_METADATA_WIDTH_TIMESTAMP_SIZE;
if (isEditedMessage) {
result += GUESS_METADATA_WIDTH_EDITED_SIZE;
}
const hasExpireTimer = Boolean(expirationLength);
if (hasExpireTimer) {
result += GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE;
@ -790,6 +796,7 @@ export class Message extends React.PureComponent<Props, State> {
isEditedMessage,
isSticker,
isTapToViewExpired,
retryMessageSend,
pushPanelForConversation,
showEditHistoryModal,
status,
@ -816,6 +823,7 @@ export class Message extends React.PureComponent<Props, State> {
isTapToViewExpired={isTapToViewExpired}
onWidthMeasured={isInline ? this.updateMetadataWidth : undefined}
pushPanelForConversation={pushPanelForConversation}
retryMessageSend={retryMessageSend}
showEditHistoryModal={showEditHistoryModal}
status={status}
textPending={textAttachment?.pending}

View file

@ -22,6 +22,7 @@ import { PlaybackButton } from '../PlaybackButton';
import { WaveformScrubber } from './WaveformScrubber';
import { useComputePeaks } from '../../hooks/useComputePeaks';
import { durationToPlaybackText } from '../../util/durationToPlaybackText';
import { shouldNeverBeCalled } from '../../util/shouldNeverBeCalled';
export type OwnProps = Readonly<{
active:
@ -360,6 +361,7 @@ export function MessageAudio(props: Props): JSX.Element {
isSticker={false}
isTapToViewExpired={false}
pushPanelForConversation={pushPanelForConversation}
retryMessageSend={shouldNeverBeCalled}
status={status}
textPending={textPending}
timestamp={timestamp}

View file

@ -87,6 +87,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
renderAudioAttachment: () => <div>*AudioAttachment*</div>,
saveAttachment: action('saveAttachment'),
showSpoiler: action('showSpoiler'),
retryMessageSend: action('retryMessageSend'),
pushPanelForConversation: action('pushPanelForConversation'),
showContactModal: action('showContactModal'),
showExpiredIncomingTapToViewToast: action(

View file

@ -84,6 +84,7 @@ export type PropsReduxActions = Pick<
| 'messageExpanded'
| 'openGiftBadge'
| 'pushPanelForConversation'
| 'retryMessageSend'
| 'saveAttachment'
| 'showContactModal'
| 'showConversation'
@ -125,6 +126,7 @@ export function MessageDetail({
openGiftBadge,
platform,
pushPanelForConversation,
retryMessageSend,
renderAudioAttachment,
saveAttachment,
showContactModal,
@ -345,6 +347,7 @@ export function MessageDetail({
openGiftBadge={openGiftBadge}
platform={platform}
pushPanelForConversation={pushPanelForConversation}
retryMessageSend={retryMessageSend}
renderAudioAttachment={renderAudioAttachment}
saveAttachment={saveAttachment}
shouldCollapseAbove={false}

View file

@ -2,17 +2,20 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactChild, ReactElement } from 'react';
import React from 'react';
import React, { useCallback, useState } from 'react';
import classNames from 'classnames';
import type { ContentRect } from 'react-measure';
import Measure from 'react-measure';
import type { LocalizerType } from '../../types/Util';
import type { DirectionType, MessageStatusType } from './Message';
import type { PushPanelForConversationActionType } from '../../state/ducks/conversations';
import { missingCaseError } from '../../util/missingCaseError';
import { ExpireTimer } from './ExpireTimer';
import { MessageTimestamp } from './MessageTimestamp';
import { PanelType } from '../../types/Panels';
import { Spinner } from '../Spinner';
import { ConfirmationDialog } from '../ConfirmationDialog';
type PropsType = {
deletedForEveryone?: boolean;
@ -29,12 +32,17 @@ type PropsType = {
isTapToViewExpired?: boolean;
onWidthMeasured?: (width: number) => unknown;
pushPanelForConversation: PushPanelForConversationActionType;
retryMessageSend: (messageId: string) => unknown;
showEditHistoryModal?: (id: string) => unknown;
status?: MessageStatusType;
textPending?: boolean;
timestamp: number;
};
enum ConfirmationType {
EditError = 'EditError',
}
export function MessageMetadata({
deletedForEveryone,
direction,
@ -50,11 +58,15 @@ export function MessageMetadata({
isTapToViewExpired,
onWidthMeasured,
pushPanelForConversation,
retryMessageSend,
showEditHistoryModal,
status,
textPending,
timestamp,
}: Readonly<PropsType>): ReactElement {
const [confirmationType, setConfirmationType] = useState<
ConfirmationType | undefined
>();
const withImageNoCaption = Boolean(!isSticker && !hasText && isShowingImage);
const metadataDirection = isSticker ? undefined : direction;
@ -68,9 +80,26 @@ export function MessageMetadata({
if (isError || isPartiallySent || isPaused) {
let statusInfo: React.ReactChild;
if (isError) {
statusInfo = deletedForEveryone
? i18n('icu:deleteFailed')
: i18n('icu:sendFailed');
if (deletedForEveryone) {
statusInfo = i18n('icu:deleteFailed');
} else if (isEditedMessage) {
statusInfo = (
<button
type="button"
className="module-message__metadata__tapable"
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
setConfirmationType(ConfirmationType.EditError);
}}
>
{i18n('icu:editFailed')}
</button>
);
} else {
statusInfo = i18n('icu:sendFailed');
}
} else if (isPaused) {
statusInfo = i18n('icu:sendPaused');
} else {
@ -126,6 +155,35 @@ export function MessageMetadata({
}
}
let confirmation: JSX.Element | undefined;
if (confirmationType === undefined) {
// no-op
} else if (confirmationType === ConfirmationType.EditError) {
confirmation = (
<ConfirmationDialog
dialogName="MessageMetadata.confirmEditResend"
actions={[
{
action: () => {
retryMessageSend(id);
setConfirmationType(undefined);
},
style: 'negative',
text: i18n('icu:ResendMessageEdit__button'),
},
]}
i18n={i18n}
onClose={() => {
setConfirmationType(undefined);
}}
>
{i18n('icu:ResendMessageEdit__body')}
</ConfirmationDialog>
);
} else {
throw missingCaseError(confirmationType);
}
const className = classNames(
'module-message__metadata',
isInline && 'module-message__metadata--inline',
@ -184,17 +242,20 @@ export function MessageMetadata({
)}
/>
) : null}
{confirmation}
</>
);
const onResize = useCallback(
({ bounds }: ContentRect) => {
onWidthMeasured?.(bounds?.width || 0);
},
[onWidthMeasured]
);
if (onWidthMeasured) {
return (
<Measure
bounds
onResize={({ bounds }) => {
onWidthMeasured(bounds?.width || 0);
}}
>
<Measure bounds onResize={onResize}>
{({ measureRef }) => (
<div className={className} ref={measureRef}>
{children}

View file

@ -83,6 +83,7 @@ const defaultMessageProps: TimelineMessagesProps = {
id: 'some-id',
title: 'Person X',
}),
canEditMessage: true,
canReact: true,
canReply: true,
canRetry: true,
@ -125,6 +126,7 @@ const defaultMessageProps: TimelineMessagesProps = {
renderEmojiPicker: () => <div />,
renderReactionPicker: () => <div />,
renderAudioAttachment: () => <div>*AudioAttachment*</div>,
setMessageToEdit: action('setMessageToEdit'),
setQuoteByMessageId: action('default--setQuoteByMessageId'),
retryMessageSend: action('default--retryMessageSend'),
retryDeleteForEveryone: action('default--retryDeleteForEveryone'),

View file

@ -49,6 +49,7 @@ function mockMessageTimelineItem(
author: getDefaultConversation({}),
canDeleteForEveryone: false,
canDownload: true,
canEditMessage: true,
canReact: true,
canReply: true,
canRetry: true,
@ -279,6 +280,7 @@ const actions = () => ({
updateSharedGroups: action('updateSharedGroups'),
reactToMessage: action('reactToMessage'),
setMessageToEdit: action('setMessageToEdit'),
setQuoteByMessageId: action('setQuoteByMessageId'),
retryDeleteForEveryone: action('retryDeleteForEveryone'),
retryMessageSend: action('retryMessageSend'),

View file

@ -67,6 +67,7 @@ const getDefaultProps = () => ({
reactToMessage: action('reactToMessage'),
checkForAccount: action('checkForAccount'),
clearTargetedMessage: action('clearTargetedMessage'),
setMessageToEdit: action('setMessageToEdit'),
setQuoteByMessageId: action('setQuoteByMessageId'),
retryDeleteForEveryone: action('retryDeleteForEveryone'),
retryMessageSend: action('retryMessageSend'),

View file

@ -197,6 +197,7 @@ export const TimelineItem = memo(function TimelineItem({
renderUniversalTimerNotification,
returnToActiveCall,
targetMessage,
setMessageToEdit,
shouldCollapseAbove,
shouldCollapseBelow,
shouldHideMetadata,
@ -223,6 +224,7 @@ export const TimelineItem = memo(function TimelineItem({
{...item.data}
isTargeted={isTargeted}
targetMessage={targetMessage}
setMessageToEdit={setMessageToEdit}
shouldCollapseAbove={shouldCollapseAbove}
shouldCollapseBelow={shouldCollapseBelow}
shouldHideMetadata={shouldHideMetadata}

View file

@ -245,6 +245,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
attachments: overrideProps.attachments,
author: overrideProps.author || getDefaultConversation(),
bodyRanges: overrideProps.bodyRanges,
canEditMessage: true,
canReact: true,
canReply: true,
canDownload: true,
@ -330,6 +331,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
overrideProps.toggleSelectMessage == null
? action('toggleSelectMessage')
: overrideProps.toggleSelectMessage,
setMessageToEdit: action('setMessageToEdit'),
shouldCollapseAbove: isBoolean(overrideProps.shouldCollapseAbove)
? overrideProps.shouldCollapseAbove
: false,
@ -878,6 +880,13 @@ Error.args = {
text: 'I hope you get this.',
};
export const EditError = Template.bind({});
EditError.args = {
status: 'error',
isEditedMessage: true,
text: 'I hope you get this.',
};
export const Paused = Template.bind({});
Paused.args = {
status: 'paused',

View file

@ -32,6 +32,7 @@ import type { DeleteMessagesPropsType } from '../../state/ducks/globalModals';
export type PropsData = {
canDownload: boolean;
canEditMessage: boolean;
canRetry: boolean;
canRetryDeleteForEveryone: boolean;
canReact: boolean;
@ -50,6 +51,7 @@ export type PropsActions = {
) => void;
retryMessageSend: (id: string) => void;
retryDeleteForEveryone: (id: string) => void;
setMessageToEdit: (conversationId: string, messageId: string) => unknown;
setQuoteByMessageId: (conversationId: string, messageId: string) => void;
toggleSelectMessage: (
conversationId: string,
@ -80,6 +82,7 @@ export function TimelineMessage(props: Props): JSX.Element {
attachments,
author,
canDownload,
canEditMessage,
canReact,
canReply,
canRetry,
@ -107,6 +110,7 @@ export function TimelineMessage(props: Props): JSX.Element {
saveAttachment,
selectedReaction,
setQuoteByMessageId,
setMessageToEdit,
text,
timestamp,
toggleDeleteMessagesModal,
@ -350,6 +354,11 @@ export function TimelineMessage(props: Props): JSX.Element {
triggerId={triggerId}
shouldShowAdditional={shouldShowAdditional}
onDownload={handleDownload}
onEdit={
canEditMessage
? () => setMessageToEdit(conversationId, id)
: undefined
}
onReplyToMessage={handleReplyToMessage}
onReact={handleReact}
onRetryMessageSend={canRetry ? () => retryMessageSend(id) : undefined}
@ -540,6 +549,7 @@ type MessageContextProps = {
shouldShowAdditional: boolean;
onDownload: (() => void) | undefined;
onEdit: (() => void) | undefined;
onReplyToMessage: (() => void) | undefined;
onReact: (() => void) | undefined;
onRetryMessageSend: (() => void) | undefined;
@ -555,6 +565,7 @@ const MessageContextMenu = ({
triggerId,
shouldShowAdditional,
onDownload,
onEdit,
onReplyToMessage,
onReact,
onMoreInfo,
@ -686,6 +697,22 @@ const MessageContextMenu = ({
{i18n('icu:forwardMessage')}
</MenuItem>
)}
{onEdit && (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__edit-message',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onEdit();
}}
>
{i18n('icu:edit')}
</MenuItem>
)}
<MenuItem
attributes={{
className: