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.",
"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": {
"message": "Story",
"description": "aria-label for the story list button"

View file

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

View file

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

View file

@ -24,10 +24,11 @@ export type ContextMenuOptionType<T> = Readonly<{
}>;
type RenderButtonProps = Readonly<{
openMenu: (() => void) | ((ev: React.MouseEvent) => void);
openMenu: (ev: React.MouseEvent) => void;
onKeyDown: (ev: KeyboardEvent) => void;
isMenuShowing: boolean;
ref: React.Ref<HTMLButtonElement> | null;
menuNode: ReactNode;
}>;
export type PropsType<T> = Readonly<{
@ -38,7 +39,7 @@ export type PropsType<T> = Readonly<{
menuOptions: ReadonlyArray<ContextMenuOptionType<T>>;
moduleClassName?: string;
button?: () => JSX.Element;
onClick?: () => unknown;
onClick?: (ev: React.MouseEvent) => unknown;
onMenuShowingChanged?: (value: boolean) => unknown;
popperOptions?: Pick<Options, 'placement' | 'strategy'>;
theme?: Theme;
@ -260,59 +261,60 @@ export function ContextMenu<T>({
);
}
let buttonNode: ReactNode;
const menuNode = isMenuShowing ? (
<div className={theme ? themeClassName(theme) : undefined}>
<div
className={classNames(
getClassName('__popper'),
menuOptions.length === 1
? getClassName('__popper--single-item')
: undefined
)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
{title && <div className={getClassName('__title')}>{title}</div>}
{optionElements}
</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 = (
<button
aria-label={ariaLabel || i18n('ContextMenu--button')}
<div
className={classNames(
getClassName('__button'),
isMenuShowing ? getClassName('__button--active') : undefined
getClassName('__container'),
theme ? themeClassName(theme) : undefined
)}
onClick={onClick || handleClick}
onContextMenu={handleClick}
onKeyDown={handleKeyDown}
ref={setReferenceElement}
type="button"
>
{children}
</button>
<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>
);
}
return (
<div
className={classNames(
getClassName('__container'),
theme ? themeClassName(theme) : undefined
)}
>
{buttonNode}
{isMenuShowing && (
<div className={theme ? themeClassName(theme) : undefined}>
<div
className={classNames(
getClassName('__popper'),
menuOptions.length === 1
? getClassName('__popper--single-item')
: undefined
)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
{title && <div className={getClassName('__title')}>{title}</div>}
{optionElements}
</div>
</div>
)}
</div>
);
return buttonNode;
}

View file

@ -646,17 +646,20 @@ export const SendStoryModal = ({
}}
theme={Theme.Dark}
>
{({ openMenu, onKeyDown, ref }) => (
<Button
ref={ref}
className="SendStoryModal__new-story__button"
variant={ButtonVariant.Secondary}
size={ButtonSize.Small}
onClick={openMenu}
onKeyDown={onKeyDown}
>
{i18n('SendStoryModal__new')}
</Button>
{({ openMenu, onKeyDown, ref, menuNode }) => (
<div>
<Button
ref={ref}
className="SendStoryModal__new-story__button"
variant={ButtonVariant.Secondary}
size={ButtonSize.Small}
onClick={openMenu}
onKeyDown={onKeyDown}
>
{i18n('SendStoryModal__new')}
</Button>
{menuNode}
</div>
)}
</ContextMenu>
</div>

View file

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

View file

@ -36,22 +36,17 @@ import { WidthBreakpoint } from './_util';
import { getAvatarColor } from '../types/Colors';
import { getStoryReplyText } from '../util/getStoryReplyText';
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
// link previews, tap to view messages, attachments, or gifts. Just regular
// text messages and reactions.
const MESSAGE_DEFAULT_PROPS = {
canDeleteForEveryone: false,
canDownload: false,
canReact: false,
canReply: false,
canRetry: false,
canRetryDeleteForEveryone: false,
checkForAccount: shouldNeverBeCalled,
clearSelectedMessage: shouldNeverBeCalled,
containerWidthBreakpoint: WidthBreakpoint.Medium,
deleteMessage: shouldNeverBeCalled,
deleteMessageForEveryone: shouldNeverBeCalled,
displayTapToViewMessage: shouldNeverBeCalled,
doubleCheckMissingQuoteReference: shouldNeverBeCalled,
downloadAttachment: shouldNeverBeCalled,
@ -65,19 +60,12 @@ const MESSAGE_DEFAULT_PROPS = {
openGiftBadge: shouldNeverBeCalled,
openLink: shouldNeverBeCalled,
previews: [],
reactToMessage: shouldNeverBeCalled,
renderAudioAttachment: () => <div />,
renderEmojiPicker: () => <div />,
renderReactionPicker: () => <div />,
replyToMessage: shouldNeverBeCalled,
retryDeleteForEveryone: shouldNeverBeCalled,
retrySend: shouldNeverBeCalled,
scrollToQuotedMessage: shouldNeverBeCalled,
showContactDetail: shouldNeverBeCalled,
showContactModal: shouldNeverBeCalled,
showExpiredIncomingTapToViewToast: shouldNeverBeCalled,
showExpiredOutgoingTapToViewToast: shouldNeverBeCalled,
showForwardMessageModal: shouldNeverBeCalled,
showMessageDetail: shouldNeverBeCalled,
showVisualAttachment: shouldNeverBeCalled,
startConversation: shouldNeverBeCalled,
@ -118,6 +106,8 @@ export type PropsType = {
views: Array<StorySendStateType>;
viewTarget: StoryViewTargetType;
onChangeViewTarget: (target: StoryViewTargetType) => unknown;
deleteGroupStoryReply: (id: string) => void;
deleteGroupStoryReplyForEveryone: (id: string) => void;
};
export const StoryViewsNRepliesModal = ({
@ -144,7 +134,16 @@ export const StoryViewsNRepliesModal = ({
views,
viewTarget,
onChangeViewTarget,
deleteGroupStoryReply,
deleteGroupStoryReplyForEveryone,
}: 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 inputApiRef = useRef<InputApi | undefined>();
const shouldScrollToBottomRef = useRef(true);
@ -310,80 +309,36 @@ export const StoryViewsNRepliesModal = ({
className="StoryViewsNRepliesModal__replies"
ref={containerElementRef}
>
{replies.map((reply, index) =>
reply.reactionEmoji ? (
<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>
{replies.map((reply, index) => {
return reply.reactionEmoji ? (
<Reaction
key={reply.id}
i18n={i18n}
reply={reply}
getPreferredBadge={getPreferredBadge}
/>
) : (
<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}
i18n={i18n}
id={reply.id}
interactionMode="mouse"
readStatus={reply.readStatus}
renderingContext="StoryViewsNRepliesModal"
shouldCollapseAbove={
reply.conversationId === replies[index - 1]?.conversationId &&
!replies[index - 1]?.reactionEmoji
}
shouldCollapseBelow={
reply.conversationId === replies[index + 1]?.conversationId &&
!replies[index + 1]?.reactionEmoji
}
shouldHideMetadata={false}
text={reply.body}
textDirection={TextDirection.Default}
timestamp={reply.timestamp}
/>
</div>
)
)}
<Reply
key={reply.id}
i18n={i18n}
containerElementRef={containerElementRef}
reply={reply}
deleteGroupStoryReply={() => setDeleteReplyId(reply.id)}
deleteGroupStoryReplyForEveryone={() =>
setDeleteForEveryoneReplyId(reply.id)
}
getPreferredBadge={getPreferredBadge}
shouldCollapseAbove={
reply.conversationId === replies[index - 1]?.conversationId &&
!replies[index - 1]?.reactionEmoji
}
shouldCollapseBelow={
reply.conversationId === replies[index + 1]?.conversationId &&
!replies[index + 1]?.reactionEmoji
}
/>
);
})}
<div ref={bottomRef} />
</div>
);
@ -483,26 +438,197 @@ export const StoryViewsNRepliesModal = ({
}
return (
<Modal
modalName="StoryViewsNRepliesModal"
i18n={i18n}
moduleClassName="StoryViewsNRepliesModal"
onClose={onClose}
useFocusTrap={Boolean(composerElement)}
theme={Theme.Dark}
>
<div
className={classNames({
'StoryViewsNRepliesModal--group': Boolean(group),
})}
<>
<Modal
modalName="StoryViewsNRepliesModal"
i18n={i18n}
moduleClassName="StoryViewsNRepliesModal"
onClose={onClose}
useFocusTrap={Boolean(composerElement)}
theme={Theme.Dark}
>
{tabsElement || (
<>
{viewsElement || repliesElement}
{composerElement}
</>
)}
</div>
</Modal>
<div
className={classNames({
'StoryViewsNRepliesModal--group': Boolean(group),
})}
>
{tabsElement || (
<>
{viewsElement || repliesElement}
{composerElement}
</>
)}
</div>
</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 React from 'react';
import ReactDOM, { createPortal } from 'react-dom';
import { createPortal } from 'react-dom';
import classNames from 'classnames';
import getDirection from 'direction';
import { drop, groupBy, orderBy, take, unescape } from 'lodash';
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
import { Manager, Popper, Reference } from 'react-popper';
import type { PreventOverflowModifier } from '@popperjs/core/lib/modifiers/preventOverflow';
@ -17,15 +16,11 @@ import type {
InteractionModeType,
} from '../../state/ducks/conversations';
import type { ViewStoryActionCreatorType } from '../../state/ducks/stories';
import type { TimelineItemType } from './TimelineItem';
import type { ReadStatus } from '../../messages/MessageReadStatus';
import { Avatar, AvatarSize } from '../Avatar';
import { AvatarSpacer } from '../AvatarSpacer';
import { Spinner } from '../Spinner';
import {
doesMessageBodyOverflow,
MessageBodyReadMore,
} from './MessageBodyReadMore';
import { MessageBodyReadMore } from './MessageBodyReadMore';
import { MessageMetadata } from './MessageMetadata';
import { MessageTextMetadataSpacer } from './MessageTextMetadataSpacer';
import { ImageGrid } from './ImageGrid';
@ -37,16 +32,14 @@ import { Quote } from './Quote';
import { EmbeddedContact } from './EmbeddedContact';
import type { OwnProps as ReactionViewerProps } from './ReactionViewer';
import { ReactionViewer } from './ReactionViewer';
import type { Props as ReactionPickerProps } from './ReactionPicker';
import { Emoji } from '../emoji/Emoji';
import { LinkPreviewDate } from './LinkPreviewDate';
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
import { shouldUseFullSizeLinkPreviewImage } from '../../linkPreviews/shouldUseFullSizeLinkPreviewImage';
import { WidthBreakpoint } from '../_util';
import type { WidthBreakpoint } from '../_util';
import { OutgoingGiftBadgeModal } from '../OutgoingGiftBadgeModal';
import * as log from '../../logging/log';
import { StoryViewModeType } from '../../types/Stories';
import type { AttachmentType } from '../../types/Attachment';
import {
canDisplayImage,
@ -84,21 +77,13 @@ import type {
import { createRefMerger } from '../../util/refMerger';
import { emojiToData, getEmojiCount } from '../emoji/lib';
import { isEmojiOnlyText } from '../../util/isEmojiOnlyText';
import type { SmartReactionPicker } from '../../state/smart/ReactionPicker';
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 { DAY, HOUR, MINUTE, SECOND } from '../../util/durations';
import { BadgeImageTheme } from '../../badges/BadgeImageTheme';
import { getBadgeImageFileLocalPath } from '../../badges/getBadgeImageFileLocalPath';
import { handleOutsideClick } from '../../util/handleOutsideClick';
type Trigger = {
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
};
const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 10;
const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18;
const GUESS_METADATA_WIDTH_OUTGOING_SIZE: Record<MessageStatusType, number> = {
@ -270,37 +255,30 @@ export type PropsData = {
expirationTimestamp?: number;
reactions?: ReactionViewerProps['reactions'];
selectedReaction?: string;
deletedForEveryone?: boolean;
canRetry: boolean;
canRetryDeleteForEveryone: boolean;
canReact: boolean;
canReply: boolean;
canDownload: boolean;
canDeleteForEveryone: boolean;
isBlocked: boolean;
isMessageRequestAccepted: boolean;
bodyRanges?: BodyRangesType;
menu: JSX.Element | undefined;
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
};
export type PropsHousekeeping = {
containerElementRef: RefObject<HTMLElement>;
containerWidthBreakpoint: WidthBreakpoint;
disableMenu?: boolean;
disableScroll?: boolean;
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
interactionMode: InteractionModeType;
item?: TimelineItemType;
renderAudioAttachment: (props: AudioAttachmentProps) => JSX.Element;
renderReactionPicker: (
props: React.ComponentProps<typeof SmartReactionPicker>
) => JSX.Element;
shouldCollapseAbove: boolean;
shouldCollapseBelow: boolean;
shouldHideMetadata: boolean;
onContextMenu?: (event: React.MouseEvent<HTMLDivElement>) => void;
theme: ThemeType;
};
@ -310,16 +288,6 @@ export type PropsActions = {
messageExpanded: (id: string, displayLimit: number) => 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;
startConversation: (e164: string, uuid: UUIDStringType) => void;
@ -366,10 +334,7 @@ export type PropsActions = {
viewStory: ViewStoryActionCreatorType;
};
export type Props = PropsData &
PropsHousekeeping &
PropsActions &
Pick<ReactionPickerProps, 'renderEmojiPicker'>;
export type Props = PropsData & PropsHousekeeping & PropsActions;
type State = {
metadataWidth: number;
@ -383,8 +348,6 @@ type State = {
reactionViewerRoot: HTMLDivElement | null;
reactionViewerOutsideClickDestructor?: () => void;
reactionPickerRoot: HTMLDivElement | null;
reactionPickerOutsideClickDestructor?: () => void;
giftBadgeCounter: number | null;
showOutgoingGiftBadgeModal: boolean;
@ -393,8 +356,6 @@ type State = {
};
export class Message extends React.PureComponent<Props, State> {
public menuTriggerRef: Trigger | undefined;
public focusRef: React.RefObject<HTMLDivElement> = React.createRef();
public audioButtonRef: React.RefObject<HTMLButtonElement> = React.createRef();
@ -428,7 +389,6 @@ export class Message extends React.PureComponent<Props, State> {
prevSelectedCounter: props.isSelectedCounter,
reactionViewerRoot: null,
reactionPickerRoot: null,
giftBadgeCounter: null,
showOutgoingGiftBadgeModal: false,
@ -466,27 +426,14 @@ export class Message extends React.PureComponent<Props, State> {
return Boolean(reactions && reactions.length);
}
public captureMenuTrigger = (triggerRef: Trigger): void => {
this.menuTriggerRef = triggerRef;
};
public handleFocus = (): void => {
const { interactionMode, isSelected } = this.props;
public showMenu = (event: React.MouseEvent<HTMLDivElement>): void => {
if (this.menuTriggerRef) {
this.menuTriggerRef.handleContextClick(event);
if (interactionMode === 'keyboard' && !isSelected) {
this.setSelected();
}
};
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 => {
const { id } = this.props;
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 => {
const { id, conversationId, selectMessage } = this.props;
@ -559,7 +498,6 @@ export class Message extends React.PureComponent<Props, State> {
clearTimeoutIfNecessary(this.deleteForEveryoneTimeout);
clearTimeoutIfNecessary(this.giftBadgeInterval);
this.toggleReactionViewer(true);
this.toggleReactionPicker(true);
}
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);
}
private canDeleteForEveryone(): boolean {
const { canDeleteForEveryone } = this.props;
const { hasDeleteForEveryoneTimerExpired } = this.state;
return canDeleteForEveryone && !hasDeleteForEveryoneTimerExpired;
}
private startDeleteForEveryoneTimerIfApplicable(): void {
const { canDeleteForEveryone } = this.props;
const { hasDeleteForEveryoneTimerExpired } = this.state;
@ -917,7 +849,6 @@ export class Message extends React.PureComponent<Props, State> {
theme,
timestamp,
} = this.props;
const { imageBroken } = this.state;
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 {
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 {
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 => {
// We don't want clicks on body text to result in the 'default action' for the message
const { text } = this.props;
@ -2888,6 +2385,8 @@ export class Message extends React.PureComponent<Props, State> {
isTapToView,
isTapToViewExpired,
isTapToViewError,
onContextMenu,
onKeyDown,
text,
} = this.props;
const { isSelected } = this.state;
@ -2947,9 +2446,9 @@ export class Message extends React.PureComponent<Props, State> {
<div
className={containerClassnames}
style={containerStyles}
onContextMenu={this.showContextMenu}
onContextMenu={onContextMenu}
role="row"
onKeyDown={this.handleKeyDown}
onKeyDown={onKeyDown}
onClick={this.handleClick}
tabIndex={-1}
>
@ -2963,20 +2462,15 @@ export class Message extends React.PureComponent<Props, State> {
public override render(): JSX.Element | null {
const {
author,
attachments,
direction,
id,
isSticker,
shouldCollapseAbove,
shouldCollapseBelow,
timestamp,
menu,
onKeyDown,
} = this.props;
const { expired, expiring, imageBroken, isSelected } = 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}`);
const { expired, expiring, isSelected, imageBroken } = this.state;
if (expired) {
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
// read the message, but we can't be a button; that would break inner buttons.
role="row"
onKeyDown={this.handleKeyDown}
onKeyDown={onKeyDown}
onFocus={this.handleFocus}
ref={this.focusRef}
>
{this.renderError()}
{this.renderAvatar()}
{this.renderContainer()}
{this.renderMenu(triggerId)}
{this.renderContextMenu(triggerId)}
{menu}
</div>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -28,6 +28,7 @@ import { ToastReactionFailed } from '../../components/ToastReactionFailed';
import { assertDev } from '../../util/assert';
import { blockSendUntilConversationsAreVerified } from '../../util/blockSendUntilConversationsAreVerified';
import { deleteStoryForEveryone as doDeleteStoryForEveryone } from '../../util/deleteStoryForEveryone';
import { deleteGroupStoryReplyForEveryone as doDeleteGroupStoryReplyForEveryone } from '../../util/deleteGroupStoryReplyForEveryone';
import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend';
import { getMessageById } from '../../messages/getMessageById';
import { markViewed } from '../../services/MessageUpdater';
@ -131,6 +132,7 @@ const SEND_STORY_MODAL_OPEN_STATE_CHANGED =
const STORY_CHANGED = 'stories/STORY_CHANGED';
const TOGGLE_VIEW = 'stories/TOGGLE_VIEW';
const VIEW_STORY = 'stories/VIEW_STORY';
const STORY_REPLY_DELETED = 'stories/STORY_REPLY_DELETED';
const REMOVE_ALL_STORIES = 'stories/REMOVE_ALL_STORIES';
const SET_ADD_STORY_DATA = 'stories/SET_ADD_STORY_DATA';
const SET_STORY_SENDING = 'stories/SET_STORY_SENDING';
@ -188,6 +190,11 @@ type ViewStoryActionType = {
payload: SelectedStoryDataType | undefined;
};
type StoryReplyDeletedActionType = {
type: typeof STORY_REPLY_DELETED;
payload: string;
};
type RemoveAllStoriesActionType = {
type: typeof REMOVE_ALL_STORIES;
};
@ -215,12 +222,40 @@ export type StoriesActionType =
| StoryChangedActionType
| ToggleViewActionType
| ViewStoryActionType
| StoryReplyDeletedActionType
| RemoveAllStoriesActionType
| SetAddStoryDataType
| SetStorySendingType;
// 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(
story: StoryViewType
): ThunkAction<void, RootStateType, unknown, DOEStoryActionType> {
@ -1211,6 +1246,8 @@ export const actions = {
verifyStoryListMembers,
viewUserStories,
viewStory,
deleteGroupStoryReply,
deleteGroupStoryReplyForEveryone,
setAddStoryData,
setStoriesDisabled,
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') {
const nextStories = state.stories.filter(
story => story.messageId !== action.payload.id

View file

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

View file

@ -16,6 +16,7 @@ import type {
import type { TimelineItemType } from '../../components/conversation/TimelineItem';
import type { PropsData } from '../../components/conversation/Message';
import type { PropsData as TimelineMessagePropsData } from '../../components/conversation/TimelineMessage';
import { TextDirection } from '../../components/conversation/Message';
import type { PropsData as TimerNotificationProps } from '../../components/conversation/TimerNotification';
import type { PropsData as ChangeNumberNotificationProps } from '../../components/conversation/ChangeNumberNotification';
@ -113,7 +114,7 @@ type FormattedContact = Partial<ConversationType> &
| 'type'
| 'unblurredAvatarPath'
>;
export type PropsForMessage = Omit<PropsData, 'interactionMode'>;
export type PropsForMessage = Omit<TimelineMessagePropsData, 'interactionMode'>;
type PropsForUnsupportedMessage = {
canProcessNow: boolean;
contact: FormattedContact;
@ -761,46 +762,45 @@ function getTextDirection(body?: string): TextDirection {
export const getPropsForMessage: (
message: MessageWithUIFieldsType,
options: GetPropsForMessageOptions
) => Omit<PropsForMessage, 'renderingContext'> = createSelectorCreator(
memoizeByRoot
)(
// `memoizeByRoot` requirement
identity,
) => Omit<PropsForMessage, 'renderingContext' | 'menu' | 'contextMenu'> =
createSelectorCreator(memoizeByRoot)(
// `memoizeByRoot` requirement
identity,
getAttachmentsForMessage,
processBodyRanges,
getCachedAuthorForMessage,
getPreviewsForMessage,
getReactionsForMessage,
getPropsForQuote,
getPropsForStoryReplyContext,
getTextAttachment,
getShallowPropsForMessage,
(
_,
attachments: Array<AttachmentType>,
bodyRanges: BodyRangesType | undefined,
author: PropsData['author'],
previews: Array<LinkPreviewType>,
reactions: PropsData['reactions'],
quote: PropsData['quote'],
storyReplyContext: PropsData['storyReplyContext'],
textAttachment: PropsData['textAttachment'],
shallowProps: ShallowPropsType
): Omit<PropsForMessage, 'renderingContext'> => {
return {
attachments,
author,
bodyRanges,
previews,
quote,
reactions,
storyReplyContext,
textAttachment,
...shallowProps,
};
}
);
getAttachmentsForMessage,
processBodyRanges,
getCachedAuthorForMessage,
getPreviewsForMessage,
getReactionsForMessage,
getPropsForQuote,
getPropsForStoryReplyContext,
getTextAttachment,
getShallowPropsForMessage,
(
_,
attachments: Array<AttachmentType>,
bodyRanges: BodyRangesType | undefined,
author: PropsData['author'],
previews: Array<LinkPreviewType>,
reactions: PropsData['reactions'],
quote: PropsData['quote'],
storyReplyContext: PropsData['storyReplyContext'],
textAttachment: PropsData['textAttachment'],
shallowProps: ShallowPropsType
): Omit<PropsForMessage, 'renderingContext' | 'menu' | 'contextMenu'> => {
return {
attachments,
author,
bodyRanges,
previews,
quote,
reactions,
storyReplyContext,
textAttachment,
...shallowProps,
};
}
);
// This is getPropsForMessage but wrapped in reselect's createSelector so that
// we can derive all of the selector dependencies that getPropsForMessage

View file

@ -6,41 +6,12 @@ import { pick } from 'underscore';
import { MessageAudio } from '../../components/conversation/MessageAudio';
import type { OwnProps as MessageAudioOwnProps } from '../../components/conversation/MessageAudio';
import type { ComputePeaksResult } from '../../components/GlobalAudioContext';
import { mapDispatchToProps } from '../actions';
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';
export type Props = {
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;
};
export type Props = Omit<MessageAudioOwnProps, 'active'>;
const mapStateToProps = (
state: StateType,

View file

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

View file

@ -7,10 +7,7 @@ import { GlobalAudioContext } from '../../components/GlobalAudioContext';
import type { Props as MessageAudioProps } from './MessageAudio';
import { SmartMessageAudio } from './MessageAudio';
type AudioAttachmentProps = Omit<
MessageAudioProps,
'computePeaks' | 'buttonRef'
>;
type AudioAttachmentProps = Omit<MessageAudioProps, 'computePeaks'>;
export function renderAudioAttachment(
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",
"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(",
"path": "components/mp3lameencoder/lib/Mp3LameEncoder.js",