Implement group story reply deletion

This commit is contained in:
Alvaro 2022-11-04 07:22:07 -06:00 committed by GitHub
parent 7164b603e9
commit 4445ef80eb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1218 additions and 934 deletions

View file

@ -5867,6 +5867,14 @@
"messageformat": "You cant reply to this story because youre longer a member of this group.", "messageformat": "You cant reply to this story because youre longer a member of this group.",
"description": "Shown in the composer area of the reply-to-story modal when a user can't make a reply because they are no longer a member" "description": "Shown in the composer area of the reply-to-story modal when a user can't make a reply because they are no longer a member"
}, },
"icu:StoryViewsNRepliesModal__delete-reply": {
"messageformat": "Delete for me",
"description": "Shown as a menu item in the context menu of a story reply, to the author of the reply, for deleting the reply just for the author"
},
"icu:StoryViewsNRepliesModal__delete-reply-for-everyone": {
"messageformat": "Delete for everyone",
"description": "Shown as a menu item in the context menu of a story reply, to the author of the reply, for deleting the reply for everyone"
},
"StoryListItem__label": { "StoryListItem__label": {
"message": "Story", "message": "Story",
"description": "aria-label for the story list button" "description": "aria-label for the story list button"

View file

@ -466,14 +466,14 @@ $message-padding-horizontal: 12px;
@include light-theme { @include light-theme {
color: $color-gray-90; color: $color-gray-90;
border: 1px solid $color-gray-25; border: 1px solid $color-gray-25;
background-color: $color-white; background-color: transparent;
background-image: none; background-image: none;
} }
@include dark-theme { @include dark-theme {
color: $color-gray-05; color: $color-gray-05;
border: 1px solid $color-gray-75; border: 1px solid $color-gray-75;
background-color: $color-gray-95; background-color: transparent;
background-image: none; background-image: none;
} }
} }

View file

@ -32,7 +32,7 @@ export type OwnProps = Readonly<{
onClose: () => unknown; onClose: () => unknown;
onTopOfEverything?: boolean; onTopOfEverything?: boolean;
theme?: Theme; theme?: Theme;
title?: string | React.ReactNode; title?: React.ReactNode;
}>; }>;
export type Props = OwnProps; export type Props = OwnProps;

View file

@ -24,10 +24,11 @@ export type ContextMenuOptionType<T> = Readonly<{
}>; }>;
type RenderButtonProps = Readonly<{ type RenderButtonProps = Readonly<{
openMenu: (() => void) | ((ev: React.MouseEvent) => void); openMenu: (ev: React.MouseEvent) => void;
onKeyDown: (ev: KeyboardEvent) => void; onKeyDown: (ev: KeyboardEvent) => void;
isMenuShowing: boolean; isMenuShowing: boolean;
ref: React.Ref<HTMLButtonElement> | null; ref: React.Ref<HTMLButtonElement> | null;
menuNode: ReactNode;
}>; }>;
export type PropsType<T> = Readonly<{ export type PropsType<T> = Readonly<{
@ -38,7 +39,7 @@ export type PropsType<T> = Readonly<{
menuOptions: ReadonlyArray<ContextMenuOptionType<T>>; menuOptions: ReadonlyArray<ContextMenuOptionType<T>>;
moduleClassName?: string; moduleClassName?: string;
button?: () => JSX.Element; button?: () => JSX.Element;
onClick?: () => unknown; onClick?: (ev: React.MouseEvent) => unknown;
onMenuShowingChanged?: (value: boolean) => unknown; onMenuShowingChanged?: (value: boolean) => unknown;
popperOptions?: Pick<Options, 'placement' | 'strategy'>; popperOptions?: Pick<Options, 'placement' | 'strategy'>;
theme?: Theme; theme?: Theme;
@ -260,42 +261,7 @@ export function ContextMenu<T>({
); );
} }
let buttonNode: ReactNode; const menuNode = isMenuShowing ? (
if (typeof children === 'function') {
buttonNode = (children as (props: RenderButtonProps) => JSX.Element)({
openMenu: onClick || handleClick,
onKeyDown: handleKeyDown,
isMenuShowing,
ref: setReferenceElement,
});
} else {
buttonNode = (
<button
aria-label={ariaLabel || i18n('ContextMenu--button')}
className={classNames(
getClassName('__button'),
isMenuShowing ? getClassName('__button--active') : undefined
)}
onClick={onClick || handleClick}
onContextMenu={handleClick}
onKeyDown={handleKeyDown}
ref={setReferenceElement}
type="button"
>
{children}
</button>
);
}
return (
<div
className={classNames(
getClassName('__container'),
theme ? themeClassName(theme) : undefined
)}
>
{buttonNode}
{isMenuShowing && (
<div className={theme ? themeClassName(theme) : undefined}> <div className={theme ? themeClassName(theme) : undefined}>
<div <div
className={classNames( className={classNames(
@ -312,7 +278,43 @@ export function ContextMenu<T>({
{optionElements} {optionElements}
</div> </div>
</div> </div>
) : undefined;
let buttonNode: JSX.Element;
if (typeof children === 'function') {
buttonNode = (children as (props: RenderButtonProps) => JSX.Element)({
openMenu: onClick || handleClick,
onKeyDown: handleKeyDown,
isMenuShowing,
ref: setReferenceElement,
menuNode,
});
} else {
buttonNode = (
<div
className={classNames(
getClassName('__container'),
theme ? themeClassName(theme) : undefined
)} )}
>
<button
aria-label={ariaLabel || i18n('ContextMenu--button')}
className={classNames(
getClassName('__button'),
isMenuShowing ? getClassName('__button--active') : undefined
)}
onClick={onClick || handleClick}
onContextMenu={handleClick}
onKeyDown={handleKeyDown}
ref={setReferenceElement}
type="button"
>
{children}
</button>
{menuNode}
</div> </div>
); );
} }
return buttonNode;
}

View file

@ -646,7 +646,8 @@ export const SendStoryModal = ({
}} }}
theme={Theme.Dark} theme={Theme.Dark}
> >
{({ openMenu, onKeyDown, ref }) => ( {({ openMenu, onKeyDown, ref, menuNode }) => (
<div>
<Button <Button
ref={ref} ref={ref}
className="SendStoryModal__new-story__button" className="SendStoryModal__new-story__button"
@ -657,6 +658,8 @@ export const SendStoryModal = ({
> >
{i18n('SendStoryModal__new')} {i18n('SendStoryModal__new')}
</Button> </Button>
{menuNode}
</div>
)} )}
</ContextMenu> </ContextMenu>
</div> </div>

View file

@ -96,6 +96,8 @@ export type PropsType = {
storyViewMode: StoryViewModeType; storyViewMode: StoryViewModeType;
toggleHasAllStoriesMuted: () => unknown; toggleHasAllStoriesMuted: () => unknown;
viewStory: ViewStoryActionCreatorType; viewStory: ViewStoryActionCreatorType;
deleteGroupStoryReply: (id: string) => void;
deleteGroupStoryReplyForEveryone: (id: string) => void;
}; };
const CAPTION_BUFFER = 20; const CAPTION_BUFFER = 20;
@ -141,6 +143,8 @@ export const StoryViewer = ({
storyViewMode, storyViewMode,
toggleHasAllStoriesMuted, toggleHasAllStoriesMuted,
viewStory, viewStory,
deleteGroupStoryReply,
deleteGroupStoryReplyForEveryone,
}: PropsType): JSX.Element => { }: PropsType): JSX.Element => {
const [isShowingContextMenu, setIsShowingContextMenu] = const [isShowingContextMenu, setIsShowingContextMenu] =
useState<boolean>(false); useState<boolean>(false);
@ -829,6 +833,8 @@ export const StoryViewer = ({
views={views} views={views}
viewTarget={currentViewTarget} viewTarget={currentViewTarget}
onChangeViewTarget={setCurrentViewTarget} onChangeViewTarget={setCurrentViewTarget}
deleteGroupStoryReply={deleteGroupStoryReply}
deleteGroupStoryReplyForEveryone={deleteGroupStoryReplyForEveryone}
/> />
)} )}
{hasConfirmHideStory && ( {hasConfirmHideStory && (

View file

@ -36,22 +36,17 @@ import { WidthBreakpoint } from './_util';
import { getAvatarColor } from '../types/Colors'; import { getAvatarColor } from '../types/Colors';
import { getStoryReplyText } from '../util/getStoryReplyText'; import { getStoryReplyText } from '../util/getStoryReplyText';
import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled'; import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled';
import { ContextMenu } from './ContextMenu';
import { ConfirmationDialog } from './ConfirmationDialog';
// Menu is disabled so these actions are inaccessible. We also don't support // Menu is disabled so these actions are inaccessible. We also don't support
// link previews, tap to view messages, attachments, or gifts. Just regular // link previews, tap to view messages, attachments, or gifts. Just regular
// text messages and reactions. // text messages and reactions.
const MESSAGE_DEFAULT_PROPS = { const MESSAGE_DEFAULT_PROPS = {
canDeleteForEveryone: false, canDeleteForEveryone: false,
canDownload: false,
canReact: false,
canReply: false,
canRetry: false,
canRetryDeleteForEveryone: false,
checkForAccount: shouldNeverBeCalled, checkForAccount: shouldNeverBeCalled,
clearSelectedMessage: shouldNeverBeCalled, clearSelectedMessage: shouldNeverBeCalled,
containerWidthBreakpoint: WidthBreakpoint.Medium, containerWidthBreakpoint: WidthBreakpoint.Medium,
deleteMessage: shouldNeverBeCalled,
deleteMessageForEveryone: shouldNeverBeCalled,
displayTapToViewMessage: shouldNeverBeCalled, displayTapToViewMessage: shouldNeverBeCalled,
doubleCheckMissingQuoteReference: shouldNeverBeCalled, doubleCheckMissingQuoteReference: shouldNeverBeCalled,
downloadAttachment: shouldNeverBeCalled, downloadAttachment: shouldNeverBeCalled,
@ -65,19 +60,12 @@ const MESSAGE_DEFAULT_PROPS = {
openGiftBadge: shouldNeverBeCalled, openGiftBadge: shouldNeverBeCalled,
openLink: shouldNeverBeCalled, openLink: shouldNeverBeCalled,
previews: [], previews: [],
reactToMessage: shouldNeverBeCalled,
renderAudioAttachment: () => <div />, renderAudioAttachment: () => <div />,
renderEmojiPicker: () => <div />,
renderReactionPicker: () => <div />,
replyToMessage: shouldNeverBeCalled,
retryDeleteForEveryone: shouldNeverBeCalled,
retrySend: shouldNeverBeCalled,
scrollToQuotedMessage: shouldNeverBeCalled, scrollToQuotedMessage: shouldNeverBeCalled,
showContactDetail: shouldNeverBeCalled, showContactDetail: shouldNeverBeCalled,
showContactModal: shouldNeverBeCalled, showContactModal: shouldNeverBeCalled,
showExpiredIncomingTapToViewToast: shouldNeverBeCalled, showExpiredIncomingTapToViewToast: shouldNeverBeCalled,
showExpiredOutgoingTapToViewToast: shouldNeverBeCalled, showExpiredOutgoingTapToViewToast: shouldNeverBeCalled,
showForwardMessageModal: shouldNeverBeCalled,
showMessageDetail: shouldNeverBeCalled, showMessageDetail: shouldNeverBeCalled,
showVisualAttachment: shouldNeverBeCalled, showVisualAttachment: shouldNeverBeCalled,
startConversation: shouldNeverBeCalled, startConversation: shouldNeverBeCalled,
@ -118,6 +106,8 @@ export type PropsType = {
views: Array<StorySendStateType>; views: Array<StorySendStateType>;
viewTarget: StoryViewTargetType; viewTarget: StoryViewTargetType;
onChangeViewTarget: (target: StoryViewTargetType) => unknown; onChangeViewTarget: (target: StoryViewTargetType) => unknown;
deleteGroupStoryReply: (id: string) => void;
deleteGroupStoryReplyForEveryone: (id: string) => void;
}; };
export const StoryViewsNRepliesModal = ({ export const StoryViewsNRepliesModal = ({
@ -144,7 +134,16 @@ export const StoryViewsNRepliesModal = ({
views, views,
viewTarget, viewTarget,
onChangeViewTarget, onChangeViewTarget,
deleteGroupStoryReply,
deleteGroupStoryReplyForEveryone,
}: PropsType): JSX.Element | null => { }: PropsType): JSX.Element | null => {
const [deleteReplyId, setDeleteReplyId] = useState<string | undefined>(
undefined
);
const [deleteForEveryoneReplyId, setDeleteForEveryoneReplyId] = useState<
string | undefined
>(undefined);
const containerElementRef = useRef<HTMLDivElement | null>(null); const containerElementRef = useRef<HTMLDivElement | null>(null);
const inputApiRef = useRef<InputApi | undefined>(); const inputApiRef = useRef<InputApi | undefined>();
const shouldScrollToBottomRef = useRef(true); const shouldScrollToBottomRef = useRef(true);
@ -310,64 +309,25 @@ export const StoryViewsNRepliesModal = ({
className="StoryViewsNRepliesModal__replies" className="StoryViewsNRepliesModal__replies"
ref={containerElementRef} ref={containerElementRef}
> >
{replies.map((reply, index) => {replies.map((reply, index) => {
reply.reactionEmoji ? ( return reply.reactionEmoji ? (
<div className="StoryViewsNRepliesModal__reaction" key={reply.id}> <Reaction
<div className="StoryViewsNRepliesModal__reaction--container"> key={reply.id}
<Avatar
acceptedMessageRequest={reply.author.acceptedMessageRequest}
avatarPath={reply.author.avatarPath}
badge={getPreferredBadge(reply.author.badges)}
color={getAvatarColor(reply.author.color)}
conversationType="direct"
i18n={i18n} i18n={i18n}
isMe={Boolean(reply.author.isMe)} reply={reply}
profileName={reply.author.profileName}
sharedGroupNames={reply.author.sharedGroupNames || []}
size={AvatarSize.TWENTY_EIGHT}
theme={ThemeType.dark}
title={reply.author.title}
/>
<div className="StoryViewsNRepliesModal__reaction--body">
<div className="StoryViewsNRepliesModal__reply--title">
<ContactName
contactNameColor={reply.contactNameColor}
title={
reply.author.isMe ? i18n('you') : reply.author.title
}
/>
</div>
{i18n('StoryViewsNRepliesModal__reacted')}
<MessageTimestamp
i18n={i18n}
isRelativeTime
module="StoryViewsNRepliesModal__reply--timestamp"
timestamp={reply.timestamp}
/>
</div>
</div>
<Emojify text={reply.reactionEmoji} />
</div>
) : (
<div key={reply.id}>
<Message
{...MESSAGE_DEFAULT_PROPS}
author={reply.author}
bodyRanges={reply.bodyRanges}
contactNameColor={reply.contactNameColor}
containerElementRef={containerElementRef}
conversationColor="ultramarine"
conversationId={reply.conversationId}
conversationTitle={reply.author.title}
conversationType="group"
direction="incoming"
disableMenu
getPreferredBadge={getPreferredBadge} getPreferredBadge={getPreferredBadge}
/>
) : (
<Reply
key={reply.id}
i18n={i18n} i18n={i18n}
id={reply.id} containerElementRef={containerElementRef}
interactionMode="mouse" reply={reply}
readStatus={reply.readStatus} deleteGroupStoryReply={() => setDeleteReplyId(reply.id)}
renderingContext="StoryViewsNRepliesModal" deleteGroupStoryReplyForEveryone={() =>
setDeleteForEveryoneReplyId(reply.id)
}
getPreferredBadge={getPreferredBadge}
shouldCollapseAbove={ shouldCollapseAbove={
reply.conversationId === replies[index - 1]?.conversationId && reply.conversationId === replies[index - 1]?.conversationId &&
!replies[index - 1]?.reactionEmoji !replies[index - 1]?.reactionEmoji
@ -376,14 +336,9 @@ export const StoryViewsNRepliesModal = ({
reply.conversationId === replies[index + 1]?.conversationId && reply.conversationId === replies[index + 1]?.conversationId &&
!replies[index + 1]?.reactionEmoji !replies[index + 1]?.reactionEmoji
} }
shouldHideMetadata={false}
text={reply.body}
textDirection={TextDirection.Default}
timestamp={reply.timestamp}
/> />
</div> );
) })}
)}
<div ref={bottomRef} /> <div ref={bottomRef} />
</div> </div>
); );
@ -483,6 +438,7 @@ export const StoryViewsNRepliesModal = ({
} }
return ( return (
<>
<Modal <Modal
modalName="StoryViewsNRepliesModal" modalName="StoryViewsNRepliesModal"
i18n={i18n} i18n={i18n}
@ -504,5 +460,175 @@ export const StoryViewsNRepliesModal = ({
)} )}
</div> </div>
</Modal> </Modal>
{deleteReplyId && (
<ConfirmationDialog
i18n={i18n}
theme={Theme.Dark}
dialogName="confirmDialog"
actions={[
{
text: i18n('delete'),
action: () => deleteGroupStoryReply(deleteReplyId),
style: 'negative',
},
]}
title={i18n('deleteWarning')}
onClose={() => setDeleteReplyId(undefined)}
onCancel={() => setDeleteReplyId(undefined)}
/>
)}
{deleteForEveryoneReplyId && (
<ConfirmationDialog
i18n={i18n}
theme={Theme.Dark}
dialogName="confirmDialog"
actions={[
{
text: i18n('delete'),
action: () =>
deleteGroupStoryReplyForEveryone(deleteForEveryoneReplyId),
style: 'negative',
},
]}
title={i18n('deleteWarning')}
onClose={() => setDeleteForEveryoneReplyId(undefined)}
onCancel={() => setDeleteForEveryoneReplyId(undefined)}
>
{i18n('deleteForEveryoneWarning')}
</ConfirmationDialog>
)}
</>
);
};
type ReactionProps = {
i18n: LocalizerType;
reply: ReplyType;
getPreferredBadge: PreferredBadgeSelectorType;
};
const Reaction = ({
i18n,
reply,
getPreferredBadge,
}: ReactionProps): JSX.Element => {
// TODO: DESKTOP-4503 - reactions delete/doe
return (
<div className="StoryViewsNRepliesModal__reaction" key={reply.id}>
<div className="StoryViewsNRepliesModal__reaction--container">
<Avatar
acceptedMessageRequest={reply.author.acceptedMessageRequest}
avatarPath={reply.author.avatarPath}
badge={getPreferredBadge(reply.author.badges)}
color={getAvatarColor(reply.author.color)}
conversationType="direct"
i18n={i18n}
isMe={Boolean(reply.author.isMe)}
profileName={reply.author.profileName}
sharedGroupNames={reply.author.sharedGroupNames || []}
size={AvatarSize.TWENTY_EIGHT}
theme={ThemeType.dark}
title={reply.author.title}
/>
<div className="StoryViewsNRepliesModal__reaction--body">
<div className="StoryViewsNRepliesModal__reply--title">
<ContactName
contactNameColor={reply.contactNameColor}
title={reply.author.isMe ? i18n('you') : reply.author.title}
/>
</div>
{i18n('StoryViewsNRepliesModal__reacted')}
<MessageTimestamp
i18n={i18n}
isRelativeTime
module="StoryViewsNRepliesModal__reply--timestamp"
timestamp={reply.timestamp}
/>
</div>
</div>
<Emojify text={reply.reactionEmoji} />
</div>
);
};
type ReplyProps = {
i18n: LocalizerType;
reply: ReplyType;
deleteGroupStoryReply: (replyId: string) => void;
deleteGroupStoryReplyForEveryone: (replyId: string) => void;
getPreferredBadge: PreferredBadgeSelectorType;
shouldCollapseAbove: boolean;
shouldCollapseBelow: boolean;
containerElementRef: React.RefObject<HTMLElement>;
};
const Reply = ({
i18n,
reply,
deleteGroupStoryReply,
deleteGroupStoryReplyForEveryone,
getPreferredBadge,
shouldCollapseAbove,
shouldCollapseBelow,
containerElementRef,
}: ReplyProps): JSX.Element => {
const renderMessage = (onContextMenu?: (ev: React.MouseEvent) => void) => (
<div key={reply.id}>
<Message
{...MESSAGE_DEFAULT_PROPS}
author={reply.author}
bodyRanges={reply.bodyRanges}
contactNameColor={reply.contactNameColor}
containerElementRef={containerElementRef}
conversationColor="ultramarine"
conversationId={reply.conversationId}
conversationTitle={reply.author.title}
conversationType="group"
direction="incoming"
deletedForEveryone={reply.deletedForEveryone}
menu={undefined}
onContextMenu={onContextMenu}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
id={reply.id}
interactionMode="mouse"
readStatus={reply.readStatus}
renderingContext="StoryViewsNRepliesModal"
shouldCollapseAbove={shouldCollapseAbove}
shouldCollapseBelow={shouldCollapseBelow}
shouldHideMetadata={false}
text={reply.body}
textDirection={TextDirection.Default}
timestamp={reply.timestamp}
/>
</div>
);
return reply.author.isMe ? (
<ContextMenu
i18n={i18n}
key={reply.id}
menuOptions={[
{
icon: 'module-message__context--icon module-message__context__delete-message',
label: i18n('icu:StoryViewsNRepliesModal__delete-reply'),
onClick: () => deleteGroupStoryReply(reply.id),
},
{
icon: 'module-message__context--icon module-message__context__delete-message-for-everyone',
label: i18n('icu:StoryViewsNRepliesModal__delete-reply-for-everyone'),
onClick: () => deleteGroupStoryReplyForEveryone(reply.id),
},
]}
>
{({ openMenu, menuNode }) => (
<>
{renderMessage(openMenu)}
{menuNode}
</>
)}
</ContextMenu>
) : (
renderMessage()
); );
}; };

View file

@ -3,11 +3,10 @@
import type { ReactNode, RefObject } from 'react'; import type { ReactNode, RefObject } from 'react';
import React from 'react'; import React from 'react';
import ReactDOM, { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import classNames from 'classnames'; import classNames from 'classnames';
import getDirection from 'direction'; import getDirection from 'direction';
import { drop, groupBy, orderBy, take, unescape } from 'lodash'; import { drop, groupBy, orderBy, take, unescape } from 'lodash';
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
import { Manager, Popper, Reference } from 'react-popper'; import { Manager, Popper, Reference } from 'react-popper';
import type { PreventOverflowModifier } from '@popperjs/core/lib/modifiers/preventOverflow'; import type { PreventOverflowModifier } from '@popperjs/core/lib/modifiers/preventOverflow';
@ -17,15 +16,11 @@ import type {
InteractionModeType, InteractionModeType,
} from '../../state/ducks/conversations'; } from '../../state/ducks/conversations';
import type { ViewStoryActionCreatorType } from '../../state/ducks/stories'; import type { ViewStoryActionCreatorType } from '../../state/ducks/stories';
import type { TimelineItemType } from './TimelineItem';
import type { ReadStatus } from '../../messages/MessageReadStatus'; import type { ReadStatus } from '../../messages/MessageReadStatus';
import { Avatar, AvatarSize } from '../Avatar'; import { Avatar, AvatarSize } from '../Avatar';
import { AvatarSpacer } from '../AvatarSpacer'; import { AvatarSpacer } from '../AvatarSpacer';
import { Spinner } from '../Spinner'; import { Spinner } from '../Spinner';
import { import { MessageBodyReadMore } from './MessageBodyReadMore';
doesMessageBodyOverflow,
MessageBodyReadMore,
} from './MessageBodyReadMore';
import { MessageMetadata } from './MessageMetadata'; import { MessageMetadata } from './MessageMetadata';
import { MessageTextMetadataSpacer } from './MessageTextMetadataSpacer'; import { MessageTextMetadataSpacer } from './MessageTextMetadataSpacer';
import { ImageGrid } from './ImageGrid'; import { ImageGrid } from './ImageGrid';
@ -37,16 +32,14 @@ import { Quote } from './Quote';
import { EmbeddedContact } from './EmbeddedContact'; import { EmbeddedContact } from './EmbeddedContact';
import type { OwnProps as ReactionViewerProps } from './ReactionViewer'; import type { OwnProps as ReactionViewerProps } from './ReactionViewer';
import { ReactionViewer } from './ReactionViewer'; import { ReactionViewer } from './ReactionViewer';
import type { Props as ReactionPickerProps } from './ReactionPicker';
import { Emoji } from '../emoji/Emoji'; import { Emoji } from '../emoji/Emoji';
import { LinkPreviewDate } from './LinkPreviewDate'; import { LinkPreviewDate } from './LinkPreviewDate';
import type { LinkPreviewType } from '../../types/message/LinkPreviews'; import type { LinkPreviewType } from '../../types/message/LinkPreviews';
import { shouldUseFullSizeLinkPreviewImage } from '../../linkPreviews/shouldUseFullSizeLinkPreviewImage'; import { shouldUseFullSizeLinkPreviewImage } from '../../linkPreviews/shouldUseFullSizeLinkPreviewImage';
import { WidthBreakpoint } from '../_util'; import type { WidthBreakpoint } from '../_util';
import { OutgoingGiftBadgeModal } from '../OutgoingGiftBadgeModal'; import { OutgoingGiftBadgeModal } from '../OutgoingGiftBadgeModal';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import { StoryViewModeType } from '../../types/Stories'; import { StoryViewModeType } from '../../types/Stories';
import type { AttachmentType } from '../../types/Attachment'; import type { AttachmentType } from '../../types/Attachment';
import { import {
canDisplayImage, canDisplayImage,
@ -84,21 +77,13 @@ import type {
import { createRefMerger } from '../../util/refMerger'; import { createRefMerger } from '../../util/refMerger';
import { emojiToData, getEmojiCount } from '../emoji/lib'; import { emojiToData, getEmojiCount } from '../emoji/lib';
import { isEmojiOnlyText } from '../../util/isEmojiOnlyText'; import { isEmojiOnlyText } from '../../util/isEmojiOnlyText';
import type { SmartReactionPicker } from '../../state/smart/ReactionPicker';
import { getCustomColorStyle } from '../../util/getCustomColorStyle'; import { getCustomColorStyle } from '../../util/getCustomColorStyle';
import { offsetDistanceModifier } from '../../util/popperUtil';
import * as KeyboardLayout from '../../services/keyboardLayout';
import { StopPropagation } from '../StopPropagation';
import type { UUIDStringType } from '../../types/UUID'; import type { UUIDStringType } from '../../types/UUID';
import { DAY, HOUR, MINUTE, SECOND } from '../../util/durations'; import { DAY, HOUR, MINUTE, SECOND } from '../../util/durations';
import { BadgeImageTheme } from '../../badges/BadgeImageTheme'; import { BadgeImageTheme } from '../../badges/BadgeImageTheme';
import { getBadgeImageFileLocalPath } from '../../badges/getBadgeImageFileLocalPath'; import { getBadgeImageFileLocalPath } from '../../badges/getBadgeImageFileLocalPath';
import { handleOutsideClick } from '../../util/handleOutsideClick'; import { handleOutsideClick } from '../../util/handleOutsideClick';
type Trigger = {
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
};
const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 10; const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 10;
const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18; const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18;
const GUESS_METADATA_WIDTH_OUTGOING_SIZE: Record<MessageStatusType, number> = { const GUESS_METADATA_WIDTH_OUTGOING_SIZE: Record<MessageStatusType, number> = {
@ -270,37 +255,30 @@ export type PropsData = {
expirationTimestamp?: number; expirationTimestamp?: number;
reactions?: ReactionViewerProps['reactions']; reactions?: ReactionViewerProps['reactions'];
selectedReaction?: string;
deletedForEveryone?: boolean; deletedForEveryone?: boolean;
canRetry: boolean;
canRetryDeleteForEveryone: boolean;
canReact: boolean;
canReply: boolean;
canDownload: boolean;
canDeleteForEveryone: boolean; canDeleteForEveryone: boolean;
isBlocked: boolean; isBlocked: boolean;
isMessageRequestAccepted: boolean; isMessageRequestAccepted: boolean;
bodyRanges?: BodyRangesType; bodyRanges?: BodyRangesType;
menu: JSX.Element | undefined;
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
}; };
export type PropsHousekeeping = { export type PropsHousekeeping = {
containerElementRef: RefObject<HTMLElement>; containerElementRef: RefObject<HTMLElement>;
containerWidthBreakpoint: WidthBreakpoint; containerWidthBreakpoint: WidthBreakpoint;
disableMenu?: boolean;
disableScroll?: boolean; disableScroll?: boolean;
getPreferredBadge: PreferredBadgeSelectorType; getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType; i18n: LocalizerType;
interactionMode: InteractionModeType; interactionMode: InteractionModeType;
item?: TimelineItemType;
renderAudioAttachment: (props: AudioAttachmentProps) => JSX.Element; renderAudioAttachment: (props: AudioAttachmentProps) => JSX.Element;
renderReactionPicker: (
props: React.ComponentProps<typeof SmartReactionPicker>
) => JSX.Element;
shouldCollapseAbove: boolean; shouldCollapseAbove: boolean;
shouldCollapseBelow: boolean; shouldCollapseBelow: boolean;
shouldHideMetadata: boolean; shouldHideMetadata: boolean;
onContextMenu?: (event: React.MouseEvent<HTMLDivElement>) => void;
theme: ThemeType; theme: ThemeType;
}; };
@ -310,16 +288,6 @@ export type PropsActions = {
messageExpanded: (id: string, displayLimit: number) => unknown; messageExpanded: (id: string, displayLimit: number) => unknown;
checkForAccount: (phoneNumber: string) => unknown; checkForAccount: (phoneNumber: string) => unknown;
reactToMessage: (
id: string,
{ emoji, remove }: { emoji: string; remove: boolean }
) => void;
replyToMessage: (id: string) => void;
retryDeleteForEveryone: (id: string) => void;
retrySend: (id: string) => void;
showForwardMessageModal: (id: string) => void;
deleteMessage: (id: string) => void;
deleteMessageForEveryone: (id: string) => void;
showMessageDetail: (id: string) => void; showMessageDetail: (id: string) => void;
startConversation: (e164: string, uuid: UUIDStringType) => void; startConversation: (e164: string, uuid: UUIDStringType) => void;
@ -366,10 +334,7 @@ export type PropsActions = {
viewStory: ViewStoryActionCreatorType; viewStory: ViewStoryActionCreatorType;
}; };
export type Props = PropsData & export type Props = PropsData & PropsHousekeeping & PropsActions;
PropsHousekeeping &
PropsActions &
Pick<ReactionPickerProps, 'renderEmojiPicker'>;
type State = { type State = {
metadataWidth: number; metadataWidth: number;
@ -383,8 +348,6 @@ type State = {
reactionViewerRoot: HTMLDivElement | null; reactionViewerRoot: HTMLDivElement | null;
reactionViewerOutsideClickDestructor?: () => void; reactionViewerOutsideClickDestructor?: () => void;
reactionPickerRoot: HTMLDivElement | null;
reactionPickerOutsideClickDestructor?: () => void;
giftBadgeCounter: number | null; giftBadgeCounter: number | null;
showOutgoingGiftBadgeModal: boolean; showOutgoingGiftBadgeModal: boolean;
@ -393,8 +356,6 @@ type State = {
}; };
export class Message extends React.PureComponent<Props, State> { export class Message extends React.PureComponent<Props, State> {
public menuTriggerRef: Trigger | undefined;
public focusRef: React.RefObject<HTMLDivElement> = React.createRef(); public focusRef: React.RefObject<HTMLDivElement> = React.createRef();
public audioButtonRef: React.RefObject<HTMLButtonElement> = React.createRef(); public audioButtonRef: React.RefObject<HTMLButtonElement> = React.createRef();
@ -428,7 +389,6 @@ export class Message extends React.PureComponent<Props, State> {
prevSelectedCounter: props.isSelectedCounter, prevSelectedCounter: props.isSelectedCounter,
reactionViewerRoot: null, reactionViewerRoot: null,
reactionPickerRoot: null,
giftBadgeCounter: null, giftBadgeCounter: null,
showOutgoingGiftBadgeModal: false, showOutgoingGiftBadgeModal: false,
@ -466,27 +426,14 @@ export class Message extends React.PureComponent<Props, State> {
return Boolean(reactions && reactions.length); return Boolean(reactions && reactions.length);
} }
public captureMenuTrigger = (triggerRef: Trigger): void => { public handleFocus = (): void => {
this.menuTriggerRef = triggerRef; const { interactionMode, isSelected } = this.props;
};
public showMenu = (event: React.MouseEvent<HTMLDivElement>): void => { if (interactionMode === 'keyboard' && !isSelected) {
if (this.menuTriggerRef) { this.setSelected();
this.menuTriggerRef.handleContextClick(event);
} }
}; };
public showContextMenu = (event: React.MouseEvent<HTMLDivElement>): void => {
const selection = window.getSelection();
if (selection && !selection.isCollapsed) {
return;
}
if (event.target instanceof HTMLAnchorElement) {
return;
}
this.showMenu(event);
};
public handleImageError = (): void => { public handleImageError = (): void => {
const { id } = this.props; const { id } = this.props;
log.info( log.info(
@ -497,14 +444,6 @@ export class Message extends React.PureComponent<Props, State> {
}); });
}; };
public handleFocus = (): void => {
const { interactionMode, isSelected } = this.props;
if (interactionMode === 'keyboard' && !isSelected) {
this.setSelected();
}
};
public setSelected = (): void => { public setSelected = (): void => {
const { id, conversationId, selectMessage } = this.props; const { id, conversationId, selectMessage } = this.props;
@ -559,7 +498,6 @@ export class Message extends React.PureComponent<Props, State> {
clearTimeoutIfNecessary(this.deleteForEveryoneTimeout); clearTimeoutIfNecessary(this.deleteForEveryoneTimeout);
clearTimeoutIfNecessary(this.giftBadgeInterval); clearTimeoutIfNecessary(this.giftBadgeInterval);
this.toggleReactionViewer(true); this.toggleReactionViewer(true);
this.toggleReactionPicker(true);
} }
public override componentDidUpdate(prevProps: Readonly<Props>): void { public override componentDidUpdate(prevProps: Readonly<Props>): void {
@ -711,12 +649,6 @@ export class Message extends React.PureComponent<Props, State> {
return Math.max(timestamp - Date.now() + THREE_HOURS, 0); return Math.max(timestamp - Date.now() + THREE_HOURS, 0);
} }
private canDeleteForEveryone(): boolean {
const { canDeleteForEveryone } = this.props;
const { hasDeleteForEveryoneTimerExpired } = this.state;
return canDeleteForEveryone && !hasDeleteForEveryoneTimerExpired;
}
private startDeleteForEveryoneTimerIfApplicable(): void { private startDeleteForEveryoneTimerIfApplicable(): void {
const { canDeleteForEveryone } = this.props; const { canDeleteForEveryone } = this.props;
const { hasDeleteForEveryoneTimerExpired } = this.state; const { hasDeleteForEveryoneTimerExpired } = this.state;
@ -917,7 +849,6 @@ export class Message extends React.PureComponent<Props, State> {
theme, theme,
timestamp, timestamp,
} = this.props; } = this.props;
const { imageBroken } = this.state; const { imageBroken } = this.state;
const collapseMetadata = const collapseMetadata =
@ -1814,382 +1745,6 @@ export class Message extends React.PureComponent<Props, State> {
); );
} }
private renderMenu(triggerId: string): ReactNode {
const {
attachments,
canDownload,
canReact,
canReply,
direction,
disableMenu,
i18n,
id,
isSticker,
isTapToView,
reactToMessage,
renderEmojiPicker,
renderReactionPicker,
replyToMessage,
selectedReaction,
} = this.props;
if (disableMenu) {
return null;
}
const { reactionPickerRoot } = this.state;
const multipleAttachments = attachments && attachments.length > 1;
const firstAttachment = attachments && attachments[0];
const downloadButton =
!isSticker &&
!multipleAttachments &&
!isTapToView &&
firstAttachment &&
!firstAttachment.pending ? (
// This a menu meant for mouse use only
// eslint-disable-next-line max-len
// eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events
<div
onClick={this.openGenericAttachment}
role="button"
aria-label={i18n('downloadAttachment')}
className={classNames(
'module-message__buttons__download',
`module-message__buttons__download--${direction}`
)}
/>
) : null;
const reactButton = (
<Reference>
{({ ref: popperRef }) => {
// Only attach the popper reference to the reaction button if it is
// visible (it is hidden when the timeline is narrow)
const maybePopperRef = this.isWindowWidthNotNarrow()
? popperRef
: undefined;
return (
// This a menu meant for mouse use only
// eslint-disable-next-line max-len
// eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events
<div
ref={maybePopperRef}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
this.toggleReactionPicker();
}}
role="button"
className="module-message__buttons__react"
aria-label={i18n('reactToMessage')}
/>
);
}}
</Reference>
);
const replyButton = (
// This a menu meant for mouse use only
// eslint-disable-next-line max-len
// eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events
<div
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
replyToMessage(id);
}}
// This a menu meant for mouse use only
role="button"
aria-label={i18n('replyToMessage')}
className={classNames(
'module-message__buttons__reply',
`module-message__buttons__download--${direction}`
)}
/>
);
// This a menu meant for mouse use only
/* eslint-disable jsx-a11y/interactive-supports-focus */
/* eslint-disable jsx-a11y/click-events-have-key-events */
const menuButton = (
<Reference>
{({ ref: popperRef }) => {
// Only attach the popper reference to the collapsed menu button if the reaction
// button is not visible (it is hidden when the timeline is narrow)
const maybePopperRef = !this.isWindowWidthNotNarrow()
? popperRef
: undefined;
return (
<StopPropagation className="module-message__buttons__menu--container">
<ContextMenuTrigger
id={triggerId}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ref={this.captureMenuTrigger as any}
>
<div
ref={maybePopperRef}
role="button"
onClick={this.showMenu}
aria-label={i18n('messageContextMenuButton')}
className={classNames(
'module-message__buttons__menu',
`module-message__buttons__download--${direction}`
)}
/>
</ContextMenuTrigger>
</StopPropagation>
);
}}
</Reference>
);
/* eslint-enable jsx-a11y/interactive-supports-focus */
/* eslint-enable jsx-a11y/click-events-have-key-events */
return (
<Manager>
<div
className={classNames(
'module-message__buttons',
`module-message__buttons--${direction}`
)}
>
{this.isWindowWidthNotNarrow() && (
<>
{canReact ? reactButton : null}
{canDownload ? downloadButton : null}
{canReply ? replyButton : null}
</>
)}
{menuButton}
</div>
{reactionPickerRoot &&
createPortal(
<Popper
placement="top"
modifiers={[
offsetDistanceModifier(4),
this.popperPreventOverflowModifier(),
]}
>
{({ ref, style }) =>
renderReactionPicker({
ref,
style,
selected: selectedReaction,
onClose: this.toggleReactionPicker,
onPick: emoji => {
this.toggleReactionPicker(true);
reactToMessage(id, {
emoji,
remove: emoji === selectedReaction,
});
},
renderEmojiPicker,
})
}
</Popper>,
reactionPickerRoot
)}
</Manager>
);
}
public renderContextMenu(triggerId: string): JSX.Element {
const {
attachments,
canDownload,
contact,
canReact,
canReply,
canRetry,
canRetryDeleteForEveryone,
deleteMessage,
deleteMessageForEveryone,
deletedForEveryone,
giftBadge,
i18n,
id,
isSticker,
isTapToView,
replyToMessage,
retrySend,
retryDeleteForEveryone,
showForwardMessageModal,
showMessageDetail,
text,
} = this.props;
const canForward =
!isTapToView && !deletedForEveryone && !giftBadge && !contact;
const multipleAttachments = attachments && attachments.length > 1;
const shouldShowAdditional =
doesMessageBodyOverflow(text || '') || !this.isWindowWidthNotNarrow();
const menu = (
<ContextMenu id={triggerId}>
{canDownload &&
shouldShowAdditional &&
!isSticker &&
!multipleAttachments &&
!isTapToView &&
attachments &&
attachments[0] ? (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__download',
}}
onClick={this.openGenericAttachment}
>
{i18n('downloadAttachment')}
</MenuItem>
) : null}
{shouldShowAdditional ? (
<>
{canReply && (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__reply',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
replyToMessage(id);
}}
>
{i18n('replyToMessage')}
</MenuItem>
)}
{canReact && (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__react',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
this.toggleReactionPicker();
}}
>
{i18n('reactToMessage')}
</MenuItem>
)}
</>
) : null}
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__more-info',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
showMessageDetail(id);
}}
>
{i18n('moreInfo')}
</MenuItem>
{canRetry ? (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__retry-send',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
retrySend(id);
}}
>
{i18n('retrySend')}
</MenuItem>
) : null}
{canRetryDeleteForEveryone ? (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__delete-message-for-everyone',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
retryDeleteForEveryone(id);
}}
>
{i18n('retryDeleteForEveryone')}
</MenuItem>
) : null}
{canForward ? (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__forward-message',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
showForwardMessageModal(id);
}}
>
{i18n('forwardMessage')}
</MenuItem>
) : null}
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__delete-message',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
deleteMessage(id);
}}
>
{i18n('deleteMessage')}
</MenuItem>
{this.canDeleteForEveryone() ? (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__delete-message-for-everyone',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
deleteMessageForEveryone(id);
}}
>
{i18n('deleteMessageForEveryone')}
</MenuItem>
) : null}
</ContextMenu>
);
return ReactDOM.createPortal(menu, document.body);
}
private isWindowWidthNotNarrow(): boolean {
const { containerWidthBreakpoint } = this.props;
return containerWidthBreakpoint !== WidthBreakpoint.Narrow;
}
public getWidth(): number | undefined { public getWidth(): number | undefined {
const { attachments, giftBadge, isSticker, previews } = this.props; const { attachments, giftBadge, isSticker, previews } = this.props;
@ -2420,42 +1975,6 @@ export class Message extends React.PureComponent<Props, State> {
}); });
}; };
public toggleReactionPicker = (onlyRemove = false): void => {
this.setState(oldState => {
const { reactionPickerRoot } = oldState;
if (reactionPickerRoot) {
document.body.removeChild(reactionPickerRoot);
oldState.reactionPickerOutsideClickDestructor?.();
return {
reactionPickerRoot: null,
reactionPickerOutsideClickDestructor: undefined,
};
}
if (!onlyRemove) {
const root = document.createElement('div');
document.body.appendChild(root);
const reactionPickerOutsideClickDestructor = handleOutsideClick(
() => {
this.toggleReactionPicker(true);
return true;
},
{ containerElements: [root], name: 'Message.reactionPicker' }
);
return {
reactionPickerRoot: root,
reactionPickerOutsideClickDestructor,
};
}
return null;
});
};
public renderReactions(outgoing: boolean): JSX.Element | null { public renderReactions(outgoing: boolean): JSX.Element | null {
const { getPreferredBadge, reactions = [], i18n, theme } = this.props; const { getPreferredBadge, reactions = [], i18n, theme } = this.props;
@ -2844,28 +2363,6 @@ export class Message extends React.PureComponent<Props, State> {
}); });
}; };
public handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
// Do not allow reactions to error messages
const { canReact } = this.props;
const key = KeyboardLayout.lookup(event.nativeEvent);
if (
(key === 'E' || key === 'e') &&
(event.metaKey || event.ctrlKey) &&
event.shiftKey &&
canReact
) {
this.toggleReactionPicker();
}
if (event.key !== 'Enter' && event.key !== 'Space') {
return;
}
this.handleOpen(event);
};
public handleClick = (event: React.MouseEvent): void => { public handleClick = (event: React.MouseEvent): void => {
// We don't want clicks on body text to result in the 'default action' for the message // We don't want clicks on body text to result in the 'default action' for the message
const { text } = this.props; const { text } = this.props;
@ -2888,6 +2385,8 @@ export class Message extends React.PureComponent<Props, State> {
isTapToView, isTapToView,
isTapToViewExpired, isTapToViewExpired,
isTapToViewError, isTapToViewError,
onContextMenu,
onKeyDown,
text, text,
} = this.props; } = this.props;
const { isSelected } = this.state; const { isSelected } = this.state;
@ -2947,9 +2446,9 @@ export class Message extends React.PureComponent<Props, State> {
<div <div
className={containerClassnames} className={containerClassnames}
style={containerStyles} style={containerStyles}
onContextMenu={this.showContextMenu} onContextMenu={onContextMenu}
role="row" role="row"
onKeyDown={this.handleKeyDown} onKeyDown={onKeyDown}
onClick={this.handleClick} onClick={this.handleClick}
tabIndex={-1} tabIndex={-1}
> >
@ -2963,20 +2462,15 @@ export class Message extends React.PureComponent<Props, State> {
public override render(): JSX.Element | null { public override render(): JSX.Element | null {
const { const {
author,
attachments, attachments,
direction, direction,
id,
isSticker, isSticker,
shouldCollapseAbove, shouldCollapseAbove,
shouldCollapseBelow, shouldCollapseBelow,
timestamp, menu,
onKeyDown,
} = this.props; } = this.props;
const { expired, expiring, imageBroken, isSelected } = this.state; const { expired, expiring, isSelected, imageBroken } = this.state;
// This id is what connects our triple-dot click with our associated pop-up menu.
// It needs to be unique.
const triggerId = String(id || `${author.id}-${timestamp}`);
if (expired) { if (expired) {
return null; return null;
@ -3000,15 +2494,14 @@ export class Message extends React.PureComponent<Props, State> {
// We need to have a role because screenreaders need to be able to focus here to // We need to have a role because screenreaders need to be able to focus here to
// read the message, but we can't be a button; that would break inner buttons. // read the message, but we can't be a button; that would break inner buttons.
role="row" role="row"
onKeyDown={this.handleKeyDown} onKeyDown={onKeyDown}
onFocus={this.handleFocus} onFocus={this.handleFocus}
ref={this.focusRef} ref={this.focusRef}
> >
{this.renderError()} {this.renderError()}
{this.renderAvatar()} {this.renderAvatar()}
{this.renderContainer()} {this.renderContainer()}
{this.renderMenu(triggerId)} {menu}
{this.renderContextMenu(triggerId)}
</div> </div>
); );
} }

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useRef, useEffect, useState } from 'react'; import React, { useCallback, useRef, useEffect, useState } from 'react';
import type { RefObject, ReactNode } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { noop } from 'lodash'; import { noop } from 'lodash';
import { animated, useSpring } from '@react-spring/web'; import { animated, useSpring } from '@react-spring/web';
@ -18,6 +19,7 @@ import type { ActiveAudioPlayerStateType } from '../../state/ducks/audioPlayer';
export type OwnProps = Readonly<{ export type OwnProps = Readonly<{
active: ActiveAudioPlayerStateType | undefined; active: ActiveAudioPlayerStateType | undefined;
buttonRef: RefObject<HTMLButtonElement>;
renderingContext: string; renderingContext: string;
i18n: LocalizerType; i18n: LocalizerType;
attachment: AttachmentType; attachment: AttachmentType;
@ -66,6 +68,7 @@ type ButtonProps = {
onClick: () => void; onClick: () => void;
onMouseDown?: () => void; onMouseDown?: () => void;
onMouseUp?: () => void; onMouseUp?: () => void;
children?: ReactNode;
}; };
enum State { enum State {
@ -122,7 +125,8 @@ const timeToText = (time: number): string => {
* Handles animations, key events, and stoping event propagation * Handles animations, key events, and stoping event propagation
* for play button and playback rate button * for play button and playback rate button
*/ */
const Button: React.FC<ButtonProps> = props => { const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => {
const { const {
i18n, i18n,
variant, variant,
@ -138,7 +142,8 @@ const Button: React.FC<ButtonProps> = props => {
const [animProps] = useSpring( const [animProps] = useSpring(
{ {
config: SPRING_CONFIG, config: SPRING_CONFIG,
to: isDown && animateClick ? { scale: 1.3 } : { scale: visible ? 1 : 0 }, to:
isDown && animateClick ? { scale: 1.3 } : { scale: visible ? 1 : 0 },
}, },
[visible, isDown, animateClick] [visible, isDown, animateClick]
); );
@ -172,6 +177,7 @@ const Button: React.FC<ButtonProps> = props => {
<animated.div style={animProps}> <animated.div style={animProps}>
<button <button
type="button" type="button"
ref={ref}
className={classNames( className={classNames(
`${CSS_BASE}__${variant}-button`, `${CSS_BASE}__${variant}-button`,
mod ? `${CSS_BASE}__${variant}-button--${mod}` : undefined mod ? `${CSS_BASE}__${variant}-button--${mod}` : undefined
@ -188,7 +194,8 @@ const Button: React.FC<ButtonProps> = props => {
</button> </button>
</animated.div> </animated.div>
); );
}; }
);
const PlayedDot = ({ const PlayedDot = ({
played, played,
@ -242,6 +249,7 @@ const PlayedDot = ({
export const MessageAudio: React.FC<Props> = (props: Props) => { export const MessageAudio: React.FC<Props> = (props: Props) => {
const { const {
active, active,
buttonRef,
i18n, i18n,
renderingContext, renderingContext,
attachment, attachment,
@ -506,6 +514,7 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
} else if (state === State.NotDownloaded) { } else if (state === State.NotDownloaded) {
button = ( button = (
<Button <Button
ref={buttonRef}
i18n={i18n} i18n={i18n}
variant="play" variant="play"
mod="download" mod="download"
@ -518,6 +527,7 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
// State.Normal // State.Normal
button = ( button = (
<Button <Button
ref={buttonRef}
i18n={i18n} i18n={i18n}
variant="play" variant="play"
mod={isPlaying ? 'pause' : 'play'} mod={isPlaying ? 'pause' : 'play'}

View file

@ -29,12 +29,7 @@ const defaultMessage: MessageDataPropsType = {
id: 'some-id', id: 'some-id',
title: 'Max', title: 'Max',
}), }),
canReact: true,
canReply: true,
canRetry: true,
canRetryDeleteForEveryone: true,
canDeleteForEveryone: true, canDeleteForEveryone: true,
canDownload: true,
conversationColor: 'crimson', conversationColor: 'crimson',
conversationId: 'my-convo', conversationId: 'my-convo',
conversationTitle: 'Conversation Title', conversationTitle: 'Conversation Title',
@ -42,6 +37,7 @@ const defaultMessage: MessageDataPropsType = {
direction: 'incoming', direction: 'incoming',
id: 'my-message', id: 'my-message',
renderingContext: 'storybook', renderingContext: 'storybook',
menu: undefined,
isBlocked: false, isBlocked: false,
isMessageRequestAccepted: true, isMessageRequestAccepted: true,
previews: [], previews: [],
@ -85,13 +81,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
openConversation: action('openConversation'), openConversation: action('openConversation'),
openGiftBadge: action('openGiftBadge'), openGiftBadge: action('openGiftBadge'),
openLink: action('openLink'), openLink: action('openLink'),
reactToMessage: action('reactToMessage'),
renderAudioAttachment: () => <div>*AudioAttachment*</div>, renderAudioAttachment: () => <div>*AudioAttachment*</div>,
renderEmojiPicker: () => <div />,
renderReactionPicker: () => <div />,
replyToMessage: action('replyToMessage'),
retrySend: action('retrySend'),
retryDeleteForEveryone: action('retryDeleteForEveryone'),
showContactDetail: action('showContactDetail'), showContactDetail: action('showContactDetail'),
showContactModal: action('showContactModal'), showContactModal: action('showContactModal'),
showExpiredIncomingTapToViewToast: action( showExpiredIncomingTapToViewToast: action(
@ -100,7 +90,6 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
showExpiredOutgoingTapToViewToast: action( showExpiredOutgoingTapToViewToast: action(
'showExpiredOutgoingTapToViewToast' 'showExpiredOutgoingTapToViewToast'
), ),
showForwardMessageModal: action('showForwardMessageModal'),
showVisualAttachment: action('showVisualAttachment'), showVisualAttachment: action('showVisualAttachment'),
startConversation: action('startConversation'), startConversation: action('startConversation'),
viewStory: action('viewStory'), viewStory: action('viewStory'),

View file

@ -57,7 +57,10 @@ export type PropsData = {
contactNameColor?: ContactNameColorType; contactNameColor?: ContactNameColorType;
errors: Array<Error>; errors: Array<Error>;
message: Omit<MessagePropsDataType, 'renderingContext'>; message: Omit<
MessagePropsDataType,
'renderingContext' | 'menu' | 'contextMenu' | 'showMenu'
>;
receivedAt: number; receivedAt: number;
sentAt: number; sentAt: number;
@ -82,18 +85,11 @@ export type PropsBackboneActions = Pick<
| 'openConversation' | 'openConversation'
| 'openGiftBadge' | 'openGiftBadge'
| 'openLink' | 'openLink'
| 'reactToMessage'
| 'renderAudioAttachment' | 'renderAudioAttachment'
| 'renderEmojiPicker'
| 'renderReactionPicker'
| 'replyToMessage'
| 'retryDeleteForEveryone'
| 'retrySend'
| 'showContactDetail' | 'showContactDetail'
| 'showContactModal' | 'showContactModal'
| 'showExpiredIncomingTapToViewToast' | 'showExpiredIncomingTapToViewToast'
| 'showExpiredOutgoingTapToViewToast' | 'showExpiredOutgoingTapToViewToast'
| 'showForwardMessageModal'
| 'showVisualAttachment' | 'showVisualAttachment'
| 'startConversation' | 'startConversation'
>; >;
@ -294,18 +290,11 @@ export class MessageDetail extends React.Component<Props> {
openConversation, openConversation,
openGiftBadge, openGiftBadge,
openLink, openLink,
reactToMessage,
renderAudioAttachment, renderAudioAttachment,
renderEmojiPicker,
renderReactionPicker,
replyToMessage,
retryDeleteForEveryone,
retrySend,
showContactDetail, showContactDetail,
showContactModal, showContactModal,
showExpiredIncomingTapToViewToast, showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast, showExpiredOutgoingTapToViewToast,
showForwardMessageModal,
showVisualAttachment, showVisualAttachment,
startConversation, startConversation,
theme, theme,
@ -331,13 +320,7 @@ export class MessageDetail extends React.Component<Props> {
contactNameColor={contactNameColor} contactNameColor={contactNameColor}
containerElementRef={this.messageContainerRef} containerElementRef={this.messageContainerRef}
containerWidthBreakpoint={WidthBreakpoint.Wide} containerWidthBreakpoint={WidthBreakpoint.Wide}
deleteMessage={() => menu={undefined}
log.warn('MessageDetail: deleteMessage called!')
}
deleteMessageForEveryone={() =>
log.warn('MessageDetail: deleteMessageForEveryone called!')
}
disableMenu
disableScroll disableScroll
displayLimit={Number.MAX_SAFE_INTEGER} displayLimit={Number.MAX_SAFE_INTEGER}
displayTapToViewMessage={displayTapToViewMessage} displayTapToViewMessage={displayTapToViewMessage}
@ -355,17 +338,10 @@ export class MessageDetail extends React.Component<Props> {
openConversation={openConversation} openConversation={openConversation}
openGiftBadge={openGiftBadge} openGiftBadge={openGiftBadge}
openLink={openLink} openLink={openLink}
reactToMessage={reactToMessage}
renderAudioAttachment={renderAudioAttachment} renderAudioAttachment={renderAudioAttachment}
renderEmojiPicker={renderEmojiPicker}
renderReactionPicker={renderReactionPicker}
replyToMessage={replyToMessage}
retryDeleteForEveryone={retryDeleteForEveryone}
retrySend={retrySend}
shouldCollapseAbove={false} shouldCollapseAbove={false}
shouldCollapseBelow={false} shouldCollapseBelow={false}
shouldHideMetadata={false} shouldHideMetadata={false}
showForwardMessageModal={showForwardMessageModal}
scrollToQuotedMessage={() => { scrollToQuotedMessage={() => {
log.warn('MessageDetail: scrollToQuotedMessage called!'); log.warn('MessageDetail: scrollToQuotedMessage called!');
}} }}

View file

@ -8,8 +8,9 @@ import { action } from '@storybook/addon-actions';
import { ConversationColors } from '../../types/Colors'; import { ConversationColors } from '../../types/Colors';
import { pngUrl } from '../../storybook/Fixtures'; import { pngUrl } from '../../storybook/Fixtures';
import type { Props as MessagesProps } from './Message'; import type { Props as TimelineMessagesProps } from './TimelineMessage';
import { Message, TextDirection } from './Message'; import { TimelineMessage } from './TimelineMessage';
import { TextDirection } from './Message';
import { import {
AUDIO_MP3, AUDIO_MP3,
IMAGE_PNG, IMAGE_PNG,
@ -73,7 +74,7 @@ export default {
}, },
} as Meta; } as Meta;
const defaultMessageProps: MessagesProps = { const defaultMessageProps: TimelineMessagesProps = {
author: getDefaultConversation({ author: getDefaultConversation({
id: 'some-id', id: 'some-id',
title: 'Person X', title: 'Person X',
@ -103,7 +104,7 @@ const defaultMessageProps: MessagesProps = {
getPreferredBadge: () => undefined, getPreferredBadge: () => undefined,
i18n, i18n,
id: 'messageId', id: 'messageId',
renderingContext: 'storybook', // renderingContext: 'storybook',
interactionMode: 'keyboard', interactionMode: 'keyboard',
isBlocked: false, isBlocked: false,
isMessageRequestAccepted: true, isMessageRequestAccepted: true,
@ -177,9 +178,9 @@ const renderInMessage = ({
return ( return (
<div style={{ overflow: 'hidden' }}> <div style={{ overflow: 'hidden' }}>
<Message {...messageProps} /> <TimelineMessage {...messageProps} />
<br /> <br />
<Message {...messageProps} direction="outgoing" /> <TimelineMessage {...messageProps} direction="outgoing" />
</div> </div>
); );
}; };

View file

@ -18,7 +18,7 @@ import { missingCaseError } from '../../util/missingCaseError';
import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary'; import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary';
import { WidthBreakpoint } from '../_util'; import { WidthBreakpoint } from '../_util';
import type { PropsActions as MessageActionsType } from './Message'; import type { PropsActions as MessageActionsType } from './TimelineMessage';
import type { PropsActions as UnsupportedMessageActionsType } from './UnsupportedMessage'; import type { PropsActions as UnsupportedMessageActionsType } from './UnsupportedMessage';
import type { PropsActionsType as ChatSessionRefreshedNotificationActionsType } from './ChatSessionRefreshedNotification'; import type { PropsActionsType as ChatSessionRefreshedNotificationActionsType } from './ChatSessionRefreshedNotification';
import type { PropsActionsType as GroupV2ChangeActionsType } from './GroupV2Change'; import type { PropsActionsType as GroupV2ChangeActionsType } from './GroupV2Change';

View file

@ -10,10 +10,9 @@ import type { InteractionModeType } from '../../state/ducks/conversations';
import { TimelineDateHeader } from './TimelineDateHeader'; import { TimelineDateHeader } from './TimelineDateHeader';
import type { import type {
Props as AllMessageProps, Props as AllMessageProps,
PropsData as TimelineMessageProps,
PropsActions as MessageActionsType, PropsActions as MessageActionsType,
PropsData as MessageProps, } from './TimelineMessage';
} from './Message';
import { Message } from './Message';
import type { PropsActionsType as CallingNotificationActionsType } from './CallingNotification'; import type { PropsActionsType as CallingNotificationActionsType } from './CallingNotification';
import { CallingNotification } from './CallingNotification'; import { CallingNotification } from './CallingNotification';
import type { PropsActionsType as PropsChatSessionRefreshedActionsType } from './ChatSessionRefreshedNotification'; import type { PropsActionsType as PropsChatSessionRefreshedActionsType } from './ChatSessionRefreshedNotification';
@ -55,6 +54,7 @@ import { ResetSessionNotification } from './ResetSessionNotification';
import type { PropsType as ProfileChangeNotificationPropsType } from './ProfileChangeNotification'; import type { PropsType as ProfileChangeNotificationPropsType } from './ProfileChangeNotification';
import { ProfileChangeNotification } from './ProfileChangeNotification'; import { ProfileChangeNotification } from './ProfileChangeNotification';
import type { FullJSXType } from '../Intl'; import type { FullJSXType } from '../Intl';
import { TimelineMessage } from './TimelineMessage';
type CallHistoryType = { type CallHistoryType = {
type: 'callHistory'; type: 'callHistory';
@ -70,7 +70,7 @@ type DeliveryIssueType = {
}; };
type MessageType = { type MessageType = {
type: 'message'; type: 'message';
data: Omit<MessageProps, 'renderingContext'>; data: TimelineMessageProps;
}; };
type UnsupportedMessageType = { type UnsupportedMessageType = {
type: 'unsupportedMessage'; type: 'unsupportedMessage';
@ -208,7 +208,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
let itemContents: ReactChild; let itemContents: ReactChild;
if (item.type === 'message') { if (item.type === 'message') {
itemContents = ( itemContents = (
<Message <TimelineMessage
{...this.props} {...this.props}
{...item.data} {...item.data}
shouldCollapseAbove={shouldCollapseAbove} shouldCollapseAbove={shouldCollapseAbove}
@ -218,7 +218,6 @@ export class TimelineItem extends React.PureComponent<PropsType> {
getPreferredBadge={getPreferredBadge} getPreferredBadge={getPreferredBadge}
i18n={i18n} i18n={i18n}
theme={theme} theme={theme}
renderingContext="conversation/TimelineItem"
/> />
); );
} else { } else {

View file

@ -11,8 +11,10 @@ import type { Meta, Story } from '@storybook/react';
import { SignalService } from '../../protobuf'; import { SignalService } from '../../protobuf';
import { ConversationColors } from '../../types/Colors'; import { ConversationColors } from '../../types/Colors';
import { EmojiPicker } from '../emoji/EmojiPicker'; import { EmojiPicker } from '../emoji/EmojiPicker';
import type { Props, AudioAttachmentProps } from './Message'; import type { AudioAttachmentProps } from './Message';
import { GiftBadgeStates, Message, TextDirection } from './Message'; import type { Props } from './TimelineMessage';
import { TimelineMessage } from './TimelineMessage';
import { GiftBadgeStates, TextDirection } from './Message';
import { import {
AUDIO_MP3, AUDIO_MP3,
IMAGE_JPEG, IMAGE_JPEG,
@ -61,7 +63,7 @@ const quoteOptions = {
}; };
export default { export default {
title: 'Components/Conversation/Message', title: 'Components/Conversation/TimelineMessage',
argTypes: { argTypes: {
conversationType: { conversationType: {
control: 'select', control: 'select',
@ -243,7 +245,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
deletedForEveryone: overrideProps.deletedForEveryone, deletedForEveryone: overrideProps.deletedForEveryone,
deleteMessage: action('deleteMessage'), deleteMessage: action('deleteMessage'),
deleteMessageForEveryone: action('deleteMessageForEveryone'), deleteMessageForEveryone: action('deleteMessageForEveryone'),
disableMenu: overrideProps.disableMenu, // disableMenu: overrideProps.disableMenu,
disableScroll: overrideProps.disableScroll, disableScroll: overrideProps.disableScroll,
direction: overrideProps.direction || 'incoming', direction: overrideProps.direction || 'incoming',
displayTapToViewMessage: action('displayTapToViewMessage'), displayTapToViewMessage: action('displayTapToViewMessage'),
@ -259,7 +261,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
giftBadge: overrideProps.giftBadge, giftBadge: overrideProps.giftBadge,
i18n, i18n,
id: text('id', overrideProps.id || 'random-message-id'), id: text('id', overrideProps.id || 'random-message-id'),
renderingContext: 'storybook', // renderingContext: 'storybook',
interactionMode: overrideProps.interactionMode || 'keyboard', interactionMode: overrideProps.interactionMode || 'keyboard',
isSticker: isBoolean(overrideProps.isSticker) isSticker: isBoolean(overrideProps.isSticker)
? overrideProps.isSticker ? overrideProps.isSticker
@ -330,21 +332,13 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
viewStory: action('viewStory'), viewStory: action('viewStory'),
}); });
const createTimelineItem = (data: undefined | Props) =>
data && {
type: 'message' as const,
data,
timestamp: data.timestamp,
};
const renderMany = (propsArray: ReadonlyArray<Props>) => ( const renderMany = (propsArray: ReadonlyArray<Props>) => (
<> <>
{propsArray.map((message, index) => ( {propsArray.map((message, index) => (
<Message <TimelineMessage
key={message.text} key={message.text}
{...message} {...message}
shouldCollapseAbove={Boolean(propsArray[index - 1])} shouldCollapseAbove={Boolean(propsArray[index - 1])}
item={createTimelineItem(message)}
shouldCollapseBelow={Boolean(propsArray[index + 1])} shouldCollapseBelow={Boolean(propsArray[index + 1])}
/> />
))} ))}
@ -380,19 +374,19 @@ PlainRtlMessage.story = {
export const EmojiMessages = (): JSX.Element => ( export const EmojiMessages = (): JSX.Element => (
<> <>
<Message {...createProps({ text: '😀' })} /> <TimelineMessage {...createProps({ text: '😀' })} />
<br /> <br />
<Message {...createProps({ text: '😀😀' })} /> <TimelineMessage {...createProps({ text: '😀😀' })} />
<br /> <br />
<Message {...createProps({ text: '😀😀😀' })} /> <TimelineMessage {...createProps({ text: '😀😀😀' })} />
<br /> <br />
<Message {...createProps({ text: '😀😀😀😀' })} /> <TimelineMessage {...createProps({ text: '😀😀😀😀' })} />
<br /> <br />
<Message {...createProps({ text: '😀😀😀😀😀' })} /> <TimelineMessage {...createProps({ text: '😀😀😀😀😀' })} />
<br /> <br />
<Message {...createProps({ text: '😀😀😀😀😀😀😀' })} /> <TimelineMessage {...createProps({ text: '😀😀😀😀😀😀😀' })} />
<br /> <br />
<Message <TimelineMessage
{...createProps({ {...createProps({
previews: [ previews: [
{ {
@ -416,7 +410,7 @@ export const EmojiMessages = (): JSX.Element => (
})} })}
/> />
<br /> <br />
<Message <TimelineMessage
{...createProps({ {...createProps({
attachments: [ attachments: [
fakeAttachment({ fakeAttachment({
@ -431,7 +425,7 @@ export const EmojiMessages = (): JSX.Element => (
})} })}
/> />
<br /> <br />
<Message <TimelineMessage
{...createProps({ {...createProps({
attachments: [ attachments: [
fakeAttachment({ fakeAttachment({
@ -444,7 +438,7 @@ export const EmojiMessages = (): JSX.Element => (
})} })}
/> />
<br /> <br />
<Message <TimelineMessage
{...createProps({ {...createProps({
attachments: [ attachments: [
fakeAttachment({ fakeAttachment({
@ -457,7 +451,7 @@ export const EmojiMessages = (): JSX.Element => (
})} })}
/> />
<br /> <br />
<Message <TimelineMessage
{...createProps({ {...createProps({
attachments: [ attachments: [
fakeAttachment({ fakeAttachment({
@ -779,7 +773,7 @@ DeletedWithExpireTimer.story = {
export const DeletedWithError = (): JSX.Element => { export const DeletedWithError = (): JSX.Element => {
const propsPartialError = createProps({ const propsPartialError = createProps({
timestamp: Date.now() - 60 * 1000, timestamp: Date.now() - 60 * 1000,
canDeleteForEveryone: true, // canDeleteForEveryone: true,
conversationType: 'group', conversationType: 'group',
deletedForEveryone: true, deletedForEveryone: true,
status: 'partial-sent', status: 'partial-sent',
@ -787,7 +781,7 @@ export const DeletedWithError = (): JSX.Element => {
}); });
const propsError = createProps({ const propsError = createProps({
timestamp: Date.now() - 60 * 1000, timestamp: Date.now() - 60 * 1000,
canDeleteForEveryone: true, // canDeleteForEveryone: true,
conversationType: 'group', conversationType: 'group',
deletedForEveryone: true, deletedForEveryone: true,
status: 'error', status: 'error',
@ -809,7 +803,7 @@ export const CanDeleteForEveryone = Template.bind({});
CanDeleteForEveryone.args = { CanDeleteForEveryone.args = {
status: 'read', status: 'read',
text: 'I hope you get this.', text: 'I hope you get this.',
canDeleteForEveryone: true, // canDeleteForEveryone: true,
direction: 'outgoing', direction: 'outgoing',
}; };
CanDeleteForEveryone.story = { CanDeleteForEveryone.story = {
@ -819,7 +813,7 @@ CanDeleteForEveryone.story = {
export const Error = Template.bind({}); export const Error = Template.bind({});
Error.args = { Error.args = {
status: 'error', status: 'error',
canRetry: true, // canRetry: true,
text: 'I hope you get this.', text: 'I hope you get this.',
}; };
@ -1637,7 +1631,7 @@ export const AllTheContextMenus = (): JSX.Element => {
canRetryDeleteForEveryone: true, canRetryDeleteForEveryone: true,
}); });
return <Message {...props} direction="outgoing" />; return <TimelineMessage {...props} direction="outgoing" />;
}; };
AllTheContextMenus.story = { AllTheContextMenus.story = {
name: 'All the context menus', name: 'All the context menus',

View file

@ -0,0 +1,625 @@
// Copyright 2019-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import classNames from 'classnames';
import React, { useEffect, useRef, useState } from 'react';
import type { Ref } from 'react';
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
import ReactDOM, { createPortal } from 'react-dom';
import { Manager, Popper, Reference } from 'react-popper';
import type { PreventOverflowModifier } from '@popperjs/core/lib/modifiers/preventOverflow';
import { isDownloaded } from '../../types/Attachment';
import type { LocalizerType } from '../../types/I18N';
import { handleOutsideClick } from '../../util/handleOutsideClick';
import { isFileDangerous } from '../../util/isFileDangerous';
import { offsetDistanceModifier } from '../../util/popperUtil';
import { StopPropagation } from '../StopPropagation';
import { WidthBreakpoint } from '../_util';
import { Message } from './Message';
import type { SmartReactionPicker } from '../../state/smart/ReactionPicker';
import type {
Props as MessageProps,
PropsActions as MessagePropsActions,
PropsData as MessagePropsData,
PropsHousekeeping,
} from './Message';
import { doesMessageBodyOverflow } from './MessageBodyReadMore';
import type { Props as ReactionPickerProps } from './ReactionPicker';
export type PropsData = {
canDownload: boolean;
canRetry: boolean;
canRetryDeleteForEveryone: boolean;
canReact: boolean;
canReply: boolean;
selectedReaction?: string;
} & Omit<MessagePropsData, 'renderingContext' | 'menu'>;
export type PropsActions = {
deleteMessage: (id: string) => void;
deleteMessageForEveryone: (id: string) => void;
showForwardMessageModal: (id: string) => void;
reactToMessage: (
id: string,
{ emoji, remove }: { emoji: string; remove: boolean }
) => void;
retrySend: (id: string) => void;
retryDeleteForEveryone: (id: string) => void;
replyToMessage: (id: string) => void;
} & MessagePropsActions;
export type Props = PropsData &
PropsActions &
Omit<PropsHousekeeping, 'isAttachmentPending'> &
Pick<ReactionPickerProps, 'renderEmojiPicker'> & {
renderReactionPicker: (
props: React.ComponentProps<typeof SmartReactionPicker>
) => JSX.Element;
};
type Trigger = {
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
};
/**
* Message with menu/context-menu (as necessary for rendering in the timeline)
*/
export const TimelineMessage = (props: Props): JSX.Element => {
const {
i18n,
id,
author,
attachments,
canDownload,
canReact,
canReply,
canRetry,
canDeleteForEveryone,
canRetryDeleteForEveryone,
contact,
containerElementRef,
containerWidthBreakpoint,
deletedForEveryone,
deleteMessage,
deleteMessageForEveryone,
direction,
giftBadge,
isSticker,
isTapToView,
reactToMessage,
replyToMessage,
renderReactionPicker,
renderEmojiPicker,
retrySend,
retryDeleteForEveryone,
selectedReaction,
showForwardMessageModal,
showMessageDetail,
text,
timestamp,
} = props;
const [reactionPickerRoot, setReactionPickerRoot] = useState<
HTMLDivElement | undefined
>(undefined);
const menuTriggerRef = useRef<Trigger | null>(null);
const isWindowWidthNotNarrow =
containerWidthBreakpoint !== WidthBreakpoint.Narrow;
function popperPreventOverflowModifier(): Partial<PreventOverflowModifier> {
return {
name: 'preventOverflow',
options: {
altAxis: true,
boundary: containerElementRef.current || undefined,
padding: {
bottom: 16,
left: 8,
right: 8,
top: 16,
},
},
};
}
// This id is what connects our triple-dot click with our associated pop-up menu.
// It needs to be unique.
const triggerId = String(id || `${author.id}-${timestamp}`);
const toggleReactionPicker = React.useCallback(
(onlyRemove = false): void => {
if (reactionPickerRoot) {
document.body.removeChild(reactionPickerRoot);
setReactionPickerRoot(undefined);
return;
}
if (!onlyRemove) {
const root = document.createElement('div');
document.body.appendChild(root);
setReactionPickerRoot(root);
}
},
[reactionPickerRoot]
);
useEffect(() => {
let cleanUpHandler: (() => void) | undefined;
if (reactionPickerRoot) {
cleanUpHandler = handleOutsideClick(
() => {
toggleReactionPicker(true);
return true;
},
{
containerElements: [reactionPickerRoot],
name: 'Message.reactionPicker',
}
);
}
return () => {
cleanUpHandler?.();
};
});
const openGenericAttachment = (event?: React.MouseEvent): void => {
const { downloadAttachment, kickOffAttachmentDownload } = props;
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (!attachments || attachments.length !== 1) {
return;
}
const attachment = attachments[0];
if (!isDownloaded(attachment)) {
kickOffAttachmentDownload({
attachment,
messageId: id,
});
return;
}
const { fileName } = attachment;
const isDangerous = isFileDangerous(fileName || '');
downloadAttachment({
isDangerous,
attachment,
timestamp,
});
};
const handleContextMenu = (event: React.MouseEvent<HTMLDivElement>): void => {
const selection = window.getSelection();
if (selection && !selection.isCollapsed) {
return;
}
if (event.target instanceof HTMLAnchorElement) {
return;
}
if (menuTriggerRef.current) {
menuTriggerRef.current.handleContextClick(event);
}
};
const canForward =
!isTapToView && !deletedForEveryone && !giftBadge && !contact;
const shouldShowAdditional =
doesMessageBodyOverflow(text || '') || !isWindowWidthNotNarrow;
const multipleAttachments = attachments && attachments.length > 1;
const firstAttachment = attachments && attachments[0];
const handleDownload =
canDownload &&
!isSticker &&
!multipleAttachments &&
!isTapToView &&
firstAttachment &&
!firstAttachment.pending
? openGenericAttachment
: undefined;
const handleReplyToMessage = canReply ? () => replyToMessage(id) : undefined;
const handleReact = canReact ? () => toggleReactionPicker() : undefined;
return (
<>
<Message
{...props}
renderingContext="conversation/TimelineItem"
onContextMenu={handleContextMenu}
menu={
<Manager>
<MessageMenu
i18n={i18n}
triggerId={triggerId}
isWindowWidthNotNarrow={isWindowWidthNotNarrow}
direction={direction}
menuTriggerRef={menuTriggerRef}
showMenu={handleContextMenu}
onDownload={handleDownload}
onReplyToMessage={handleReplyToMessage}
onReact={handleReact}
/>
{reactionPickerRoot &&
createPortal(
<Popper
placement="top"
modifiers={[
offsetDistanceModifier(4),
popperPreventOverflowModifier(),
]}
>
{({ ref, style }) =>
renderReactionPicker({
ref,
style,
selected: selectedReaction,
onClose: toggleReactionPicker,
onPick: emoji => {
toggleReactionPicker(true);
reactToMessage(id, {
emoji,
remove: emoji === selectedReaction,
});
},
renderEmojiPicker,
})
}
</Popper>,
reactionPickerRoot
)}
</Manager>
}
/>
<MessageContextMenu
i18n={i18n}
triggerId={triggerId}
shouldShowAdditional={shouldShowAdditional}
onDownload={handleDownload}
onReplyToMessage={handleReplyToMessage}
onReact={handleReact}
onRetrySend={canRetry ? () => retrySend(id) : undefined}
onRetryDeleteForEveryone={
canRetryDeleteForEveryone
? () => retryDeleteForEveryone(id)
: undefined
}
onForward={canForward ? () => showForwardMessageModal(id) : undefined}
onDeleteForMe={() => deleteMessage(id)}
onDeleteForEveryone={
canDeleteForEveryone ? () => deleteMessageForEveryone(id) : undefined
}
onMoreInfo={() => showMessageDetail(id)}
/>
</>
);
};
type MessageMenuProps = {
i18n: LocalizerType;
triggerId: string;
isWindowWidthNotNarrow: boolean;
menuTriggerRef: Ref<Trigger>;
showMenu: (event: React.MouseEvent<HTMLDivElement>) => void;
onDownload: (() => void) | undefined;
onReplyToMessage: (() => void) | undefined;
onReact: (() => void) | undefined;
} & Pick<MessageProps, 'i18n' | 'direction'>;
const MessageMenu = ({
i18n,
triggerId,
direction,
isWindowWidthNotNarrow,
menuTriggerRef,
showMenu,
onDownload,
onReplyToMessage,
onReact,
}: MessageMenuProps) => {
// This a menu meant for mouse use only
/* eslint-disable jsx-a11y/interactive-supports-focus */
/* eslint-disable jsx-a11y/click-events-have-key-events */
const menuButton = (
<Reference>
{({ ref: popperRef }) => {
// Only attach the popper reference to the collapsed menu button if the reaction
// button is not visible (it is hidden when the timeline is narrow)
const maybePopperRef = !isWindowWidthNotNarrow ? popperRef : undefined;
return (
<StopPropagation className="module-message__buttons__menu--container">
<ContextMenuTrigger
id={triggerId}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ref={menuTriggerRef as any}
>
<div
ref={maybePopperRef}
role="button"
onClick={showMenu}
aria-label={i18n('messageContextMenuButton')}
className={classNames(
'module-message__buttons__menu',
`module-message__buttons__download--${direction}`
)}
/>
</ContextMenuTrigger>
</StopPropagation>
);
}}
</Reference>
);
/* eslint-enable jsx-a11y/interactive-supports-focus */
/* eslint-enable jsx-a11y/click-events-have-key-events */
return (
<div
className={classNames(
'module-message__buttons',
`module-message__buttons--${direction}`
)}
>
{isWindowWidthNotNarrow && (
<>
{onReact && (
<Reference>
{({ ref: popperRef }) => {
// Only attach the popper reference to the reaction button if it is
// visible (it is hidden when the timeline is narrow)
const maybePopperRef = isWindowWidthNotNarrow
? popperRef
: undefined;
return (
// This a menu meant for mouse use only
// eslint-disable-next-line max-len
// eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events
<div
ref={maybePopperRef}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onReact();
}}
role="button"
className="module-message__buttons__react"
aria-label={i18n('reactToMessage')}
/>
);
}}
</Reference>
)}
{onDownload && (
// This a menu meant for mouse use only
// eslint-disable-next-line max-len
// eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events
<div
onClick={onDownload}
role="button"
aria-label={i18n('downloadAttachment')}
className={classNames(
'module-message__buttons__download',
`module-message__buttons__download--${direction}`
)}
/>
)}
{onReplyToMessage && (
// This a menu meant for mouse use only
// eslint-disable-next-line max-len
// eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events
<div
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onReplyToMessage();
}}
// This a menu meant for mouse use only
role="button"
aria-label={i18n('replyToMessage')}
className={classNames(
'module-message__buttons__reply',
`module-message__buttons__download--${direction}`
)}
/>
)}
</>
)}
{menuButton}
</div>
);
};
type MessageContextProps = {
i18n: LocalizerType;
triggerId: string;
shouldShowAdditional: boolean;
onDownload: (() => void) | undefined;
onReplyToMessage: (() => void) | undefined;
onReact: (() => void) | undefined;
onRetrySend: (() => void) | undefined;
onRetryDeleteForEveryone: (() => void) | undefined;
onForward: (() => void) | undefined;
onDeleteForMe: () => void;
onDeleteForEveryone: (() => void) | undefined;
onMoreInfo: () => void;
};
const MessageContextMenu = ({
i18n,
triggerId,
shouldShowAdditional,
onDownload,
onReplyToMessage,
onReact,
onMoreInfo,
onRetrySend,
onRetryDeleteForEveryone,
onForward,
onDeleteForMe,
onDeleteForEveryone,
}: MessageContextProps): JSX.Element => {
const menu = (
<ContextMenu id={triggerId}>
{shouldShowAdditional && (
<>
{onDownload && (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__download',
}}
onClick={onDownload}
>
{i18n('downloadAttachment')}
</MenuItem>
)}
{onReplyToMessage && (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__reply',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onReplyToMessage();
}}
>
{i18n('replyToMessage')}
</MenuItem>
)}
{onReact && (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__react',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onReact();
}}
>
{i18n('reactToMessage')}
</MenuItem>
)}
</>
)}
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__more-info',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onMoreInfo();
}}
>
{i18n('moreInfo')}
</MenuItem>
{onRetrySend && (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__retry-send',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onRetrySend();
}}
>
{i18n('retrySend')}
</MenuItem>
)}
{onRetryDeleteForEveryone && (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__delete-message-for-everyone',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onRetryDeleteForEveryone();
}}
>
{i18n('retryDeleteForEveryone')}
</MenuItem>
)}
{onForward && (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__forward-message',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onForward();
}}
>
{i18n('forwardMessage')}
</MenuItem>
)}
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__delete-message',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onDeleteForMe();
}}
>
{i18n('deleteMessage')}
</MenuItem>
{onDeleteForEveryone && (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__delete-message-for-everyone',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onDeleteForEveryone();
}}
>
{i18n('deleteMessageForEveryone')}
</MenuItem>
)}
</ContextMenu>
);
return ReactDOM.createPortal(menu, document.body);
};

View file

@ -199,6 +199,8 @@ export async function sendNormalMessage(
} }
// We're sending to Note to Self or a 'lonely group' with just us in it // We're sending to Note to Self or a 'lonely group' with just us in it
// or sending a story to a group where all other users don't have the stories
// capabilities (effectively a 'lonely group' in the context of stories)
log.info('sending sync message only'); log.info('sending sync message only');
const dataMessage = await messaging.getDataMessage({ const dataMessage = await messaging.getDataMessage({
attachments, attachments,
@ -214,7 +216,7 @@ export async function sendNormalMessage(
quote, quote,
recipients: allRecipientIdentifiers, recipients: allRecipientIdentifiers,
sticker, sticker,
// No storyContext; you can't reply to your own stories storyContext,
timestamp: messageTimestamp, timestamp: messageTimestamp,
reaction, reaction,
}); });

View file

@ -14,7 +14,10 @@ import { useBoundActions } from '../../hooks/useBoundActions';
// State // State
export type ForwardMessagePropsType = Omit<PropsForMessage, 'renderingContext'>; export type ForwardMessagePropsType = Omit<
PropsForMessage,
'renderingContext' | 'menu' | 'contextMenu'
>;
export type SafetyNumberChangedBlockingDataType = Readonly<{ export type SafetyNumberChangedBlockingDataType = Readonly<{
promiseUuid: UUIDStringType; promiseUuid: UUIDStringType;
source?: SafetyNumberChangeSource; source?: SafetyNumberChangeSource;

View file

@ -28,6 +28,7 @@ import { ToastReactionFailed } from '../../components/ToastReactionFailed';
import { assertDev } from '../../util/assert'; import { assertDev } from '../../util/assert';
import { blockSendUntilConversationsAreVerified } from '../../util/blockSendUntilConversationsAreVerified'; import { blockSendUntilConversationsAreVerified } from '../../util/blockSendUntilConversationsAreVerified';
import { deleteStoryForEveryone as doDeleteStoryForEveryone } from '../../util/deleteStoryForEveryone'; import { deleteStoryForEveryone as doDeleteStoryForEveryone } from '../../util/deleteStoryForEveryone';
import { deleteGroupStoryReplyForEveryone as doDeleteGroupStoryReplyForEveryone } from '../../util/deleteGroupStoryReplyForEveryone';
import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend'; import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend';
import { getMessageById } from '../../messages/getMessageById'; import { getMessageById } from '../../messages/getMessageById';
import { markViewed } from '../../services/MessageUpdater'; import { markViewed } from '../../services/MessageUpdater';
@ -131,6 +132,7 @@ const SEND_STORY_MODAL_OPEN_STATE_CHANGED =
const STORY_CHANGED = 'stories/STORY_CHANGED'; const STORY_CHANGED = 'stories/STORY_CHANGED';
const TOGGLE_VIEW = 'stories/TOGGLE_VIEW'; const TOGGLE_VIEW = 'stories/TOGGLE_VIEW';
const VIEW_STORY = 'stories/VIEW_STORY'; const VIEW_STORY = 'stories/VIEW_STORY';
const STORY_REPLY_DELETED = 'stories/STORY_REPLY_DELETED';
const REMOVE_ALL_STORIES = 'stories/REMOVE_ALL_STORIES'; const REMOVE_ALL_STORIES = 'stories/REMOVE_ALL_STORIES';
const SET_ADD_STORY_DATA = 'stories/SET_ADD_STORY_DATA'; const SET_ADD_STORY_DATA = 'stories/SET_ADD_STORY_DATA';
const SET_STORY_SENDING = 'stories/SET_STORY_SENDING'; const SET_STORY_SENDING = 'stories/SET_STORY_SENDING';
@ -188,6 +190,11 @@ type ViewStoryActionType = {
payload: SelectedStoryDataType | undefined; payload: SelectedStoryDataType | undefined;
}; };
type StoryReplyDeletedActionType = {
type: typeof STORY_REPLY_DELETED;
payload: string;
};
type RemoveAllStoriesActionType = { type RemoveAllStoriesActionType = {
type: typeof REMOVE_ALL_STORIES; type: typeof REMOVE_ALL_STORIES;
}; };
@ -215,12 +222,40 @@ export type StoriesActionType =
| StoryChangedActionType | StoryChangedActionType
| ToggleViewActionType | ToggleViewActionType
| ViewStoryActionType | ViewStoryActionType
| StoryReplyDeletedActionType
| RemoveAllStoriesActionType | RemoveAllStoriesActionType
| SetAddStoryDataType | SetAddStoryDataType
| SetStorySendingType; | SetStorySendingType;
// Action Creators // Action Creators
function deleteGroupStoryReply(
messageId: string
): ThunkAction<void, RootStateType, unknown, StoryReplyDeletedActionType> {
return async dispatch => {
await window.Signal.Data.removeMessage(messageId);
dispatch({
type: STORY_REPLY_DELETED,
payload: messageId,
});
};
}
function deleteGroupStoryReplyForEveryone(
replyMessageId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
await doDeleteGroupStoryReplyForEveryone(replyMessageId);
// the call above re-uses the sync-message processing code to update the UI
// we don't need to do anything here
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function deleteStoryForEveryone( function deleteStoryForEveryone(
story: StoryViewType story: StoryViewType
): ThunkAction<void, RootStateType, unknown, DOEStoryActionType> { ): ThunkAction<void, RootStateType, unknown, DOEStoryActionType> {
@ -1211,6 +1246,8 @@ export const actions = {
verifyStoryListMembers, verifyStoryListMembers,
viewUserStories, viewUserStories,
viewStory, viewStory,
deleteGroupStoryReply,
deleteGroupStoryReplyForEveryone,
setAddStoryData, setAddStoryData,
setStoriesDisabled, setStoriesDisabled,
setStorySending, setStorySending,
@ -1253,6 +1290,20 @@ export function reducer(
}; };
} }
if (action.type === STORY_REPLY_DELETED) {
return {
...state,
replyState: state.replyState
? {
...state.replyState,
replies: state.replyState.replies.filter(
reply => reply.id !== action.payload
),
}
: undefined,
};
}
if (action.type === 'MESSAGE_DELETED') { if (action.type === 'MESSAGE_DELETED') {
const nextStories = state.stories.filter( const nextStories = state.stories.filter(
story => story.messageId !== action.payload.id story => story.messageId !== action.payload.id

View file

@ -540,9 +540,7 @@ export const getNonGroupStories = createSelector(
conversationIdsWithStories: Set<string> conversationIdsWithStories: Set<string>
): Array<ConversationType> => { ): Array<ConversationType> => {
return groups.filter( return groups.filter(
group => group => !isGroupInStoryMode(group, conversationIdsWithStories)
!isGroupV2(group) ||
!isGroupInStoryMode(group, conversationIdsWithStories)
); );
} }
); );
@ -554,9 +552,7 @@ export const getGroupStories = createSelector(
conversationLookup: ConversationLookupType, conversationLookup: ConversationLookupType,
conversationIdsWithStories: Set<string> conversationIdsWithStories: Set<string>
): Array<ConversationType> => { ): Array<ConversationType> => {
return Object.values(conversationLookup).filter( return Object.values(conversationLookup).filter(conversation =>
conversation =>
isGroupV2(conversation) &&
isGroupInStoryMode(conversation, conversationIdsWithStories) isGroupInStoryMode(conversation, conversationIdsWithStories)
); );
} }

View file

@ -16,6 +16,7 @@ import type {
import type { TimelineItemType } from '../../components/conversation/TimelineItem'; import type { TimelineItemType } from '../../components/conversation/TimelineItem';
import type { PropsData } from '../../components/conversation/Message'; import type { PropsData } from '../../components/conversation/Message';
import type { PropsData as TimelineMessagePropsData } from '../../components/conversation/TimelineMessage';
import { TextDirection } from '../../components/conversation/Message'; import { TextDirection } from '../../components/conversation/Message';
import type { PropsData as TimerNotificationProps } from '../../components/conversation/TimerNotification'; import type { PropsData as TimerNotificationProps } from '../../components/conversation/TimerNotification';
import type { PropsData as ChangeNumberNotificationProps } from '../../components/conversation/ChangeNumberNotification'; import type { PropsData as ChangeNumberNotificationProps } from '../../components/conversation/ChangeNumberNotification';
@ -113,7 +114,7 @@ type FormattedContact = Partial<ConversationType> &
| 'type' | 'type'
| 'unblurredAvatarPath' | 'unblurredAvatarPath'
>; >;
export type PropsForMessage = Omit<PropsData, 'interactionMode'>; export type PropsForMessage = Omit<TimelineMessagePropsData, 'interactionMode'>;
type PropsForUnsupportedMessage = { type PropsForUnsupportedMessage = {
canProcessNow: boolean; canProcessNow: boolean;
contact: FormattedContact; contact: FormattedContact;
@ -761,9 +762,8 @@ function getTextDirection(body?: string): TextDirection {
export const getPropsForMessage: ( export const getPropsForMessage: (
message: MessageWithUIFieldsType, message: MessageWithUIFieldsType,
options: GetPropsForMessageOptions options: GetPropsForMessageOptions
) => Omit<PropsForMessage, 'renderingContext'> = createSelectorCreator( ) => Omit<PropsForMessage, 'renderingContext' | 'menu' | 'contextMenu'> =
memoizeByRoot createSelectorCreator(memoizeByRoot)(
)(
// `memoizeByRoot` requirement // `memoizeByRoot` requirement
identity, identity,
@ -787,7 +787,7 @@ export const getPropsForMessage: (
storyReplyContext: PropsData['storyReplyContext'], storyReplyContext: PropsData['storyReplyContext'],
textAttachment: PropsData['textAttachment'], textAttachment: PropsData['textAttachment'],
shallowProps: ShallowPropsType shallowProps: ShallowPropsType
): Omit<PropsForMessage, 'renderingContext'> => { ): Omit<PropsForMessage, 'renderingContext' | 'menu' | 'contextMenu'> => {
return { return {
attachments, attachments,
author, author,

View file

@ -6,41 +6,12 @@ import { pick } from 'underscore';
import { MessageAudio } from '../../components/conversation/MessageAudio'; import { MessageAudio } from '../../components/conversation/MessageAudio';
import type { OwnProps as MessageAudioOwnProps } from '../../components/conversation/MessageAudio'; import type { OwnProps as MessageAudioOwnProps } from '../../components/conversation/MessageAudio';
import type { ComputePeaksResult } from '../../components/GlobalAudioContext';
import { mapDispatchToProps } from '../actions'; import { mapDispatchToProps } from '../actions';
import type { StateType } from '../reducer'; import type { StateType } from '../reducer';
import type { LocalizerType } from '../../types/Util';
import type { AttachmentType } from '../../types/Attachment';
import type {
DirectionType,
MessageStatusType,
} from '../../components/conversation/Message';
import type { ActiveAudioPlayerStateType } from '../ducks/audioPlayer'; import type { ActiveAudioPlayerStateType } from '../ducks/audioPlayer';
export type Props = { export type Props = Omit<MessageAudioOwnProps, 'active'>;
renderingContext: string;
i18n: LocalizerType;
attachment: AttachmentType;
collapseMetadata: boolean;
withContentAbove: boolean;
withContentBelow: boolean;
direction: DirectionType;
expirationLength?: number;
expirationTimestamp?: number;
id: string;
conversationId: string;
played: boolean;
showMessageDetail: (id: string) => void;
status?: MessageStatusType;
textPending?: boolean;
timestamp: number;
computePeaks(url: string, barCount: number): Promise<ComputePeaksResult>;
kickOffAttachmentDownload(): void;
onCorrupted(): void;
};
const mapStateToProps = ( const mapStateToProps = (
state: StateType, state: StateType,

View file

@ -11,8 +11,6 @@ import type { StateType } from '../reducer';
import { getPreferredBadgeSelector } from '../selectors/badges'; import { getPreferredBadgeSelector } from '../selectors/badges';
import { getIntl, getInteractionMode, getTheme } from '../selectors/user'; import { getIntl, getInteractionMode, getTheme } from '../selectors/user';
import { renderAudioAttachment } from './renderAudioAttachment'; import { renderAudioAttachment } from './renderAudioAttachment';
import { renderEmojiPicker } from './renderEmojiPicker';
import { renderReactionPicker } from './renderReactionPicker';
import { getContactNameColorSelector } from '../selectors/conversations'; import { getContactNameColorSelector } from '../selectors/conversations';
import { markViewed } from '../ducks/conversations'; import { markViewed } from '../ducks/conversations';
@ -48,15 +46,10 @@ const mapStateToProps = (
openConversation, openConversation,
openGiftBadge, openGiftBadge,
openLink, openLink,
reactToMessage,
replyToMessage,
retryDeleteForEveryone,
retrySend,
showContactDetail, showContactDetail,
showContactModal, showContactModal,
showExpiredIncomingTapToViewToast, showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast, showExpiredOutgoingTapToViewToast,
showForwardMessageModal,
showVisualAttachment, showVisualAttachment,
startConversation, startConversation,
} = props; } = props;
@ -93,18 +86,11 @@ const mapStateToProps = (
openConversation, openConversation,
openGiftBadge, openGiftBadge,
openLink, openLink,
reactToMessage,
renderAudioAttachment, renderAudioAttachment,
renderEmojiPicker,
renderReactionPicker,
replyToMessage,
retryDeleteForEveryone,
retrySend,
showContactDetail, showContactDetail,
showContactModal, showContactModal,
showExpiredIncomingTapToViewToast, showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast, showExpiredOutgoingTapToViewToast,
showForwardMessageModal,
showVisualAttachment, showVisualAttachment,
startConversation, startConversation,
}; };

View file

@ -7,10 +7,7 @@ import { GlobalAudioContext } from '../../components/GlobalAudioContext';
import type { Props as MessageAudioProps } from './MessageAudio'; import type { Props as MessageAudioProps } from './MessageAudio';
import { SmartMessageAudio } from './MessageAudio'; import { SmartMessageAudio } from './MessageAudio';
type AudioAttachmentProps = Omit< type AudioAttachmentProps = Omit<MessageAudioProps, 'computePeaks'>;
MessageAudioProps,
'computePeaks' | 'buttonRef'
>;
export function renderAudioAttachment( export function renderAudioAttachment(
props: AudioAttachmentProps props: AudioAttachmentProps

View file

@ -0,0 +1,39 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { DAY } from './durations';
import { sendDeleteForEveryoneMessage } from './sendDeleteForEveryoneMessage';
import { getMessageById } from '../messages/getMessageById';
import * as log from '../logging/log';
export async function deleteGroupStoryReplyForEveryone(
replyMessageId: string
): Promise<void> {
const messageModel = await getMessageById(replyMessageId);
if (!messageModel) {
log.warn(
`deleteStoryReplyForEveryone: No message model found for reply: ${replyMessageId}`
);
return;
}
const timestamp = messageModel.get('timestamp');
const group = messageModel.getConversation();
if (!group) {
log.warn(
`deleteGroupStoryReplyForEveryone: No conversation model found for: ${messageModel.get(
'conversationId'
)}`
);
return;
}
sendDeleteForEveryoneMessage(group.attributes, {
deleteForEveryoneDuration: DAY,
id: replyMessageId,
timestamp,
});
}

View file

@ -22,6 +22,13 @@
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z" "updated": "2018-09-19T18:13:29.628Z"
}, },
{
"rule": "React-useRef",
"path": "ts/components/conversation/TimelineMessage.tsx",
"line": " const menuTriggerRef = useRef<Trigger | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2022-11-03T14:21:47.456Z"
},
{ {
"rule": "jQuery-globalEval(", "rule": "jQuery-globalEval(",
"path": "components/mp3lameencoder/lib/Mp3LameEncoder.js", "path": "components/mp3lameencoder/lib/Mp3LameEncoder.js",