Implement group story reply deletion
This commit is contained in:
parent
7164b603e9
commit
4445ef80eb
26 changed files with 1218 additions and 934 deletions
|
@ -5867,6 +5867,14 @@
|
|||
"messageformat": "You can’t reply to this story because you’re 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"
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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'}
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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!');
|
||||
}}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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',
|
625
ts/components/conversation/TimelineMessage.tsx
Normal file
625
ts/components/conversation/TimelineMessage.tsx
Normal 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);
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
39
ts/util/deleteGroupStoryReplyForEveryone.ts
Normal file
39
ts/util/deleteGroupStoryReplyForEveryone.ts
Normal 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,
|
||||
});
|
||||
}
|
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue