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.",
|
"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"
|
"description": "Shown in the composer area of the reply-to-story modal when a user can't make a reply because they are no longer a member"
|
||||||
},
|
},
|
||||||
|
"icu:StoryViewsNRepliesModal__delete-reply": {
|
||||||
|
"messageformat": "Delete for me",
|
||||||
|
"description": "Shown as a menu item in the context menu of a story reply, to the author of the reply, for deleting the reply just for the author"
|
||||||
|
},
|
||||||
|
"icu:StoryViewsNRepliesModal__delete-reply-for-everyone": {
|
||||||
|
"messageformat": "Delete for everyone",
|
||||||
|
"description": "Shown as a menu item in the context menu of a story reply, to the author of the reply, for deleting the reply for everyone"
|
||||||
|
},
|
||||||
"StoryListItem__label": {
|
"StoryListItem__label": {
|
||||||
"message": "Story",
|
"message": "Story",
|
||||||
"description": "aria-label for the story list button"
|
"description": "aria-label for the story list button"
|
||||||
|
|
|
@ -466,14 +466,14 @@ $message-padding-horizontal: 12px;
|
||||||
@include light-theme {
|
@include light-theme {
|
||||||
color: $color-gray-90;
|
color: $color-gray-90;
|
||||||
border: 1px solid $color-gray-25;
|
border: 1px solid $color-gray-25;
|
||||||
background-color: $color-white;
|
background-color: transparent;
|
||||||
background-image: none;
|
background-image: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include dark-theme {
|
@include dark-theme {
|
||||||
color: $color-gray-05;
|
color: $color-gray-05;
|
||||||
border: 1px solid $color-gray-75;
|
border: 1px solid $color-gray-75;
|
||||||
background-color: $color-gray-95;
|
background-color: transparent;
|
||||||
background-image: none;
|
background-image: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,7 +32,7 @@ export type OwnProps = Readonly<{
|
||||||
onClose: () => unknown;
|
onClose: () => unknown;
|
||||||
onTopOfEverything?: boolean;
|
onTopOfEverything?: boolean;
|
||||||
theme?: Theme;
|
theme?: Theme;
|
||||||
title?: string | React.ReactNode;
|
title?: React.ReactNode;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type Props = OwnProps;
|
export type Props = OwnProps;
|
||||||
|
|
|
@ -24,10 +24,11 @@ export type ContextMenuOptionType<T> = Readonly<{
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type RenderButtonProps = Readonly<{
|
type RenderButtonProps = Readonly<{
|
||||||
openMenu: (() => void) | ((ev: React.MouseEvent) => void);
|
openMenu: (ev: React.MouseEvent) => void;
|
||||||
onKeyDown: (ev: KeyboardEvent) => void;
|
onKeyDown: (ev: KeyboardEvent) => void;
|
||||||
isMenuShowing: boolean;
|
isMenuShowing: boolean;
|
||||||
ref: React.Ref<HTMLButtonElement> | null;
|
ref: React.Ref<HTMLButtonElement> | null;
|
||||||
|
menuNode: ReactNode;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type PropsType<T> = Readonly<{
|
export type PropsType<T> = Readonly<{
|
||||||
|
@ -38,7 +39,7 @@ export type PropsType<T> = Readonly<{
|
||||||
menuOptions: ReadonlyArray<ContextMenuOptionType<T>>;
|
menuOptions: ReadonlyArray<ContextMenuOptionType<T>>;
|
||||||
moduleClassName?: string;
|
moduleClassName?: string;
|
||||||
button?: () => JSX.Element;
|
button?: () => JSX.Element;
|
||||||
onClick?: () => unknown;
|
onClick?: (ev: React.MouseEvent) => unknown;
|
||||||
onMenuShowingChanged?: (value: boolean) => unknown;
|
onMenuShowingChanged?: (value: boolean) => unknown;
|
||||||
popperOptions?: Pick<Options, 'placement' | 'strategy'>;
|
popperOptions?: Pick<Options, 'placement' | 'strategy'>;
|
||||||
theme?: Theme;
|
theme?: Theme;
|
||||||
|
@ -260,42 +261,7 @@ export function ContextMenu<T>({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let buttonNode: ReactNode;
|
const menuNode = isMenuShowing ? (
|
||||||
if (typeof children === 'function') {
|
|
||||||
buttonNode = (children as (props: RenderButtonProps) => JSX.Element)({
|
|
||||||
openMenu: onClick || handleClick,
|
|
||||||
onKeyDown: handleKeyDown,
|
|
||||||
isMenuShowing,
|
|
||||||
ref: setReferenceElement,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
buttonNode = (
|
|
||||||
<button
|
|
||||||
aria-label={ariaLabel || i18n('ContextMenu--button')}
|
|
||||||
className={classNames(
|
|
||||||
getClassName('__button'),
|
|
||||||
isMenuShowing ? getClassName('__button--active') : undefined
|
|
||||||
)}
|
|
||||||
onClick={onClick || handleClick}
|
|
||||||
onContextMenu={handleClick}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
ref={setReferenceElement}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
getClassName('__container'),
|
|
||||||
theme ? themeClassName(theme) : undefined
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{buttonNode}
|
|
||||||
{isMenuShowing && (
|
|
||||||
<div className={theme ? themeClassName(theme) : undefined}>
|
<div className={theme ? themeClassName(theme) : undefined}>
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
@ -312,7 +278,43 @@ export function ContextMenu<T>({
|
||||||
{optionElements}
|
{optionElements}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
|
let buttonNode: JSX.Element;
|
||||||
|
|
||||||
|
if (typeof children === 'function') {
|
||||||
|
buttonNode = (children as (props: RenderButtonProps) => JSX.Element)({
|
||||||
|
openMenu: onClick || handleClick,
|
||||||
|
onKeyDown: handleKeyDown,
|
||||||
|
isMenuShowing,
|
||||||
|
ref: setReferenceElement,
|
||||||
|
menuNode,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
buttonNode = (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
getClassName('__container'),
|
||||||
|
theme ? themeClassName(theme) : undefined
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label={ariaLabel || i18n('ContextMenu--button')}
|
||||||
|
className={classNames(
|
||||||
|
getClassName('__button'),
|
||||||
|
isMenuShowing ? getClassName('__button--active') : undefined
|
||||||
|
)}
|
||||||
|
onClick={onClick || handleClick}
|
||||||
|
onContextMenu={handleClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
ref={setReferenceElement}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
{menuNode}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
return buttonNode;
|
||||||
}
|
}
|
||||||
|
|
|
@ -646,7 +646,8 @@ export const SendStoryModal = ({
|
||||||
}}
|
}}
|
||||||
theme={Theme.Dark}
|
theme={Theme.Dark}
|
||||||
>
|
>
|
||||||
{({ openMenu, onKeyDown, ref }) => (
|
{({ openMenu, onKeyDown, ref, menuNode }) => (
|
||||||
|
<div>
|
||||||
<Button
|
<Button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="SendStoryModal__new-story__button"
|
className="SendStoryModal__new-story__button"
|
||||||
|
@ -657,6 +658,8 @@ export const SendStoryModal = ({
|
||||||
>
|
>
|
||||||
{i18n('SendStoryModal__new')}
|
{i18n('SendStoryModal__new')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{menuNode}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -96,6 +96,8 @@ export type PropsType = {
|
||||||
storyViewMode: StoryViewModeType;
|
storyViewMode: StoryViewModeType;
|
||||||
toggleHasAllStoriesMuted: () => unknown;
|
toggleHasAllStoriesMuted: () => unknown;
|
||||||
viewStory: ViewStoryActionCreatorType;
|
viewStory: ViewStoryActionCreatorType;
|
||||||
|
deleteGroupStoryReply: (id: string) => void;
|
||||||
|
deleteGroupStoryReplyForEveryone: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CAPTION_BUFFER = 20;
|
const CAPTION_BUFFER = 20;
|
||||||
|
@ -141,6 +143,8 @@ export const StoryViewer = ({
|
||||||
storyViewMode,
|
storyViewMode,
|
||||||
toggleHasAllStoriesMuted,
|
toggleHasAllStoriesMuted,
|
||||||
viewStory,
|
viewStory,
|
||||||
|
deleteGroupStoryReply,
|
||||||
|
deleteGroupStoryReplyForEveryone,
|
||||||
}: PropsType): JSX.Element => {
|
}: PropsType): JSX.Element => {
|
||||||
const [isShowingContextMenu, setIsShowingContextMenu] =
|
const [isShowingContextMenu, setIsShowingContextMenu] =
|
||||||
useState<boolean>(false);
|
useState<boolean>(false);
|
||||||
|
@ -829,6 +833,8 @@ export const StoryViewer = ({
|
||||||
views={views}
|
views={views}
|
||||||
viewTarget={currentViewTarget}
|
viewTarget={currentViewTarget}
|
||||||
onChangeViewTarget={setCurrentViewTarget}
|
onChangeViewTarget={setCurrentViewTarget}
|
||||||
|
deleteGroupStoryReply={deleteGroupStoryReply}
|
||||||
|
deleteGroupStoryReplyForEveryone={deleteGroupStoryReplyForEveryone}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasConfirmHideStory && (
|
{hasConfirmHideStory && (
|
||||||
|
|
|
@ -36,22 +36,17 @@ import { WidthBreakpoint } from './_util';
|
||||||
import { getAvatarColor } from '../types/Colors';
|
import { getAvatarColor } from '../types/Colors';
|
||||||
import { getStoryReplyText } from '../util/getStoryReplyText';
|
import { getStoryReplyText } from '../util/getStoryReplyText';
|
||||||
import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled';
|
import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled';
|
||||||
|
import { ContextMenu } from './ContextMenu';
|
||||||
|
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||||
|
|
||||||
// Menu is disabled so these actions are inaccessible. We also don't support
|
// Menu is disabled so these actions are inaccessible. We also don't support
|
||||||
// link previews, tap to view messages, attachments, or gifts. Just regular
|
// link previews, tap to view messages, attachments, or gifts. Just regular
|
||||||
// text messages and reactions.
|
// text messages and reactions.
|
||||||
const MESSAGE_DEFAULT_PROPS = {
|
const MESSAGE_DEFAULT_PROPS = {
|
||||||
canDeleteForEveryone: false,
|
canDeleteForEveryone: false,
|
||||||
canDownload: false,
|
|
||||||
canReact: false,
|
|
||||||
canReply: false,
|
|
||||||
canRetry: false,
|
|
||||||
canRetryDeleteForEveryone: false,
|
|
||||||
checkForAccount: shouldNeverBeCalled,
|
checkForAccount: shouldNeverBeCalled,
|
||||||
clearSelectedMessage: shouldNeverBeCalled,
|
clearSelectedMessage: shouldNeverBeCalled,
|
||||||
containerWidthBreakpoint: WidthBreakpoint.Medium,
|
containerWidthBreakpoint: WidthBreakpoint.Medium,
|
||||||
deleteMessage: shouldNeverBeCalled,
|
|
||||||
deleteMessageForEveryone: shouldNeverBeCalled,
|
|
||||||
displayTapToViewMessage: shouldNeverBeCalled,
|
displayTapToViewMessage: shouldNeverBeCalled,
|
||||||
doubleCheckMissingQuoteReference: shouldNeverBeCalled,
|
doubleCheckMissingQuoteReference: shouldNeverBeCalled,
|
||||||
downloadAttachment: shouldNeverBeCalled,
|
downloadAttachment: shouldNeverBeCalled,
|
||||||
|
@ -65,19 +60,12 @@ const MESSAGE_DEFAULT_PROPS = {
|
||||||
openGiftBadge: shouldNeverBeCalled,
|
openGiftBadge: shouldNeverBeCalled,
|
||||||
openLink: shouldNeverBeCalled,
|
openLink: shouldNeverBeCalled,
|
||||||
previews: [],
|
previews: [],
|
||||||
reactToMessage: shouldNeverBeCalled,
|
|
||||||
renderAudioAttachment: () => <div />,
|
renderAudioAttachment: () => <div />,
|
||||||
renderEmojiPicker: () => <div />,
|
|
||||||
renderReactionPicker: () => <div />,
|
|
||||||
replyToMessage: shouldNeverBeCalled,
|
|
||||||
retryDeleteForEveryone: shouldNeverBeCalled,
|
|
||||||
retrySend: shouldNeverBeCalled,
|
|
||||||
scrollToQuotedMessage: shouldNeverBeCalled,
|
scrollToQuotedMessage: shouldNeverBeCalled,
|
||||||
showContactDetail: shouldNeverBeCalled,
|
showContactDetail: shouldNeverBeCalled,
|
||||||
showContactModal: shouldNeverBeCalled,
|
showContactModal: shouldNeverBeCalled,
|
||||||
showExpiredIncomingTapToViewToast: shouldNeverBeCalled,
|
showExpiredIncomingTapToViewToast: shouldNeverBeCalled,
|
||||||
showExpiredOutgoingTapToViewToast: shouldNeverBeCalled,
|
showExpiredOutgoingTapToViewToast: shouldNeverBeCalled,
|
||||||
showForwardMessageModal: shouldNeverBeCalled,
|
|
||||||
showMessageDetail: shouldNeverBeCalled,
|
showMessageDetail: shouldNeverBeCalled,
|
||||||
showVisualAttachment: shouldNeverBeCalled,
|
showVisualAttachment: shouldNeverBeCalled,
|
||||||
startConversation: shouldNeverBeCalled,
|
startConversation: shouldNeverBeCalled,
|
||||||
|
@ -118,6 +106,8 @@ export type PropsType = {
|
||||||
views: Array<StorySendStateType>;
|
views: Array<StorySendStateType>;
|
||||||
viewTarget: StoryViewTargetType;
|
viewTarget: StoryViewTargetType;
|
||||||
onChangeViewTarget: (target: StoryViewTargetType) => unknown;
|
onChangeViewTarget: (target: StoryViewTargetType) => unknown;
|
||||||
|
deleteGroupStoryReply: (id: string) => void;
|
||||||
|
deleteGroupStoryReplyForEveryone: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StoryViewsNRepliesModal = ({
|
export const StoryViewsNRepliesModal = ({
|
||||||
|
@ -144,7 +134,16 @@ export const StoryViewsNRepliesModal = ({
|
||||||
views,
|
views,
|
||||||
viewTarget,
|
viewTarget,
|
||||||
onChangeViewTarget,
|
onChangeViewTarget,
|
||||||
|
deleteGroupStoryReply,
|
||||||
|
deleteGroupStoryReplyForEveryone,
|
||||||
}: PropsType): JSX.Element | null => {
|
}: PropsType): JSX.Element | null => {
|
||||||
|
const [deleteReplyId, setDeleteReplyId] = useState<string | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const [deleteForEveryoneReplyId, setDeleteForEveryoneReplyId] = useState<
|
||||||
|
string | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
const containerElementRef = useRef<HTMLDivElement | null>(null);
|
const containerElementRef = useRef<HTMLDivElement | null>(null);
|
||||||
const inputApiRef = useRef<InputApi | undefined>();
|
const inputApiRef = useRef<InputApi | undefined>();
|
||||||
const shouldScrollToBottomRef = useRef(true);
|
const shouldScrollToBottomRef = useRef(true);
|
||||||
|
@ -310,64 +309,25 @@ export const StoryViewsNRepliesModal = ({
|
||||||
className="StoryViewsNRepliesModal__replies"
|
className="StoryViewsNRepliesModal__replies"
|
||||||
ref={containerElementRef}
|
ref={containerElementRef}
|
||||||
>
|
>
|
||||||
{replies.map((reply, index) =>
|
{replies.map((reply, index) => {
|
||||||
reply.reactionEmoji ? (
|
return reply.reactionEmoji ? (
|
||||||
<div className="StoryViewsNRepliesModal__reaction" key={reply.id}>
|
<Reaction
|
||||||
<div className="StoryViewsNRepliesModal__reaction--container">
|
key={reply.id}
|
||||||
<Avatar
|
|
||||||
acceptedMessageRequest={reply.author.acceptedMessageRequest}
|
|
||||||
avatarPath={reply.author.avatarPath}
|
|
||||||
badge={getPreferredBadge(reply.author.badges)}
|
|
||||||
color={getAvatarColor(reply.author.color)}
|
|
||||||
conversationType="direct"
|
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
isMe={Boolean(reply.author.isMe)}
|
reply={reply}
|
||||||
profileName={reply.author.profileName}
|
|
||||||
sharedGroupNames={reply.author.sharedGroupNames || []}
|
|
||||||
size={AvatarSize.TWENTY_EIGHT}
|
|
||||||
theme={ThemeType.dark}
|
|
||||||
title={reply.author.title}
|
|
||||||
/>
|
|
||||||
<div className="StoryViewsNRepliesModal__reaction--body">
|
|
||||||
<div className="StoryViewsNRepliesModal__reply--title">
|
|
||||||
<ContactName
|
|
||||||
contactNameColor={reply.contactNameColor}
|
|
||||||
title={
|
|
||||||
reply.author.isMe ? i18n('you') : reply.author.title
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{i18n('StoryViewsNRepliesModal__reacted')}
|
|
||||||
<MessageTimestamp
|
|
||||||
i18n={i18n}
|
|
||||||
isRelativeTime
|
|
||||||
module="StoryViewsNRepliesModal__reply--timestamp"
|
|
||||||
timestamp={reply.timestamp}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Emojify text={reply.reactionEmoji} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div key={reply.id}>
|
|
||||||
<Message
|
|
||||||
{...MESSAGE_DEFAULT_PROPS}
|
|
||||||
author={reply.author}
|
|
||||||
bodyRanges={reply.bodyRanges}
|
|
||||||
contactNameColor={reply.contactNameColor}
|
|
||||||
containerElementRef={containerElementRef}
|
|
||||||
conversationColor="ultramarine"
|
|
||||||
conversationId={reply.conversationId}
|
|
||||||
conversationTitle={reply.author.title}
|
|
||||||
conversationType="group"
|
|
||||||
direction="incoming"
|
|
||||||
disableMenu
|
|
||||||
getPreferredBadge={getPreferredBadge}
|
getPreferredBadge={getPreferredBadge}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Reply
|
||||||
|
key={reply.id}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
id={reply.id}
|
containerElementRef={containerElementRef}
|
||||||
interactionMode="mouse"
|
reply={reply}
|
||||||
readStatus={reply.readStatus}
|
deleteGroupStoryReply={() => setDeleteReplyId(reply.id)}
|
||||||
renderingContext="StoryViewsNRepliesModal"
|
deleteGroupStoryReplyForEveryone={() =>
|
||||||
|
setDeleteForEveryoneReplyId(reply.id)
|
||||||
|
}
|
||||||
|
getPreferredBadge={getPreferredBadge}
|
||||||
shouldCollapseAbove={
|
shouldCollapseAbove={
|
||||||
reply.conversationId === replies[index - 1]?.conversationId &&
|
reply.conversationId === replies[index - 1]?.conversationId &&
|
||||||
!replies[index - 1]?.reactionEmoji
|
!replies[index - 1]?.reactionEmoji
|
||||||
|
@ -376,14 +336,9 @@ export const StoryViewsNRepliesModal = ({
|
||||||
reply.conversationId === replies[index + 1]?.conversationId &&
|
reply.conversationId === replies[index + 1]?.conversationId &&
|
||||||
!replies[index + 1]?.reactionEmoji
|
!replies[index + 1]?.reactionEmoji
|
||||||
}
|
}
|
||||||
shouldHideMetadata={false}
|
|
||||||
text={reply.body}
|
|
||||||
textDirection={TextDirection.Default}
|
|
||||||
timestamp={reply.timestamp}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
);
|
||||||
)
|
})}
|
||||||
)}
|
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -483,6 +438,7 @@ export const StoryViewsNRepliesModal = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Modal
|
<Modal
|
||||||
modalName="StoryViewsNRepliesModal"
|
modalName="StoryViewsNRepliesModal"
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
@ -504,5 +460,175 @@ export const StoryViewsNRepliesModal = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
{deleteReplyId && (
|
||||||
|
<ConfirmationDialog
|
||||||
|
i18n={i18n}
|
||||||
|
theme={Theme.Dark}
|
||||||
|
dialogName="confirmDialog"
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
text: i18n('delete'),
|
||||||
|
action: () => deleteGroupStoryReply(deleteReplyId),
|
||||||
|
style: 'negative',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
title={i18n('deleteWarning')}
|
||||||
|
onClose={() => setDeleteReplyId(undefined)}
|
||||||
|
onCancel={() => setDeleteReplyId(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{deleteForEveryoneReplyId && (
|
||||||
|
<ConfirmationDialog
|
||||||
|
i18n={i18n}
|
||||||
|
theme={Theme.Dark}
|
||||||
|
dialogName="confirmDialog"
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
text: i18n('delete'),
|
||||||
|
action: () =>
|
||||||
|
deleteGroupStoryReplyForEveryone(deleteForEveryoneReplyId),
|
||||||
|
style: 'negative',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
title={i18n('deleteWarning')}
|
||||||
|
onClose={() => setDeleteForEveryoneReplyId(undefined)}
|
||||||
|
onCancel={() => setDeleteForEveryoneReplyId(undefined)}
|
||||||
|
>
|
||||||
|
{i18n('deleteForEveryoneWarning')}
|
||||||
|
</ConfirmationDialog>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReactionProps = {
|
||||||
|
i18n: LocalizerType;
|
||||||
|
reply: ReplyType;
|
||||||
|
getPreferredBadge: PreferredBadgeSelectorType;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Reaction = ({
|
||||||
|
i18n,
|
||||||
|
reply,
|
||||||
|
getPreferredBadge,
|
||||||
|
}: ReactionProps): JSX.Element => {
|
||||||
|
// TODO: DESKTOP-4503 - reactions delete/doe
|
||||||
|
return (
|
||||||
|
<div className="StoryViewsNRepliesModal__reaction" key={reply.id}>
|
||||||
|
<div className="StoryViewsNRepliesModal__reaction--container">
|
||||||
|
<Avatar
|
||||||
|
acceptedMessageRequest={reply.author.acceptedMessageRequest}
|
||||||
|
avatarPath={reply.author.avatarPath}
|
||||||
|
badge={getPreferredBadge(reply.author.badges)}
|
||||||
|
color={getAvatarColor(reply.author.color)}
|
||||||
|
conversationType="direct"
|
||||||
|
i18n={i18n}
|
||||||
|
isMe={Boolean(reply.author.isMe)}
|
||||||
|
profileName={reply.author.profileName}
|
||||||
|
sharedGroupNames={reply.author.sharedGroupNames || []}
|
||||||
|
size={AvatarSize.TWENTY_EIGHT}
|
||||||
|
theme={ThemeType.dark}
|
||||||
|
title={reply.author.title}
|
||||||
|
/>
|
||||||
|
<div className="StoryViewsNRepliesModal__reaction--body">
|
||||||
|
<div className="StoryViewsNRepliesModal__reply--title">
|
||||||
|
<ContactName
|
||||||
|
contactNameColor={reply.contactNameColor}
|
||||||
|
title={reply.author.isMe ? i18n('you') : reply.author.title}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{i18n('StoryViewsNRepliesModal__reacted')}
|
||||||
|
<MessageTimestamp
|
||||||
|
i18n={i18n}
|
||||||
|
isRelativeTime
|
||||||
|
module="StoryViewsNRepliesModal__reply--timestamp"
|
||||||
|
timestamp={reply.timestamp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Emojify text={reply.reactionEmoji} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReplyProps = {
|
||||||
|
i18n: LocalizerType;
|
||||||
|
reply: ReplyType;
|
||||||
|
deleteGroupStoryReply: (replyId: string) => void;
|
||||||
|
deleteGroupStoryReplyForEveryone: (replyId: string) => void;
|
||||||
|
getPreferredBadge: PreferredBadgeSelectorType;
|
||||||
|
shouldCollapseAbove: boolean;
|
||||||
|
shouldCollapseBelow: boolean;
|
||||||
|
containerElementRef: React.RefObject<HTMLElement>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Reply = ({
|
||||||
|
i18n,
|
||||||
|
reply,
|
||||||
|
deleteGroupStoryReply,
|
||||||
|
deleteGroupStoryReplyForEveryone,
|
||||||
|
getPreferredBadge,
|
||||||
|
shouldCollapseAbove,
|
||||||
|
shouldCollapseBelow,
|
||||||
|
containerElementRef,
|
||||||
|
}: ReplyProps): JSX.Element => {
|
||||||
|
const renderMessage = (onContextMenu?: (ev: React.MouseEvent) => void) => (
|
||||||
|
<div key={reply.id}>
|
||||||
|
<Message
|
||||||
|
{...MESSAGE_DEFAULT_PROPS}
|
||||||
|
author={reply.author}
|
||||||
|
bodyRanges={reply.bodyRanges}
|
||||||
|
contactNameColor={reply.contactNameColor}
|
||||||
|
containerElementRef={containerElementRef}
|
||||||
|
conversationColor="ultramarine"
|
||||||
|
conversationId={reply.conversationId}
|
||||||
|
conversationTitle={reply.author.title}
|
||||||
|
conversationType="group"
|
||||||
|
direction="incoming"
|
||||||
|
deletedForEveryone={reply.deletedForEveryone}
|
||||||
|
menu={undefined}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
getPreferredBadge={getPreferredBadge}
|
||||||
|
i18n={i18n}
|
||||||
|
id={reply.id}
|
||||||
|
interactionMode="mouse"
|
||||||
|
readStatus={reply.readStatus}
|
||||||
|
renderingContext="StoryViewsNRepliesModal"
|
||||||
|
shouldCollapseAbove={shouldCollapseAbove}
|
||||||
|
shouldCollapseBelow={shouldCollapseBelow}
|
||||||
|
shouldHideMetadata={false}
|
||||||
|
text={reply.body}
|
||||||
|
textDirection={TextDirection.Default}
|
||||||
|
timestamp={reply.timestamp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return reply.author.isMe ? (
|
||||||
|
<ContextMenu
|
||||||
|
i18n={i18n}
|
||||||
|
key={reply.id}
|
||||||
|
menuOptions={[
|
||||||
|
{
|
||||||
|
icon: 'module-message__context--icon module-message__context__delete-message',
|
||||||
|
label: i18n('icu:StoryViewsNRepliesModal__delete-reply'),
|
||||||
|
onClick: () => deleteGroupStoryReply(reply.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'module-message__context--icon module-message__context__delete-message-for-everyone',
|
||||||
|
label: i18n('icu:StoryViewsNRepliesModal__delete-reply-for-everyone'),
|
||||||
|
onClick: () => deleteGroupStoryReplyForEveryone(reply.id),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{({ openMenu, menuNode }) => (
|
||||||
|
<>
|
||||||
|
{renderMessage(openMenu)}
|
||||||
|
{menuNode}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
) : (
|
||||||
|
renderMessage()
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,11 +3,10 @@
|
||||||
|
|
||||||
import type { ReactNode, RefObject } from 'react';
|
import type { ReactNode, RefObject } from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM, { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import getDirection from 'direction';
|
import getDirection from 'direction';
|
||||||
import { drop, groupBy, orderBy, take, unescape } from 'lodash';
|
import { drop, groupBy, orderBy, take, unescape } from 'lodash';
|
||||||
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
|
|
||||||
import { Manager, Popper, Reference } from 'react-popper';
|
import { Manager, Popper, Reference } from 'react-popper';
|
||||||
import type { PreventOverflowModifier } from '@popperjs/core/lib/modifiers/preventOverflow';
|
import type { PreventOverflowModifier } from '@popperjs/core/lib/modifiers/preventOverflow';
|
||||||
|
|
||||||
|
@ -17,15 +16,11 @@ import type {
|
||||||
InteractionModeType,
|
InteractionModeType,
|
||||||
} from '../../state/ducks/conversations';
|
} from '../../state/ducks/conversations';
|
||||||
import type { ViewStoryActionCreatorType } from '../../state/ducks/stories';
|
import type { ViewStoryActionCreatorType } from '../../state/ducks/stories';
|
||||||
import type { TimelineItemType } from './TimelineItem';
|
|
||||||
import type { ReadStatus } from '../../messages/MessageReadStatus';
|
import type { ReadStatus } from '../../messages/MessageReadStatus';
|
||||||
import { Avatar, AvatarSize } from '../Avatar';
|
import { Avatar, AvatarSize } from '../Avatar';
|
||||||
import { AvatarSpacer } from '../AvatarSpacer';
|
import { AvatarSpacer } from '../AvatarSpacer';
|
||||||
import { Spinner } from '../Spinner';
|
import { Spinner } from '../Spinner';
|
||||||
import {
|
import { MessageBodyReadMore } from './MessageBodyReadMore';
|
||||||
doesMessageBodyOverflow,
|
|
||||||
MessageBodyReadMore,
|
|
||||||
} from './MessageBodyReadMore';
|
|
||||||
import { MessageMetadata } from './MessageMetadata';
|
import { MessageMetadata } from './MessageMetadata';
|
||||||
import { MessageTextMetadataSpacer } from './MessageTextMetadataSpacer';
|
import { MessageTextMetadataSpacer } from './MessageTextMetadataSpacer';
|
||||||
import { ImageGrid } from './ImageGrid';
|
import { ImageGrid } from './ImageGrid';
|
||||||
|
@ -37,16 +32,14 @@ import { Quote } from './Quote';
|
||||||
import { EmbeddedContact } from './EmbeddedContact';
|
import { EmbeddedContact } from './EmbeddedContact';
|
||||||
import type { OwnProps as ReactionViewerProps } from './ReactionViewer';
|
import type { OwnProps as ReactionViewerProps } from './ReactionViewer';
|
||||||
import { ReactionViewer } from './ReactionViewer';
|
import { ReactionViewer } from './ReactionViewer';
|
||||||
import type { Props as ReactionPickerProps } from './ReactionPicker';
|
|
||||||
import { Emoji } from '../emoji/Emoji';
|
import { Emoji } from '../emoji/Emoji';
|
||||||
import { LinkPreviewDate } from './LinkPreviewDate';
|
import { LinkPreviewDate } from './LinkPreviewDate';
|
||||||
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
|
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
|
||||||
import { shouldUseFullSizeLinkPreviewImage } from '../../linkPreviews/shouldUseFullSizeLinkPreviewImage';
|
import { shouldUseFullSizeLinkPreviewImage } from '../../linkPreviews/shouldUseFullSizeLinkPreviewImage';
|
||||||
import { WidthBreakpoint } from '../_util';
|
import type { WidthBreakpoint } from '../_util';
|
||||||
import { OutgoingGiftBadgeModal } from '../OutgoingGiftBadgeModal';
|
import { OutgoingGiftBadgeModal } from '../OutgoingGiftBadgeModal';
|
||||||
import * as log from '../../logging/log';
|
import * as log from '../../logging/log';
|
||||||
import { StoryViewModeType } from '../../types/Stories';
|
import { StoryViewModeType } from '../../types/Stories';
|
||||||
|
|
||||||
import type { AttachmentType } from '../../types/Attachment';
|
import type { AttachmentType } from '../../types/Attachment';
|
||||||
import {
|
import {
|
||||||
canDisplayImage,
|
canDisplayImage,
|
||||||
|
@ -84,21 +77,13 @@ import type {
|
||||||
import { createRefMerger } from '../../util/refMerger';
|
import { createRefMerger } from '../../util/refMerger';
|
||||||
import { emojiToData, getEmojiCount } from '../emoji/lib';
|
import { emojiToData, getEmojiCount } from '../emoji/lib';
|
||||||
import { isEmojiOnlyText } from '../../util/isEmojiOnlyText';
|
import { isEmojiOnlyText } from '../../util/isEmojiOnlyText';
|
||||||
import type { SmartReactionPicker } from '../../state/smart/ReactionPicker';
|
|
||||||
import { getCustomColorStyle } from '../../util/getCustomColorStyle';
|
import { getCustomColorStyle } from '../../util/getCustomColorStyle';
|
||||||
import { offsetDistanceModifier } from '../../util/popperUtil';
|
|
||||||
import * as KeyboardLayout from '../../services/keyboardLayout';
|
|
||||||
import { StopPropagation } from '../StopPropagation';
|
|
||||||
import type { UUIDStringType } from '../../types/UUID';
|
import type { UUIDStringType } from '../../types/UUID';
|
||||||
import { DAY, HOUR, MINUTE, SECOND } from '../../util/durations';
|
import { DAY, HOUR, MINUTE, SECOND } from '../../util/durations';
|
||||||
import { BadgeImageTheme } from '../../badges/BadgeImageTheme';
|
import { BadgeImageTheme } from '../../badges/BadgeImageTheme';
|
||||||
import { getBadgeImageFileLocalPath } from '../../badges/getBadgeImageFileLocalPath';
|
import { getBadgeImageFileLocalPath } from '../../badges/getBadgeImageFileLocalPath';
|
||||||
import { handleOutsideClick } from '../../util/handleOutsideClick';
|
import { handleOutsideClick } from '../../util/handleOutsideClick';
|
||||||
|
|
||||||
type Trigger = {
|
|
||||||
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 10;
|
const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 10;
|
||||||
const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18;
|
const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18;
|
||||||
const GUESS_METADATA_WIDTH_OUTGOING_SIZE: Record<MessageStatusType, number> = {
|
const GUESS_METADATA_WIDTH_OUTGOING_SIZE: Record<MessageStatusType, number> = {
|
||||||
|
@ -270,37 +255,30 @@ export type PropsData = {
|
||||||
expirationTimestamp?: number;
|
expirationTimestamp?: number;
|
||||||
|
|
||||||
reactions?: ReactionViewerProps['reactions'];
|
reactions?: ReactionViewerProps['reactions'];
|
||||||
selectedReaction?: string;
|
|
||||||
|
|
||||||
deletedForEveryone?: boolean;
|
deletedForEveryone?: boolean;
|
||||||
|
|
||||||
canRetry: boolean;
|
|
||||||
canRetryDeleteForEveryone: boolean;
|
|
||||||
canReact: boolean;
|
|
||||||
canReply: boolean;
|
|
||||||
canDownload: boolean;
|
|
||||||
canDeleteForEveryone: boolean;
|
canDeleteForEveryone: boolean;
|
||||||
isBlocked: boolean;
|
isBlocked: boolean;
|
||||||
isMessageRequestAccepted: boolean;
|
isMessageRequestAccepted: boolean;
|
||||||
bodyRanges?: BodyRangesType;
|
bodyRanges?: BodyRangesType;
|
||||||
|
|
||||||
|
menu: JSX.Element | undefined;
|
||||||
|
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PropsHousekeeping = {
|
export type PropsHousekeeping = {
|
||||||
containerElementRef: RefObject<HTMLElement>;
|
containerElementRef: RefObject<HTMLElement>;
|
||||||
containerWidthBreakpoint: WidthBreakpoint;
|
containerWidthBreakpoint: WidthBreakpoint;
|
||||||
disableMenu?: boolean;
|
|
||||||
disableScroll?: boolean;
|
disableScroll?: boolean;
|
||||||
getPreferredBadge: PreferredBadgeSelectorType;
|
getPreferredBadge: PreferredBadgeSelectorType;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
interactionMode: InteractionModeType;
|
interactionMode: InteractionModeType;
|
||||||
item?: TimelineItemType;
|
|
||||||
renderAudioAttachment: (props: AudioAttachmentProps) => JSX.Element;
|
renderAudioAttachment: (props: AudioAttachmentProps) => JSX.Element;
|
||||||
renderReactionPicker: (
|
|
||||||
props: React.ComponentProps<typeof SmartReactionPicker>
|
|
||||||
) => JSX.Element;
|
|
||||||
shouldCollapseAbove: boolean;
|
shouldCollapseAbove: boolean;
|
||||||
shouldCollapseBelow: boolean;
|
shouldCollapseBelow: boolean;
|
||||||
shouldHideMetadata: boolean;
|
shouldHideMetadata: boolean;
|
||||||
|
onContextMenu?: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
theme: ThemeType;
|
theme: ThemeType;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -310,16 +288,6 @@ export type PropsActions = {
|
||||||
messageExpanded: (id: string, displayLimit: number) => unknown;
|
messageExpanded: (id: string, displayLimit: number) => unknown;
|
||||||
checkForAccount: (phoneNumber: string) => unknown;
|
checkForAccount: (phoneNumber: string) => unknown;
|
||||||
|
|
||||||
reactToMessage: (
|
|
||||||
id: string,
|
|
||||||
{ emoji, remove }: { emoji: string; remove: boolean }
|
|
||||||
) => void;
|
|
||||||
replyToMessage: (id: string) => void;
|
|
||||||
retryDeleteForEveryone: (id: string) => void;
|
|
||||||
retrySend: (id: string) => void;
|
|
||||||
showForwardMessageModal: (id: string) => void;
|
|
||||||
deleteMessage: (id: string) => void;
|
|
||||||
deleteMessageForEveryone: (id: string) => void;
|
|
||||||
showMessageDetail: (id: string) => void;
|
showMessageDetail: (id: string) => void;
|
||||||
|
|
||||||
startConversation: (e164: string, uuid: UUIDStringType) => void;
|
startConversation: (e164: string, uuid: UUIDStringType) => void;
|
||||||
|
@ -366,10 +334,7 @@ export type PropsActions = {
|
||||||
viewStory: ViewStoryActionCreatorType;
|
viewStory: ViewStoryActionCreatorType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Props = PropsData &
|
export type Props = PropsData & PropsHousekeeping & PropsActions;
|
||||||
PropsHousekeeping &
|
|
||||||
PropsActions &
|
|
||||||
Pick<ReactionPickerProps, 'renderEmojiPicker'>;
|
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
metadataWidth: number;
|
metadataWidth: number;
|
||||||
|
@ -383,8 +348,6 @@ type State = {
|
||||||
|
|
||||||
reactionViewerRoot: HTMLDivElement | null;
|
reactionViewerRoot: HTMLDivElement | null;
|
||||||
reactionViewerOutsideClickDestructor?: () => void;
|
reactionViewerOutsideClickDestructor?: () => void;
|
||||||
reactionPickerRoot: HTMLDivElement | null;
|
|
||||||
reactionPickerOutsideClickDestructor?: () => void;
|
|
||||||
|
|
||||||
giftBadgeCounter: number | null;
|
giftBadgeCounter: number | null;
|
||||||
showOutgoingGiftBadgeModal: boolean;
|
showOutgoingGiftBadgeModal: boolean;
|
||||||
|
@ -393,8 +356,6 @@ type State = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export class Message extends React.PureComponent<Props, State> {
|
export class Message extends React.PureComponent<Props, State> {
|
||||||
public menuTriggerRef: Trigger | undefined;
|
|
||||||
|
|
||||||
public focusRef: React.RefObject<HTMLDivElement> = React.createRef();
|
public focusRef: React.RefObject<HTMLDivElement> = React.createRef();
|
||||||
|
|
||||||
public audioButtonRef: React.RefObject<HTMLButtonElement> = React.createRef();
|
public audioButtonRef: React.RefObject<HTMLButtonElement> = React.createRef();
|
||||||
|
@ -428,7 +389,6 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
prevSelectedCounter: props.isSelectedCounter,
|
prevSelectedCounter: props.isSelectedCounter,
|
||||||
|
|
||||||
reactionViewerRoot: null,
|
reactionViewerRoot: null,
|
||||||
reactionPickerRoot: null,
|
|
||||||
|
|
||||||
giftBadgeCounter: null,
|
giftBadgeCounter: null,
|
||||||
showOutgoingGiftBadgeModal: false,
|
showOutgoingGiftBadgeModal: false,
|
||||||
|
@ -466,27 +426,14 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
return Boolean(reactions && reactions.length);
|
return Boolean(reactions && reactions.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
public captureMenuTrigger = (triggerRef: Trigger): void => {
|
public handleFocus = (): void => {
|
||||||
this.menuTriggerRef = triggerRef;
|
const { interactionMode, isSelected } = this.props;
|
||||||
};
|
|
||||||
|
|
||||||
public showMenu = (event: React.MouseEvent<HTMLDivElement>): void => {
|
if (interactionMode === 'keyboard' && !isSelected) {
|
||||||
if (this.menuTriggerRef) {
|
this.setSelected();
|
||||||
this.menuTriggerRef.handleContextClick(event);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
public showContextMenu = (event: React.MouseEvent<HTMLDivElement>): void => {
|
|
||||||
const selection = window.getSelection();
|
|
||||||
if (selection && !selection.isCollapsed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.target instanceof HTMLAnchorElement) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.showMenu(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
public handleImageError = (): void => {
|
public handleImageError = (): void => {
|
||||||
const { id } = this.props;
|
const { id } = this.props;
|
||||||
log.info(
|
log.info(
|
||||||
|
@ -497,14 +444,6 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
public handleFocus = (): void => {
|
|
||||||
const { interactionMode, isSelected } = this.props;
|
|
||||||
|
|
||||||
if (interactionMode === 'keyboard' && !isSelected) {
|
|
||||||
this.setSelected();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public setSelected = (): void => {
|
public setSelected = (): void => {
|
||||||
const { id, conversationId, selectMessage } = this.props;
|
const { id, conversationId, selectMessage } = this.props;
|
||||||
|
|
||||||
|
@ -559,7 +498,6 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
clearTimeoutIfNecessary(this.deleteForEveryoneTimeout);
|
clearTimeoutIfNecessary(this.deleteForEveryoneTimeout);
|
||||||
clearTimeoutIfNecessary(this.giftBadgeInterval);
|
clearTimeoutIfNecessary(this.giftBadgeInterval);
|
||||||
this.toggleReactionViewer(true);
|
this.toggleReactionViewer(true);
|
||||||
this.toggleReactionPicker(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override componentDidUpdate(prevProps: Readonly<Props>): void {
|
public override componentDidUpdate(prevProps: Readonly<Props>): void {
|
||||||
|
@ -711,12 +649,6 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
return Math.max(timestamp - Date.now() + THREE_HOURS, 0);
|
return Math.max(timestamp - Date.now() + THREE_HOURS, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private canDeleteForEveryone(): boolean {
|
|
||||||
const { canDeleteForEveryone } = this.props;
|
|
||||||
const { hasDeleteForEveryoneTimerExpired } = this.state;
|
|
||||||
return canDeleteForEveryone && !hasDeleteForEveryoneTimerExpired;
|
|
||||||
}
|
|
||||||
|
|
||||||
private startDeleteForEveryoneTimerIfApplicable(): void {
|
private startDeleteForEveryoneTimerIfApplicable(): void {
|
||||||
const { canDeleteForEveryone } = this.props;
|
const { canDeleteForEveryone } = this.props;
|
||||||
const { hasDeleteForEveryoneTimerExpired } = this.state;
|
const { hasDeleteForEveryoneTimerExpired } = this.state;
|
||||||
|
@ -917,7 +849,6 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
theme,
|
theme,
|
||||||
timestamp,
|
timestamp,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const { imageBroken } = this.state;
|
const { imageBroken } = this.state;
|
||||||
|
|
||||||
const collapseMetadata =
|
const collapseMetadata =
|
||||||
|
@ -1814,382 +1745,6 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderMenu(triggerId: string): ReactNode {
|
|
||||||
const {
|
|
||||||
attachments,
|
|
||||||
canDownload,
|
|
||||||
canReact,
|
|
||||||
canReply,
|
|
||||||
direction,
|
|
||||||
disableMenu,
|
|
||||||
i18n,
|
|
||||||
id,
|
|
||||||
isSticker,
|
|
||||||
isTapToView,
|
|
||||||
reactToMessage,
|
|
||||||
renderEmojiPicker,
|
|
||||||
renderReactionPicker,
|
|
||||||
replyToMessage,
|
|
||||||
selectedReaction,
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
if (disableMenu) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { reactionPickerRoot } = this.state;
|
|
||||||
|
|
||||||
const multipleAttachments = attachments && attachments.length > 1;
|
|
||||||
const firstAttachment = attachments && attachments[0];
|
|
||||||
|
|
||||||
const downloadButton =
|
|
||||||
!isSticker &&
|
|
||||||
!multipleAttachments &&
|
|
||||||
!isTapToView &&
|
|
||||||
firstAttachment &&
|
|
||||||
!firstAttachment.pending ? (
|
|
||||||
// This a menu meant for mouse use only
|
|
||||||
// eslint-disable-next-line max-len
|
|
||||||
// eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events
|
|
||||||
<div
|
|
||||||
onClick={this.openGenericAttachment}
|
|
||||||
role="button"
|
|
||||||
aria-label={i18n('downloadAttachment')}
|
|
||||||
className={classNames(
|
|
||||||
'module-message__buttons__download',
|
|
||||||
`module-message__buttons__download--${direction}`
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
const reactButton = (
|
|
||||||
<Reference>
|
|
||||||
{({ ref: popperRef }) => {
|
|
||||||
// Only attach the popper reference to the reaction button if it is
|
|
||||||
// visible (it is hidden when the timeline is narrow)
|
|
||||||
const maybePopperRef = this.isWindowWidthNotNarrow()
|
|
||||||
? popperRef
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
// This a menu meant for mouse use only
|
|
||||||
// eslint-disable-next-line max-len
|
|
||||||
// eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events
|
|
||||||
<div
|
|
||||||
ref={maybePopperRef}
|
|
||||||
onClick={(event: React.MouseEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
this.toggleReactionPicker();
|
|
||||||
}}
|
|
||||||
role="button"
|
|
||||||
className="module-message__buttons__react"
|
|
||||||
aria-label={i18n('reactToMessage')}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Reference>
|
|
||||||
);
|
|
||||||
|
|
||||||
const replyButton = (
|
|
||||||
// This a menu meant for mouse use only
|
|
||||||
// eslint-disable-next-line max-len
|
|
||||||
// eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events
|
|
||||||
<div
|
|
||||||
onClick={(event: React.MouseEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
replyToMessage(id);
|
|
||||||
}}
|
|
||||||
// This a menu meant for mouse use only
|
|
||||||
role="button"
|
|
||||||
aria-label={i18n('replyToMessage')}
|
|
||||||
className={classNames(
|
|
||||||
'module-message__buttons__reply',
|
|
||||||
`module-message__buttons__download--${direction}`
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// This a menu meant for mouse use only
|
|
||||||
/* eslint-disable jsx-a11y/interactive-supports-focus */
|
|
||||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
|
||||||
const menuButton = (
|
|
||||||
<Reference>
|
|
||||||
{({ ref: popperRef }) => {
|
|
||||||
// Only attach the popper reference to the collapsed menu button if the reaction
|
|
||||||
// button is not visible (it is hidden when the timeline is narrow)
|
|
||||||
const maybePopperRef = !this.isWindowWidthNotNarrow()
|
|
||||||
? popperRef
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StopPropagation className="module-message__buttons__menu--container">
|
|
||||||
<ContextMenuTrigger
|
|
||||||
id={triggerId}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
ref={this.captureMenuTrigger as any}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={maybePopperRef}
|
|
||||||
role="button"
|
|
||||||
onClick={this.showMenu}
|
|
||||||
aria-label={i18n('messageContextMenuButton')}
|
|
||||||
className={classNames(
|
|
||||||
'module-message__buttons__menu',
|
|
||||||
`module-message__buttons__download--${direction}`
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</ContextMenuTrigger>
|
|
||||||
</StopPropagation>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Reference>
|
|
||||||
);
|
|
||||||
/* eslint-enable jsx-a11y/interactive-supports-focus */
|
|
||||||
/* eslint-enable jsx-a11y/click-events-have-key-events */
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Manager>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
'module-message__buttons',
|
|
||||||
`module-message__buttons--${direction}`
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{this.isWindowWidthNotNarrow() && (
|
|
||||||
<>
|
|
||||||
{canReact ? reactButton : null}
|
|
||||||
{canDownload ? downloadButton : null}
|
|
||||||
{canReply ? replyButton : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{menuButton}
|
|
||||||
</div>
|
|
||||||
{reactionPickerRoot &&
|
|
||||||
createPortal(
|
|
||||||
<Popper
|
|
||||||
placement="top"
|
|
||||||
modifiers={[
|
|
||||||
offsetDistanceModifier(4),
|
|
||||||
this.popperPreventOverflowModifier(),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{({ ref, style }) =>
|
|
||||||
renderReactionPicker({
|
|
||||||
ref,
|
|
||||||
style,
|
|
||||||
selected: selectedReaction,
|
|
||||||
onClose: this.toggleReactionPicker,
|
|
||||||
onPick: emoji => {
|
|
||||||
this.toggleReactionPicker(true);
|
|
||||||
reactToMessage(id, {
|
|
||||||
emoji,
|
|
||||||
remove: emoji === selectedReaction,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
renderEmojiPicker,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</Popper>,
|
|
||||||
reactionPickerRoot
|
|
||||||
)}
|
|
||||||
</Manager>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public renderContextMenu(triggerId: string): JSX.Element {
|
|
||||||
const {
|
|
||||||
attachments,
|
|
||||||
canDownload,
|
|
||||||
contact,
|
|
||||||
canReact,
|
|
||||||
canReply,
|
|
||||||
canRetry,
|
|
||||||
canRetryDeleteForEveryone,
|
|
||||||
deleteMessage,
|
|
||||||
deleteMessageForEveryone,
|
|
||||||
deletedForEveryone,
|
|
||||||
giftBadge,
|
|
||||||
i18n,
|
|
||||||
id,
|
|
||||||
isSticker,
|
|
||||||
isTapToView,
|
|
||||||
replyToMessage,
|
|
||||||
retrySend,
|
|
||||||
retryDeleteForEveryone,
|
|
||||||
showForwardMessageModal,
|
|
||||||
showMessageDetail,
|
|
||||||
text,
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const canForward =
|
|
||||||
!isTapToView && !deletedForEveryone && !giftBadge && !contact;
|
|
||||||
const multipleAttachments = attachments && attachments.length > 1;
|
|
||||||
|
|
||||||
const shouldShowAdditional =
|
|
||||||
doesMessageBodyOverflow(text || '') || !this.isWindowWidthNotNarrow();
|
|
||||||
|
|
||||||
const menu = (
|
|
||||||
<ContextMenu id={triggerId}>
|
|
||||||
{canDownload &&
|
|
||||||
shouldShowAdditional &&
|
|
||||||
!isSticker &&
|
|
||||||
!multipleAttachments &&
|
|
||||||
!isTapToView &&
|
|
||||||
attachments &&
|
|
||||||
attachments[0] ? (
|
|
||||||
<MenuItem
|
|
||||||
attributes={{
|
|
||||||
className:
|
|
||||||
'module-message__context--icon module-message__context__download',
|
|
||||||
}}
|
|
||||||
onClick={this.openGenericAttachment}
|
|
||||||
>
|
|
||||||
{i18n('downloadAttachment')}
|
|
||||||
</MenuItem>
|
|
||||||
) : null}
|
|
||||||
{shouldShowAdditional ? (
|
|
||||||
<>
|
|
||||||
{canReply && (
|
|
||||||
<MenuItem
|
|
||||||
attributes={{
|
|
||||||
className:
|
|
||||||
'module-message__context--icon module-message__context__reply',
|
|
||||||
}}
|
|
||||||
onClick={(event: React.MouseEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
replyToMessage(id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i18n('replyToMessage')}
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
{canReact && (
|
|
||||||
<MenuItem
|
|
||||||
attributes={{
|
|
||||||
className:
|
|
||||||
'module-message__context--icon module-message__context__react',
|
|
||||||
}}
|
|
||||||
onClick={(event: React.MouseEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
this.toggleReactionPicker();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i18n('reactToMessage')}
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<MenuItem
|
|
||||||
attributes={{
|
|
||||||
className:
|
|
||||||
'module-message__context--icon module-message__context__more-info',
|
|
||||||
}}
|
|
||||||
onClick={(event: React.MouseEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
showMessageDetail(id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i18n('moreInfo')}
|
|
||||||
</MenuItem>
|
|
||||||
{canRetry ? (
|
|
||||||
<MenuItem
|
|
||||||
attributes={{
|
|
||||||
className:
|
|
||||||
'module-message__context--icon module-message__context__retry-send',
|
|
||||||
}}
|
|
||||||
onClick={(event: React.MouseEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
retrySend(id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i18n('retrySend')}
|
|
||||||
</MenuItem>
|
|
||||||
) : null}
|
|
||||||
{canRetryDeleteForEveryone ? (
|
|
||||||
<MenuItem
|
|
||||||
attributes={{
|
|
||||||
className:
|
|
||||||
'module-message__context--icon module-message__context__delete-message-for-everyone',
|
|
||||||
}}
|
|
||||||
onClick={(event: React.MouseEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
retryDeleteForEveryone(id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i18n('retryDeleteForEveryone')}
|
|
||||||
</MenuItem>
|
|
||||||
) : null}
|
|
||||||
{canForward ? (
|
|
||||||
<MenuItem
|
|
||||||
attributes={{
|
|
||||||
className:
|
|
||||||
'module-message__context--icon module-message__context__forward-message',
|
|
||||||
}}
|
|
||||||
onClick={(event: React.MouseEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
showForwardMessageModal(id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i18n('forwardMessage')}
|
|
||||||
</MenuItem>
|
|
||||||
) : null}
|
|
||||||
<MenuItem
|
|
||||||
attributes={{
|
|
||||||
className:
|
|
||||||
'module-message__context--icon module-message__context__delete-message',
|
|
||||||
}}
|
|
||||||
onClick={(event: React.MouseEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
deleteMessage(id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i18n('deleteMessage')}
|
|
||||||
</MenuItem>
|
|
||||||
{this.canDeleteForEveryone() ? (
|
|
||||||
<MenuItem
|
|
||||||
attributes={{
|
|
||||||
className:
|
|
||||||
'module-message__context--icon module-message__context__delete-message-for-everyone',
|
|
||||||
}}
|
|
||||||
onClick={(event: React.MouseEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
deleteMessageForEveryone(id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i18n('deleteMessageForEveryone')}
|
|
||||||
</MenuItem>
|
|
||||||
) : null}
|
|
||||||
</ContextMenu>
|
|
||||||
);
|
|
||||||
|
|
||||||
return ReactDOM.createPortal(menu, document.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
private isWindowWidthNotNarrow(): boolean {
|
|
||||||
const { containerWidthBreakpoint } = this.props;
|
|
||||||
return containerWidthBreakpoint !== WidthBreakpoint.Narrow;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getWidth(): number | undefined {
|
public getWidth(): number | undefined {
|
||||||
const { attachments, giftBadge, isSticker, previews } = this.props;
|
const { attachments, giftBadge, isSticker, previews } = this.props;
|
||||||
|
|
||||||
|
@ -2420,42 +1975,6 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
public toggleReactionPicker = (onlyRemove = false): void => {
|
|
||||||
this.setState(oldState => {
|
|
||||||
const { reactionPickerRoot } = oldState;
|
|
||||||
if (reactionPickerRoot) {
|
|
||||||
document.body.removeChild(reactionPickerRoot);
|
|
||||||
|
|
||||||
oldState.reactionPickerOutsideClickDestructor?.();
|
|
||||||
|
|
||||||
return {
|
|
||||||
reactionPickerRoot: null,
|
|
||||||
reactionPickerOutsideClickDestructor: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!onlyRemove) {
|
|
||||||
const root = document.createElement('div');
|
|
||||||
document.body.appendChild(root);
|
|
||||||
|
|
||||||
const reactionPickerOutsideClickDestructor = handleOutsideClick(
|
|
||||||
() => {
|
|
||||||
this.toggleReactionPicker(true);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{ containerElements: [root], name: 'Message.reactionPicker' }
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
reactionPickerRoot: root,
|
|
||||||
reactionPickerOutsideClickDestructor,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
public renderReactions(outgoing: boolean): JSX.Element | null {
|
public renderReactions(outgoing: boolean): JSX.Element | null {
|
||||||
const { getPreferredBadge, reactions = [], i18n, theme } = this.props;
|
const { getPreferredBadge, reactions = [], i18n, theme } = this.props;
|
||||||
|
|
||||||
|
@ -2844,28 +2363,6 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
public handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
|
|
||||||
// Do not allow reactions to error messages
|
|
||||||
const { canReact } = this.props;
|
|
||||||
|
|
||||||
const key = KeyboardLayout.lookup(event.nativeEvent);
|
|
||||||
|
|
||||||
if (
|
|
||||||
(key === 'E' || key === 'e') &&
|
|
||||||
(event.metaKey || event.ctrlKey) &&
|
|
||||||
event.shiftKey &&
|
|
||||||
canReact
|
|
||||||
) {
|
|
||||||
this.toggleReactionPicker();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key !== 'Enter' && event.key !== 'Space') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.handleOpen(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
public handleClick = (event: React.MouseEvent): void => {
|
public handleClick = (event: React.MouseEvent): void => {
|
||||||
// We don't want clicks on body text to result in the 'default action' for the message
|
// We don't want clicks on body text to result in the 'default action' for the message
|
||||||
const { text } = this.props;
|
const { text } = this.props;
|
||||||
|
@ -2888,6 +2385,8 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
isTapToView,
|
isTapToView,
|
||||||
isTapToViewExpired,
|
isTapToViewExpired,
|
||||||
isTapToViewError,
|
isTapToViewError,
|
||||||
|
onContextMenu,
|
||||||
|
onKeyDown,
|
||||||
text,
|
text,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { isSelected } = this.state;
|
const { isSelected } = this.state;
|
||||||
|
@ -2947,9 +2446,9 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
<div
|
<div
|
||||||
className={containerClassnames}
|
className={containerClassnames}
|
||||||
style={containerStyles}
|
style={containerStyles}
|
||||||
onContextMenu={this.showContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
role="row"
|
role="row"
|
||||||
onKeyDown={this.handleKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onClick={this.handleClick}
|
onClick={this.handleClick}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
|
@ -2963,20 +2462,15 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
|
|
||||||
public override render(): JSX.Element | null {
|
public override render(): JSX.Element | null {
|
||||||
const {
|
const {
|
||||||
author,
|
|
||||||
attachments,
|
attachments,
|
||||||
direction,
|
direction,
|
||||||
id,
|
|
||||||
isSticker,
|
isSticker,
|
||||||
shouldCollapseAbove,
|
shouldCollapseAbove,
|
||||||
shouldCollapseBelow,
|
shouldCollapseBelow,
|
||||||
timestamp,
|
menu,
|
||||||
|
onKeyDown,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { expired, expiring, imageBroken, isSelected } = this.state;
|
const { expired, expiring, isSelected, imageBroken } = this.state;
|
||||||
|
|
||||||
// This id is what connects our triple-dot click with our associated pop-up menu.
|
|
||||||
// It needs to be unique.
|
|
||||||
const triggerId = String(id || `${author.id}-${timestamp}`);
|
|
||||||
|
|
||||||
if (expired) {
|
if (expired) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -3000,15 +2494,14 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
// We need to have a role because screenreaders need to be able to focus here to
|
// We need to have a role because screenreaders need to be able to focus here to
|
||||||
// read the message, but we can't be a button; that would break inner buttons.
|
// read the message, but we can't be a button; that would break inner buttons.
|
||||||
role="row"
|
role="row"
|
||||||
onKeyDown={this.handleKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onFocus={this.handleFocus}
|
onFocus={this.handleFocus}
|
||||||
ref={this.focusRef}
|
ref={this.focusRef}
|
||||||
>
|
>
|
||||||
{this.renderError()}
|
{this.renderError()}
|
||||||
{this.renderAvatar()}
|
{this.renderAvatar()}
|
||||||
{this.renderContainer()}
|
{this.renderContainer()}
|
||||||
{this.renderMenu(triggerId)}
|
{menu}
|
||||||
{this.renderContextMenu(triggerId)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React, { useCallback, useRef, useEffect, useState } from 'react';
|
import React, { useCallback, useRef, useEffect, useState } from 'react';
|
||||||
|
import type { RefObject, ReactNode } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { noop } from 'lodash';
|
import { noop } from 'lodash';
|
||||||
import { animated, useSpring } from '@react-spring/web';
|
import { animated, useSpring } from '@react-spring/web';
|
||||||
|
@ -18,6 +19,7 @@ import type { ActiveAudioPlayerStateType } from '../../state/ducks/audioPlayer';
|
||||||
|
|
||||||
export type OwnProps = Readonly<{
|
export type OwnProps = Readonly<{
|
||||||
active: ActiveAudioPlayerStateType | undefined;
|
active: ActiveAudioPlayerStateType | undefined;
|
||||||
|
buttonRef: RefObject<HTMLButtonElement>;
|
||||||
renderingContext: string;
|
renderingContext: string;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
attachment: AttachmentType;
|
attachment: AttachmentType;
|
||||||
|
@ -66,6 +68,7 @@ type ButtonProps = {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
onMouseDown?: () => void;
|
onMouseDown?: () => void;
|
||||||
onMouseUp?: () => void;
|
onMouseUp?: () => void;
|
||||||
|
children?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
enum State {
|
enum State {
|
||||||
|
@ -122,7 +125,8 @@ const timeToText = (time: number): string => {
|
||||||
* Handles animations, key events, and stoping event propagation
|
* Handles animations, key events, and stoping event propagation
|
||||||
* for play button and playback rate button
|
* for play button and playback rate button
|
||||||
*/
|
*/
|
||||||
const Button: React.FC<ButtonProps> = props => {
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
(props, ref) => {
|
||||||
const {
|
const {
|
||||||
i18n,
|
i18n,
|
||||||
variant,
|
variant,
|
||||||
|
@ -138,7 +142,8 @@ const Button: React.FC<ButtonProps> = props => {
|
||||||
const [animProps] = useSpring(
|
const [animProps] = useSpring(
|
||||||
{
|
{
|
||||||
config: SPRING_CONFIG,
|
config: SPRING_CONFIG,
|
||||||
to: isDown && animateClick ? { scale: 1.3 } : { scale: visible ? 1 : 0 },
|
to:
|
||||||
|
isDown && animateClick ? { scale: 1.3 } : { scale: visible ? 1 : 0 },
|
||||||
},
|
},
|
||||||
[visible, isDown, animateClick]
|
[visible, isDown, animateClick]
|
||||||
);
|
);
|
||||||
|
@ -172,6 +177,7 @@ const Button: React.FC<ButtonProps> = props => {
|
||||||
<animated.div style={animProps}>
|
<animated.div style={animProps}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
ref={ref}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
`${CSS_BASE}__${variant}-button`,
|
`${CSS_BASE}__${variant}-button`,
|
||||||
mod ? `${CSS_BASE}__${variant}-button--${mod}` : undefined
|
mod ? `${CSS_BASE}__${variant}-button--${mod}` : undefined
|
||||||
|
@ -188,7 +194,8 @@ const Button: React.FC<ButtonProps> = props => {
|
||||||
</button>
|
</button>
|
||||||
</animated.div>
|
</animated.div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const PlayedDot = ({
|
const PlayedDot = ({
|
||||||
played,
|
played,
|
||||||
|
@ -242,6 +249,7 @@ const PlayedDot = ({
|
||||||
export const MessageAudio: React.FC<Props> = (props: Props) => {
|
export const MessageAudio: React.FC<Props> = (props: Props) => {
|
||||||
const {
|
const {
|
||||||
active,
|
active,
|
||||||
|
buttonRef,
|
||||||
i18n,
|
i18n,
|
||||||
renderingContext,
|
renderingContext,
|
||||||
attachment,
|
attachment,
|
||||||
|
@ -506,6 +514,7 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
|
||||||
} else if (state === State.NotDownloaded) {
|
} else if (state === State.NotDownloaded) {
|
||||||
button = (
|
button = (
|
||||||
<Button
|
<Button
|
||||||
|
ref={buttonRef}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
variant="play"
|
variant="play"
|
||||||
mod="download"
|
mod="download"
|
||||||
|
@ -518,6 +527,7 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
|
||||||
// State.Normal
|
// State.Normal
|
||||||
button = (
|
button = (
|
||||||
<Button
|
<Button
|
||||||
|
ref={buttonRef}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
variant="play"
|
variant="play"
|
||||||
mod={isPlaying ? 'pause' : 'play'}
|
mod={isPlaying ? 'pause' : 'play'}
|
||||||
|
|
|
@ -29,12 +29,7 @@ const defaultMessage: MessageDataPropsType = {
|
||||||
id: 'some-id',
|
id: 'some-id',
|
||||||
title: 'Max',
|
title: 'Max',
|
||||||
}),
|
}),
|
||||||
canReact: true,
|
|
||||||
canReply: true,
|
|
||||||
canRetry: true,
|
|
||||||
canRetryDeleteForEveryone: true,
|
|
||||||
canDeleteForEveryone: true,
|
canDeleteForEveryone: true,
|
||||||
canDownload: true,
|
|
||||||
conversationColor: 'crimson',
|
conversationColor: 'crimson',
|
||||||
conversationId: 'my-convo',
|
conversationId: 'my-convo',
|
||||||
conversationTitle: 'Conversation Title',
|
conversationTitle: 'Conversation Title',
|
||||||
|
@ -42,6 +37,7 @@ const defaultMessage: MessageDataPropsType = {
|
||||||
direction: 'incoming',
|
direction: 'incoming',
|
||||||
id: 'my-message',
|
id: 'my-message',
|
||||||
renderingContext: 'storybook',
|
renderingContext: 'storybook',
|
||||||
|
menu: undefined,
|
||||||
isBlocked: false,
|
isBlocked: false,
|
||||||
isMessageRequestAccepted: true,
|
isMessageRequestAccepted: true,
|
||||||
previews: [],
|
previews: [],
|
||||||
|
@ -85,13 +81,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
openConversation: action('openConversation'),
|
openConversation: action('openConversation'),
|
||||||
openGiftBadge: action('openGiftBadge'),
|
openGiftBadge: action('openGiftBadge'),
|
||||||
openLink: action('openLink'),
|
openLink: action('openLink'),
|
||||||
reactToMessage: action('reactToMessage'),
|
|
||||||
renderAudioAttachment: () => <div>*AudioAttachment*</div>,
|
renderAudioAttachment: () => <div>*AudioAttachment*</div>,
|
||||||
renderEmojiPicker: () => <div />,
|
|
||||||
renderReactionPicker: () => <div />,
|
|
||||||
replyToMessage: action('replyToMessage'),
|
|
||||||
retrySend: action('retrySend'),
|
|
||||||
retryDeleteForEveryone: action('retryDeleteForEveryone'),
|
|
||||||
showContactDetail: action('showContactDetail'),
|
showContactDetail: action('showContactDetail'),
|
||||||
showContactModal: action('showContactModal'),
|
showContactModal: action('showContactModal'),
|
||||||
showExpiredIncomingTapToViewToast: action(
|
showExpiredIncomingTapToViewToast: action(
|
||||||
|
@ -100,7 +90,6 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
showExpiredOutgoingTapToViewToast: action(
|
showExpiredOutgoingTapToViewToast: action(
|
||||||
'showExpiredOutgoingTapToViewToast'
|
'showExpiredOutgoingTapToViewToast'
|
||||||
),
|
),
|
||||||
showForwardMessageModal: action('showForwardMessageModal'),
|
|
||||||
showVisualAttachment: action('showVisualAttachment'),
|
showVisualAttachment: action('showVisualAttachment'),
|
||||||
startConversation: action('startConversation'),
|
startConversation: action('startConversation'),
|
||||||
viewStory: action('viewStory'),
|
viewStory: action('viewStory'),
|
||||||
|
|
|
@ -57,7 +57,10 @@ export type PropsData = {
|
||||||
|
|
||||||
contactNameColor?: ContactNameColorType;
|
contactNameColor?: ContactNameColorType;
|
||||||
errors: Array<Error>;
|
errors: Array<Error>;
|
||||||
message: Omit<MessagePropsDataType, 'renderingContext'>;
|
message: Omit<
|
||||||
|
MessagePropsDataType,
|
||||||
|
'renderingContext' | 'menu' | 'contextMenu' | 'showMenu'
|
||||||
|
>;
|
||||||
receivedAt: number;
|
receivedAt: number;
|
||||||
sentAt: number;
|
sentAt: number;
|
||||||
|
|
||||||
|
@ -82,18 +85,11 @@ export type PropsBackboneActions = Pick<
|
||||||
| 'openConversation'
|
| 'openConversation'
|
||||||
| 'openGiftBadge'
|
| 'openGiftBadge'
|
||||||
| 'openLink'
|
| 'openLink'
|
||||||
| 'reactToMessage'
|
|
||||||
| 'renderAudioAttachment'
|
| 'renderAudioAttachment'
|
||||||
| 'renderEmojiPicker'
|
|
||||||
| 'renderReactionPicker'
|
|
||||||
| 'replyToMessage'
|
|
||||||
| 'retryDeleteForEveryone'
|
|
||||||
| 'retrySend'
|
|
||||||
| 'showContactDetail'
|
| 'showContactDetail'
|
||||||
| 'showContactModal'
|
| 'showContactModal'
|
||||||
| 'showExpiredIncomingTapToViewToast'
|
| 'showExpiredIncomingTapToViewToast'
|
||||||
| 'showExpiredOutgoingTapToViewToast'
|
| 'showExpiredOutgoingTapToViewToast'
|
||||||
| 'showForwardMessageModal'
|
|
||||||
| 'showVisualAttachment'
|
| 'showVisualAttachment'
|
||||||
| 'startConversation'
|
| 'startConversation'
|
||||||
>;
|
>;
|
||||||
|
@ -294,18 +290,11 @@ export class MessageDetail extends React.Component<Props> {
|
||||||
openConversation,
|
openConversation,
|
||||||
openGiftBadge,
|
openGiftBadge,
|
||||||
openLink,
|
openLink,
|
||||||
reactToMessage,
|
|
||||||
renderAudioAttachment,
|
renderAudioAttachment,
|
||||||
renderEmojiPicker,
|
|
||||||
renderReactionPicker,
|
|
||||||
replyToMessage,
|
|
||||||
retryDeleteForEveryone,
|
|
||||||
retrySend,
|
|
||||||
showContactDetail,
|
showContactDetail,
|
||||||
showContactModal,
|
showContactModal,
|
||||||
showExpiredIncomingTapToViewToast,
|
showExpiredIncomingTapToViewToast,
|
||||||
showExpiredOutgoingTapToViewToast,
|
showExpiredOutgoingTapToViewToast,
|
||||||
showForwardMessageModal,
|
|
||||||
showVisualAttachment,
|
showVisualAttachment,
|
||||||
startConversation,
|
startConversation,
|
||||||
theme,
|
theme,
|
||||||
|
@ -331,13 +320,7 @@ export class MessageDetail extends React.Component<Props> {
|
||||||
contactNameColor={contactNameColor}
|
contactNameColor={contactNameColor}
|
||||||
containerElementRef={this.messageContainerRef}
|
containerElementRef={this.messageContainerRef}
|
||||||
containerWidthBreakpoint={WidthBreakpoint.Wide}
|
containerWidthBreakpoint={WidthBreakpoint.Wide}
|
||||||
deleteMessage={() =>
|
menu={undefined}
|
||||||
log.warn('MessageDetail: deleteMessage called!')
|
|
||||||
}
|
|
||||||
deleteMessageForEveryone={() =>
|
|
||||||
log.warn('MessageDetail: deleteMessageForEveryone called!')
|
|
||||||
}
|
|
||||||
disableMenu
|
|
||||||
disableScroll
|
disableScroll
|
||||||
displayLimit={Number.MAX_SAFE_INTEGER}
|
displayLimit={Number.MAX_SAFE_INTEGER}
|
||||||
displayTapToViewMessage={displayTapToViewMessage}
|
displayTapToViewMessage={displayTapToViewMessage}
|
||||||
|
@ -355,17 +338,10 @@ export class MessageDetail extends React.Component<Props> {
|
||||||
openConversation={openConversation}
|
openConversation={openConversation}
|
||||||
openGiftBadge={openGiftBadge}
|
openGiftBadge={openGiftBadge}
|
||||||
openLink={openLink}
|
openLink={openLink}
|
||||||
reactToMessage={reactToMessage}
|
|
||||||
renderAudioAttachment={renderAudioAttachment}
|
renderAudioAttachment={renderAudioAttachment}
|
||||||
renderEmojiPicker={renderEmojiPicker}
|
|
||||||
renderReactionPicker={renderReactionPicker}
|
|
||||||
replyToMessage={replyToMessage}
|
|
||||||
retryDeleteForEveryone={retryDeleteForEveryone}
|
|
||||||
retrySend={retrySend}
|
|
||||||
shouldCollapseAbove={false}
|
shouldCollapseAbove={false}
|
||||||
shouldCollapseBelow={false}
|
shouldCollapseBelow={false}
|
||||||
shouldHideMetadata={false}
|
shouldHideMetadata={false}
|
||||||
showForwardMessageModal={showForwardMessageModal}
|
|
||||||
scrollToQuotedMessage={() => {
|
scrollToQuotedMessage={() => {
|
||||||
log.warn('MessageDetail: scrollToQuotedMessage called!');
|
log.warn('MessageDetail: scrollToQuotedMessage called!');
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -8,8 +8,9 @@ import { action } from '@storybook/addon-actions';
|
||||||
|
|
||||||
import { ConversationColors } from '../../types/Colors';
|
import { ConversationColors } from '../../types/Colors';
|
||||||
import { pngUrl } from '../../storybook/Fixtures';
|
import { pngUrl } from '../../storybook/Fixtures';
|
||||||
import type { Props as MessagesProps } from './Message';
|
import type { Props as TimelineMessagesProps } from './TimelineMessage';
|
||||||
import { Message, TextDirection } from './Message';
|
import { TimelineMessage } from './TimelineMessage';
|
||||||
|
import { TextDirection } from './Message';
|
||||||
import {
|
import {
|
||||||
AUDIO_MP3,
|
AUDIO_MP3,
|
||||||
IMAGE_PNG,
|
IMAGE_PNG,
|
||||||
|
@ -73,7 +74,7 @@ export default {
|
||||||
},
|
},
|
||||||
} as Meta;
|
} as Meta;
|
||||||
|
|
||||||
const defaultMessageProps: MessagesProps = {
|
const defaultMessageProps: TimelineMessagesProps = {
|
||||||
author: getDefaultConversation({
|
author: getDefaultConversation({
|
||||||
id: 'some-id',
|
id: 'some-id',
|
||||||
title: 'Person X',
|
title: 'Person X',
|
||||||
|
@ -103,7 +104,7 @@ const defaultMessageProps: MessagesProps = {
|
||||||
getPreferredBadge: () => undefined,
|
getPreferredBadge: () => undefined,
|
||||||
i18n,
|
i18n,
|
||||||
id: 'messageId',
|
id: 'messageId',
|
||||||
renderingContext: 'storybook',
|
// renderingContext: 'storybook',
|
||||||
interactionMode: 'keyboard',
|
interactionMode: 'keyboard',
|
||||||
isBlocked: false,
|
isBlocked: false,
|
||||||
isMessageRequestAccepted: true,
|
isMessageRequestAccepted: true,
|
||||||
|
@ -177,9 +178,9 @@ const renderInMessage = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ overflow: 'hidden' }}>
|
<div style={{ overflow: 'hidden' }}>
|
||||||
<Message {...messageProps} />
|
<TimelineMessage {...messageProps} />
|
||||||
<br />
|
<br />
|
||||||
<Message {...messageProps} direction="outgoing" />
|
<TimelineMessage {...messageProps} direction="outgoing" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -18,7 +18,7 @@ import { missingCaseError } from '../../util/missingCaseError';
|
||||||
import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary';
|
import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary';
|
||||||
import { WidthBreakpoint } from '../_util';
|
import { WidthBreakpoint } from '../_util';
|
||||||
|
|
||||||
import type { PropsActions as MessageActionsType } from './Message';
|
import type { PropsActions as MessageActionsType } from './TimelineMessage';
|
||||||
import type { PropsActions as UnsupportedMessageActionsType } from './UnsupportedMessage';
|
import type { PropsActions as UnsupportedMessageActionsType } from './UnsupportedMessage';
|
||||||
import type { PropsActionsType as ChatSessionRefreshedNotificationActionsType } from './ChatSessionRefreshedNotification';
|
import type { PropsActionsType as ChatSessionRefreshedNotificationActionsType } from './ChatSessionRefreshedNotification';
|
||||||
import type { PropsActionsType as GroupV2ChangeActionsType } from './GroupV2Change';
|
import type { PropsActionsType as GroupV2ChangeActionsType } from './GroupV2Change';
|
||||||
|
|
|
@ -10,10 +10,9 @@ import type { InteractionModeType } from '../../state/ducks/conversations';
|
||||||
import { TimelineDateHeader } from './TimelineDateHeader';
|
import { TimelineDateHeader } from './TimelineDateHeader';
|
||||||
import type {
|
import type {
|
||||||
Props as AllMessageProps,
|
Props as AllMessageProps,
|
||||||
|
PropsData as TimelineMessageProps,
|
||||||
PropsActions as MessageActionsType,
|
PropsActions as MessageActionsType,
|
||||||
PropsData as MessageProps,
|
} from './TimelineMessage';
|
||||||
} from './Message';
|
|
||||||
import { Message } from './Message';
|
|
||||||
import type { PropsActionsType as CallingNotificationActionsType } from './CallingNotification';
|
import type { PropsActionsType as CallingNotificationActionsType } from './CallingNotification';
|
||||||
import { CallingNotification } from './CallingNotification';
|
import { CallingNotification } from './CallingNotification';
|
||||||
import type { PropsActionsType as PropsChatSessionRefreshedActionsType } from './ChatSessionRefreshedNotification';
|
import type { PropsActionsType as PropsChatSessionRefreshedActionsType } from './ChatSessionRefreshedNotification';
|
||||||
|
@ -55,6 +54,7 @@ import { ResetSessionNotification } from './ResetSessionNotification';
|
||||||
import type { PropsType as ProfileChangeNotificationPropsType } from './ProfileChangeNotification';
|
import type { PropsType as ProfileChangeNotificationPropsType } from './ProfileChangeNotification';
|
||||||
import { ProfileChangeNotification } from './ProfileChangeNotification';
|
import { ProfileChangeNotification } from './ProfileChangeNotification';
|
||||||
import type { FullJSXType } from '../Intl';
|
import type { FullJSXType } from '../Intl';
|
||||||
|
import { TimelineMessage } from './TimelineMessage';
|
||||||
|
|
||||||
type CallHistoryType = {
|
type CallHistoryType = {
|
||||||
type: 'callHistory';
|
type: 'callHistory';
|
||||||
|
@ -70,7 +70,7 @@ type DeliveryIssueType = {
|
||||||
};
|
};
|
||||||
type MessageType = {
|
type MessageType = {
|
||||||
type: 'message';
|
type: 'message';
|
||||||
data: Omit<MessageProps, 'renderingContext'>;
|
data: TimelineMessageProps;
|
||||||
};
|
};
|
||||||
type UnsupportedMessageType = {
|
type UnsupportedMessageType = {
|
||||||
type: 'unsupportedMessage';
|
type: 'unsupportedMessage';
|
||||||
|
@ -208,7 +208,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
||||||
let itemContents: ReactChild;
|
let itemContents: ReactChild;
|
||||||
if (item.type === 'message') {
|
if (item.type === 'message') {
|
||||||
itemContents = (
|
itemContents = (
|
||||||
<Message
|
<TimelineMessage
|
||||||
{...this.props}
|
{...this.props}
|
||||||
{...item.data}
|
{...item.data}
|
||||||
shouldCollapseAbove={shouldCollapseAbove}
|
shouldCollapseAbove={shouldCollapseAbove}
|
||||||
|
@ -218,7 +218,6 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
||||||
getPreferredBadge={getPreferredBadge}
|
getPreferredBadge={getPreferredBadge}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
renderingContext="conversation/TimelineItem"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -11,8 +11,10 @@ import type { Meta, Story } from '@storybook/react';
|
||||||
import { SignalService } from '../../protobuf';
|
import { SignalService } from '../../protobuf';
|
||||||
import { ConversationColors } from '../../types/Colors';
|
import { ConversationColors } from '../../types/Colors';
|
||||||
import { EmojiPicker } from '../emoji/EmojiPicker';
|
import { EmojiPicker } from '../emoji/EmojiPicker';
|
||||||
import type { Props, AudioAttachmentProps } from './Message';
|
import type { AudioAttachmentProps } from './Message';
|
||||||
import { GiftBadgeStates, Message, TextDirection } from './Message';
|
import type { Props } from './TimelineMessage';
|
||||||
|
import { TimelineMessage } from './TimelineMessage';
|
||||||
|
import { GiftBadgeStates, TextDirection } from './Message';
|
||||||
import {
|
import {
|
||||||
AUDIO_MP3,
|
AUDIO_MP3,
|
||||||
IMAGE_JPEG,
|
IMAGE_JPEG,
|
||||||
|
@ -61,7 +63,7 @@ const quoteOptions = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'Components/Conversation/Message',
|
title: 'Components/Conversation/TimelineMessage',
|
||||||
argTypes: {
|
argTypes: {
|
||||||
conversationType: {
|
conversationType: {
|
||||||
control: 'select',
|
control: 'select',
|
||||||
|
@ -243,7 +245,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
deletedForEveryone: overrideProps.deletedForEveryone,
|
deletedForEveryone: overrideProps.deletedForEveryone,
|
||||||
deleteMessage: action('deleteMessage'),
|
deleteMessage: action('deleteMessage'),
|
||||||
deleteMessageForEveryone: action('deleteMessageForEveryone'),
|
deleteMessageForEveryone: action('deleteMessageForEveryone'),
|
||||||
disableMenu: overrideProps.disableMenu,
|
// disableMenu: overrideProps.disableMenu,
|
||||||
disableScroll: overrideProps.disableScroll,
|
disableScroll: overrideProps.disableScroll,
|
||||||
direction: overrideProps.direction || 'incoming',
|
direction: overrideProps.direction || 'incoming',
|
||||||
displayTapToViewMessage: action('displayTapToViewMessage'),
|
displayTapToViewMessage: action('displayTapToViewMessage'),
|
||||||
|
@ -259,7 +261,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
giftBadge: overrideProps.giftBadge,
|
giftBadge: overrideProps.giftBadge,
|
||||||
i18n,
|
i18n,
|
||||||
id: text('id', overrideProps.id || 'random-message-id'),
|
id: text('id', overrideProps.id || 'random-message-id'),
|
||||||
renderingContext: 'storybook',
|
// renderingContext: 'storybook',
|
||||||
interactionMode: overrideProps.interactionMode || 'keyboard',
|
interactionMode: overrideProps.interactionMode || 'keyboard',
|
||||||
isSticker: isBoolean(overrideProps.isSticker)
|
isSticker: isBoolean(overrideProps.isSticker)
|
||||||
? overrideProps.isSticker
|
? overrideProps.isSticker
|
||||||
|
@ -330,21 +332,13 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
viewStory: action('viewStory'),
|
viewStory: action('viewStory'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createTimelineItem = (data: undefined | Props) =>
|
|
||||||
data && {
|
|
||||||
type: 'message' as const,
|
|
||||||
data,
|
|
||||||
timestamp: data.timestamp,
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderMany = (propsArray: ReadonlyArray<Props>) => (
|
const renderMany = (propsArray: ReadonlyArray<Props>) => (
|
||||||
<>
|
<>
|
||||||
{propsArray.map((message, index) => (
|
{propsArray.map((message, index) => (
|
||||||
<Message
|
<TimelineMessage
|
||||||
key={message.text}
|
key={message.text}
|
||||||
{...message}
|
{...message}
|
||||||
shouldCollapseAbove={Boolean(propsArray[index - 1])}
|
shouldCollapseAbove={Boolean(propsArray[index - 1])}
|
||||||
item={createTimelineItem(message)}
|
|
||||||
shouldCollapseBelow={Boolean(propsArray[index + 1])}
|
shouldCollapseBelow={Boolean(propsArray[index + 1])}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
@ -380,19 +374,19 @@ PlainRtlMessage.story = {
|
||||||
|
|
||||||
export const EmojiMessages = (): JSX.Element => (
|
export const EmojiMessages = (): JSX.Element => (
|
||||||
<>
|
<>
|
||||||
<Message {...createProps({ text: '😀' })} />
|
<TimelineMessage {...createProps({ text: '😀' })} />
|
||||||
<br />
|
<br />
|
||||||
<Message {...createProps({ text: '😀😀' })} />
|
<TimelineMessage {...createProps({ text: '😀😀' })} />
|
||||||
<br />
|
<br />
|
||||||
<Message {...createProps({ text: '😀😀😀' })} />
|
<TimelineMessage {...createProps({ text: '😀😀😀' })} />
|
||||||
<br />
|
<br />
|
||||||
<Message {...createProps({ text: '😀😀😀😀' })} />
|
<TimelineMessage {...createProps({ text: '😀😀😀😀' })} />
|
||||||
<br />
|
<br />
|
||||||
<Message {...createProps({ text: '😀😀😀😀😀' })} />
|
<TimelineMessage {...createProps({ text: '😀😀😀😀😀' })} />
|
||||||
<br />
|
<br />
|
||||||
<Message {...createProps({ text: '😀😀😀😀😀😀😀' })} />
|
<TimelineMessage {...createProps({ text: '😀😀😀😀😀😀😀' })} />
|
||||||
<br />
|
<br />
|
||||||
<Message
|
<TimelineMessage
|
||||||
{...createProps({
|
{...createProps({
|
||||||
previews: [
|
previews: [
|
||||||
{
|
{
|
||||||
|
@ -416,7 +410,7 @@ export const EmojiMessages = (): JSX.Element => (
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<br />
|
<br />
|
||||||
<Message
|
<TimelineMessage
|
||||||
{...createProps({
|
{...createProps({
|
||||||
attachments: [
|
attachments: [
|
||||||
fakeAttachment({
|
fakeAttachment({
|
||||||
|
@ -431,7 +425,7 @@ export const EmojiMessages = (): JSX.Element => (
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<br />
|
<br />
|
||||||
<Message
|
<TimelineMessage
|
||||||
{...createProps({
|
{...createProps({
|
||||||
attachments: [
|
attachments: [
|
||||||
fakeAttachment({
|
fakeAttachment({
|
||||||
|
@ -444,7 +438,7 @@ export const EmojiMessages = (): JSX.Element => (
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<br />
|
<br />
|
||||||
<Message
|
<TimelineMessage
|
||||||
{...createProps({
|
{...createProps({
|
||||||
attachments: [
|
attachments: [
|
||||||
fakeAttachment({
|
fakeAttachment({
|
||||||
|
@ -457,7 +451,7 @@ export const EmojiMessages = (): JSX.Element => (
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<br />
|
<br />
|
||||||
<Message
|
<TimelineMessage
|
||||||
{...createProps({
|
{...createProps({
|
||||||
attachments: [
|
attachments: [
|
||||||
fakeAttachment({
|
fakeAttachment({
|
||||||
|
@ -779,7 +773,7 @@ DeletedWithExpireTimer.story = {
|
||||||
export const DeletedWithError = (): JSX.Element => {
|
export const DeletedWithError = (): JSX.Element => {
|
||||||
const propsPartialError = createProps({
|
const propsPartialError = createProps({
|
||||||
timestamp: Date.now() - 60 * 1000,
|
timestamp: Date.now() - 60 * 1000,
|
||||||
canDeleteForEveryone: true,
|
// canDeleteForEveryone: true,
|
||||||
conversationType: 'group',
|
conversationType: 'group',
|
||||||
deletedForEveryone: true,
|
deletedForEveryone: true,
|
||||||
status: 'partial-sent',
|
status: 'partial-sent',
|
||||||
|
@ -787,7 +781,7 @@ export const DeletedWithError = (): JSX.Element => {
|
||||||
});
|
});
|
||||||
const propsError = createProps({
|
const propsError = createProps({
|
||||||
timestamp: Date.now() - 60 * 1000,
|
timestamp: Date.now() - 60 * 1000,
|
||||||
canDeleteForEveryone: true,
|
// canDeleteForEveryone: true,
|
||||||
conversationType: 'group',
|
conversationType: 'group',
|
||||||
deletedForEveryone: true,
|
deletedForEveryone: true,
|
||||||
status: 'error',
|
status: 'error',
|
||||||
|
@ -809,7 +803,7 @@ export const CanDeleteForEveryone = Template.bind({});
|
||||||
CanDeleteForEveryone.args = {
|
CanDeleteForEveryone.args = {
|
||||||
status: 'read',
|
status: 'read',
|
||||||
text: 'I hope you get this.',
|
text: 'I hope you get this.',
|
||||||
canDeleteForEveryone: true,
|
// canDeleteForEveryone: true,
|
||||||
direction: 'outgoing',
|
direction: 'outgoing',
|
||||||
};
|
};
|
||||||
CanDeleteForEveryone.story = {
|
CanDeleteForEveryone.story = {
|
||||||
|
@ -819,7 +813,7 @@ CanDeleteForEveryone.story = {
|
||||||
export const Error = Template.bind({});
|
export const Error = Template.bind({});
|
||||||
Error.args = {
|
Error.args = {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
canRetry: true,
|
// canRetry: true,
|
||||||
text: 'I hope you get this.',
|
text: 'I hope you get this.',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1637,7 +1631,7 @@ export const AllTheContextMenus = (): JSX.Element => {
|
||||||
canRetryDeleteForEveryone: true,
|
canRetryDeleteForEveryone: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return <Message {...props} direction="outgoing" />;
|
return <TimelineMessage {...props} direction="outgoing" />;
|
||||||
};
|
};
|
||||||
AllTheContextMenus.story = {
|
AllTheContextMenus.story = {
|
||||||
name: 'All the context menus',
|
name: 'All the context menus',
|
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
|
// We're sending to Note to Self or a 'lonely group' with just us in it
|
||||||
|
// or sending a story to a group where all other users don't have the stories
|
||||||
|
// capabilities (effectively a 'lonely group' in the context of stories)
|
||||||
log.info('sending sync message only');
|
log.info('sending sync message only');
|
||||||
const dataMessage = await messaging.getDataMessage({
|
const dataMessage = await messaging.getDataMessage({
|
||||||
attachments,
|
attachments,
|
||||||
|
@ -214,7 +216,7 @@ export async function sendNormalMessage(
|
||||||
quote,
|
quote,
|
||||||
recipients: allRecipientIdentifiers,
|
recipients: allRecipientIdentifiers,
|
||||||
sticker,
|
sticker,
|
||||||
// No storyContext; you can't reply to your own stories
|
storyContext,
|
||||||
timestamp: messageTimestamp,
|
timestamp: messageTimestamp,
|
||||||
reaction,
|
reaction,
|
||||||
});
|
});
|
||||||
|
|
|
@ -14,7 +14,10 @@ import { useBoundActions } from '../../hooks/useBoundActions';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|
||||||
export type ForwardMessagePropsType = Omit<PropsForMessage, 'renderingContext'>;
|
export type ForwardMessagePropsType = Omit<
|
||||||
|
PropsForMessage,
|
||||||
|
'renderingContext' | 'menu' | 'contextMenu'
|
||||||
|
>;
|
||||||
export type SafetyNumberChangedBlockingDataType = Readonly<{
|
export type SafetyNumberChangedBlockingDataType = Readonly<{
|
||||||
promiseUuid: UUIDStringType;
|
promiseUuid: UUIDStringType;
|
||||||
source?: SafetyNumberChangeSource;
|
source?: SafetyNumberChangeSource;
|
||||||
|
|
|
@ -28,6 +28,7 @@ import { ToastReactionFailed } from '../../components/ToastReactionFailed';
|
||||||
import { assertDev } from '../../util/assert';
|
import { assertDev } from '../../util/assert';
|
||||||
import { blockSendUntilConversationsAreVerified } from '../../util/blockSendUntilConversationsAreVerified';
|
import { blockSendUntilConversationsAreVerified } from '../../util/blockSendUntilConversationsAreVerified';
|
||||||
import { deleteStoryForEveryone as doDeleteStoryForEveryone } from '../../util/deleteStoryForEveryone';
|
import { deleteStoryForEveryone as doDeleteStoryForEveryone } from '../../util/deleteStoryForEveryone';
|
||||||
|
import { deleteGroupStoryReplyForEveryone as doDeleteGroupStoryReplyForEveryone } from '../../util/deleteGroupStoryReplyForEveryone';
|
||||||
import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend';
|
import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend';
|
||||||
import { getMessageById } from '../../messages/getMessageById';
|
import { getMessageById } from '../../messages/getMessageById';
|
||||||
import { markViewed } from '../../services/MessageUpdater';
|
import { markViewed } from '../../services/MessageUpdater';
|
||||||
|
@ -131,6 +132,7 @@ const SEND_STORY_MODAL_OPEN_STATE_CHANGED =
|
||||||
const STORY_CHANGED = 'stories/STORY_CHANGED';
|
const STORY_CHANGED = 'stories/STORY_CHANGED';
|
||||||
const TOGGLE_VIEW = 'stories/TOGGLE_VIEW';
|
const TOGGLE_VIEW = 'stories/TOGGLE_VIEW';
|
||||||
const VIEW_STORY = 'stories/VIEW_STORY';
|
const VIEW_STORY = 'stories/VIEW_STORY';
|
||||||
|
const STORY_REPLY_DELETED = 'stories/STORY_REPLY_DELETED';
|
||||||
const REMOVE_ALL_STORIES = 'stories/REMOVE_ALL_STORIES';
|
const REMOVE_ALL_STORIES = 'stories/REMOVE_ALL_STORIES';
|
||||||
const SET_ADD_STORY_DATA = 'stories/SET_ADD_STORY_DATA';
|
const SET_ADD_STORY_DATA = 'stories/SET_ADD_STORY_DATA';
|
||||||
const SET_STORY_SENDING = 'stories/SET_STORY_SENDING';
|
const SET_STORY_SENDING = 'stories/SET_STORY_SENDING';
|
||||||
|
@ -188,6 +190,11 @@ type ViewStoryActionType = {
|
||||||
payload: SelectedStoryDataType | undefined;
|
payload: SelectedStoryDataType | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type StoryReplyDeletedActionType = {
|
||||||
|
type: typeof STORY_REPLY_DELETED;
|
||||||
|
payload: string;
|
||||||
|
};
|
||||||
|
|
||||||
type RemoveAllStoriesActionType = {
|
type RemoveAllStoriesActionType = {
|
||||||
type: typeof REMOVE_ALL_STORIES;
|
type: typeof REMOVE_ALL_STORIES;
|
||||||
};
|
};
|
||||||
|
@ -215,12 +222,40 @@ export type StoriesActionType =
|
||||||
| StoryChangedActionType
|
| StoryChangedActionType
|
||||||
| ToggleViewActionType
|
| ToggleViewActionType
|
||||||
| ViewStoryActionType
|
| ViewStoryActionType
|
||||||
|
| StoryReplyDeletedActionType
|
||||||
| RemoveAllStoriesActionType
|
| RemoveAllStoriesActionType
|
||||||
| SetAddStoryDataType
|
| SetAddStoryDataType
|
||||||
| SetStorySendingType;
|
| SetStorySendingType;
|
||||||
|
|
||||||
// Action Creators
|
// Action Creators
|
||||||
|
|
||||||
|
function deleteGroupStoryReply(
|
||||||
|
messageId: string
|
||||||
|
): ThunkAction<void, RootStateType, unknown, StoryReplyDeletedActionType> {
|
||||||
|
return async dispatch => {
|
||||||
|
await window.Signal.Data.removeMessage(messageId);
|
||||||
|
dispatch({
|
||||||
|
type: STORY_REPLY_DELETED,
|
||||||
|
payload: messageId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteGroupStoryReplyForEveryone(
|
||||||
|
replyMessageId: string
|
||||||
|
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
|
||||||
|
return async dispatch => {
|
||||||
|
await doDeleteGroupStoryReplyForEveryone(replyMessageId);
|
||||||
|
|
||||||
|
// the call above re-uses the sync-message processing code to update the UI
|
||||||
|
// we don't need to do anything here
|
||||||
|
dispatch({
|
||||||
|
type: 'NOOP',
|
||||||
|
payload: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function deleteStoryForEveryone(
|
function deleteStoryForEveryone(
|
||||||
story: StoryViewType
|
story: StoryViewType
|
||||||
): ThunkAction<void, RootStateType, unknown, DOEStoryActionType> {
|
): ThunkAction<void, RootStateType, unknown, DOEStoryActionType> {
|
||||||
|
@ -1211,6 +1246,8 @@ export const actions = {
|
||||||
verifyStoryListMembers,
|
verifyStoryListMembers,
|
||||||
viewUserStories,
|
viewUserStories,
|
||||||
viewStory,
|
viewStory,
|
||||||
|
deleteGroupStoryReply,
|
||||||
|
deleteGroupStoryReplyForEveryone,
|
||||||
setAddStoryData,
|
setAddStoryData,
|
||||||
setStoriesDisabled,
|
setStoriesDisabled,
|
||||||
setStorySending,
|
setStorySending,
|
||||||
|
@ -1253,6 +1290,20 @@ export function reducer(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action.type === STORY_REPLY_DELETED) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
replyState: state.replyState
|
||||||
|
? {
|
||||||
|
...state.replyState,
|
||||||
|
replies: state.replyState.replies.filter(
|
||||||
|
reply => reply.id !== action.payload
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (action.type === 'MESSAGE_DELETED') {
|
if (action.type === 'MESSAGE_DELETED') {
|
||||||
const nextStories = state.stories.filter(
|
const nextStories = state.stories.filter(
|
||||||
story => story.messageId !== action.payload.id
|
story => story.messageId !== action.payload.id
|
||||||
|
|
|
@ -540,9 +540,7 @@ export const getNonGroupStories = createSelector(
|
||||||
conversationIdsWithStories: Set<string>
|
conversationIdsWithStories: Set<string>
|
||||||
): Array<ConversationType> => {
|
): Array<ConversationType> => {
|
||||||
return groups.filter(
|
return groups.filter(
|
||||||
group =>
|
group => !isGroupInStoryMode(group, conversationIdsWithStories)
|
||||||
!isGroupV2(group) ||
|
|
||||||
!isGroupInStoryMode(group, conversationIdsWithStories)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -554,9 +552,7 @@ export const getGroupStories = createSelector(
|
||||||
conversationLookup: ConversationLookupType,
|
conversationLookup: ConversationLookupType,
|
||||||
conversationIdsWithStories: Set<string>
|
conversationIdsWithStories: Set<string>
|
||||||
): Array<ConversationType> => {
|
): Array<ConversationType> => {
|
||||||
return Object.values(conversationLookup).filter(
|
return Object.values(conversationLookup).filter(conversation =>
|
||||||
conversation =>
|
|
||||||
isGroupV2(conversation) &&
|
|
||||||
isGroupInStoryMode(conversation, conversationIdsWithStories)
|
isGroupInStoryMode(conversation, conversationIdsWithStories)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@ import type {
|
||||||
|
|
||||||
import type { TimelineItemType } from '../../components/conversation/TimelineItem';
|
import type { TimelineItemType } from '../../components/conversation/TimelineItem';
|
||||||
import type { PropsData } from '../../components/conversation/Message';
|
import type { PropsData } from '../../components/conversation/Message';
|
||||||
|
import type { PropsData as TimelineMessagePropsData } from '../../components/conversation/TimelineMessage';
|
||||||
import { TextDirection } from '../../components/conversation/Message';
|
import { TextDirection } from '../../components/conversation/Message';
|
||||||
import type { PropsData as TimerNotificationProps } from '../../components/conversation/TimerNotification';
|
import type { PropsData as TimerNotificationProps } from '../../components/conversation/TimerNotification';
|
||||||
import type { PropsData as ChangeNumberNotificationProps } from '../../components/conversation/ChangeNumberNotification';
|
import type { PropsData as ChangeNumberNotificationProps } from '../../components/conversation/ChangeNumberNotification';
|
||||||
|
@ -113,7 +114,7 @@ type FormattedContact = Partial<ConversationType> &
|
||||||
| 'type'
|
| 'type'
|
||||||
| 'unblurredAvatarPath'
|
| 'unblurredAvatarPath'
|
||||||
>;
|
>;
|
||||||
export type PropsForMessage = Omit<PropsData, 'interactionMode'>;
|
export type PropsForMessage = Omit<TimelineMessagePropsData, 'interactionMode'>;
|
||||||
type PropsForUnsupportedMessage = {
|
type PropsForUnsupportedMessage = {
|
||||||
canProcessNow: boolean;
|
canProcessNow: boolean;
|
||||||
contact: FormattedContact;
|
contact: FormattedContact;
|
||||||
|
@ -761,9 +762,8 @@ function getTextDirection(body?: string): TextDirection {
|
||||||
export const getPropsForMessage: (
|
export const getPropsForMessage: (
|
||||||
message: MessageWithUIFieldsType,
|
message: MessageWithUIFieldsType,
|
||||||
options: GetPropsForMessageOptions
|
options: GetPropsForMessageOptions
|
||||||
) => Omit<PropsForMessage, 'renderingContext'> = createSelectorCreator(
|
) => Omit<PropsForMessage, 'renderingContext' | 'menu' | 'contextMenu'> =
|
||||||
memoizeByRoot
|
createSelectorCreator(memoizeByRoot)(
|
||||||
)(
|
|
||||||
// `memoizeByRoot` requirement
|
// `memoizeByRoot` requirement
|
||||||
identity,
|
identity,
|
||||||
|
|
||||||
|
@ -787,7 +787,7 @@ export const getPropsForMessage: (
|
||||||
storyReplyContext: PropsData['storyReplyContext'],
|
storyReplyContext: PropsData['storyReplyContext'],
|
||||||
textAttachment: PropsData['textAttachment'],
|
textAttachment: PropsData['textAttachment'],
|
||||||
shallowProps: ShallowPropsType
|
shallowProps: ShallowPropsType
|
||||||
): Omit<PropsForMessage, 'renderingContext'> => {
|
): Omit<PropsForMessage, 'renderingContext' | 'menu' | 'contextMenu'> => {
|
||||||
return {
|
return {
|
||||||
attachments,
|
attachments,
|
||||||
author,
|
author,
|
||||||
|
@ -800,7 +800,7 @@ export const getPropsForMessage: (
|
||||||
...shallowProps,
|
...shallowProps,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// This is getPropsForMessage but wrapped in reselect's createSelector so that
|
// This is getPropsForMessage but wrapped in reselect's createSelector so that
|
||||||
// we can derive all of the selector dependencies that getPropsForMessage
|
// 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 { MessageAudio } from '../../components/conversation/MessageAudio';
|
||||||
import type { OwnProps as MessageAudioOwnProps } from '../../components/conversation/MessageAudio';
|
import type { OwnProps as MessageAudioOwnProps } from '../../components/conversation/MessageAudio';
|
||||||
import type { ComputePeaksResult } from '../../components/GlobalAudioContext';
|
|
||||||
|
|
||||||
import { mapDispatchToProps } from '../actions';
|
import { mapDispatchToProps } from '../actions';
|
||||||
import type { StateType } from '../reducer';
|
import type { StateType } from '../reducer';
|
||||||
import type { LocalizerType } from '../../types/Util';
|
|
||||||
import type { AttachmentType } from '../../types/Attachment';
|
|
||||||
import type {
|
|
||||||
DirectionType,
|
|
||||||
MessageStatusType,
|
|
||||||
} from '../../components/conversation/Message';
|
|
||||||
import type { ActiveAudioPlayerStateType } from '../ducks/audioPlayer';
|
import type { ActiveAudioPlayerStateType } from '../ducks/audioPlayer';
|
||||||
|
|
||||||
export type Props = {
|
export type Props = Omit<MessageAudioOwnProps, 'active'>;
|
||||||
renderingContext: string;
|
|
||||||
i18n: LocalizerType;
|
|
||||||
attachment: AttachmentType;
|
|
||||||
collapseMetadata: boolean;
|
|
||||||
withContentAbove: boolean;
|
|
||||||
withContentBelow: boolean;
|
|
||||||
|
|
||||||
direction: DirectionType;
|
|
||||||
expirationLength?: number;
|
|
||||||
expirationTimestamp?: number;
|
|
||||||
id: string;
|
|
||||||
conversationId: string;
|
|
||||||
played: boolean;
|
|
||||||
showMessageDetail: (id: string) => void;
|
|
||||||
status?: MessageStatusType;
|
|
||||||
textPending?: boolean;
|
|
||||||
timestamp: number;
|
|
||||||
|
|
||||||
computePeaks(url: string, barCount: number): Promise<ComputePeaksResult>;
|
|
||||||
kickOffAttachmentDownload(): void;
|
|
||||||
onCorrupted(): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapStateToProps = (
|
const mapStateToProps = (
|
||||||
state: StateType,
|
state: StateType,
|
||||||
|
|
|
@ -11,8 +11,6 @@ import type { StateType } from '../reducer';
|
||||||
import { getPreferredBadgeSelector } from '../selectors/badges';
|
import { getPreferredBadgeSelector } from '../selectors/badges';
|
||||||
import { getIntl, getInteractionMode, getTheme } from '../selectors/user';
|
import { getIntl, getInteractionMode, getTheme } from '../selectors/user';
|
||||||
import { renderAudioAttachment } from './renderAudioAttachment';
|
import { renderAudioAttachment } from './renderAudioAttachment';
|
||||||
import { renderEmojiPicker } from './renderEmojiPicker';
|
|
||||||
import { renderReactionPicker } from './renderReactionPicker';
|
|
||||||
import { getContactNameColorSelector } from '../selectors/conversations';
|
import { getContactNameColorSelector } from '../selectors/conversations';
|
||||||
import { markViewed } from '../ducks/conversations';
|
import { markViewed } from '../ducks/conversations';
|
||||||
|
|
||||||
|
@ -48,15 +46,10 @@ const mapStateToProps = (
|
||||||
openConversation,
|
openConversation,
|
||||||
openGiftBadge,
|
openGiftBadge,
|
||||||
openLink,
|
openLink,
|
||||||
reactToMessage,
|
|
||||||
replyToMessage,
|
|
||||||
retryDeleteForEveryone,
|
|
||||||
retrySend,
|
|
||||||
showContactDetail,
|
showContactDetail,
|
||||||
showContactModal,
|
showContactModal,
|
||||||
showExpiredIncomingTapToViewToast,
|
showExpiredIncomingTapToViewToast,
|
||||||
showExpiredOutgoingTapToViewToast,
|
showExpiredOutgoingTapToViewToast,
|
||||||
showForwardMessageModal,
|
|
||||||
showVisualAttachment,
|
showVisualAttachment,
|
||||||
startConversation,
|
startConversation,
|
||||||
} = props;
|
} = props;
|
||||||
|
@ -93,18 +86,11 @@ const mapStateToProps = (
|
||||||
openConversation,
|
openConversation,
|
||||||
openGiftBadge,
|
openGiftBadge,
|
||||||
openLink,
|
openLink,
|
||||||
reactToMessage,
|
|
||||||
renderAudioAttachment,
|
renderAudioAttachment,
|
||||||
renderEmojiPicker,
|
|
||||||
renderReactionPicker,
|
|
||||||
replyToMessage,
|
|
||||||
retryDeleteForEveryone,
|
|
||||||
retrySend,
|
|
||||||
showContactDetail,
|
showContactDetail,
|
||||||
showContactModal,
|
showContactModal,
|
||||||
showExpiredIncomingTapToViewToast,
|
showExpiredIncomingTapToViewToast,
|
||||||
showExpiredOutgoingTapToViewToast,
|
showExpiredOutgoingTapToViewToast,
|
||||||
showForwardMessageModal,
|
|
||||||
showVisualAttachment,
|
showVisualAttachment,
|
||||||
startConversation,
|
startConversation,
|
||||||
};
|
};
|
||||||
|
|
|
@ -7,10 +7,7 @@ import { GlobalAudioContext } from '../../components/GlobalAudioContext';
|
||||||
import type { Props as MessageAudioProps } from './MessageAudio';
|
import type { Props as MessageAudioProps } from './MessageAudio';
|
||||||
import { SmartMessageAudio } from './MessageAudio';
|
import { SmartMessageAudio } from './MessageAudio';
|
||||||
|
|
||||||
type AudioAttachmentProps = Omit<
|
type AudioAttachmentProps = Omit<MessageAudioProps, 'computePeaks'>;
|
||||||
MessageAudioProps,
|
|
||||||
'computePeaks' | 'buttonRef'
|
|
||||||
>;
|
|
||||||
|
|
||||||
export function renderAudioAttachment(
|
export function renderAudioAttachment(
|
||||||
props: AudioAttachmentProps
|
props: AudioAttachmentProps
|
||||||
|
|
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",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2018-09-19T18:13:29.628Z"
|
"updated": "2018-09-19T18:13:29.628Z"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"rule": "React-useRef",
|
||||||
|
"path": "ts/components/conversation/TimelineMessage.tsx",
|
||||||
|
"line": " const menuTriggerRef = useRef<Trigger | null>(null);",
|
||||||
|
"reasonCategory": "usageTrusted",
|
||||||
|
"updated": "2022-11-03T14:21:47.456Z"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"rule": "jQuery-globalEval(",
|
"rule": "jQuery-globalEval(",
|
||||||
"path": "components/mp3lameencoder/lib/Mp3LameEncoder.js",
|
"path": "components/mp3lameencoder/lib/Mp3LameEncoder.js",
|
||||||
|
|
Loading…
Reference in a new issue