Fixes story viewing behavior
This commit is contained in:
parent
c4b6eebcd6
commit
3e644f45cf
26 changed files with 960 additions and 939 deletions
|
@ -2,13 +2,7 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useSpring, animated, to } from '@react-spring/web';
|
||||
import type { BodyRangeType, LocalizerType } from '../types/Util';
|
||||
|
@ -17,6 +11,8 @@ import type { EmojiPickDataType } from './emoji/EmojiPicker';
|
|||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||
import type { RenderEmojiPickerProps } from './conversation/ReactionPicker';
|
||||
import type { ReplyStateType, StoryViewType } from '../types/Stories';
|
||||
import type { ViewStoryActionCreatorType } from '../state/ducks/stories';
|
||||
import * as log from '../logging/log';
|
||||
import { AnimatedEmojiGalore } from './AnimatedEmojiGalore';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
|
@ -25,18 +21,17 @@ import { Intl } from './Intl';
|
|||
import { MessageTimestamp } from './conversation/MessageTimestamp';
|
||||
import { SendStatus } from '../messages/MessageSendState';
|
||||
import { StoryImage } from './StoryImage';
|
||||
import { StoryViewDirectionType, StoryViewModeType } from '../types/Stories';
|
||||
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
|
||||
import { Theme } from '../util/theme';
|
||||
import { getAvatarColor } from '../types/Colors';
|
||||
import { getStoryBackground } from '../util/getStoryBackground';
|
||||
import { getStoryDuration } from '../util/getStoryDuration';
|
||||
import { graphemeAwareSlice } from '../util/graphemeAwareSlice';
|
||||
import { isDownloaded, isDownloading } from '../types/Attachment';
|
||||
import { useEscapeHandling } from '../hooks/useEscapeHandling';
|
||||
import * as log from '../logging/log';
|
||||
|
||||
export type PropsType = {
|
||||
conversationId: string;
|
||||
currentIndex: number;
|
||||
getPreferredBadge: PreferredBadgeSelectorType;
|
||||
group?: Pick<
|
||||
ConversationType,
|
||||
|
@ -53,11 +48,9 @@ export type PropsType = {
|
|||
i18n: LocalizerType;
|
||||
loadStoryReplies: (conversationId: string, messageId: string) => unknown;
|
||||
markStoryRead: (mId: string) => unknown;
|
||||
onClose: () => unknown;
|
||||
numStories: number;
|
||||
onGoToConversation: (conversationId: string) => unknown;
|
||||
onHideStory: (conversationId: string) => unknown;
|
||||
onNextUserStories?: () => unknown;
|
||||
onPrevUserStories?: () => unknown;
|
||||
onSetSkinTone: (tone: number) => unknown;
|
||||
onTextTooLong: () => unknown;
|
||||
onReactToStory: (emoji: string, story: StoryViewType) => unknown;
|
||||
|
@ -74,8 +67,10 @@ export type PropsType = {
|
|||
renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element;
|
||||
replyState?: ReplyStateType;
|
||||
skinTone?: number;
|
||||
stories: Array<StoryViewType>;
|
||||
story: StoryViewType;
|
||||
storyViewMode?: StoryViewModeType;
|
||||
toggleHasAllStoriesMuted: () => unknown;
|
||||
viewStory: ViewStoryActionCreatorType;
|
||||
};
|
||||
|
||||
const CAPTION_BUFFER = 20;
|
||||
|
@ -90,18 +85,16 @@ enum Arrow {
|
|||
}
|
||||
|
||||
export const StoryViewer = ({
|
||||
conversationId,
|
||||
currentIndex,
|
||||
getPreferredBadge,
|
||||
group,
|
||||
hasAllStoriesMuted,
|
||||
i18n,
|
||||
loadStoryReplies,
|
||||
markStoryRead,
|
||||
onClose,
|
||||
numStories,
|
||||
onGoToConversation,
|
||||
onHideStory,
|
||||
onNextUserStories,
|
||||
onPrevUserStories,
|
||||
onReactToStory,
|
||||
onReplyToStory,
|
||||
onSetSkinTone,
|
||||
|
@ -113,10 +106,11 @@ export const StoryViewer = ({
|
|||
renderEmojiPicker,
|
||||
replyState,
|
||||
skinTone,
|
||||
stories,
|
||||
story,
|
||||
storyViewMode,
|
||||
toggleHasAllStoriesMuted,
|
||||
viewStory,
|
||||
}: PropsType): JSX.Element => {
|
||||
const [currentStoryIndex, setCurrentStoryIndex] = useState(0);
|
||||
const [storyDuration, setStoryDuration] = useState<number | undefined>();
|
||||
const [isShowingContextMenu, setIsShowingContextMenu] = useState(false);
|
||||
const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false);
|
||||
|
@ -124,10 +118,8 @@ export const StoryViewer = ({
|
|||
useState<HTMLButtonElement | null>(null);
|
||||
const [reactionEmoji, setReactionEmoji] = useState<string | undefined>();
|
||||
|
||||
const visibleStory = stories[currentStoryIndex];
|
||||
|
||||
const { attachment, canReply, isHidden, messageId, sendState, timestamp } =
|
||||
visibleStory;
|
||||
story;
|
||||
const {
|
||||
acceptedMessageRequest,
|
||||
avatarPath,
|
||||
|
@ -139,10 +131,14 @@ export const StoryViewer = ({
|
|||
profileName,
|
||||
sharedGroupNames,
|
||||
title,
|
||||
} = visibleStory.sender;
|
||||
} = story.sender;
|
||||
|
||||
const [hasReplyModal, setHasReplyModal] = useState(false);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
viewStory();
|
||||
}, [viewStory]);
|
||||
|
||||
const onEscape = useCallback(() => {
|
||||
if (hasReplyModal) {
|
||||
setHasReplyModal(false);
|
||||
|
@ -173,48 +169,6 @@ export const StoryViewer = ({
|
|||
setHasExpandedCaption(false);
|
||||
}, [messageId]);
|
||||
|
||||
// These exist to change currentStoryIndex to the oldest unread story a user
|
||||
// has, or set to 0 whenever conversationId changes.
|
||||
// We use a ref so that we only depend on conversationId changing, since
|
||||
// the stories Array will change once we mark as story as viewed.
|
||||
const storiesRef = useRef(stories);
|
||||
|
||||
useEffect(() => {
|
||||
const unreadStoryIndex = storiesRef.current.findIndex(
|
||||
story => story.isUnread
|
||||
);
|
||||
log.info('stories.findUnreadStory', {
|
||||
unreadStoryIndex,
|
||||
stories: storiesRef.current.length,
|
||||
});
|
||||
setCurrentStoryIndex(unreadStoryIndex < 0 ? 0 : unreadStoryIndex);
|
||||
}, [conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
storiesRef.current = stories;
|
||||
}, [stories]);
|
||||
|
||||
// Either we show the next story in the current user's stories or we ask
|
||||
// for the next user's stories.
|
||||
const showNextStory = useCallback(() => {
|
||||
if (currentStoryIndex < stories.length - 1) {
|
||||
setCurrentStoryIndex(currentStoryIndex + 1);
|
||||
} else {
|
||||
setCurrentStoryIndex(0);
|
||||
onNextUserStories?.();
|
||||
}
|
||||
}, [currentStoryIndex, onNextUserStories, stories.length]);
|
||||
|
||||
// Either we show the previous story in the current user's stories or we ask
|
||||
// for the prior user's stories.
|
||||
const showPrevStory = useCallback(() => {
|
||||
if (currentStoryIndex === 0) {
|
||||
onPrevUserStories?.();
|
||||
} else {
|
||||
setCurrentStoryIndex(currentStoryIndex - 1);
|
||||
}
|
||||
}, [currentStoryIndex, onPrevUserStories]);
|
||||
|
||||
useEffect(() => {
|
||||
let shouldCancel = false;
|
||||
(async function hydrateStoryDuration() {
|
||||
|
@ -247,12 +201,16 @@ export const StoryViewer = ({
|
|||
onRest: {
|
||||
width: ({ value }) => {
|
||||
if (value === 100) {
|
||||
showNextStory();
|
||||
viewStory(
|
||||
story.messageId,
|
||||
storyViewMode,
|
||||
StoryViewDirectionType.Next
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
[showNextStory]
|
||||
[story.messageId, storyViewMode, viewStory]
|
||||
);
|
||||
|
||||
// We need to be careful about this effect refreshing, it should only run
|
||||
|
@ -274,7 +232,7 @@ export const StoryViewer = ({
|
|||
return () => {
|
||||
spring.stop();
|
||||
};
|
||||
}, [currentStoryIndex, spring, storyDuration]);
|
||||
}, [currentIndex, spring, storyDuration]);
|
||||
|
||||
const [pauseStory, setPauseStory] = useState(false);
|
||||
|
||||
|
@ -299,32 +257,23 @@ export const StoryViewer = ({
|
|||
log.info('stories.markStoryRead', { messageId });
|
||||
}, [markStoryRead, messageId]);
|
||||
|
||||
// Queue all undownloaded stories once we're viewing someone's stories
|
||||
const storiesToDownload = useMemo(() => {
|
||||
return stories
|
||||
.filter(
|
||||
story =>
|
||||
!isDownloaded(story.attachment) && !isDownloading(story.attachment)
|
||||
)
|
||||
.map(story => story.messageId);
|
||||
}, [stories]);
|
||||
useEffect(() => {
|
||||
storiesToDownload.forEach(storyId => queueStoryDownload(storyId));
|
||||
}, [queueStoryDownload, storiesToDownload]);
|
||||
|
||||
const navigateStories = useCallback(
|
||||
(ev: KeyboardEvent) => {
|
||||
if (ev.key === 'ArrowRight') {
|
||||
showNextStory();
|
||||
viewStory(story.messageId, storyViewMode, StoryViewDirectionType.Next);
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
} else if (ev.key === 'ArrowLeft') {
|
||||
showPrevStory();
|
||||
viewStory(
|
||||
story.messageId,
|
||||
storyViewMode,
|
||||
StoryViewDirectionType.Previous
|
||||
);
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
},
|
||||
[showPrevStory, showNextStory]
|
||||
[story.messageId, storyViewMode, viewStory]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -335,13 +284,14 @@ export const StoryViewer = ({
|
|||
};
|
||||
}, [navigateStories]);
|
||||
|
||||
const isGroupStory = Boolean(group?.id);
|
||||
const groupId = group?.id;
|
||||
const isGroupStory = Boolean(groupId);
|
||||
useEffect(() => {
|
||||
if (!isGroupStory) {
|
||||
if (!groupId) {
|
||||
return;
|
||||
}
|
||||
loadStoryReplies(conversationId, messageId);
|
||||
}, [conversationId, isGroupStory, loadStoryReplies, messageId]);
|
||||
loadStoryReplies(groupId, messageId);
|
||||
}, [groupId, loadStoryReplies, messageId]);
|
||||
|
||||
const [arrowToShow, setArrowToShow] = useState<Arrow>(Arrow.None);
|
||||
|
||||
|
@ -385,6 +335,8 @@ export const StoryViewer = ({
|
|||
|
||||
const shouldShowContextMenu = !sendState;
|
||||
|
||||
const hasPrevNextArrows = storyViewMode !== StoryViewModeType.Single;
|
||||
|
||||
return (
|
||||
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
|
||||
<div className="StoryViewer">
|
||||
|
@ -393,7 +345,7 @@ export const StoryViewer = ({
|
|||
style={{ background: getStoryBackground(attachment) }}
|
||||
/>
|
||||
<div className="StoryViewer__content">
|
||||
{onPrevUserStories && (
|
||||
{hasPrevNextArrows && (
|
||||
<button
|
||||
aria-label={i18n('back')}
|
||||
className={classNames(
|
||||
|
@ -402,7 +354,13 @@ export const StoryViewer = ({
|
|||
'StoryViewer__arrow--visible': arrowToShow === Arrow.Left,
|
||||
}
|
||||
)}
|
||||
onClick={showPrevStory}
|
||||
onClick={() =>
|
||||
viewStory(
|
||||
story.messageId,
|
||||
storyViewMode,
|
||||
StoryViewDirectionType.Previous
|
||||
)
|
||||
}
|
||||
onMouseMove={() => setArrowToShow(Arrow.Left)}
|
||||
type="button"
|
||||
/>
|
||||
|
@ -549,12 +507,9 @@ export const StoryViewer = ({
|
|||
</div>
|
||||
</div>
|
||||
<div className="StoryViewer__progress">
|
||||
{stories.map((story, index) => (
|
||||
<div
|
||||
className="StoryViewer__progress--container"
|
||||
key={story.messageId}
|
||||
>
|
||||
{currentStoryIndex === index ? (
|
||||
{Array.from(Array(numStories), (_, index) => (
|
||||
<div className="StoryViewer__progress--container" key={index}>
|
||||
{currentIndex === index ? (
|
||||
<animated.div
|
||||
className="StoryViewer__progress--bar"
|
||||
style={{
|
||||
|
@ -565,7 +520,7 @@ export const StoryViewer = ({
|
|||
<div
|
||||
className="StoryViewer__progress--bar"
|
||||
style={{
|
||||
width: currentStoryIndex < index ? '0%' : '100%',
|
||||
width: currentIndex < index ? '0%' : '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@ -626,7 +581,7 @@ export const StoryViewer = ({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onNextUserStories && (
|
||||
{hasPrevNextArrows && (
|
||||
<button
|
||||
aria-label={i18n('forward')}
|
||||
className={classNames(
|
||||
|
@ -635,7 +590,13 @@ export const StoryViewer = ({
|
|||
'StoryViewer__arrow--visible': arrowToShow === Arrow.Right,
|
||||
}
|
||||
)}
|
||||
onClick={showNextStory}
|
||||
onClick={() =>
|
||||
viewStory(
|
||||
story.messageId,
|
||||
storyViewMode,
|
||||
StoryViewDirectionType.Next
|
||||
)
|
||||
}
|
||||
onMouseMove={() => setArrowToShow(Arrow.Right)}
|
||||
type="button"
|
||||
/>
|
||||
|
@ -686,7 +647,7 @@ export const StoryViewer = ({
|
|||
isMyStory={isMe}
|
||||
onClose={() => setHasReplyModal(false)}
|
||||
onReact={emoji => {
|
||||
onReactToStory(emoji, visibleStory);
|
||||
onReactToStory(emoji, story);
|
||||
setHasReplyModal(false);
|
||||
setReactionEmoji(emoji);
|
||||
}}
|
||||
|
@ -694,7 +655,7 @@ export const StoryViewer = ({
|
|||
if (!isGroupStory) {
|
||||
setHasReplyModal(false);
|
||||
}
|
||||
onReplyToStory(message, mentions, replyTimestamp, visibleStory);
|
||||
onReplyToStory(message, mentions, replyTimestamp, story);
|
||||
}}
|
||||
onSetSkinTone={onSetSkinTone}
|
||||
onTextTooLong={onTextTooLong}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue