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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import React, { useCallback, useState } from 'react'; import React, { useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import type { import type {
ConversationType, ConversationType,
@ -15,8 +15,7 @@ import type {
} from '../types/Stories'; } from '../types/Stories';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import type { PropsType as SmartStoryCreatorPropsType } from '../state/smart/StoryCreator'; import type { PropsType as SmartStoryCreatorPropsType } from '../state/smart/StoryCreator';
import type { PropsType as SmartStoryViewerPropsType } from '../state/smart/StoryViewer'; import type { ViewStoryActionCreatorType } from '../state/ducks/stories';
import * as log from '../logging/log';
import { MyStories } from './MyStories'; import { MyStories } from './MyStories';
import { StoriesPane } from './StoriesPane'; import { StoriesPane } from './StoriesPane';
import { Theme, themeClassName } from '../util/theme'; import { Theme, themeClassName } from '../util/theme';
@ -30,15 +29,15 @@ export type PropsType = {
myStories: Array<MyStoryType>; myStories: Array<MyStoryType>;
onForwardStory: (storyId: string) => unknown; onForwardStory: (storyId: string) => unknown;
onSaveStory: (story: StoryViewType) => unknown; onSaveStory: (story: StoryViewType) => unknown;
ourConversationId: string;
preferredWidthFromStorage: number; preferredWidthFromStorage: number;
queueStoryDownload: (storyId: string) => unknown; queueStoryDownload: (storyId: string) => unknown;
renderStoryCreator: (props: SmartStoryCreatorPropsType) => JSX.Element; renderStoryCreator: (props: SmartStoryCreatorPropsType) => JSX.Element;
renderStoryViewer: (props: SmartStoryViewerPropsType) => JSX.Element;
showConversation: ShowConversationType; showConversation: ShowConversationType;
stories: Array<ConversationStoryType>; stories: Array<ConversationStoryType>;
toggleHideStories: (conversationId: string) => unknown; toggleHideStories: (conversationId: string) => unknown;
toggleStoriesView: () => unknown; toggleStoriesView: () => unknown;
viewUserStories: (conversationId: string) => unknown;
viewStory: ViewStoryActionCreatorType;
}; };
export const Stories = ({ export const Stories = ({
@ -49,76 +48,20 @@ export const Stories = ({
myStories, myStories,
onForwardStory, onForwardStory,
onSaveStory, onSaveStory,
ourConversationId,
preferredWidthFromStorage, preferredWidthFromStorage,
queueStoryDownload, queueStoryDownload,
renderStoryCreator, renderStoryCreator,
renderStoryViewer,
showConversation, showConversation,
stories, stories,
toggleHideStories, toggleHideStories,
toggleStoriesView, toggleStoriesView,
viewUserStories,
viewStory,
}: PropsType): JSX.Element => { }: PropsType): JSX.Element => {
const [conversationIdToView, setConversationIdToView] = useState<
undefined | string
>();
const width = getWidthFromPreferredWidth(preferredWidthFromStorage, { const width = getWidthFromPreferredWidth(preferredWidthFromStorage, {
requiresFullWidth: true, 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 [isShowingStoryCreator, setIsShowingStoryCreator] = useState(false);
const [isMyStories, setIsMyStories] = useState(false); const [isMyStories, setIsMyStories] = useState(false);
@ -128,13 +71,6 @@ export const Stories = ({
renderStoryCreator({ renderStoryCreator({
onClose: () => setIsShowingStoryCreator(false), onClose: () => setIsShowingStoryCreator(false),
})} })}
{conversationIdToView &&
renderStoryViewer({
conversationId: conversationIdToView,
onClose: () => setConversationIdToView(undefined),
onNextUserStories,
onPrevUserStories,
})}
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}> <FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
<div className="Stories__pane" style={{ width }}> <div className="Stories__pane" style={{ width }}>
{isMyStories && myStories.length ? ( {isMyStories && myStories.length ? (
@ -145,9 +81,8 @@ export const Stories = ({
onDelete={deleteStoryForEveryone} onDelete={deleteStoryForEveryone}
onForward={onForwardStory} onForward={onForwardStory}
onSave={onSaveStory} onSave={onSaveStory}
ourConversationId={ourConversationId}
queueStoryDownload={queueStoryDownload} queueStoryDownload={queueStoryDownload}
renderStoryViewer={renderStoryViewer} viewStory={viewStory}
/> />
) : ( ) : (
<StoriesPane <StoriesPane
@ -163,16 +98,7 @@ export const Stories = ({
setIsShowingStoryCreator(true); setIsShowingStoryCreator(true);
} }
}} }}
onStoryClicked={clickedIdToView => { onStoryClicked={viewUserStories}
const storyIndex = stories.findIndex(
x => x.conversationId === clickedIdToView
);
log.info('stories.onStoryClicked[StoriesPane]', {
storyIndex,
length: stories.length,
});
setConversationIdToView(clickedIdToView);
}}
queueStoryDownload={queueStoryDownload} queueStoryDownload={queueStoryDownload}
showConversation={showConversation} showConversation={showConversation}
stories={stories} stories={stories}

View file

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

View file

@ -1,8 +1,8 @@
// Copyright 2022 Signal Messenger, LLC // Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, Story } from '@storybook/react';
import React from 'react'; import React from 'react';
import { action } from '@storybook/addon-actions';
import type { PropsType } from './StoryViewer'; import type { PropsType } from './StoryViewer';
import { StoryViewer } from './StoryViewer'; import { StoryViewer } from './StoryViewer';
@ -10,184 +10,114 @@ import enMessages from '../../_locales/en/messages.json';
import { setupI18n } from '../util/setupI18n'; import { setupI18n } from '../util/setupI18n';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { fakeAttachment } from '../test-both/helpers/fakeAttachment'; import { fakeAttachment } from '../test-both/helpers/fakeAttachment';
import { getFakeStoryView } from '../test-both/helpers/getFakeStory';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
export default { export default {
title: 'Components/StoryViewer', 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 Template: Story<PropsType> = args => <StoryViewer {...args} />;
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()} />
);
export const SomeonesStory = Template.bind({});
SomeonesStory.args = {};
SomeonesStory.story = { SomeonesStory.story = {
name: "Someone's story", name: "Someone's story",
}; };
export const WideStory = (): JSX.Element => ( export const WideStory = Template.bind({});
<StoryViewer WideStory.args = {
{...getDefaultProps()} story: getFakeStoryView('/fixtures/nathan-anderson-316188-unsplash.jpg'),
stories={[ };
{
attachment: fakeAttachment({
path: 'file.jpg',
url: '/fixtures/nathan-anderson-316188-unsplash.jpg',
}),
canReply: true,
messageId: '123',
sender: getDefaultConversation(),
timestamp: Date.now(),
},
]}
/>
);
WideStory.story = { WideStory.story = {
name: 'Wide story', name: 'Wide story',
}; };
export const InAGroup = (): JSX.Element => ( export const InAGroup = Template.bind({});
<StoryViewer InAGroup.args = {
{...getDefaultProps()} group: getDefaultConversation({
group={getDefaultConversation({ avatarPath: '/fixtures/kitten-4-112-112.jpg',
avatarPath: '/fixtures/kitten-4-112-112.jpg', title: 'Family Group',
title: 'Family Group', type: 'group',
type: 'group', }),
})} };
/>
);
InAGroup.story = { InAGroup.story = {
name: 'In a group', name: 'In a group',
}; };
export const MultiStory = (): JSX.Element => { export const MultiStory = Template.bind({});
const sender = getDefaultConversation(); MultiStory.args = {
return ( currentIndex: 2,
<StoryViewer numStories: 7,
{...getDefaultProps()} story: getFakeStoryView('/fixtures/snow.jpg'),
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,
},
]}
/>
);
}; };
MultiStory.story = { MultiStory.story = {
name: 'Multi story', name: 'Multi story',
}; };
export const Caption = (): JSX.Element => ( export const Caption = Template.bind({});
<StoryViewer Caption.args = {
{...getDefaultProps()} story: {
group={getDefaultConversation({ ...getFakeStoryView(),
avatarPath: '/fixtures/kitten-4-112-112.jpg', attachment: fakeAttachment({
title: 'Broskis', caption: 'This place looks lovely',
type: 'group', path: 'file.jpg',
})} url: '/fixtures/nathan-anderson-316188-unsplash.jpg',
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 LongCaption = (): JSX.Element => ( export const LongCaption = Template.bind({});
<StoryViewer LongCaption.args = {
{...getDefaultProps()} story: {
hasAllStoriesMuted ...getFakeStoryView(),
stories={[ attachment: fakeAttachment({
{ caption:
attachment: fakeAttachment({ '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',
caption: path: 'file.jpg',
'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', url: '/fixtures/snow.jpg',
path: 'file.jpg', }),
url: '/fixtures/snow.jpg', },
}), };
canReply: true,
messageId: '123',
sender: getDefaultConversation(),
timestamp: Date.now(),
},
]}
/>
);

View file

@ -2,13 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import React, { import React, { useCallback, useEffect, useMemo, useState } from 'react';
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { useSpring, animated, to } from '@react-spring/web'; import { useSpring, animated, to } from '@react-spring/web';
import type { BodyRangeType, LocalizerType } from '../types/Util'; 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 { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { RenderEmojiPickerProps } from './conversation/ReactionPicker'; import type { RenderEmojiPickerProps } from './conversation/ReactionPicker';
import type { ReplyStateType, StoryViewType } from '../types/Stories'; 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 { AnimatedEmojiGalore } from './AnimatedEmojiGalore';
import { Avatar, AvatarSize } from './Avatar'; import { Avatar, AvatarSize } from './Avatar';
import { ConfirmationDialog } from './ConfirmationDialog'; import { ConfirmationDialog } from './ConfirmationDialog';
@ -25,18 +21,17 @@ import { Intl } from './Intl';
import { MessageTimestamp } from './conversation/MessageTimestamp'; import { MessageTimestamp } from './conversation/MessageTimestamp';
import { SendStatus } from '../messages/MessageSendState'; import { SendStatus } from '../messages/MessageSendState';
import { StoryImage } from './StoryImage'; import { StoryImage } from './StoryImage';
import { StoryViewDirectionType, StoryViewModeType } from '../types/Stories';
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal'; import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
import { Theme } from '../util/theme'; import { Theme } from '../util/theme';
import { getAvatarColor } from '../types/Colors'; import { getAvatarColor } from '../types/Colors';
import { getStoryBackground } from '../util/getStoryBackground'; import { getStoryBackground } from '../util/getStoryBackground';
import { getStoryDuration } from '../util/getStoryDuration'; import { getStoryDuration } from '../util/getStoryDuration';
import { graphemeAwareSlice } from '../util/graphemeAwareSlice'; import { graphemeAwareSlice } from '../util/graphemeAwareSlice';
import { isDownloaded, isDownloading } from '../types/Attachment';
import { useEscapeHandling } from '../hooks/useEscapeHandling'; import { useEscapeHandling } from '../hooks/useEscapeHandling';
import * as log from '../logging/log';
export type PropsType = { export type PropsType = {
conversationId: string; currentIndex: number;
getPreferredBadge: PreferredBadgeSelectorType; getPreferredBadge: PreferredBadgeSelectorType;
group?: Pick< group?: Pick<
ConversationType, ConversationType,
@ -53,11 +48,9 @@ export type PropsType = {
i18n: LocalizerType; i18n: LocalizerType;
loadStoryReplies: (conversationId: string, messageId: string) => unknown; loadStoryReplies: (conversationId: string, messageId: string) => unknown;
markStoryRead: (mId: string) => unknown; markStoryRead: (mId: string) => unknown;
onClose: () => unknown; numStories: number;
onGoToConversation: (conversationId: string) => unknown; onGoToConversation: (conversationId: string) => unknown;
onHideStory: (conversationId: string) => unknown; onHideStory: (conversationId: string) => unknown;
onNextUserStories?: () => unknown;
onPrevUserStories?: () => unknown;
onSetSkinTone: (tone: number) => unknown; onSetSkinTone: (tone: number) => unknown;
onTextTooLong: () => unknown; onTextTooLong: () => unknown;
onReactToStory: (emoji: string, story: StoryViewType) => unknown; onReactToStory: (emoji: string, story: StoryViewType) => unknown;
@ -74,8 +67,10 @@ export type PropsType = {
renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element; renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element;
replyState?: ReplyStateType; replyState?: ReplyStateType;
skinTone?: number; skinTone?: number;
stories: Array<StoryViewType>; story: StoryViewType;
storyViewMode?: StoryViewModeType;
toggleHasAllStoriesMuted: () => unknown; toggleHasAllStoriesMuted: () => unknown;
viewStory: ViewStoryActionCreatorType;
}; };
const CAPTION_BUFFER = 20; const CAPTION_BUFFER = 20;
@ -90,18 +85,16 @@ enum Arrow {
} }
export const StoryViewer = ({ export const StoryViewer = ({
conversationId, currentIndex,
getPreferredBadge, getPreferredBadge,
group, group,
hasAllStoriesMuted, hasAllStoriesMuted,
i18n, i18n,
loadStoryReplies, loadStoryReplies,
markStoryRead, markStoryRead,
onClose, numStories,
onGoToConversation, onGoToConversation,
onHideStory, onHideStory,
onNextUserStories,
onPrevUserStories,
onReactToStory, onReactToStory,
onReplyToStory, onReplyToStory,
onSetSkinTone, onSetSkinTone,
@ -113,10 +106,11 @@ export const StoryViewer = ({
renderEmojiPicker, renderEmojiPicker,
replyState, replyState,
skinTone, skinTone,
stories, story,
storyViewMode,
toggleHasAllStoriesMuted, toggleHasAllStoriesMuted,
viewStory,
}: PropsType): JSX.Element => { }: PropsType): JSX.Element => {
const [currentStoryIndex, setCurrentStoryIndex] = useState(0);
const [storyDuration, setStoryDuration] = useState<number | undefined>(); const [storyDuration, setStoryDuration] = useState<number | undefined>();
const [isShowingContextMenu, setIsShowingContextMenu] = useState(false); const [isShowingContextMenu, setIsShowingContextMenu] = useState(false);
const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false); const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false);
@ -124,10 +118,8 @@ export const StoryViewer = ({
useState<HTMLButtonElement | null>(null); useState<HTMLButtonElement | null>(null);
const [reactionEmoji, setReactionEmoji] = useState<string | undefined>(); const [reactionEmoji, setReactionEmoji] = useState<string | undefined>();
const visibleStory = stories[currentStoryIndex];
const { attachment, canReply, isHidden, messageId, sendState, timestamp } = const { attachment, canReply, isHidden, messageId, sendState, timestamp } =
visibleStory; story;
const { const {
acceptedMessageRequest, acceptedMessageRequest,
avatarPath, avatarPath,
@ -139,10 +131,14 @@ export const StoryViewer = ({
profileName, profileName,
sharedGroupNames, sharedGroupNames,
title, title,
} = visibleStory.sender; } = story.sender;
const [hasReplyModal, setHasReplyModal] = useState(false); const [hasReplyModal, setHasReplyModal] = useState(false);
const onClose = useCallback(() => {
viewStory();
}, [viewStory]);
const onEscape = useCallback(() => { const onEscape = useCallback(() => {
if (hasReplyModal) { if (hasReplyModal) {
setHasReplyModal(false); setHasReplyModal(false);
@ -173,48 +169,6 @@ export const StoryViewer = ({
setHasExpandedCaption(false); setHasExpandedCaption(false);
}, [messageId]); }, [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(() => { useEffect(() => {
let shouldCancel = false; let shouldCancel = false;
(async function hydrateStoryDuration() { (async function hydrateStoryDuration() {
@ -247,12 +201,16 @@ export const StoryViewer = ({
onRest: { onRest: {
width: ({ value }) => { width: ({ value }) => {
if (value === 100) { 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 // We need to be careful about this effect refreshing, it should only run
@ -274,7 +232,7 @@ export const StoryViewer = ({
return () => { return () => {
spring.stop(); spring.stop();
}; };
}, [currentStoryIndex, spring, storyDuration]); }, [currentIndex, spring, storyDuration]);
const [pauseStory, setPauseStory] = useState(false); const [pauseStory, setPauseStory] = useState(false);
@ -299,32 +257,23 @@ export const StoryViewer = ({
log.info('stories.markStoryRead', { messageId }); log.info('stories.markStoryRead', { messageId });
}, [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( const navigateStories = useCallback(
(ev: KeyboardEvent) => { (ev: KeyboardEvent) => {
if (ev.key === 'ArrowRight') { if (ev.key === 'ArrowRight') {
showNextStory(); viewStory(story.messageId, storyViewMode, StoryViewDirectionType.Next);
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
} else if (ev.key === 'ArrowLeft') { } else if (ev.key === 'ArrowLeft') {
showPrevStory(); viewStory(
story.messageId,
storyViewMode,
StoryViewDirectionType.Previous
);
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
} }
}, },
[showPrevStory, showNextStory] [story.messageId, storyViewMode, viewStory]
); );
useEffect(() => { useEffect(() => {
@ -335,13 +284,14 @@ export const StoryViewer = ({
}; };
}, [navigateStories]); }, [navigateStories]);
const isGroupStory = Boolean(group?.id); const groupId = group?.id;
const isGroupStory = Boolean(groupId);
useEffect(() => { useEffect(() => {
if (!isGroupStory) { if (!groupId) {
return; return;
} }
loadStoryReplies(conversationId, messageId); loadStoryReplies(groupId, messageId);
}, [conversationId, isGroupStory, loadStoryReplies, messageId]); }, [groupId, loadStoryReplies, messageId]);
const [arrowToShow, setArrowToShow] = useState<Arrow>(Arrow.None); const [arrowToShow, setArrowToShow] = useState<Arrow>(Arrow.None);
@ -385,6 +335,8 @@ export const StoryViewer = ({
const shouldShowContextMenu = !sendState; const shouldShowContextMenu = !sendState;
const hasPrevNextArrows = storyViewMode !== StoryViewModeType.Single;
return ( return (
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}> <FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
<div className="StoryViewer"> <div className="StoryViewer">
@ -393,7 +345,7 @@ export const StoryViewer = ({
style={{ background: getStoryBackground(attachment) }} style={{ background: getStoryBackground(attachment) }}
/> />
<div className="StoryViewer__content"> <div className="StoryViewer__content">
{onPrevUserStories && ( {hasPrevNextArrows && (
<button <button
aria-label={i18n('back')} aria-label={i18n('back')}
className={classNames( className={classNames(
@ -402,7 +354,13 @@ export const StoryViewer = ({
'StoryViewer__arrow--visible': arrowToShow === Arrow.Left, 'StoryViewer__arrow--visible': arrowToShow === Arrow.Left,
} }
)} )}
onClick={showPrevStory} onClick={() =>
viewStory(
story.messageId,
storyViewMode,
StoryViewDirectionType.Previous
)
}
onMouseMove={() => setArrowToShow(Arrow.Left)} onMouseMove={() => setArrowToShow(Arrow.Left)}
type="button" type="button"
/> />
@ -549,12 +507,9 @@ export const StoryViewer = ({
</div> </div>
</div> </div>
<div className="StoryViewer__progress"> <div className="StoryViewer__progress">
{stories.map((story, index) => ( {Array.from(Array(numStories), (_, index) => (
<div <div className="StoryViewer__progress--container" key={index}>
className="StoryViewer__progress--container" {currentIndex === index ? (
key={story.messageId}
>
{currentStoryIndex === index ? (
<animated.div <animated.div
className="StoryViewer__progress--bar" className="StoryViewer__progress--bar"
style={{ style={{
@ -565,7 +520,7 @@ export const StoryViewer = ({
<div <div
className="StoryViewer__progress--bar" className="StoryViewer__progress--bar"
style={{ style={{
width: currentStoryIndex < index ? '0%' : '100%', width: currentIndex < index ? '0%' : '100%',
}} }}
/> />
)} )}
@ -626,7 +581,7 @@ export const StoryViewer = ({
)} )}
</div> </div>
</div> </div>
{onNextUserStories && ( {hasPrevNextArrows && (
<button <button
aria-label={i18n('forward')} aria-label={i18n('forward')}
className={classNames( className={classNames(
@ -635,7 +590,13 @@ export const StoryViewer = ({
'StoryViewer__arrow--visible': arrowToShow === Arrow.Right, 'StoryViewer__arrow--visible': arrowToShow === Arrow.Right,
} }
)} )}
onClick={showNextStory} onClick={() =>
viewStory(
story.messageId,
storyViewMode,
StoryViewDirectionType.Next
)
}
onMouseMove={() => setArrowToShow(Arrow.Right)} onMouseMove={() => setArrowToShow(Arrow.Right)}
type="button" type="button"
/> />
@ -686,7 +647,7 @@ export const StoryViewer = ({
isMyStory={isMe} isMyStory={isMe}
onClose={() => setHasReplyModal(false)} onClose={() => setHasReplyModal(false)}
onReact={emoji => { onReact={emoji => {
onReactToStory(emoji, visibleStory); onReactToStory(emoji, story);
setHasReplyModal(false); setHasReplyModal(false);
setReactionEmoji(emoji); setReactionEmoji(emoji);
}} }}
@ -694,7 +655,7 @@ export const StoryViewer = ({
if (!isGroupStory) { if (!isGroupStory) {
setHasReplyModal(false); setHasReplyModal(false);
} }
onReplyToStory(message, mentions, replyTimestamp, visibleStory); onReplyToStory(message, mentions, replyTimestamp, story);
}} }}
onSetSkinTone={onSetSkinTone} onSetSkinTone={onSetSkinTone}
onTextTooLong={onTextTooLong} onTextTooLong={onTextTooLong}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // 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 { isEqual, pick } from 'lodash';
import type { AttachmentType } from '../../types/Attachment'; import type { AttachmentType } from '../../types/Attachment';
import type { BodyRangeType } from '../../types/Util'; import type { BodyRangeType } from '../../types/Util';
@ -18,6 +18,7 @@ import * as log from '../../logging/log';
import dataInterface from '../../sql/Client'; import dataInterface from '../../sql/Client';
import { DAY } from '../../util/durations'; import { DAY } from '../../util/durations';
import { ReadStatus } from '../../messages/MessageReadStatus'; import { ReadStatus } from '../../messages/MessageReadStatus';
import { StoryViewDirectionType, StoryViewModeType } from '../../types/Stories';
import { ToastReactionFailed } from '../../components/ToastReactionFailed'; import { ToastReactionFailed } from '../../components/ToastReactionFailed';
import { UUID } from '../../types/UUID'; import { UUID } from '../../types/UUID';
import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend'; import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend';
@ -32,11 +33,12 @@ import {
isDownloaded, isDownloaded,
isDownloading, isDownloading,
} from '../../types/Attachment'; } from '../../types/Attachment';
import { getConversationSelector } from '../selectors/conversations';
import { getStories } from '../selectors/stories';
import { isGroup } from '../../util/whatTypeOfConversation';
import { useBoundActions } from '../../hooks/useBoundActions'; import { useBoundActions } from '../../hooks/useBoundActions';
import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue'; import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue';
import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue'; import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue';
import { isGroup } from '../../util/whatTypeOfConversation';
import { getConversationSelector } from '../selectors/conversations';
export type StoryDataType = { export type StoryDataType = {
attachment?: AttachmentType; attachment?: AttachmentType;
@ -56,6 +58,12 @@ export type StoryDataType = {
| 'type' | 'type'
>; >;
export type SelectedStoryDataType = {
currentIndex: number;
numStories: number;
story: StoryDataType;
};
// State // State
export type StoriesStateType = { export type StoriesStateType = {
@ -64,7 +72,9 @@ export type StoriesStateType = {
messageId: string; messageId: string;
replies: Array<MessageAttributesType>; replies: Array<MessageAttributesType>;
}; };
readonly selectedStoryData?: SelectedStoryDataType;
readonly stories: Array<StoryDataType>; readonly stories: Array<StoryDataType>;
readonly storyViewMode?: StoryViewModeType;
}; };
// Actions // Actions
@ -76,6 +86,7 @@ const REPLY_TO_STORY = 'stories/REPLY_TO_STORY';
export const RESOLVE_ATTACHMENT_URL = 'stories/RESOLVE_ATTACHMENT_URL'; export const RESOLVE_ATTACHMENT_URL = 'stories/RESOLVE_ATTACHMENT_URL';
const STORY_CHANGED = 'stories/STORY_CHANGED'; const STORY_CHANGED = 'stories/STORY_CHANGED';
const TOGGLE_VIEW = 'stories/TOGGLE_VIEW'; const TOGGLE_VIEW = 'stories/TOGGLE_VIEW';
const VIEW_STORY = 'stories/VIEW_STORY';
type DOEStoryActionType = { type DOEStoryActionType = {
type: typeof DOE_STORY; type: typeof DOE_STORY;
@ -117,6 +128,16 @@ type ToggleViewActionType = {
type: typeof TOGGLE_VIEW; type: typeof TOGGLE_VIEW;
}; };
type ViewStoryActionType = {
type: typeof VIEW_STORY;
payload:
| {
selectedStoryData: SelectedStoryDataType;
storyViewMode: StoryViewModeType;
}
| undefined;
};
export type StoriesActionType = export type StoriesActionType =
| DOEStoryActionType | DOEStoryActionType
| LoadStoryRepliesActionType | LoadStoryRepliesActionType
@ -126,23 +147,11 @@ export type StoriesActionType =
| ReplyToStoryActionType | ReplyToStoryActionType
| ResolveAttachmentUrlActionType | ResolveAttachmentUrlActionType
| StoryChangedActionType | StoryChangedActionType
| ToggleViewActionType; | ToggleViewActionType
| ViewStoryActionType;
// Action Creators // Action Creators
export const actions = {
deleteStoryForEveryone,
loadStoryReplies,
markStoryRead,
queueStoryDownload,
reactToStory,
replyToStory,
storyChanged,
toggleStoriesView,
};
export const useStoriesActions = (): typeof actions => useBoundActions(actions);
function deleteStoryForEveryone( function deleteStoryForEveryone(
story: StoryViewType story: StoryViewType
): ThunkAction<void, RootStateType, unknown, DOEStoryActionType> { ): 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 // Reducer
export function getEmptyState( 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; return state;
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -45,7 +45,7 @@ export type ConversationStoryType = {
>; >;
isHidden?: boolean; isHidden?: boolean;
searchNames?: string; // This is just here to satisfy Fuse's types searchNames?: string; // This is just here to satisfy Fuse's types
stories: Array<StoryViewType>; storyView: StoryViewType;
}; };
export type StorySendStateType = { export type StorySendStateType = {
@ -99,3 +99,14 @@ export type MyStoryType = {
}; };
export const MY_STORIES_ID = '00000000-0000-0000-0000-000000000000'; 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", "reasonCategory": "usageTrusted",
"updated": "2022-04-29T23:54:21.656Z" "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", "rule": "React-useRef",
"path": "ts/components/StoryViewsNRepliesModal.tsx", "path": "ts/components/StoryViewsNRepliesModal.tsx",