Fixes story viewing behavior

This commit is contained in:
Josh Perez 2022-07-06 15:06:20 -04:00 committed by GitHub
parent c4b6eebcd6
commit 3e644f45cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 960 additions and 939 deletions

View File

@ -17,6 +17,7 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
import type { MenuOptionsType, MenuActionType } from '../types/menu';
import { TitleBarContainer } from './TitleBarContainer';
import type { ExecuteMenuRoleType } from './TitleBarContainer';
import type { SelectedStoryDataType } from '../state/ducks/stories';
type PropsType = {
appView: AppViewType;
@ -27,6 +28,8 @@ type PropsType = {
renderGlobalModalContainer: () => JSX.Element;
isShowingStoriesView: boolean;
renderStories: () => JSX.Element;
selectedStoryData?: SelectedStoryDataType;
renderStoryViewer: () => JSX.Element;
requestVerification: (
type: 'sms' | 'voice',
number: string,
@ -69,9 +72,11 @@ export const App = ({
renderLeftPane,
renderSafetyNumber,
renderStories,
renderStoryViewer,
requestVerification,
selectedConversationId,
selectedMessage,
selectedStoryData,
showConversation,
showWhatsNewModal,
theme,
@ -169,6 +174,7 @@ export const App = ({
{renderGlobalModalContainer()}
{renderCallManager()}
{isShowingStoriesView && renderStories()}
{selectedStoryData && renderStoryViewer()}
{contents}
</div>
</TitleBarContainer>

View File

@ -47,6 +47,7 @@ export default {
renderStoryViewer: {
action: true,
},
viewStory: { action: true },
},
} as Meta;

View File

@ -4,10 +4,10 @@
import React, { useState } from 'react';
import type { MyStoryType, StoryViewType } from '../types/Stories';
import type { LocalizerType } from '../types/Util';
import type { PropsType as SmartStoryViewerPropsType } from '../state/smart/StoryViewer';
import type { ViewStoryActionCreatorType } from '../state/ducks/stories';
import { ConfirmationDialog } from './ConfirmationDialog';
import { ContextMenu } from './ContextMenu';
import { MY_STORIES_ID } from '../types/Stories';
import { MY_STORIES_ID, StoryViewModeType } from '../types/Stories';
import { MessageTimestamp } from './conversation/MessageTimestamp';
import { StoryImage } from './StoryImage';
import { Theme } from '../util/theme';
@ -19,9 +19,8 @@ export type PropsType = {
onDelete: (story: StoryViewType) => unknown;
onForward: (storyId: string) => unknown;
onSave: (story: StoryViewType) => unknown;
ourConversationId: string;
queueStoryDownload: (storyId: string) => unknown;
renderStoryViewer: (props: SmartStoryViewerPropsType) => JSX.Element;
viewStory: ViewStoryActionCreatorType;
};
export const MyStories = ({
@ -31,16 +30,13 @@ export const MyStories = ({
onDelete,
onForward,
onSave,
ourConversationId,
queueStoryDownload,
renderStoryViewer,
viewStory,
}: PropsType): JSX.Element => {
const [confirmDeleteStory, setConfirmDeleteStory] = useState<
StoryViewType | undefined
>();
const [storyToView, setStoryToView] = useState<StoryViewType | undefined>();
return (
<>
{confirmDeleteStory && (
@ -58,12 +54,6 @@ export const MyStories = ({
{i18n('MyStories__delete')}
</ConfirmationDialog>
)}
{storyToView &&
renderStoryViewer({
conversationId: ourConversationId,
onClose: () => setStoryToView(undefined),
storyToView,
})}
<div className="Stories__pane__header Stories__pane__header--centered">
<button
aria-label={i18n('back')}
@ -89,7 +79,9 @@ export const MyStories = ({
<button
aria-label={i18n('MyStories__story')}
className="MyStories__story__preview"
onClick={() => setStoryToView(story)}
onClick={() =>
viewStory(story.messageId, StoryViewModeType.Single)
}
type="button"
>
<StoryImage

View File

@ -28,6 +28,9 @@ export default {
i18n: {
defaultValue: i18n,
},
me: {
defaultValue: getDefaultConversation(),
},
myStories: {
defaultValue: [],
},
@ -48,6 +51,8 @@ export default {
},
toggleHideStories: { action: true },
toggleStoriesView: { action: true },
viewUserStories: { action: true },
viewStory: { action: true },
},
} as Meta;

View File

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import FocusTrap from 'focus-trap-react';
import React, { useCallback, useState } from 'react';
import React, { useState } from 'react';
import classNames from 'classnames';
import type {
ConversationType,
@ -15,8 +15,7 @@ import type {
} from '../types/Stories';
import type { LocalizerType } from '../types/Util';
import type { PropsType as SmartStoryCreatorPropsType } from '../state/smart/StoryCreator';
import type { PropsType as SmartStoryViewerPropsType } from '../state/smart/StoryViewer';
import * as log from '../logging/log';
import type { ViewStoryActionCreatorType } from '../state/ducks/stories';
import { MyStories } from './MyStories';
import { StoriesPane } from './StoriesPane';
import { Theme, themeClassName } from '../util/theme';
@ -30,15 +29,15 @@ export type PropsType = {
myStories: Array<MyStoryType>;
onForwardStory: (storyId: string) => unknown;
onSaveStory: (story: StoryViewType) => unknown;
ourConversationId: string;
preferredWidthFromStorage: number;
queueStoryDownload: (storyId: string) => unknown;
renderStoryCreator: (props: SmartStoryCreatorPropsType) => JSX.Element;
renderStoryViewer: (props: SmartStoryViewerPropsType) => JSX.Element;
showConversation: ShowConversationType;
stories: Array<ConversationStoryType>;
toggleHideStories: (conversationId: string) => unknown;
toggleStoriesView: () => unknown;
viewUserStories: (conversationId: string) => unknown;
viewStory: ViewStoryActionCreatorType;
};
export const Stories = ({
@ -49,76 +48,20 @@ export const Stories = ({
myStories,
onForwardStory,
onSaveStory,
ourConversationId,
preferredWidthFromStorage,
queueStoryDownload,
renderStoryCreator,
renderStoryViewer,
showConversation,
stories,
toggleHideStories,
toggleStoriesView,
viewUserStories,
viewStory,
}: PropsType): JSX.Element => {
const [conversationIdToView, setConversationIdToView] = useState<
undefined | string
>();
const width = getWidthFromPreferredWidth(preferredWidthFromStorage, {
requiresFullWidth: true,
});
const onNextUserStories = useCallback(() => {
// First find the next unread story if there are any
const nextUnreadIndex = stories.findIndex(conversationStory =>
conversationStory.stories.some(story => story.isUnread)
);
log.info('stories.onNextUserStories', { nextUnreadIndex });
if (nextUnreadIndex >= 0) {
const nextStory = stories[nextUnreadIndex];
setConversationIdToView(nextStory.conversationId);
return;
}
// If not then play the next available story
const storyIndex = stories.findIndex(
x => x.conversationId === conversationIdToView
);
log.info('stories.onNextUserStories', {
storyIndex,
length: stories.length,
});
// If we've reached the end, close the viewer
if (storyIndex >= stories.length - 1 || storyIndex === -1) {
setConversationIdToView(undefined);
return;
}
const nextStory = stories[storyIndex + 1];
setConversationIdToView(nextStory.conversationId);
}, [conversationIdToView, stories]);
const onPrevUserStories = useCallback(() => {
const storyIndex = stories.findIndex(
x => x.conversationId === conversationIdToView
);
log.info('stories.onPrevUserStories', {
storyIndex,
length: stories.length,
});
if (storyIndex <= 0) {
// Restart playback on the story if it's the oldest
setConversationIdToView(conversationIdToView);
return;
}
const prevStory = stories[storyIndex - 1];
setConversationIdToView(prevStory.conversationId);
}, [conversationIdToView, stories]);
const [isShowingStoryCreator, setIsShowingStoryCreator] = useState(false);
const [isMyStories, setIsMyStories] = useState(false);
@ -128,13 +71,6 @@ export const Stories = ({
renderStoryCreator({
onClose: () => setIsShowingStoryCreator(false),
})}
{conversationIdToView &&
renderStoryViewer({
conversationId: conversationIdToView,
onClose: () => setConversationIdToView(undefined),
onNextUserStories,
onPrevUserStories,
})}
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
<div className="Stories__pane" style={{ width }}>
{isMyStories && myStories.length ? (
@ -145,9 +81,8 @@ export const Stories = ({
onDelete={deleteStoryForEveryone}
onForward={onForwardStory}
onSave={onSaveStory}
ourConversationId={ourConversationId}
queueStoryDownload={queueStoryDownload}
renderStoryViewer={renderStoryViewer}
viewStory={viewStory}
/>
) : (
<StoriesPane
@ -163,16 +98,7 @@ export const Stories = ({
setIsShowingStoryCreator(true);
}
}}
onStoryClicked={clickedIdToView => {
const storyIndex = stories.findIndex(
x => x.conversationId === clickedIdToView
);
log.info('stories.onStoryClicked[StoriesPane]', {
storyIndex,
length: stories.length,
});
setConversationIdToView(clickedIdToView);
}}
onStoryClicked={viewUserStories}
queueStoryDownload={queueStoryDownload}
showConversation={showConversation}
stories={stories}

View File

@ -21,17 +21,14 @@ import { StoryListItem } from './StoryListItem';
import { isNotNil } from '../util/isNotNil';
const FUSE_OPTIONS: Fuse.IFuseOptions<ConversationStoryType> = {
getFn: (obj, path) => {
getFn: (story, path) => {
if (path === 'searchNames') {
return obj.stories
.flatMap((story: StoryViewType) => [
story.sender.title,
story.sender.name,
])
.filter(isNotNil);
return [story.storyView.sender.title, story.storyView.sender.name].filter(
isNotNil
);
}
return obj.group?.title ?? '';
return story.group?.title ?? '';
},
keys: [
{
@ -55,9 +52,7 @@ function search(
.map(result => result.item);
}
function getNewestStory(
story: ConversationStoryType | MyStoryType
): StoryViewType {
function getNewestMyStory(story: MyStoryType): StoryViewType {
return story.stories[story.stories.length - 1];
}
@ -137,7 +132,7 @@ export const StoriesPane = ({
i18n={i18n}
me={me}
newestStory={
myStories.length ? getNewestStory(myStories[0]) : undefined
myStories.length ? getNewestMyStory(myStories[0]) : undefined
}
onClick={onMyStoriesClicked}
queueStoryDownload={queueStoryDownload}
@ -151,7 +146,7 @@ export const StoriesPane = ({
<StoryListItem
group={story.group}
i18n={i18n}
key={getNewestStory(story).timestamp}
key={story.storyView.timestamp}
onClick={() => {
onStoryClicked(story.conversationId);
}}
@ -161,7 +156,7 @@ export const StoriesPane = ({
toggleStoriesView();
}}
queueStoryDownload={queueStoryDownload}
story={getNewestStory(story)}
story={story.storyView}
/>
))}
{Boolean(hiddenStories.length) && (
@ -178,7 +173,7 @@ export const StoriesPane = ({
{isShowingHiddenStories &&
hiddenStories.map(story => (
<StoryListItem
key={getNewestStory(story).timestamp}
key={story.storyView.timestamp}
i18n={i18n}
isHidden
onClick={() => {
@ -190,7 +185,7 @@ export const StoriesPane = ({
toggleStoriesView();
}}
queueStoryDownload={queueStoryDownload}
story={getNewestStory(story)}
story={story.storyView}
/>
))}
</>

View File

@ -1,8 +1,8 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, Story } from '@storybook/react';
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { PropsType } from './StoryViewer';
import { StoryViewer } from './StoryViewer';
@ -10,184 +10,114 @@ import enMessages from '../../_locales/en/messages.json';
import { setupI18n } from '../util/setupI18n';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { fakeAttachment } from '../test-both/helpers/fakeAttachment';
import { getFakeStoryView } from '../test-both/helpers/getFakeStory';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/StoryViewer',
};
component: StoryViewer,
argTypes: {
currentIndex: {
defaultvalue: 0,
},
getPreferredBadge: { action: true },
group: {
defaultValue: undefined,
},
hasAllStoriesMuted: {
defaultValue: false,
},
i18n: {
defaultValue: i18n,
},
loadStoryReplies: { action: true },
markStoryRead: { action: true },
numStories: {
defaultValue: 1,
},
onGoToConversation: { action: true },
onHideStory: { action: true },
onReactToStory: { action: true },
onReplyToStory: { action: true },
onSetSkinTone: { action: true },
onTextTooLong: { action: true },
onUseEmoji: { action: true },
preferredReactionEmoji: {
defaultValue: ['❤️', '👍', '👎', '😂', '😮', '😢'],
},
queueStoryDownload: { action: true },
renderEmojiPicker: { action: true },
skinTone: {
defaultValue: 0,
},
story: {
defaultValue: getFakeStoryView(),
},
toggleHasAllStoriesMuted: { action: true },
viewStory: { action: true },
},
} as Meta;
function getDefaultProps(): PropsType {
const sender = getDefaultConversation();
return {
conversationId: sender.id,
getPreferredBadge: () => undefined,
group: undefined,
hasAllStoriesMuted: false,
i18n,
loadStoryReplies: action('loadStoryReplies'),
markStoryRead: action('markStoryRead'),
onClose: action('onClose'),
onGoToConversation: action('onGoToConversation'),
onHideStory: action('onHideStory'),
onNextUserStories: action('onNextUserStories'),
onPrevUserStories: action('onPrevUserStories'),
onReactToStory: action('onReactToStory'),
onReplyToStory: action('onReplyToStory'),
onSetSkinTone: action('onSetSkinTone'),
onTextTooLong: action('onTextTooLong'),
onUseEmoji: action('onUseEmoji'),
preferredReactionEmoji: ['❤️', '👍', '👎', '😂', '😮', '😢'],
queueStoryDownload: action('queueStoryDownload'),
renderEmojiPicker: () => <div />,
stories: [
{
attachment: fakeAttachment({
path: 'snow.jpg',
url: '/fixtures/snow.jpg',
}),
canReply: true,
messageId: '123',
sender,
timestamp: Date.now(),
},
],
toggleHasAllStoriesMuted: action('toggleHasAllStoriesMuted'),
};
}
export const SomeonesStory = (): JSX.Element => (
<StoryViewer {...getDefaultProps()} />
);
const Template: Story<PropsType> = args => <StoryViewer {...args} />;
export const SomeonesStory = Template.bind({});
SomeonesStory.args = {};
SomeonesStory.story = {
name: "Someone's story",
};
export const WideStory = (): JSX.Element => (
<StoryViewer
{...getDefaultProps()}
stories={[
{
attachment: fakeAttachment({
path: 'file.jpg',
url: '/fixtures/nathan-anderson-316188-unsplash.jpg',
}),
canReply: true,
messageId: '123',
sender: getDefaultConversation(),
timestamp: Date.now(),
},
]}
/>
);
export const WideStory = Template.bind({});
WideStory.args = {
story: getFakeStoryView('/fixtures/nathan-anderson-316188-unsplash.jpg'),
};
WideStory.story = {
name: 'Wide story',
};
export const InAGroup = (): JSX.Element => (
<StoryViewer
{...getDefaultProps()}
group={getDefaultConversation({
avatarPath: '/fixtures/kitten-4-112-112.jpg',
title: 'Family Group',
type: 'group',
})}
/>
);
export const InAGroup = Template.bind({});
InAGroup.args = {
group: getDefaultConversation({
avatarPath: '/fixtures/kitten-4-112-112.jpg',
title: 'Family Group',
type: 'group',
}),
};
InAGroup.story = {
name: 'In a group',
};
export const MultiStory = (): JSX.Element => {
const sender = getDefaultConversation();
return (
<StoryViewer
{...getDefaultProps()}
stories={[
{
attachment: fakeAttachment({
path: 'snow.jpg',
url: '/fixtures/snow.jpg',
}),
messageId: '123',
sender,
timestamp: Date.now(),
},
{
attachment: fakeAttachment({
path: 'file.jpg',
url: '/fixtures/nathan-anderson-316188-unsplash.jpg',
}),
canReply: true,
messageId: '456',
sender,
timestamp: Date.now() - 3600,
},
]}
/>
);
export const MultiStory = Template.bind({});
MultiStory.args = {
currentIndex: 2,
numStories: 7,
story: getFakeStoryView('/fixtures/snow.jpg'),
};
MultiStory.story = {
name: 'Multi story',
};
export const Caption = (): JSX.Element => (
<StoryViewer
{...getDefaultProps()}
group={getDefaultConversation({
avatarPath: '/fixtures/kitten-4-112-112.jpg',
title: 'Broskis',
type: 'group',
})}
replyState={{
messageId: '123',
replies: [
{
...getDefaultConversation(),
body: 'Cool',
id: 'abc',
timestamp: Date.now(),
},
],
}}
stories={[
{
attachment: fakeAttachment({
caption: 'This place looks lovely',
path: 'file.jpg',
url: '/fixtures/nathan-anderson-316188-unsplash.jpg',
}),
canReply: true,
messageId: '123',
sender: getDefaultConversation(),
timestamp: Date.now(),
},
]}
/>
);
export const Caption = Template.bind({});
Caption.args = {
story: {
...getFakeStoryView(),
attachment: fakeAttachment({
caption: 'This place looks lovely',
path: 'file.jpg',
url: '/fixtures/nathan-anderson-316188-unsplash.jpg',
}),
},
};
export const LongCaption = (): JSX.Element => (
<StoryViewer
{...getDefaultProps()}
hasAllStoriesMuted
stories={[
{
attachment: fakeAttachment({
caption:
'Snowycle, snowycle, snowycle\nI want to ride my snowycle, snowycle, snowycle\nI want to ride my snowycle\nI want to ride my snow\nI want to ride my snowycle\nI want to ride it where I like\nSnowycle, snowycle, snowycle\nI want to ride my snowycle, snowycle, snowycle\nI want to ride my snowycle\nI want to ride my snow\nI want to ride my snowycle\nI want to ride it where I like\nSnowycle, snowycle, snowycle\nI want to ride my snowycle, snowycle, snowycle\nI want to ride my snowycle\nI want to ride my snow\nI want to ride my snowycle\nI want to ride it where I like',
path: 'file.jpg',
url: '/fixtures/snow.jpg',
}),
canReply: true,
messageId: '123',
sender: getDefaultConversation(),
timestamp: Date.now(),
},
]}
/>
);
export const LongCaption = Template.bind({});
LongCaption.args = {
story: {
...getFakeStoryView(),
attachment: fakeAttachment({
caption:
'Snowycle, snowycle, snowycle\nI want to ride my snowycle, snowycle, snowycle\nI want to ride my snowycle\nI want to ride my snow\nI want to ride my snowycle\nI want to ride it where I like\nSnowycle, snowycle, snowycle\nI want to ride my snowycle, snowycle, snowycle\nI want to ride my snowycle\nI want to ride my snow\nI want to ride my snowycle\nI want to ride it where I like\nSnowycle, snowycle, snowycle\nI want to ride my snowycle, snowycle, snowycle\nI want to ride my snowycle\nI want to ride my snow\nI want to ride my snowycle\nI want to ride it where I like',
path: 'file.jpg',
url: '/fixtures/snow.jpg',
}),
},
};

View File

@ -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}

View File

@ -253,6 +253,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
},
theme: ThemeType.light,
timestamp: number('timestamp', overrideProps.timestamp || Date.now()),
viewStory: action('viewStory'),
});
const createTimelineItem = (data: undefined | Props) =>

View File

@ -16,6 +16,7 @@ import type {
ConversationTypeType,
InteractionModeType,
} from '../../state/ducks/conversations';
import type { ViewStoryActionCreatorType } from '../../state/ducks/stories';
import type { TimelineItemType } from './TimelineItem';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { Avatar, AvatarSize } from '../Avatar';
@ -44,6 +45,7 @@ import { shouldUseFullSizeLinkPreviewImage } from '../../linkPreviews/shouldUseF
import { WidthBreakpoint } from '../_util';
import { OutgoingGiftBadgeModal } from '../OutgoingGiftBadgeModal';
import * as log from '../../logging/log';
import { StoryViewModeType } from '../../types/Stories';
import type { AttachmentType } from '../../types/Attachment';
import {
@ -252,7 +254,7 @@ export type PropsData = {
emoji?: string;
isFromMe: boolean;
rawAttachment?: QuotedAttachmentType;
referencedMessageNotFound?: boolean;
storyId?: string;
text: string;
};
previews: Array<LinkPreviewType>;
@ -360,6 +362,7 @@ export type PropsActions = {
showExpiredIncomingTapToViewToast: () => unknown;
showExpiredOutgoingTapToViewToast: () => unknown;
viewStory: ViewStoryActionCreatorType;
};
export type Props = PropsData &
@ -1519,6 +1522,7 @@ export class Message extends React.PureComponent<Props, State> {
direction,
i18n,
storyReplyContext,
viewStory,
} = this.props;
if (!storyReplyContext) {
@ -1546,13 +1550,11 @@ export class Message extends React.PureComponent<Props, State> {
isViewOnce={false}
moduleClassName="StoryReplyQuote"
onClick={() => {
// TODO DESKTOP-3255
viewStory(storyReplyContext.storyId, StoryViewModeType.Single);
}}
rawAttachment={storyReplyContext.rawAttachment}
reactionEmoji={storyReplyContext.emoji}
referencedMessageNotFound={Boolean(
storyReplyContext.referencedMessageNotFound
)}
referencedMessageNotFound={!storyReplyContext.storyId}
text={storyReplyContext.text}
/>
</>

View File

@ -103,6 +103,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
showForwardMessageModal: action('showForwardMessageModal'),
showVisualAttachment: action('showVisualAttachment'),
startConversation: action('startConversation'),
viewStory: action('viewStory'),
});
export const DeliveredIncoming = (): JSX.Element => {

View File

@ -96,6 +96,7 @@ export type PropsReduxActions = Pick<
| 'clearSelectedMessage'
| 'doubleCheckMissingQuoteReference'
| 'checkForAccount'
| 'viewStory'
>;
export type ExternalProps = PropsData & PropsBackboneActions;
@ -302,6 +303,7 @@ export class MessageDetail extends React.Component<Props> {
showVisualAttachment,
startConversation,
theme,
viewStory,
} = this.props;
return (
@ -371,6 +373,7 @@ export class MessageDetail extends React.Component<Props> {
showVisualAttachment={showVisualAttachment}
startConversation={startConversation}
theme={theme}
viewStory={viewStory}
/>
</div>
<table className="module-message-detail__info">

View File

@ -1,11 +1,10 @@
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, Story } from '@storybook/react';
import * as React from 'react';
import { isString } from 'lodash';
import { action } from '@storybook/addon-actions';
import { boolean, text } from '@storybook/addon-knobs';
import { ConversationColors } from '../../types/Colors';
import { pngUrl } from '../../storybook/Fixtures';
@ -30,8 +29,49 @@ import { ThemeType } from '../../types/Util';
const i18n = setupI18n('en', enMessages);
export default {
component: Quote,
title: 'Components/Conversation/Quote',
};
argTypes: {
authorTitle: {
defaultValue: 'Default Sender',
},
conversationColor: {
defaultValue: 'forest',
},
doubleCheckMissingQuoteReference: { action: true },
i18n: {
defaultValue: i18n,
},
isFromMe: {
control: { type: 'checkbox' },
defaultValue: false,
},
isGiftBadge: {
control: { type: 'checkbox' },
defaultValue: false,
},
isIncoming: {
control: { type: 'checkbox' },
defaultValue: false,
},
isViewOnce: {
control: { type: 'checkbox' },
defaultValue: false,
},
onClick: { action: true },
onClose: { action: true },
rawAttachment: {
defaultValue: undefined,
},
referencedMessageNotFound: {
control: { type: 'checkbox' },
defaultValue: false,
},
text: {
defaultValue: 'A sample message from a pal',
},
},
} as Meta;
const defaultMessageProps: MessagesProps = {
author: getDefaultConversation({
@ -105,6 +145,7 @@ const defaultMessageProps: MessagesProps = {
textDirection: TextDirection.Default,
theme: ThemeType.light,
timestamp: Date.now(),
viewStory: action('viewStory'),
};
const renderInMessage = ({
@ -143,459 +184,332 @@ const renderInMessage = ({
);
};
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
authorTitle: text(
'authorTitle',
overrideProps.authorTitle || 'Default Sender'
),
conversationColor: overrideProps.conversationColor || 'forest',
doubleCheckMissingQuoteReference:
overrideProps.doubleCheckMissingQuoteReference ||
action('doubleCheckMissingQuoteReference'),
i18n,
isFromMe: boolean('isFromMe', overrideProps.isFromMe || false),
isIncoming: boolean('isIncoming', overrideProps.isIncoming || false),
onClick: action('onClick'),
onClose: action('onClose'),
rawAttachment: overrideProps.rawAttachment || undefined,
referencedMessageNotFound: boolean(
'referencedMessageNotFound',
overrideProps.referencedMessageNotFound || false
),
isGiftBadge: boolean('isGiftBadge', overrideProps.isGiftBadge || false),
isViewOnce: boolean('isViewOnce', overrideProps.isViewOnce || false),
text: text(
'text',
isString(overrideProps.text)
? overrideProps.text
: 'A sample message from a pal'
),
});
const Template: Story<Props> = args => <Quote {...args} />;
const TemplateInMessage: Story<Props> = args => renderInMessage(args);
export const OutgoingByAnotherAuthor = (): JSX.Element => {
const props = createProps({
authorTitle: 'Terrence Malick',
});
return <Quote {...props} />;
export const OutgoingByAnotherAuthor = Template.bind({});
OutgoingByAnotherAuthor.args = {
authorTitle: getDefaultConversation().title,
};
OutgoingByAnotherAuthor.story = {
name: 'Outgoing by Another Author',
};
export const OutgoingByMe = (): JSX.Element => {
const props = createProps({
isFromMe: true,
});
return <Quote {...props} />;
export const OutgoingByMe = Template.bind({});
OutgoingByMe.args = {
isFromMe: true,
};
OutgoingByMe.story = {
name: 'Outgoing by Me',
};
export const IncomingByAnotherAuthor = (): JSX.Element => {
const props = createProps({
authorTitle: 'Terrence Malick',
isIncoming: true,
});
return <Quote {...props} />;
export const IncomingByAnotherAuthor = Template.bind({});
IncomingByAnotherAuthor.args = {
authorTitle: getDefaultConversation().title,
isIncoming: true,
};
IncomingByAnotherAuthor.story = {
name: 'Incoming by Another Author',
};
export const IncomingByMe = (): JSX.Element => {
const props = createProps({
isFromMe: true,
isIncoming: true,
});
return <Quote {...props} />;
export const IncomingByMe = Template.bind({});
IncomingByMe.args = {
isFromMe: true,
isIncoming: true,
};
IncomingByMe.story = {
name: 'Incoming by Me',
};
export const IncomingOutgoingColors = (): JSX.Element => {
const props = createProps({});
export const IncomingOutgoingColors = (args: Props): JSX.Element => {
return (
<>
{ConversationColors.map(color =>
renderInMessage({ ...props, conversationColor: color })
renderInMessage({ ...args, conversationColor: color })
)}
</>
);
};
IncomingOutgoingColors.args = {};
IncomingOutgoingColors.story = {
name: 'Incoming/Outgoing Colors',
};
export const ImageOnly = (): JSX.Element => {
const props = createProps({
text: '',
rawAttachment: {
export const ImageOnly = Template.bind({});
ImageOnly.args = {
text: '',
rawAttachment: {
contentType: IMAGE_PNG,
fileName: 'sax.png',
isVoiceMessage: false,
thumbnail: {
contentType: IMAGE_PNG,
fileName: 'sax.png',
isVoiceMessage: false,
thumbnail: {
contentType: IMAGE_PNG,
height: 100,
width: 100,
path: pngUrl,
objectUrl: pngUrl,
},
height: 100,
width: 100,
path: pngUrl,
objectUrl: pngUrl,
},
});
return <Quote {...props} />;
},
};
export const ImageAttachment = (): JSX.Element => {
const props = createProps({
rawAttachment: {
export const ImageAttachment = Template.bind({});
ImageAttachment.args = {
rawAttachment: {
contentType: IMAGE_PNG,
fileName: 'sax.png',
isVoiceMessage: false,
thumbnail: {
contentType: IMAGE_PNG,
fileName: 'sax.png',
isVoiceMessage: false,
thumbnail: {
contentType: IMAGE_PNG,
height: 100,
width: 100,
path: pngUrl,
objectUrl: pngUrl,
},
height: 100,
width: 100,
path: pngUrl,
objectUrl: pngUrl,
},
});
return <Quote {...props} />;
},
};
export const ImageAttachmentWOThumbnail = (): JSX.Element => {
const props = createProps({
rawAttachment: {
contentType: IMAGE_PNG,
fileName: 'sax.png',
isVoiceMessage: false,
},
});
return <Quote {...props} />;
export const ImageAttachmentNoThumbnail = Template.bind({});
ImageAttachmentNoThumbnail.args = {
rawAttachment: {
contentType: IMAGE_PNG,
fileName: 'sax.png',
isVoiceMessage: false,
},
};
ImageAttachmentWOThumbnail.story = {
ImageAttachmentNoThumbnail.story = {
name: 'Image Attachment w/o Thumbnail',
};
export const ImageTapToView = (): JSX.Element => {
const props = createProps({
text: '',
isViewOnce: true,
rawAttachment: {
contentType: IMAGE_PNG,
fileName: 'sax.png',
isVoiceMessage: false,
},
});
return <Quote {...props} />;
export const ImageTapToView = Template.bind({});
ImageTapToView.args = {
text: '',
isViewOnce: true,
rawAttachment: {
contentType: IMAGE_PNG,
fileName: 'sax.png',
isVoiceMessage: false,
},
};
ImageTapToView.story = {
name: 'Image Tap-to-View',
};
export const VideoOnly = (): JSX.Element => {
const props = createProps({
rawAttachment: {
contentType: VIDEO_MP4,
fileName: 'great-video.mp4',
isVoiceMessage: false,
thumbnail: {
contentType: IMAGE_PNG,
height: 100,
width: 100,
path: pngUrl,
objectUrl: pngUrl,
},
export const VideoOnly = Template.bind({});
VideoOnly.args = {
rawAttachment: {
contentType: VIDEO_MP4,
fileName: 'great-video.mp4',
isVoiceMessage: false,
thumbnail: {
contentType: IMAGE_PNG,
height: 100,
width: 100,
path: pngUrl,
objectUrl: pngUrl,
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
props.text = undefined as any;
return <Quote {...props} />;
},
text: undefined,
};
export const VideoAttachment = (): JSX.Element => {
const props = createProps({
rawAttachment: {
contentType: VIDEO_MP4,
fileName: 'great-video.mp4',
isVoiceMessage: false,
thumbnail: {
contentType: IMAGE_PNG,
height: 100,
width: 100,
path: pngUrl,
objectUrl: pngUrl,
},
export const VideoAttachment = Template.bind({});
VideoAttachment.args = {
rawAttachment: {
contentType: VIDEO_MP4,
fileName: 'great-video.mp4',
isVoiceMessage: false,
thumbnail: {
contentType: IMAGE_PNG,
height: 100,
width: 100,
path: pngUrl,
objectUrl: pngUrl,
},
});
return <Quote {...props} />;
},
};
export const VideoAttachmentWOThumbnail = (): JSX.Element => {
const props = createProps({
rawAttachment: {
contentType: VIDEO_MP4,
fileName: 'great-video.mp4',
isVoiceMessage: false,
},
});
return <Quote {...props} />;
export const VideoAttachmentNoThumbnail = Template.bind({});
VideoAttachmentNoThumbnail.args = {
rawAttachment: {
contentType: VIDEO_MP4,
fileName: 'great-video.mp4',
isVoiceMessage: false,
},
};
VideoAttachmentWOThumbnail.story = {
VideoAttachmentNoThumbnail.story = {
name: 'Video Attachment w/o Thumbnail',
};
export const VideoTapToView = (): JSX.Element => {
const props = createProps({
text: '',
isViewOnce: true,
rawAttachment: {
contentType: VIDEO_MP4,
fileName: 'great-video.mp4',
isVoiceMessage: false,
},
});
return <Quote {...props} />;
export const VideoTapToView = Template.bind({});
VideoTapToView.args = {
text: '',
isViewOnce: true,
rawAttachment: {
contentType: VIDEO_MP4,
fileName: 'great-video.mp4',
isVoiceMessage: false,
},
};
VideoTapToView.story = {
name: 'Video Tap-to-View',
};
export const GiftBadge = (): JSX.Element => {
const props = createProps({
text: "Some text which shouldn't be rendered",
isGiftBadge: true,
});
return renderInMessage(props);
export const GiftBadge = TemplateInMessage.bind({});
GiftBadge.args = {
text: "Some text which shouldn't be rendered",
isGiftBadge: true,
};
export const AudioOnly = (): JSX.Element => {
const props = createProps({
rawAttachment: {
contentType: AUDIO_MP3,
fileName: 'great-video.mp3',
isVoiceMessage: false,
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
props.text = undefined as any;
return <Quote {...props} />;
export const AudioOnly = Template.bind({});
AudioOnly.args = {
rawAttachment: {
contentType: AUDIO_MP3,
fileName: 'great-video.mp3',
isVoiceMessage: false,
},
text: undefined,
};
export const AudioAttachment = (): JSX.Element => {
const props = createProps({
rawAttachment: {
contentType: AUDIO_MP3,
fileName: 'great-video.mp3',
isVoiceMessage: false,
},
});
return <Quote {...props} />;
export const AudioAttachment = Template.bind({});
AudioAttachment.args = {
rawAttachment: {
contentType: AUDIO_MP3,
fileName: 'great-video.mp3',
isVoiceMessage: false,
},
};
export const VoiceMessageOnly = (): JSX.Element => {
const props = createProps({
rawAttachment: {
contentType: AUDIO_MP3,
fileName: 'great-video.mp3',
isVoiceMessage: true,
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
props.text = undefined as any;
return <Quote {...props} />;
export const VoiceMessageOnly = Template.bind({});
VoiceMessageOnly.args = {
rawAttachment: {
contentType: AUDIO_MP3,
fileName: 'great-video.mp3',
isVoiceMessage: true,
},
text: undefined,
};
export const VoiceMessageAttachment = (): JSX.Element => {
const props = createProps({
rawAttachment: {
contentType: AUDIO_MP3,
fileName: 'great-video.mp3',
isVoiceMessage: true,
},
});
return <Quote {...props} />;
export const VoiceMessageAttachment = Template.bind({});
VoiceMessageAttachment.args = {
rawAttachment: {
contentType: AUDIO_MP3,
fileName: 'great-video.mp3',
isVoiceMessage: true,
},
};
export const OtherFileOnly = (): JSX.Element => {
const props = createProps({
rawAttachment: {
contentType: stringToMIMEType('application/json'),
fileName: 'great-data.json',
isVoiceMessage: false,
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
props.text = undefined as any;
return <Quote {...props} />;
export const OtherFileOnly = Template.bind({});
OtherFileOnly.args = {
rawAttachment: {
contentType: stringToMIMEType('application/json'),
fileName: 'great-data.json',
isVoiceMessage: false,
},
text: undefined,
};
export const MediaTapToView = (): JSX.Element => {
const props = createProps({
text: '',
isViewOnce: true,
rawAttachment: {
contentType: AUDIO_MP3,
fileName: 'great-video.mp3',
isVoiceMessage: false,
},
});
return <Quote {...props} />;
export const MediaTapToView = Template.bind({});
MediaTapToView.args = {
text: '',
isViewOnce: true,
rawAttachment: {
contentType: AUDIO_MP3,
fileName: 'great-video.mp3',
isVoiceMessage: false,
},
};
MediaTapToView.story = {
name: 'Media Tap-to-View',
};
export const OtherFileAttachment = (): JSX.Element => {
const props = createProps({
rawAttachment: {
contentType: stringToMIMEType('application/json'),
fileName: 'great-data.json',
isVoiceMessage: false,
},
});
return <Quote {...props} />;
export const OtherFileAttachment = Template.bind({});
OtherFileAttachment.args = {
rawAttachment: {
contentType: stringToMIMEType('application/json'),
fileName: 'great-data.json',
isVoiceMessage: false,
},
};
export const LongMessageAttachmentShouldBeHidden = (): JSX.Element => {
const props = createProps({
rawAttachment: {
contentType: LONG_MESSAGE,
fileName: 'signal-long-message-123.txt',
isVoiceMessage: false,
},
});
return <Quote {...props} />;
export const LongMessageAttachmentShouldBeHidden = Template.bind({});
LongMessageAttachmentShouldBeHidden.args = {
rawAttachment: {
contentType: LONG_MESSAGE,
fileName: 'signal-long-message-123.txt',
isVoiceMessage: false,
},
};
LongMessageAttachmentShouldBeHidden.story = {
name: 'Long message attachment (should be hidden)',
};
export const NoCloseButton = (): JSX.Element => {
const props = createProps();
props.onClose = undefined;
return <Quote {...props} />;
export const NoCloseButton = Template.bind({});
NoCloseButton.args = {
onClose: undefined,
};
export const MessageNotFound = (): JSX.Element => {
const props = createProps({
referencedMessageNotFound: true,
});
return renderInMessage(props);
export const MessageNotFound = TemplateInMessage.bind({});
MessageNotFound.args = {
referencedMessageNotFound: true,
};
export const MissingTextAttachment = (): JSX.Element => {
const props = createProps();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
props.text = undefined as any;
return <Quote {...props} />;
export const MissingTextAttachment = Template.bind({});
MissingTextAttachment.args = {
text: undefined,
};
MissingTextAttachment.story = {
name: 'Missing Text & Attachment',
};
export const MentionOutgoingAnotherAuthor = (): JSX.Element => {
const props = createProps({
authorTitle: 'Tony Stark',
text: '@Captain America Lunch later?',
});
return <Quote {...props} />;
export const MentionOutgoingAnotherAuthor = Template.bind({});
MentionOutgoingAnotherAuthor.args = {
authorTitle: 'Tony Stark',
text: '@Captain America Lunch later?',
};
MentionOutgoingAnotherAuthor.story = {
name: '@mention + outgoing + another author',
};
export const MentionOutgoingMe = (): JSX.Element => {
const props = createProps({
isFromMe: true,
text: '@Captain America Lunch later?',
});
return <Quote {...props} />;
export const MentionOutgoingMe = Template.bind({});
MentionOutgoingMe.args = {
isFromMe: true,
text: '@Captain America Lunch later?',
};
MentionOutgoingMe.story = {
name: '@mention + outgoing + me',
};
export const MentionIncomingAnotherAuthor = (): JSX.Element => {
const props = createProps({
authorTitle: 'Captain America',
isIncoming: true,
text: '@Tony Stark sure',
});
return <Quote {...props} />;
export const MentionIncomingAnotherAuthor = Template.bind({});
MentionIncomingAnotherAuthor.args = {
authorTitle: 'Captain America',
isIncoming: true,
text: '@Tony Stark sure',
};
MentionIncomingAnotherAuthor.story = {
name: '@mention + incoming + another author',
};
export const MentionIncomingMe = (): JSX.Element => {
const props = createProps({
isFromMe: true,
isIncoming: true,
text: '@Tony Stark sure',
});
return <Quote {...props} />;
export const MentionIncomingMe = Template.bind({});
MentionIncomingMe.args = {
isFromMe: true,
isIncoming: true,
text: '@Tony Stark sure',
};
MentionIncomingMe.story = {
name: '@mention + incoming + me',
};
export const CustomColor = (): JSX.Element => (
export const CustomColor = (args: Props): JSX.Element => (
<>
<Quote
{...createProps({ isIncoming: true, text: 'Solid + Gradient' })}
{...args}
customColor={{
start: { hue: 82, saturation: 35 },
}}
/>
<Quote
{...createProps()}
{...args}
isIncoming={false}
text="A gradient"
customColor={{
deg: 192,
start: { hue: 304, saturation: 85 },
@ -604,59 +518,48 @@ export const CustomColor = (): JSX.Element => (
/>
</>
);
export const IsStoryReply = (): JSX.Element => {
const props = createProps({
text: 'Wow!',
});
return (
<Quote
{...props}
authorTitle="Amanda"
isStoryReply
moduleClassName="StoryReplyQuote"
onClose={undefined}
rawAttachment={{
contentType: VIDEO_MP4,
fileName: 'great-video.mp4',
isVoiceMessage: false,
}}
/>
);
CustomColor.args = {
isIncoming: true,
text: 'Solid + Gradient',
};
export const IsStoryReply = Template.bind({});
IsStoryReply.args = {
text: 'Wow!',
authorTitle: 'Amanda',
isStoryReply: true,
moduleClassName: 'StoryReplyQuote',
onClose: undefined,
rawAttachment: {
contentType: VIDEO_MP4,
fileName: 'great-video.mp4',
isVoiceMessage: false,
},
};
IsStoryReply.story = {
name: 'isStoryReply',
};
export const IsStoryReplyEmoji = (): JSX.Element => {
const props = createProps();
return (
<Quote
{...props}
authorTitle="Charlie"
isStoryReply
moduleClassName="StoryReplyQuote"
onClose={undefined}
rawAttachment={{
contentType: IMAGE_PNG,
fileName: 'sax.png',
isVoiceMessage: false,
thumbnail: {
contentType: IMAGE_PNG,
height: 100,
width: 100,
path: pngUrl,
objectUrl: pngUrl,
},
}}
reactionEmoji="🏋️"
/>
);
export const IsStoryReplyEmoji = Template.bind({});
IsStoryReplyEmoji.args = {
authorTitle: getDefaultConversation().firstName,
isStoryReply: true,
moduleClassName: 'StoryReplyQuote',
onClose: undefined,
rawAttachment: {
contentType: IMAGE_PNG,
fileName: 'sax.png',
isVoiceMessage: false,
thumbnail: {
contentType: IMAGE_PNG,
height: 100,
width: 100,
path: pngUrl,
objectUrl: pngUrl,
},
},
reactionEmoji: '🏋️',
};
IsStoryReplyEmoji.story = {
name: 'isStoryReply emoji',
};

View File

@ -418,6 +418,8 @@ const actions = () => ({
peekGroupCallForTheFirstTime: action('peekGroupCallForTheFirstTime'),
peekGroupCallIfItHasMembers: action('peekGroupCallIfItHasMembers'),
viewStory: action('viewStory'),
});
const renderItem = ({

View File

@ -267,6 +267,8 @@ const getActions = createSelector(
'downloadNewVersion',
'contactSupport',
'viewStory',
]);
const safe: AssertProps<PropsActionsType, typeof unsafe> = unsafe;

View File

@ -107,6 +107,7 @@ const getDefaultProps = () => ({
renderEmojiPicker,
renderReactionPicker,
renderAudioAttachment: () => <div>*AudioAttachment*</div>,
viewStory: action('viewStory'),
});
export default {

View File

@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ThunkAction } from 'redux-thunk';
import type { ThunkAction, ThunkDispatch } from 'redux-thunk';
import { isEqual, pick } from 'lodash';
import type { AttachmentType } from '../../types/Attachment';
import type { BodyRangeType } from '../../types/Util';
@ -18,6 +18,7 @@ import * as log from '../../logging/log';
import dataInterface from '../../sql/Client';
import { DAY } from '../../util/durations';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { StoryViewDirectionType, StoryViewModeType } from '../../types/Stories';
import { ToastReactionFailed } from '../../components/ToastReactionFailed';
import { UUID } from '../../types/UUID';
import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend';
@ -32,11 +33,12 @@ import {
isDownloaded,
isDownloading,
} from '../../types/Attachment';
import { getConversationSelector } from '../selectors/conversations';
import { getStories } from '../selectors/stories';
import { isGroup } from '../../util/whatTypeOfConversation';
import { useBoundActions } from '../../hooks/useBoundActions';
import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue';
import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue';
import { isGroup } from '../../util/whatTypeOfConversation';
import { getConversationSelector } from '../selectors/conversations';
export type StoryDataType = {
attachment?: AttachmentType;
@ -56,6 +58,12 @@ export type StoryDataType = {
| 'type'
>;
export type SelectedStoryDataType = {
currentIndex: number;
numStories: number;
story: StoryDataType;
};
// State
export type StoriesStateType = {
@ -64,7 +72,9 @@ export type StoriesStateType = {
messageId: string;
replies: Array<MessageAttributesType>;
};
readonly selectedStoryData?: SelectedStoryDataType;
readonly stories: Array<StoryDataType>;
readonly storyViewMode?: StoryViewModeType;
};
// Actions
@ -76,6 +86,7 @@ const REPLY_TO_STORY = 'stories/REPLY_TO_STORY';
export const RESOLVE_ATTACHMENT_URL = 'stories/RESOLVE_ATTACHMENT_URL';
const STORY_CHANGED = 'stories/STORY_CHANGED';
const TOGGLE_VIEW = 'stories/TOGGLE_VIEW';
const VIEW_STORY = 'stories/VIEW_STORY';
type DOEStoryActionType = {
type: typeof DOE_STORY;
@ -117,6 +128,16 @@ type ToggleViewActionType = {
type: typeof TOGGLE_VIEW;
};
type ViewStoryActionType = {
type: typeof VIEW_STORY;
payload:
| {
selectedStoryData: SelectedStoryDataType;
storyViewMode: StoryViewModeType;
}
| undefined;
};
export type StoriesActionType =
| DOEStoryActionType
| LoadStoryRepliesActionType
@ -126,23 +147,11 @@ export type StoriesActionType =
| ReplyToStoryActionType
| ResolveAttachmentUrlActionType
| StoryChangedActionType
| ToggleViewActionType;
| ToggleViewActionType
| ViewStoryActionType;
// Action Creators
export const actions = {
deleteStoryForEveryone,
loadStoryReplies,
markStoryRead,
queueStoryDownload,
reactToStory,
replyToStory,
storyChanged,
toggleStoriesView,
};
export const useStoriesActions = (): typeof actions => useBoundActions(actions);
function deleteStoryForEveryone(
story: StoryViewType
): ThunkAction<void, RootStateType, unknown, DOEStoryActionType> {
@ -414,6 +423,338 @@ function toggleStoriesView(): ToggleViewActionType {
};
}
const getSelectedStoryDataForConversationId = (
dispatch: ThunkDispatch<
RootStateType,
unknown,
NoopActionType | ResolveAttachmentUrlActionType
>,
getState: () => RootStateType,
conversationId: string,
selectedStoryId?: string
): {
currentIndex: number;
hasUnread: boolean;
numStories: number;
storiesByConversationId: Array<StoryDataType>;
} => {
const state = getState();
const { stories } = state.stories;
const storiesByConversationId = stories.filter(
item => item.conversationId === conversationId
);
// Find the index of the storyId provided, or if none provided then find the
// oldest unread story from the user. If all stories are read then we can
// start at the first story.
let currentIndex = 0;
let hasUnread = false;
storiesByConversationId.forEach((item, index) => {
if (selectedStoryId && item.messageId === selectedStoryId) {
currentIndex = index;
}
if (
!selectedStoryId &&
!currentIndex &&
item.readStatus === ReadStatus.Unread
) {
hasUnread = true;
currentIndex = index;
}
});
const numStories = storiesByConversationId.length;
// Queue all undownloaded stories once we're viewing someone's stories
storiesByConversationId.forEach(item => {
if (isDownloaded(item.attachment) || isDownloading(item.attachment)) {
return;
}
queueStoryDownload(item.messageId)(dispatch, getState, null);
});
return {
currentIndex,
hasUnread,
numStories,
storiesByConversationId,
};
};
function viewUserStories(
conversationId: string
): ThunkAction<void, RootStateType, unknown, ViewStoryActionType> {
return (dispatch, getState) => {
const { currentIndex, hasUnread, numStories, storiesByConversationId } =
getSelectedStoryDataForConversationId(dispatch, getState, conversationId);
const story = storiesByConversationId[currentIndex];
dispatch({
type: VIEW_STORY,
payload: {
selectedStoryData: {
currentIndex,
numStories,
story,
},
storyViewMode: hasUnread
? StoryViewModeType.Unread
: StoryViewModeType.All,
},
});
};
}
export type ViewStoryActionCreatorType = (
storyId?: string,
storyViewMode?: StoryViewModeType,
viewDirection?: StoryViewDirectionType
) => unknown;
const viewStory: ViewStoryActionCreatorType = (
storyId,
storyViewMode,
viewDirection
): ThunkAction<void, RootStateType, unknown, ViewStoryActionType> => {
return (dispatch, getState) => {
if (!storyId || !storyViewMode) {
dispatch({
type: VIEW_STORY,
payload: undefined,
});
return;
}
const state = getState();
const { stories } = state.stories;
// Spec:
// When opening the story viewer you should always be taken to the oldest
// un viewed story of the user you tapped on
// If all stories from a user are viewed, opening the viewer should take
// you to their oldest story
const story = stories.find(item => item.messageId === storyId);
if (!story) {
return;
}
const { currentIndex, numStories, storiesByConversationId } =
getSelectedStoryDataForConversationId(
dispatch,
getState,
story.conversationId,
storyId
);
// Go directly to the storyId selected
if (!viewDirection) {
dispatch({
type: VIEW_STORY,
payload: {
selectedStoryData: {
currentIndex,
numStories,
story,
},
storyViewMode,
},
});
return;
}
// Next story within the same user's stories
if (
viewDirection === StoryViewDirectionType.Next &&
currentIndex < numStories - 1
) {
const nextIndex = currentIndex + 1;
const nextStory = storiesByConversationId[nextIndex];
dispatch({
type: VIEW_STORY,
payload: {
selectedStoryData: {
currentIndex: nextIndex,
numStories,
story: nextStory,
},
storyViewMode,
},
});
return;
}
// Prev story within the same user's stories
if (viewDirection === StoryViewDirectionType.Previous && currentIndex > 0) {
const nextIndex = currentIndex - 1;
const nextStory = storiesByConversationId[nextIndex];
dispatch({
type: VIEW_STORY,
payload: {
selectedStoryData: {
currentIndex: nextIndex,
numStories,
story: nextStory,
},
storyViewMode,
},
});
return;
}
// Are there any unviewed stories left? If so we should play the unviewed
// stories first. But only if we're going "next"
if (viewDirection === StoryViewDirectionType.Next) {
const unreadStory = stories.find(
item => item.readStatus === ReadStatus.Unread
);
if (unreadStory) {
const nextSelectedStoryData = getSelectedStoryDataForConversationId(
dispatch,
getState,
unreadStory.conversationId,
unreadStory.messageId
);
dispatch({
type: VIEW_STORY,
payload: {
selectedStoryData: {
currentIndex: nextSelectedStoryData.currentIndex,
numStories: nextSelectedStoryData.numStories,
story: unreadStory,
},
storyViewMode,
},
});
return;
}
}
const conversationStories = getStories(state).stories;
const conversationStoryIndex = conversationStories.findIndex(
item => item.conversationId === story.conversationId
);
if (conversationStoryIndex < 0) {
return;
}
// Find the next user's stories
if (
viewDirection === StoryViewDirectionType.Next &&
conversationStoryIndex < conversationStories.length - 1
) {
// Spec:
// Tapping right advances you to the next un viewed story
// If all stories are viewed, advance to the next viewed story
// When you reach the newest story from a user, tapping right again
// should take you to the next user's oldest un viewed story or oldest
// story if all stories for the next user are viewed.
// When you reach the newest story from the last user in the story list,
// tapping right should close the viewer
// Touch area for tapping right should be 80% of width of the screen
const nextConversationStoryIndex = conversationStoryIndex + 1;
const conversationStory = conversationStories[nextConversationStoryIndex];
const nextSelectedStoryData = getSelectedStoryDataForConversationId(
dispatch,
getState,
conversationStory.conversationId
);
// Close the viewer if we were viewing unread stories only and we've
// reached the last unread story.
if (
!nextSelectedStoryData.hasUnread &&
storyViewMode === StoryViewModeType.Unread
) {
dispatch({
type: VIEW_STORY,
payload: undefined,
});
return;
}
dispatch({
type: VIEW_STORY,
payload: {
selectedStoryData: {
currentIndex: 0,
numStories: nextSelectedStoryData.numStories,
story: nextSelectedStoryData.storiesByConversationId[0],
},
storyViewMode,
},
});
return;
}
// Find the previous user's stories
if (
viewDirection === StoryViewDirectionType.Previous &&
conversationStoryIndex > 0
) {
// Spec:
// Tapping left takes you back to the previous story
// When you reach the oldest story from a user, tapping left again takes
// you to the previous users oldest un viewed story or newest viewed
// story if all stories are viewed
// If you tap left on the oldest story from the first user in the story
// list, it should re-start playback on that story
// Touch area for tapping left should be 20% of width of the screen
const nextConversationStoryIndex = conversationStoryIndex - 1;
const conversationStory = conversationStories[nextConversationStoryIndex];
const nextSelectedStoryData = getSelectedStoryDataForConversationId(
dispatch,
getState,
conversationStory.conversationId
);
dispatch({
type: VIEW_STORY,
payload: {
selectedStoryData: {
currentIndex: 0,
numStories: nextSelectedStoryData.numStories,
story: nextSelectedStoryData.storiesByConversationId[0],
},
storyViewMode,
},
});
return;
}
// Could not meet any criteria, close the viewer
dispatch({
type: VIEW_STORY,
payload: undefined,
});
};
};
export const actions = {
deleteStoryForEveryone,
loadStoryReplies,
markStoryRead,
queueStoryDownload,
reactToStory,
replyToStory,
storyChanged,
toggleStoriesView,
viewUserStories,
viewStory,
};
export const useStoriesActions = (): typeof actions => useBoundActions(actions);
// Reducer
export function getEmptyState(
@ -645,5 +986,15 @@ export function reducer(
};
}
if (action.type === VIEW_STORY) {
const { selectedStoryData, storyViewMode } = action.payload || {};
return {
...state,
selectedStoryData,
storyViewMode,
};
}
return state;
}

View File

@ -483,7 +483,7 @@ export const getPropsForStoryReplyContext = createSelectorCreator(
rawAttachment: storyReplyContext.attachment
? processQuoteAttachment(storyReplyContext.attachment)
: undefined,
referencedMessageNotFound: !storyReplyContext.messageId,
storyId: storyReplyContext.messageId,
text: getStoryReplyText(window.i18n, storyReplyContext.attachment),
};
},

View File

@ -15,7 +15,12 @@ import type {
StoryViewType,
} from '../../types/Stories';
import type { StateType } from '../reducer';
import type { StoryDataType, StoriesStateType } from '../ducks/stories';
import type {
SelectedStoryDataType,
StoryDataType,
StoriesStateType,
} from '../ducks/stories';
import { MY_STORIES_ID } from '../../types/Stories';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { SendStatus } from '../../messages/MessageSendState';
import { canReply } from './message';
@ -25,7 +30,6 @@ import {
getMe,
} from './conversations';
import { getDistributionListSelector } from './storyDistributionLists';
import { getUserConversationId } from './user';
export const getStoriesState = (state: StateType): StoriesStateType =>
state.stories;
@ -35,36 +39,35 @@ export const shouldShowStoriesView = createSelector(
({ isShowingStoriesView }): boolean => isShowingStoriesView
);
function getNewestStory(x: ConversationStoryType | MyStoryType): StoryViewType {
return x.stories[x.stories.length - 1];
}
function sortByRecencyAndUnread(
a: ConversationStoryType | MyStoryType,
b: ConversationStoryType | MyStoryType
): number {
const storyA = getNewestStory(a);
const storyB = getNewestStory(b);
if (storyA.isUnread && storyB.isUnread) {
return storyA.timestamp > storyB.timestamp ? -1 : 1;
}
if (storyB.isUnread) {
return 1;
}
if (storyA.isUnread) {
return -1;
}
return storyA.timestamp > storyB.timestamp ? -1 : 1;
}
export const getSelectedStoryData = createSelector(
getStoriesState,
({ selectedStoryData }): SelectedStoryDataType | undefined =>
selectedStoryData
);
function getReactionUniqueId(reaction: MessageReactionType): string {
return `${reaction.fromId}:${reaction.targetAuthorUuid}:${reaction.timestamp}`;
}
function sortByRecencyAndUnread(
storyA: ConversationStoryType,
storyB: ConversationStoryType
): number {
if (storyA.storyView.isUnread && storyB.storyView.isUnread) {
return storyA.storyView.timestamp > storyB.storyView.timestamp ? -1 : 1;
}
if (storyB.storyView.isUnread) {
return 1;
}
if (storyA.storyView.isUnread) {
return -1;
}
return storyA.storyView.timestamp > storyB.storyView.timestamp ? -1 : 1;
}
function getAvatarData(
conversation: ConversationType
): Pick<
@ -90,10 +93,9 @@ function getAvatarData(
]);
}
function getStoryView(
export function getStoryView(
conversationSelector: GetConversationByIdType,
story: StoryDataType,
ourConversationId?: string
story: StoryDataType
): StoryViewType {
const sender = pick(conversationSelector(story.sourceUuid || story.source), [
'acceptedMessageRequest',
@ -113,7 +115,7 @@ function getStoryView(
return {
attachment,
canReply: canReply(story, ourConversationId, conversationSelector),
canReply: canReply(story, undefined, conversationSelector),
isUnread: story.readStatus === ReadStatus.Unread,
messageId: story.messageId,
sender,
@ -121,10 +123,9 @@ function getStoryView(
};
}
function getConversationStory(
export function getConversationStory(
conversationSelector: GetConversationByIdType,
story: StoryDataType,
ourConversationId?: string
story: StoryDataType
): ConversationStoryType {
const sender = pick(conversationSelector(story.sourceUuid || story.source), [
'hideStory',
@ -142,59 +143,16 @@ function getConversationStory(
'title',
]);
const storyView = getStoryView(
conversationSelector,
story,
ourConversationId
);
const storyView = getStoryView(conversationSelector, story);
return {
conversationId: conversation.id,
group: conversation.id !== sender.id ? conversation : undefined,
isHidden: Boolean(sender.hideStory),
stories: [storyView],
storyView,
};
}
export type GetStoriesByConversationIdType = (
conversationId: string
) => ConversationStoryType;
export const getStoriesSelector = createSelector(
getConversationSelector,
getUserConversationId,
getStoriesState,
(
conversationSelector,
ourConversationId,
{ stories }: Readonly<StoriesStateType>
): GetStoriesByConversationIdType => {
return conversationId => {
const conversationStoryAcc: ConversationStoryType = {
conversationId,
stories: [],
};
return stories.reduce((acc, story) => {
if (story.conversationId !== conversationId) {
return acc;
}
const conversationStory = getConversationStory(
conversationSelector,
story,
ourConversationId
);
return {
...acc,
...conversationStory,
stories: [...acc.stories, ...conversationStory.stories],
};
}, conversationStoryAcc);
};
}
);
export const getStoryReplies = createSelector(
getConversationSelector,
getContactNameColorSelector,
@ -262,13 +220,11 @@ export const getStories = createSelector(
getConversationSelector,
getDistributionListSelector,
getStoriesState,
getUserConversationId,
shouldShowStoriesView,
(
conversationSelector,
distributionListSelector,
{ stories }: Readonly<StoriesStateType>,
ourConversationId,
isShowingStoriesView
): {
hiddenStories: Array<ConversationStoryType>;
@ -293,16 +249,16 @@ export const getStories = createSelector(
}
if (story.sendStateByConversationId && story.storyDistributionListId) {
const list = distributionListSelector(story.storyDistributionListId);
const list =
story.storyDistributionListId === MY_STORIES_ID
? { id: MY_STORIES_ID, name: MY_STORIES_ID }
: distributionListSelector(story.storyDistributionListId);
if (!list) {
return;
}
const storyView = getStoryView(
conversationSelector,
story,
ourConversationId
);
const storyView = getStoryView(conversationSelector, story);
const sendState: Array<StorySendStateType> = [];
const { sendStateByConversationId } = story;
@ -352,8 +308,7 @@ export const getStories = createSelector(
const conversationStory = getConversationStory(
conversationSelector,
story,
ourConversationId
story
);
let storiesMap: Map<string, ConversationStoryType>;
@ -366,25 +321,18 @@ export const getStories = createSelector(
const existingConversationStory = storiesMap.get(
conversationStory.conversationId
) || { stories: [] };
);
storiesMap.set(conversationStory.conversationId, {
...existingConversationStory,
...conversationStory,
stories: [
...existingConversationStory.stories,
...conversationStory.stories,
],
storyView: conversationStory.storyView,
});
});
return {
hiddenStories: Array.from(hiddenStoriesById.values()).sort(
sortByRecencyAndUnread
),
myStories: Array.from(myStoriesById.values()).sort(
sortByRecencyAndUnread
),
hiddenStories: Array.from(hiddenStoriesById.values()),
myStories: Array.from(myStoriesById.values()),
stories: Array.from(storiesById.values()).sort(sortByRecencyAndUnread),
};
}

View File

@ -13,6 +13,7 @@ import { SmartGlobalModalContainer } from './GlobalModalContainer';
import { SmartLeftPane } from './LeftPane';
import { SmartSafetyNumberViewer } from './SafetyNumberViewer';
import { SmartStories } from './Stories';
import { SmartStoryViewer } from './StoryViewer';
import type { StateType } from '../reducer';
import { getPreferredBadgeSelector } from '../selectors/badges';
import {
@ -23,7 +24,10 @@ import {
getIsMainWindowFullScreen,
getMenuOptions,
} from '../selectors/user';
import { shouldShowStoriesView } from '../selectors/stories';
import {
getSelectedStoryData,
shouldShowStoriesView,
} from '../selectors/stories';
import { getHideMenuBar } from '../selectors/items';
import { getConversationsStoppingSend } from '../selectors/conversations';
import { getIsCustomizingPreferredReactions } from '../selectors/preferredReactions';
@ -54,6 +58,8 @@ const mapStateToProps = (state: StateType) => {
),
isShowingStoriesView: shouldShowStoriesView(state),
renderStories: () => <SmartStories />,
selectedStoryData: getSelectedStoryData(state),
renderStoryViewer: () => <SmartStoryViewer />,
requestVerification: (
type: 'sms' | 'voice',
number: string,

View File

@ -7,12 +7,10 @@ import { useSelector } from 'react-redux';
import type { LocalizerType } from '../../types/Util';
import type { StateType } from '../reducer';
import type { PropsType as SmartStoryCreatorPropsType } from './StoryCreator';
import type { PropsType as SmartStoryViewerPropsType } from './StoryViewer';
import { SmartStoryCreator } from './StoryCreator';
import { SmartStoryViewer } from './StoryViewer';
import { Stories } from '../../components/Stories';
import { getMe } from '../selectors/conversations';
import { getIntl, getUserConversationId } from '../selectors/user';
import { getIntl } from '../selectors/user';
import { getPreferredLeftPaneWidth } from '../selectors/items';
import { getStories } from '../selectors/stories';
import { saveAttachment } from '../../util/saveAttachment';
@ -26,24 +24,6 @@ function renderStoryCreator({
return <SmartStoryCreator onClose={onClose} />;
}
function renderStoryViewer({
conversationId,
onClose,
onNextUserStories,
onPrevUserStories,
storyToView,
}: SmartStoryViewerPropsType): JSX.Element {
return (
<SmartStoryViewer
conversationId={conversationId}
onClose={onClose}
onNextUserStories={onNextUserStories}
onPrevUserStories={onPrevUserStories}
storyToView={storyToView}
/>
);
}
export function SmartStories(): JSX.Element | null {
const storiesActions = useStoriesActions();
const { showConversation, toggleHideStories } = useConversationsActions();
@ -61,7 +41,6 @@ export function SmartStories(): JSX.Element | null {
const { hiddenStories, myStories, stories } = useSelector(getStories);
const ourConversationId = useSelector(getUserConversationId);
const me = useSelector(getMe);
if (!isShowingStoriesView) {
@ -82,10 +61,8 @@ export function SmartStories(): JSX.Element | null {
saveAttachment(story.attachment, story.timestamp);
}
}}
ourConversationId={String(ourConversationId)}
preferredWidthFromStorage={preferredWidthFromStorage}
renderStoryCreator={renderStoryCreator}
renderStoryViewer={renderStoryViewer}
showConversation={showConversation}
stories={stories}
toggleHideStories={toggleHideStories}

View File

@ -4,12 +4,14 @@
import React from 'react';
import { useSelector } from 'react-redux';
import type { GetStoriesByConversationIdType } from '../selectors/stories';
import type { GetConversationByIdType } from '../selectors/conversations';
import type { LocalizerType } from '../../types/Util';
import type { StoryViewModeType } from '../../types/Stories';
import type { StateType } from '../reducer';
import type { StoryViewType } from '../../types/Stories';
import type { SelectedStoryDataType } from '../ducks/stories';
import { StoryViewer } from '../../components/StoryViewer';
import { ToastMessageBodyTooLong } from '../../components/ToastMessageBodyTooLong';
import { getConversationSelector } from '../selectors/conversations';
import {
getEmojiSkinTone,
getHasAllStoriesMuted,
@ -17,30 +19,22 @@ import {
} from '../selectors/items';
import { getIntl } from '../selectors/user';
import { getPreferredBadgeSelector } from '../selectors/badges';
import { getStoriesSelector, getStoryReplies } from '../selectors/stories';
import {
getConversationStory,
getSelectedStoryData,
getStoryReplies,
getStoryView,
} from '../selectors/stories';
import { renderEmojiPicker } from './renderEmojiPicker';
import { showToast } from '../../util/showToast';
import { strictAssert } from '../../util/assert';
import { useActions as useEmojisActions } from '../ducks/emojis';
import { useActions as useItemsActions } from '../ducks/items';
import { useConversationsActions } from '../ducks/conversations';
import { useRecentEmojis } from '../selectors/emojis';
import { useStoriesActions } from '../ducks/stories';
export type PropsType = {
conversationId: string;
onClose: () => unknown;
onNextUserStories?: () => unknown;
onPrevUserStories?: () => unknown;
storyToView?: StoryViewType;
};
export function SmartStoryViewer({
conversationId,
onClose,
onNextUserStories,
onPrevUserStories,
storyToView,
}: PropsType): JSX.Element | null {
export function SmartStoryViewer(): JSX.Element | null {
const storiesActions = useStoriesActions();
const { onSetSkinTone, toggleHasAllStoriesMuted } = useItemsActions();
const { onUseEmoji } = useEmojisActions();
@ -52,14 +46,25 @@ export function SmartStoryViewer({
getPreferredReactionEmoji
);
const getStoriesByConversationId = useSelector<
const selectedStoryData = useSelector<
StateType,
GetStoriesByConversationIdType
>(getStoriesSelector);
SelectedStoryDataType | undefined
>(getSelectedStoryData);
const { group, stories } = storyToView
? { group: undefined, stories: [storyToView] }
: getStoriesByConversationId(conversationId);
strictAssert(selectedStoryData, 'StoryViewer: !selectedStoryData');
const conversationSelector = useSelector<StateType, GetConversationByIdType>(
getConversationSelector
);
const storyView = getStoryView(conversationSelector, selectedStoryData.story);
const conversationStory = getConversationStory(
conversationSelector,
selectedStoryData.story
);
const storyViewMode = useSelector<StateType, StoryViewModeType | undefined>(
state => state.stories.storyViewMode
);
const recentEmojis = useRecentEmojis();
const skinTone = useSelector<StateType, number>(getEmojiSkinTone);
@ -70,26 +75,24 @@ export function SmartStoryViewer({
return (
<StoryViewer
conversationId={conversationId}
currentIndex={selectedStoryData.currentIndex}
getPreferredBadge={getPreferredBadge}
group={group}
group={conversationStory.group}
hasAllStoriesMuted={hasAllStoriesMuted}
i18n={i18n}
onClose={onClose}
numStories={selectedStoryData.numStories}
onHideStory={toggleHideStories}
onGoToConversation={senderId => {
showConversation({ conversationId: senderId });
storiesActions.toggleStoriesView();
}}
onNextUserStories={onNextUserStories}
onPrevUserStories={onPrevUserStories}
onReactToStory={async (emoji, story) => {
const { messageId } = story;
storiesActions.reactToStory(emoji, messageId);
}}
onReplyToStory={(message, mentions, timestamp, story) => {
storiesActions.replyToStory(
conversationId,
conversationStory.conversationId,
message,
mentions,
timestamp,
@ -103,8 +106,9 @@ export function SmartStoryViewer({
recentEmojis={recentEmojis}
renderEmojiPicker={renderEmojiPicker}
replyState={replyState}
stories={stories}
skinTone={skinTone}
story={storyView}
storyViewMode={storyViewMode}
toggleHasAllStoriesMuted={toggleHasAllStoriesMuted}
{...storiesActions}
/>

View File

@ -69,6 +69,6 @@ export function getFakeStory({
return {
conversationId: storyView.sender.id,
group,
stories: [storyView],
storyView,
};
}

View File

@ -1936,7 +1936,7 @@ export default class MessageReceiver
distributionListToSentUuid.forEach((sentToUuids, listId) => {
const ev = new SentEvent(
{
destinationUuid: dropNull(sentMessage.destinationUuid),
destinationUuid: envelope.destinationUuid.toString(),
timestamp: envelope.timestamp,
serverTimestamp: envelope.serverTimestamp,
unidentifiedStatus: Array.from(sentToUuids).map(

View File

@ -45,7 +45,7 @@ export type ConversationStoryType = {
>;
isHidden?: boolean;
searchNames?: string; // This is just here to satisfy Fuse's types
stories: Array<StoryViewType>;
storyView: StoryViewType;
};
export type StorySendStateType = {
@ -99,3 +99,14 @@ export type MyStoryType = {
};
export const MY_STORIES_ID = '00000000-0000-0000-0000-000000000000';
export enum StoryViewDirectionType {
Next = 'Next',
Previous = 'Previous',
}
export enum StoryViewModeType {
Unread = 'Unread',
All = 'All',
Single = 'Single',
}

View File

@ -9286,13 +9286,6 @@
"reasonCategory": "usageTrusted",
"updated": "2022-04-29T23:54:21.656Z"
},
{
"rule": "React-useRef",
"path": "ts/components/StoryViewer.tsx",
"line": " const storiesRef = useRef(stories);",
"reasonCategory": "usageTrusted",
"updated": "2022-04-30T00:44:47.213Z"
},
{
"rule": "React-useRef",
"path": "ts/components/StoryViewsNRepliesModal.tsx",