signal-desktop/ts/components/StoryViewer.tsx

994 lines
31 KiB
TypeScript
Raw Normal View History

2022-03-04 21:14:52 +00:00
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import FocusTrap from 'focus-trap-react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
2022-05-03 23:50:44 +00:00
import classNames from 'classnames';
2022-11-10 04:59:36 +00:00
import type { DraftBodyRangesType, LocalizerType } from '../types/Util';
2022-07-25 18:55:44 +00:00
import type { ContextMenuOptionType } from './ContextMenu';
2022-12-14 18:12:04 +00:00
import type {
ConversationType,
SaveAttachmentActionCreatorType,
} from '../state/ducks/conversations';
2022-03-04 21:14:52 +00:00
import type { EmojiPickDataType } from './emoji/EmojiPicker';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { RenderEmojiPickerProps } from './conversation/ReactionPicker';
2022-07-01 00:52:03 +00:00
import type { ReplyStateType, StoryViewType } from '../types/Stories';
import type { ShowToastActionCreatorType } from '../state/ducks/toast';
2022-07-06 19:06:20 +00:00
import type { ViewStoryActionCreatorType } from '../state/ducks/stories';
import * as log from '../logging/log';
2022-05-04 17:43:22 +00:00
import { AnimatedEmojiGalore } from './AnimatedEmojiGalore';
2022-03-04 21:14:52 +00:00
import { Avatar, AvatarSize } from './Avatar';
import { ConfirmationDialog } from './ConfirmationDialog';
2022-07-25 18:55:44 +00:00
import { ContextMenu } from './ContextMenu';
2022-10-25 17:15:18 +00:00
import { Emojify } from './conversation/Emojify';
2022-03-04 21:14:52 +00:00
import { Intl } from './Intl';
import { MessageTimestamp } from './conversation/MessageTimestamp';
2022-07-01 00:52:03 +00:00
import { SendStatus } from '../messages/MessageSendState';
2022-11-16 22:10:11 +00:00
import { Spinner } from './Spinner';
2022-07-25 18:55:44 +00:00
import { StoryDetailsModal } from './StoryDetailsModal';
import { StoryDistributionListName } from './StoryDistributionListName';
2022-03-29 01:10:08 +00:00
import { StoryImage } from './StoryImage';
2022-10-11 17:59:02 +00:00
import {
2022-11-16 22:10:11 +00:00
ResolvedSendStatus,
2022-10-11 17:59:02 +00:00
StoryViewDirectionType,
StoryViewModeType,
StoryViewTargetType,
} from '../types/Stories';
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
2022-05-06 19:02:44 +00:00
import { Theme } from '../util/theme';
import { ToastType } from '../state/ducks/toast';
2022-03-04 21:14:52 +00:00
import { getAvatarColor } from '../types/Colors';
2022-04-22 18:36:34 +00:00
import { getStoryBackground } from '../util/getStoryBackground';
2022-04-12 19:29:30 +00:00
import { getStoryDuration } from '../util/getStoryDuration';
import { isVideoAttachment } from '../types/Attachment';
import { graphemeAndLinkAwareSlice } from '../util/graphemeAndLinkAwareSlice';
2022-03-04 21:14:52 +00:00
import { useEscapeHandling } from '../hooks/useEscapeHandling';
2022-11-16 22:10:11 +00:00
import { useRetryStorySend } from '../hooks/useRetryStorySend';
import { resolveStorySendStatus } from '../util/resolveStorySendStatus';
import { strictAssert } from '../util/assert';
2022-03-04 21:14:52 +00:00
export type PropsType = {
2022-07-06 19:06:20 +00:00
currentIndex: number;
2022-11-16 22:10:11 +00:00
deleteGroupStoryReply: (id: string) => void;
deleteGroupStoryReplyForEveryone: (id: string) => void;
2022-07-25 18:55:44 +00:00
deleteStoryForEveryone: (story: StoryViewType) => unknown;
distributionList?: { id: string; name: string };
2022-03-04 21:14:52 +00:00
getPreferredBadge: PreferredBadgeSelectorType;
2022-04-15 00:08:46 +00:00
group?: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'id'
| 'name'
| 'profileName'
| 'sharedGroupNames'
2022-08-23 18:02:51 +00:00
| 'sortedGroupMembers'
2022-04-15 00:08:46 +00:00
| 'title'
| 'left'
2022-04-15 00:08:46 +00:00
>;
hasActiveCall?: boolean;
hasAllStoriesUnmuted: boolean;
2022-10-25 22:18:42 +00:00
hasViewReceiptSetting: boolean;
2022-03-04 21:14:52 +00:00
i18n: LocalizerType;
2022-11-22 22:33:15 +00:00
isInternalUser?: boolean;
2022-11-09 02:38:19 +00:00
isSignalConversation?: boolean;
2022-11-16 22:10:11 +00:00
isWindowActive: boolean;
2022-04-15 00:08:46 +00:00
loadStoryReplies: (conversationId: string, messageId: string) => unknown;
2022-03-04 21:14:52 +00:00
markStoryRead: (mId: string) => unknown;
2022-07-06 19:06:20 +00:00
numStories: number;
onGoToConversation: (conversationId: string) => unknown;
onHideStory: (conversationId: string) => unknown;
2022-03-04 21:14:52 +00:00
onSetSkinTone: (tone: number) => unknown;
onTextTooLong: () => unknown;
onReactToStory: (emoji: string, story: StoryViewType) => unknown;
onReplyToStory: (
message: string,
2022-11-10 04:59:36 +00:00
mentions: DraftBodyRangesType,
2022-03-04 21:14:52 +00:00
timestamp: number,
story: StoryViewType
) => unknown;
onUseEmoji: (_: EmojiPickDataType) => unknown;
preferredReactionEmoji: Array<string>;
2022-03-29 01:10:08 +00:00
queueStoryDownload: (storyId: string) => unknown;
2022-03-04 21:14:52 +00:00
recentEmojis?: Array<string>;
renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element;
2022-04-15 00:08:46 +00:00
replyState?: ReplyStateType;
2022-11-16 22:10:11 +00:00
retrySend: (messageId: string) => unknown;
2022-12-14 18:12:04 +00:00
saveAttachment: SaveAttachmentActionCreatorType;
2022-11-16 22:10:11 +00:00
setHasAllStoriesUnmuted: (isUnmuted: boolean) => unknown;
showToast: ShowToastActionCreatorType;
2022-03-04 21:14:52 +00:00
skinTone?: number;
2022-07-06 19:06:20 +00:00
story: StoryViewType;
2022-08-22 17:44:23 +00:00
storyViewMode: StoryViewModeType;
2022-07-06 19:06:20 +00:00
viewStory: ViewStoryActionCreatorType;
2022-11-16 22:10:11 +00:00
viewTarget?: StoryViewTargetType;
2022-03-04 21:14:52 +00:00
};
2022-04-14 17:02:12 +00:00
const CAPTION_BUFFER = 20;
const CAPTION_INITIAL_LENGTH = 200;
const CAPTION_MAX_LENGTH = 700;
2022-05-03 23:50:44 +00:00
const MOUSE_IDLE_TIME = 3000;
enum Arrow {
None,
Left,
Right,
}
2022-04-14 17:02:12 +00:00
2022-11-18 00:45:19 +00:00
export function StoryViewer({
2022-07-06 19:06:20 +00:00
currentIndex,
2022-11-16 22:10:11 +00:00
deleteGroupStoryReply,
deleteGroupStoryReplyForEveryone,
2022-07-25 18:55:44 +00:00
deleteStoryForEveryone,
distributionList,
2022-03-04 21:14:52 +00:00
getPreferredBadge,
group,
hasActiveCall,
hasAllStoriesUnmuted,
2022-10-25 22:18:42 +00:00
hasViewReceiptSetting,
2022-03-04 21:14:52 +00:00
i18n,
2022-11-22 22:33:15 +00:00
isInternalUser,
2022-11-09 02:38:19 +00:00
isSignalConversation,
2022-11-16 22:10:11 +00:00
isWindowActive,
2022-04-15 00:08:46 +00:00
loadStoryReplies,
2022-03-04 21:14:52 +00:00
markStoryRead,
2022-07-06 19:06:20 +00:00
numStories,
onGoToConversation,
onHideStory,
2022-03-04 21:14:52 +00:00
onReactToStory,
onReplyToStory,
onSetSkinTone,
onTextTooLong,
onUseEmoji,
preferredReactionEmoji,
2022-03-29 01:10:08 +00:00
queueStoryDownload,
2022-03-04 21:14:52 +00:00
recentEmojis,
renderEmojiPicker,
2022-04-15 00:08:46 +00:00
replyState,
2022-11-16 22:10:11 +00:00
retrySend,
2022-11-22 22:33:15 +00:00
saveAttachment,
2022-11-16 22:10:11 +00:00
setHasAllStoriesUnmuted,
showToast,
2022-03-04 21:14:52 +00:00
skinTone,
2022-07-06 19:06:20 +00:00
story,
storyViewMode,
viewStory,
2022-11-16 22:10:11 +00:00
viewTarget,
2022-11-18 00:45:19 +00:00
}: PropsType): JSX.Element {
2022-07-25 18:55:44 +00:00
const [isShowingContextMenu, setIsShowingContextMenu] =
useState<boolean>(false);
2022-04-12 19:29:30 +00:00
const [storyDuration, setStoryDuration] = useState<number | undefined>();
const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false);
2022-05-04 17:43:22 +00:00
const [reactionEmoji, setReactionEmoji] = useState<string | undefined>();
2022-07-25 18:55:44 +00:00
const [confirmDeleteStory, setConfirmDeleteStory] = useState<
StoryViewType | undefined
>();
2022-03-04 21:14:52 +00:00
const {
attachment,
canReply,
isHidden,
messageId,
messageIdForLogging,
sendState,
timestamp,
} = story;
2022-03-04 21:14:52 +00:00
const {
acceptedMessageRequest,
avatarPath,
color,
isMe,
firstName,
2022-03-04 21:14:52 +00:00
profileName,
sharedGroupNames,
title,
2022-07-06 19:06:20 +00:00
} = story.sender;
2022-03-04 21:14:52 +00:00
const conversationId = group?.id || story.sender.id;
2022-11-16 22:10:11 +00:00
const sendStatus = sendState ? resolveStorySendStatus(sendState) : undefined;
const { renderAlert, setWasManuallyRetried, wasManuallyRetried } =
useRetryStorySend(i18n, sendStatus);
2022-10-11 17:59:02 +00:00
const [currentViewTarget, setCurrentViewTarget] = useState(
viewTarget ?? null
2022-07-25 18:55:44 +00:00
);
2022-03-04 21:14:52 +00:00
2022-10-11 17:59:02 +00:00
useEffect(() => {
setCurrentViewTarget(viewTarget ?? null);
}, [viewTarget]);
2022-07-06 19:06:20 +00:00
const onClose = useCallback(() => {
2022-07-25 18:55:44 +00:00
viewStory({
closeViewer: true,
});
2022-07-06 19:06:20 +00:00
}, [viewStory]);
2022-03-04 21:14:52 +00:00
const onEscape = useCallback(() => {
2022-10-11 17:59:02 +00:00
if (currentViewTarget != null) {
setCurrentViewTarget(null);
2022-03-04 21:14:52 +00:00
} else {
onClose();
}
2022-10-11 17:59:02 +00:00
}, [currentViewTarget, onClose]);
2022-03-04 21:14:52 +00:00
useEscapeHandling(onEscape);
2022-04-14 17:02:12 +00:00
// Caption related hooks
const [hasExpandedCaption, setHasExpandedCaption] = useState<boolean>(false);
const caption = useMemo(() => {
if (!attachment?.caption) {
return;
}
return graphemeAndLinkAwareSlice(
2022-04-14 17:02:12 +00:00
attachment.caption,
hasExpandedCaption ? CAPTION_MAX_LENGTH : CAPTION_INITIAL_LENGTH,
CAPTION_BUFFER
);
}, [attachment?.caption, hasExpandedCaption]);
// Reset expansion if messageId changes
useEffect(() => {
setHasExpandedCaption(false);
}, [messageId]);
// messageId is set as a dependency so that we can reset the story duration
// when a new story is selected in case the same story (and same attachment)
// are sequentially posted.
2022-04-12 19:29:30 +00:00
useEffect(() => {
let shouldCancel = false;
(async function hydrateStoryDuration() {
if (!attachment) {
return;
}
const duration = await getStoryDuration(attachment);
if (shouldCancel) {
return;
}
log.info('stories.setStoryDuration', {
contentType: attachment.textAttachment
? 'text'
: attachment.contentType,
duration,
});
2022-04-12 19:29:30 +00:00
setStoryDuration(duration);
})();
return () => {
shouldCancel = true;
};
}, [attachment, messageId]);
2022-04-12 19:29:30 +00:00
const progressBarRef = useRef<HTMLDivElement>(null);
const animationRef = useRef<Animation | null>(null);
// Putting this in a ref allows us to call it from the useEffect below without
// triggering the effect to re-run every time these values change.
const onFinishRef = useRef<(() => void) | null>(null);
useEffect(() => {
onFinishRef.current = () => {
viewStory({
storyId: story.messageId,
storyViewMode,
viewDirection: StoryViewDirectionType.Next,
});
};
}, [story.messageId, storyViewMode, viewStory]);
// This guarantees that we'll have a valid ref to the animation when we need it
strictAssert(currentIndex != null, "StoryViewer: currentIndex can't be null");
// We need to be careful about this effect refreshing, it should only run
// every time a story changes or its duration changes.
useEffect(() => {
2022-10-19 00:18:36 +00:00
if (!storyDuration) {
return;
}
strictAssert(
progressBarRef.current != null,
"progressBarRef can't be null"
);
const target = progressBarRef.current;
const animation = target.animate(
[{ transform: 'translateX(-100%)' }, { transform: 'translateX(0%)' }],
{
id: 'story-progress-bar',
duration: storyDuration,
easing: 'linear',
fill: 'forwards',
}
);
2022-03-29 01:10:08 +00:00
animationRef.current = animation;
function onFinish() {
onFinishRef.current?.();
}
animation.addEventListener('finish', onFinish);
2022-11-17 19:54:28 +00:00
// Reset the stuff that pauses a story when you switch story views
setConfirmDeleteStory(undefined);
setHasConfirmHideStory(false);
setHasExpandedCaption(false);
setIsShowingContextMenu(false);
setPauseStory(false);
2022-03-29 01:10:08 +00:00
return () => {
animation.removeEventListener('finish', onFinish);
animation.cancel();
2022-03-29 01:10:08 +00:00
};
}, [story.messageId, storyDuration]);
2022-03-04 21:14:52 +00:00
2022-05-06 19:02:44 +00:00
const [pauseStory, setPauseStory] = useState(false);
useEffect(() => {
if (!isWindowActive) {
setPauseStory(true);
}
}, [isWindowActive]);
2022-11-16 22:10:11 +00:00
const alertElement = renderAlert();
const shouldPauseViewing =
2022-11-16 22:10:11 +00:00
Boolean(alertElement) ||
2022-10-26 01:39:17 +00:00
Boolean(confirmDeleteStory) ||
currentViewTarget != null ||
hasActiveCall ||
hasConfirmHideStory ||
hasExpandedCaption ||
2022-05-04 17:43:22 +00:00
isShowingContextMenu ||
2022-05-06 19:02:44 +00:00
pauseStory ||
2022-05-04 17:43:22 +00:00
Boolean(reactionEmoji);
2022-03-04 21:14:52 +00:00
useEffect(() => {
if (shouldPauseViewing) {
animationRef.current?.pause();
2022-03-04 21:14:52 +00:00
} else {
animationRef.current?.play();
2022-03-04 21:14:52 +00:00
}
2022-11-17 19:54:28 +00:00
}, [shouldPauseViewing, story.messageId, storyDuration]);
2022-03-04 21:14:52 +00:00
useEffect(() => {
2022-03-29 01:10:08 +00:00
markStoryRead(messageId);
log.info('stories.markStoryRead', { message: messageIdForLogging });
}, [markStoryRead, messageId, messageIdForLogging]);
2022-03-29 01:10:08 +00:00
2022-08-22 17:44:23 +00:00
const canFreelyNavigateStories =
storyViewMode === StoryViewModeType.All ||
2022-09-22 18:56:39 +00:00
storyViewMode === StoryViewModeType.Hidden ||
2022-10-17 16:33:07 +00:00
storyViewMode === StoryViewModeType.MyStories ||
2022-08-22 17:44:23 +00:00
storyViewMode === StoryViewModeType.Unread;
const canNavigateLeft =
(storyViewMode === StoryViewModeType.User && currentIndex > 0) ||
canFreelyNavigateStories;
const canNavigateRight =
(storyViewMode === StoryViewModeType.User &&
currentIndex < numStories - 1) ||
canFreelyNavigateStories;
2022-03-04 21:14:52 +00:00
const navigateStories = useCallback(
(ev: KeyboardEvent) => {
// the replies modal can consume arrow keys
// we don't want to navigate while someone is typing a reply
2022-10-11 17:59:02 +00:00
if (currentViewTarget != null) {
return;
}
2022-08-22 17:44:23 +00:00
if (canNavigateRight && ev.key === 'ArrowRight') {
2022-07-25 18:55:44 +00:00
viewStory({
storyId: story.messageId,
storyViewMode,
viewDirection: StoryViewDirectionType.Next,
});
2022-03-04 21:14:52 +00:00
ev.preventDefault();
ev.stopPropagation();
2022-08-22 17:44:23 +00:00
} else if (canNavigateLeft && ev.key === 'ArrowLeft') {
2022-07-25 18:55:44 +00:00
viewStory({
storyId: story.messageId,
2022-07-06 19:06:20 +00:00
storyViewMode,
2022-07-25 18:55:44 +00:00
viewDirection: StoryViewDirectionType.Previous,
});
2022-03-04 21:14:52 +00:00
ev.preventDefault();
ev.stopPropagation();
}
},
2022-08-22 17:44:23 +00:00
[
2022-10-11 17:59:02 +00:00
currentViewTarget,
2022-08-22 17:44:23 +00:00
canNavigateLeft,
canNavigateRight,
story.messageId,
storyViewMode,
viewStory,
]
2022-03-04 21:14:52 +00:00
);
useEffect(() => {
document.addEventListener('keydown', navigateStories);
return () => {
document.removeEventListener('keydown', navigateStories);
};
}, [navigateStories]);
2022-07-06 19:06:20 +00:00
const groupId = group?.id;
const isGroupStory = Boolean(groupId);
2022-04-15 00:08:46 +00:00
useEffect(() => {
2022-07-06 19:06:20 +00:00
if (!groupId) {
2022-04-15 00:08:46 +00:00
return;
}
2022-07-06 19:06:20 +00:00
loadStoryReplies(groupId, messageId);
}, [groupId, loadStoryReplies, messageId]);
2022-04-15 00:08:46 +00:00
2022-05-03 23:50:44 +00:00
const [arrowToShow, setArrowToShow] = useState<Arrow>(Arrow.None);
useEffect(() => {
if (arrowToShow === Arrow.None) {
return;
}
let mouseMoveExpiration: number | undefined;
let timer: NodeJS.Timeout | undefined;
2022-05-03 23:50:44 +00:00
function updateLastMouseMove() {
mouseMoveExpiration = Date.now() + MOUSE_IDLE_TIME;
if (timer === undefined) {
checkMouseIdle();
}
2022-05-03 23:50:44 +00:00
}
function checkMouseIdle() {
timer = undefined;
if (mouseMoveExpiration === undefined) {
return;
}
const remaining = mouseMoveExpiration - Date.now();
if (remaining <= 0) {
setArrowToShow(Arrow.None);
return;
}
timer = setTimeout(checkMouseIdle, remaining);
2022-05-03 23:50:44 +00:00
}
document.addEventListener('mousemove', updateLastMouseMove);
return () => {
if (timer !== undefined) {
clearTimeout(timer);
}
mouseMoveExpiration = undefined;
timer = undefined;
2022-05-03 23:50:44 +00:00
document.removeEventListener('mousemove', updateLastMouseMove);
};
}, [arrowToShow]);
2022-04-15 00:08:46 +00:00
const replies =
replyState && replyState.messageId === messageId ? replyState.replies : [];
2022-07-01 00:52:03 +00:00
const views = sendState
? sendState.filter(({ status }) => status === SendStatus.Viewed)
: [];
2022-04-15 00:08:46 +00:00
const replyCount = replies.length;
2022-07-01 00:52:03 +00:00
const viewCount = views.length;
const hasAudio = isVideoAttachment(attachment);
const isStoryMuted = !hasAllStoriesUnmuted || !hasAudio;
let muteClassName: string;
let muteAriaLabel: string;
if (hasAudio) {
muteAriaLabel = hasAllStoriesUnmuted
? i18n('StoryViewer__mute')
: i18n('StoryViewer__unmute');
muteClassName = hasAllStoriesUnmuted
? 'StoryViewer__mute'
: 'StoryViewer__unmute';
} else {
muteAriaLabel = i18n('Stories__toast--hasNoSound');
muteClassName = 'StoryViewer__soundless';
}
2022-09-21 19:19:16 +00:00
const isSent = Boolean(sendState);
2022-11-09 02:38:19 +00:00
let contextMenuOptions:
| ReadonlyArray<ContextMenuOptionType<unknown>>
| undefined;
if (isSent) {
contextMenuOptions = [
{
icon: 'StoryListItem__icon--info',
label: i18n('StoryListItem__info'),
onClick: () => setCurrentViewTarget(StoryViewTargetType.Details),
},
{
icon: 'StoryListItem__icon--delete',
label: i18n('StoryListItem__delete'),
onClick: () => setConfirmDeleteStory(story),
},
];
} else if (!isSignalConversation) {
contextMenuOptions = [
{
icon: 'StoryListItem__icon--info',
label: i18n('StoryListItem__info'),
onClick: () => setCurrentViewTarget(StoryViewTargetType.Details),
},
{
icon: 'StoryListItem__icon--hide',
label: isHidden
? i18n('StoryListItem__unhide')
: i18n('StoryListItem__hide'),
onClick: () => {
if (isHidden) {
onHideStory(conversationId);
} else {
setHasConfirmHideStory(true);
}
},
},
{
icon: 'StoryListItem__icon--chat',
label: i18n('StoryListItem__go-to-chat'),
onClick: () => {
onGoToConversation(conversationId);
},
},
];
}
2022-07-25 18:55:44 +00:00
2022-11-16 22:10:11 +00:00
function doRetrySend() {
if (wasManuallyRetried) {
return;
}
if (
sendStatus !== ResolvedSendStatus.Failed &&
sendStatus !== ResolvedSendStatus.PartiallySent
) {
return;
}
setWasManuallyRetried(true);
retrySend(messageId);
}
2022-03-04 21:14:52 +00:00
return (
<FocusTrap focusTrapOptions={{ clickOutsideDeactivates: true }}>
<div className="StoryViewer">
2022-11-16 22:10:11 +00:00
{alertElement}
2022-04-22 18:36:34 +00:00
<div
className="StoryViewer__overlay"
style={{ background: getStoryBackground(attachment) }}
/>
<div className="StoryViewer__content">
2022-08-22 17:44:23 +00:00
{canNavigateLeft && (
2022-07-01 00:52:03 +00:00
<button
aria-label={i18n('back')}
className={classNames(
'StoryViewer__arrow StoryViewer__arrow--left',
{
'StoryViewer__arrow--visible': arrowToShow === Arrow.Left,
}
)}
2022-07-06 19:06:20 +00:00
onClick={() =>
2022-07-25 18:55:44 +00:00
viewStory({
storyId: story.messageId,
2022-07-06 19:06:20 +00:00
storyViewMode,
2022-07-25 18:55:44 +00:00
viewDirection: StoryViewDirectionType.Previous,
})
2022-07-06 19:06:20 +00:00
}
2022-07-01 00:52:03 +00:00
onMouseMove={() => setArrowToShow(Arrow.Left)}
type="button"
/>
)}
2022-05-03 23:50:44 +00:00
<div className="StoryViewer__protection StoryViewer__protection--top" />
<div className="StoryViewer__container">
<StoryImage
attachment={attachment}
2022-08-04 00:38:41 +00:00
firstName={firstName || title}
2022-09-12 22:03:25 +00:00
isMe={isMe}
2022-03-04 21:14:52 +00:00
i18n={i18n}
isPaused={shouldPauseViewing}
isMuted={isStoryMuted}
label={i18n('lightboxImageAlt')}
moduleClassName="StoryViewer__story"
queueStoryDownload={queueStoryDownload}
storyId={messageId}
2022-05-04 17:43:22 +00:00
>
{reactionEmoji && (
<div className="StoryViewer__animated-emojis">
<AnimatedEmojiGalore
emoji={reactionEmoji}
onAnimationEnd={() => {
setReactionEmoji(undefined);
}}
/>
</div>
)}
</StoryImage>
2022-04-14 17:02:12 +00:00
{hasExpandedCaption && (
2022-04-22 18:36:34 +00:00
<button
aria-label={i18n('close-popup')}
className="StoryViewer__caption__overlay"
onClick={() => setHasExpandedCaption(false)}
type="button"
/>
2022-04-14 17:02:12 +00:00
)}
2022-05-04 18:45:32 +00:00
</div>
<div className="StoryViewer__protection StoryViewer__protection--bottom" />
{canNavigateRight && (
<button
aria-label={i18n('forward')}
className={classNames(
'StoryViewer__arrow StoryViewer__arrow--right',
{
'StoryViewer__arrow--visible': arrowToShow === Arrow.Right,
}
)}
onClick={() =>
viewStory({
storyId: story.messageId,
storyViewMode,
viewDirection: StoryViewDirectionType.Next,
})
}
onMouseMove={() => setArrowToShow(Arrow.Right)}
type="button"
/>
)}
2022-05-04 18:45:32 +00:00
<div className="StoryViewer__meta">
{caption && (
<div className="StoryViewer__caption">
2022-10-25 17:15:18 +00:00
<Emojify text={caption.text} />
2022-05-04 18:45:32 +00:00
{caption.hasReadMore && !hasExpandedCaption && (
<button
className="MessageBody__read-more"
onClick={() => {
setHasExpandedCaption(true);
}}
onKeyDown={(ev: React.KeyboardEvent) => {
if (ev.key === 'Space' || ev.key === 'Enter') {
2022-04-14 17:02:12 +00:00
setHasExpandedCaption(true);
2022-05-04 18:45:32 +00:00
}
}}
type="button"
>
...
{i18n('MessageBody--read-more')}
</button>
)}
</div>
)}
2022-05-06 19:02:44 +00:00
<div className="StoryViewer__meta__playback-bar">
<div className="StoryViewer__meta__playback-bar__container">
2022-05-06 19:02:44 +00:00
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
badge={undefined}
color={getAvatarColor(color)}
conversationType="direct"
i18n={i18n}
isMe={Boolean(isMe)}
profileName={profileName}
sharedGroupNames={sharedGroupNames}
size={AvatarSize.TWENTY_EIGHT}
title={title}
/>
{group && (
<Avatar
acceptedMessageRequest={group.acceptedMessageRequest}
avatarPath={group.avatarPath}
badge={undefined}
className="StoryViewer__meta--group-avatar"
color={getAvatarColor(group.color)}
conversationType="group"
i18n={i18n}
isMe={false}
profileName={group.profileName}
sharedGroupNames={group.sharedGroupNames}
size={AvatarSize.TWENTY_EIGHT}
title={group.title}
/>
)}
<div className="StoryViewer__meta--title-container">
<div className="StoryViewer__meta--title">
{(group &&
i18n('Stories__from-to-group', {
name: isMe ? i18n('you') : title,
group: group.title,
})) ||
(isMe ? i18n('you') : title)}
</div>
<MessageTimestamp
i18n={i18n}
isRelativeTime
module="StoryViewer__meta--timestamp"
timestamp={timestamp}
/>
{distributionList && (
<div className="StoryViewer__meta__list">
<StoryDistributionListName
id={distributionList.id}
name={distributionList.name}
i18n={i18n}
/>
</div>
)}
2022-05-06 19:02:44 +00:00
</div>
</div>
<div className="StoryViewer__meta__playback-controls">
<button
aria-label={
pauseStory
? i18n('StoryViewer__play')
: i18n('StoryViewer__pause')
}
className={
pauseStory ? 'StoryViewer__play' : 'StoryViewer__pause'
}
onClick={() => setPauseStory(!pauseStory)}
type="button"
/>
<button
aria-label={muteAriaLabel}
className={muteClassName}
onClick={
hasAudio
? () => setHasAllStoriesUnmuted(!hasAllStoriesUnmuted)
: () => showToast(ToastType.StoryMuted)
2022-05-06 19:02:44 +00:00
}
type="button"
/>
2022-11-09 02:38:19 +00:00
{contextMenuOptions && (
<ContextMenu
aria-label={i18n('MyStories__more')}
i18n={i18n}
menuOptions={contextMenuOptions}
moduleClassName="StoryViewer__more"
onMenuShowingChanged={setIsShowingContextMenu}
theme={Theme.Dark}
/>
)}
2022-05-06 19:02:44 +00:00
</div>
2022-05-04 18:45:32 +00:00
</div>
<div className="StoryViewer__progress">
2022-07-06 19:06:20 +00:00
{Array.from(Array(numStories), (_, index) => (
<div className="StoryViewer__progress--container" key={index}>
{currentIndex === index ? (
<div
ref={progressBarRef}
2022-05-04 18:45:32 +00:00
className="StoryViewer__progress--bar"
/>
) : (
<div
className="StoryViewer__progress--bar"
style={
currentIndex < index
? {}
: {
transform: 'translateX(0%)',
}
}
2022-05-04 18:45:32 +00:00
/>
)}
</div>
))}
</div>
<div className="StoryViewer__actions">
2022-11-16 22:10:11 +00:00
{sendStatus === ResolvedSendStatus.Failed && !wasManuallyRetried && (
<button
className="StoryViewer__actions__failed"
onClick={doRetrySend}
type="button"
>
{i18n('StoryViewer__failed')}
</button>
)}
{sendStatus === ResolvedSendStatus.PartiallySent &&
!wasManuallyRetried && (
<button
className="StoryViewer__actions__failed"
onClick={doRetrySend}
type="button"
>
{i18n('StoryViewer__partial-fail')}
</button>
)}
{sendStatus === ResolvedSendStatus.Sending && (
<div className="StoryViewer__sending">
<Spinner
moduleClassName="StoryViewer__sending__spinner"
svgSize="small"
/>
{i18n('StoryViewer__sending')}
</div>
)}
2022-11-19 08:09:03 +00:00
{(canReply ||
(isSent && sendStatus === ResolvedSendStatus.Sent)) && (
2022-05-04 18:45:32 +00:00
<button
className="StoryViewer__reply"
2022-10-11 17:59:02 +00:00
onClick={() =>
setCurrentViewTarget(StoryViewTargetType.Replies)
}
2022-05-04 18:45:32 +00:00
tabIndex={0}
type="button"
>
2022-11-18 00:45:19 +00:00
{isSent || replyCount > 0 ? (
<span className="StoryViewer__reply__chevron">
{isSent && !hasViewReceiptSetting && !replyCount && (
<>{i18n('StoryViewer__views-off')}</>
)}
{isSent &&
hasViewReceiptSetting &&
(viewCount === 1 ? (
<Intl
i18n={i18n}
id="MyStories__views--singular"
components={[<strong>{viewCount}</strong>]}
/>
) : (
<Intl
i18n={i18n}
id="MyStories__views--plural"
components={[<strong>{viewCount}</strong>]}
/>
))}
{(isSent || viewCount > 0) && replyCount > 0 && ' '}
{replyCount > 0 &&
(replyCount === 1 ? (
<Intl
i18n={i18n}
id="MyStories__replies--singular"
components={[<strong>{replyCount}</strong>]}
/>
) : (
<Intl
i18n={i18n}
id="MyStories__replies--plural"
components={[<strong>{replyCount}</strong>]}
/>
))}
</span>
) : null}
{!isSent && !replyCount && (
<span className="StoryViewer__reply__arrow">
{isGroupStory
? i18n('StoryViewer__reply-group')
: i18n('StoryViewer__reply')}
</span>
)}
2022-05-04 18:45:32 +00:00
</button>
)}
2022-03-04 21:14:52 +00:00
</div>
</div>
2022-04-22 18:36:34 +00:00
<button
aria-label={i18n('close')}
className="StoryViewer__close-button"
onClick={onClose}
tabIndex={0}
type="button"
/>
2022-03-04 21:14:52 +00:00
</div>
2022-10-11 17:59:02 +00:00
{currentViewTarget === StoryViewTargetType.Details && (
2022-07-25 18:55:44 +00:00
<StoryDetailsModal
2022-11-22 22:33:15 +00:00
attachment={attachment}
2022-07-25 18:55:44 +00:00
getPreferredBadge={getPreferredBadge}
i18n={i18n}
2022-11-22 22:33:15 +00:00
isInternalUser={isInternalUser}
2022-10-11 17:59:02 +00:00
onClose={() => setCurrentViewTarget(null)}
2022-11-22 22:33:15 +00:00
saveAttachment={saveAttachment}
2022-07-25 18:55:44 +00:00
sender={story.sender}
sendState={sendState}
timestamp={timestamp}
expirationTimestamp={story.expirationTimestamp}
2022-07-25 18:55:44 +00:00
/>
)}
2022-10-11 17:59:02 +00:00
{(currentViewTarget === StoryViewTargetType.Replies ||
currentViewTarget === StoryViewTargetType.Views) && (
<StoryViewsNRepliesModal
2022-11-30 21:47:54 +00:00
conversationTitle={group?.title ?? title}
authorTitle={firstName || title}
2022-07-25 18:55:44 +00:00
canReply={Boolean(canReply)}
getPreferredBadge={getPreferredBadge}
2022-10-25 22:18:42 +00:00
hasViewReceiptSetting={hasViewReceiptSetting}
2022-09-21 19:19:16 +00:00
hasViewsCapability={isSent}
i18n={i18n}
2022-11-22 22:33:15 +00:00
isInternalUser={isInternalUser}
group={group}
2022-10-11 17:59:02 +00:00
onClose={() => setCurrentViewTarget(null)}
onReact={emoji => {
2022-07-06 19:06:20 +00:00
onReactToStory(emoji, story);
if (!isGroupStory) {
2022-10-11 17:59:02 +00:00
setCurrentViewTarget(null);
showToast(ToastType.StoryReact);
}
2022-05-04 17:43:22 +00:00
setReactionEmoji(emoji);
}}
onReply={(message, mentions, replyTimestamp) => {
2022-04-23 03:16:13 +00:00
if (!isGroupStory) {
2022-10-11 17:59:02 +00:00
setCurrentViewTarget(null);
showToast(ToastType.StoryReply);
2022-04-23 03:16:13 +00:00
}
2022-07-06 19:06:20 +00:00
onReplyToStory(message, mentions, replyTimestamp, story);
}}
onSetSkinTone={onSetSkinTone}
onTextTooLong={onTextTooLong}
onUseEmoji={onUseEmoji}
preferredReactionEmoji={preferredReactionEmoji}
recentEmojis={recentEmojis}
renderEmojiPicker={renderEmojiPicker}
2022-04-15 00:08:46 +00:00
replies={replies}
skinTone={skinTone}
2022-08-23 18:02:51 +00:00
sortedGroupMembers={group?.sortedGroupMembers}
storyPreviewAttachment={attachment}
2022-07-01 00:52:03 +00:00
views={views}
2022-10-11 17:59:02 +00:00
viewTarget={currentViewTarget}
onChangeViewTarget={setCurrentViewTarget}
2022-11-04 13:22:07 +00:00
deleteGroupStoryReply={deleteGroupStoryReply}
deleteGroupStoryReplyForEveryone={deleteGroupStoryReplyForEveryone}
/>
)}
{hasConfirmHideStory && (
<ConfirmationDialog
2022-09-27 20:24:21 +00:00
dialogName="StoryViewer.confirmHideStory"
actions={[
{
action: () => {
onHideStory(conversationId);
onClose();
},
style: 'affirmative',
text: i18n('StoryListItem__hide-modal--confirm'),
},
]}
i18n={i18n}
onClose={() => {
setHasConfirmHideStory(false);
}}
>
{i18n('StoryListItem__hide-modal--body', [String(firstName)])}
</ConfirmationDialog>
)}
2022-07-25 18:55:44 +00:00
{confirmDeleteStory && (
<ConfirmationDialog
2022-09-27 20:24:21 +00:00
dialogName="StoryViewer.deleteStory"
2022-07-25 18:55:44 +00:00
actions={[
{
text: i18n('delete'),
action: () => deleteStoryForEveryone(confirmDeleteStory),
style: 'negative',
},
]}
i18n={i18n}
onClose={() => setConfirmDeleteStory(undefined)}
>
{i18n('MyStories__delete')}
</ConfirmationDialog>
)}
2022-03-04 21:14:52 +00:00
</div>
</FocusTrap>
2022-03-04 21:14:52 +00:00
);
2022-11-18 00:45:19 +00:00
}