Send edited messages support
Co-authored-by: Fedor Indutnyy <indutny@signal.org>
This commit is contained in:
parent
d380817a44
commit
1f2cde6d04
79 changed files with 2507 additions and 1175 deletions
|
@ -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'),
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue