diff --git a/ts/components/App.tsx b/ts/components/App.tsx index cda1e1a74..3cc08a379 100644 --- a/ts/components/App.tsx +++ b/ts/components/App.tsx @@ -17,6 +17,7 @@ import { useReducedMotion } from '../hooks/useReducedMotion'; import type { MenuOptionsType, MenuActionType } from '../types/menu'; import { TitleBarContainer } from './TitleBarContainer'; import type { ExecuteMenuRoleType } from './TitleBarContainer'; +import type { SelectedStoryDataType } from '../state/ducks/stories'; type PropsType = { appView: AppViewType; @@ -27,6 +28,8 @@ type PropsType = { renderGlobalModalContainer: () => JSX.Element; isShowingStoriesView: boolean; renderStories: () => JSX.Element; + selectedStoryData?: SelectedStoryDataType; + renderStoryViewer: () => JSX.Element; requestVerification: ( type: 'sms' | 'voice', number: string, @@ -69,9 +72,11 @@ export const App = ({ renderLeftPane, renderSafetyNumber, renderStories, + renderStoryViewer, requestVerification, selectedConversationId, selectedMessage, + selectedStoryData, showConversation, showWhatsNewModal, theme, @@ -169,6 +174,7 @@ export const App = ({ {renderGlobalModalContainer()} {renderCallManager()} {isShowingStoriesView && renderStories()} + {selectedStoryData && renderStoryViewer()} {contents} diff --git a/ts/components/MyStories.stories.tsx b/ts/components/MyStories.stories.tsx index 2ac6b0d6b..5b5db1ac2 100644 --- a/ts/components/MyStories.stories.tsx +++ b/ts/components/MyStories.stories.tsx @@ -47,6 +47,7 @@ export default { renderStoryViewer: { action: true, }, + viewStory: { action: true }, }, } as Meta; diff --git a/ts/components/MyStories.tsx b/ts/components/MyStories.tsx index 033f18978..e0e40d5d9 100644 --- a/ts/components/MyStories.tsx +++ b/ts/components/MyStories.tsx @@ -4,10 +4,10 @@ import React, { useState } from 'react'; import type { MyStoryType, StoryViewType } from '../types/Stories'; import type { LocalizerType } from '../types/Util'; -import type { PropsType as SmartStoryViewerPropsType } from '../state/smart/StoryViewer'; +import type { ViewStoryActionCreatorType } from '../state/ducks/stories'; import { ConfirmationDialog } from './ConfirmationDialog'; import { ContextMenu } from './ContextMenu'; -import { MY_STORIES_ID } from '../types/Stories'; +import { MY_STORIES_ID, StoryViewModeType } from '../types/Stories'; import { MessageTimestamp } from './conversation/MessageTimestamp'; import { StoryImage } from './StoryImage'; import { Theme } from '../util/theme'; @@ -19,9 +19,8 @@ export type PropsType = { onDelete: (story: StoryViewType) => unknown; onForward: (storyId: string) => unknown; onSave: (story: StoryViewType) => unknown; - ourConversationId: string; queueStoryDownload: (storyId: string) => unknown; - renderStoryViewer: (props: SmartStoryViewerPropsType) => JSX.Element; + viewStory: ViewStoryActionCreatorType; }; export const MyStories = ({ @@ -31,16 +30,13 @@ export const MyStories = ({ onDelete, onForward, onSave, - ourConversationId, queueStoryDownload, - renderStoryViewer, + viewStory, }: PropsType): JSX.Element => { const [confirmDeleteStory, setConfirmDeleteStory] = useState< StoryViewType | undefined >(); - const [storyToView, setStoryToView] = useState(); - return ( <> {confirmDeleteStory && ( @@ -58,12 +54,6 @@ export const MyStories = ({ {i18n('MyStories__delete')} )} - {storyToView && - renderStoryViewer({ - conversationId: ourConversationId, - onClose: () => setStoryToView(undefined), - storyToView, - })}
- {stories.map((story, index) => ( -
- {currentStoryIndex === index ? ( + {Array.from(Array(numStories), (_, index) => ( +
+ {currentIndex === index ? ( )} @@ -626,7 +581,7 @@ export const StoryViewer = ({ )}
- {onNextUserStories && ( + {hasPrevNextArrows && (
diff --git a/ts/components/conversation/Quote.stories.tsx b/ts/components/conversation/Quote.stories.tsx index eda9e54b9..20a73a55b 100644 --- a/ts/components/conversation/Quote.stories.tsx +++ b/ts/components/conversation/Quote.stories.tsx @@ -1,11 +1,10 @@ // Copyright 2020-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import type { Meta, Story } from '@storybook/react'; import * as React from 'react'; -import { isString } from 'lodash'; import { action } from '@storybook/addon-actions'; -import { boolean, text } from '@storybook/addon-knobs'; import { ConversationColors } from '../../types/Colors'; import { pngUrl } from '../../storybook/Fixtures'; @@ -30,8 +29,49 @@ import { ThemeType } from '../../types/Util'; const i18n = setupI18n('en', enMessages); export default { + component: Quote, title: 'Components/Conversation/Quote', -}; + argTypes: { + authorTitle: { + defaultValue: 'Default Sender', + }, + conversationColor: { + defaultValue: 'forest', + }, + doubleCheckMissingQuoteReference: { action: true }, + i18n: { + defaultValue: i18n, + }, + isFromMe: { + control: { type: 'checkbox' }, + defaultValue: false, + }, + isGiftBadge: { + control: { type: 'checkbox' }, + defaultValue: false, + }, + isIncoming: { + control: { type: 'checkbox' }, + defaultValue: false, + }, + isViewOnce: { + control: { type: 'checkbox' }, + defaultValue: false, + }, + onClick: { action: true }, + onClose: { action: true }, + rawAttachment: { + defaultValue: undefined, + }, + referencedMessageNotFound: { + control: { type: 'checkbox' }, + defaultValue: false, + }, + text: { + defaultValue: 'A sample message from a pal', + }, + }, +} as Meta; const defaultMessageProps: MessagesProps = { author: getDefaultConversation({ @@ -105,6 +145,7 @@ const defaultMessageProps: MessagesProps = { textDirection: TextDirection.Default, theme: ThemeType.light, timestamp: Date.now(), + viewStory: action('viewStory'), }; const renderInMessage = ({ @@ -143,459 +184,332 @@ const renderInMessage = ({ ); }; -const createProps = (overrideProps: Partial = {}): Props => ({ - authorTitle: text( - 'authorTitle', - overrideProps.authorTitle || 'Default Sender' - ), - conversationColor: overrideProps.conversationColor || 'forest', - doubleCheckMissingQuoteReference: - overrideProps.doubleCheckMissingQuoteReference || - action('doubleCheckMissingQuoteReference'), - i18n, - isFromMe: boolean('isFromMe', overrideProps.isFromMe || false), - isIncoming: boolean('isIncoming', overrideProps.isIncoming || false), - onClick: action('onClick'), - onClose: action('onClose'), - rawAttachment: overrideProps.rawAttachment || undefined, - referencedMessageNotFound: boolean( - 'referencedMessageNotFound', - overrideProps.referencedMessageNotFound || false - ), - isGiftBadge: boolean('isGiftBadge', overrideProps.isGiftBadge || false), - isViewOnce: boolean('isViewOnce', overrideProps.isViewOnce || false), - text: text( - 'text', - isString(overrideProps.text) - ? overrideProps.text - : 'A sample message from a pal' - ), -}); +const Template: Story = args => ; +const TemplateInMessage: Story = args => renderInMessage(args); -export const OutgoingByAnotherAuthor = (): JSX.Element => { - const props = createProps({ - authorTitle: 'Terrence Malick', - }); - - return ; +export const OutgoingByAnotherAuthor = Template.bind({}); +OutgoingByAnotherAuthor.args = { + authorTitle: getDefaultConversation().title, }; - OutgoingByAnotherAuthor.story = { name: 'Outgoing by Another Author', }; -export const OutgoingByMe = (): JSX.Element => { - const props = createProps({ - isFromMe: true, - }); - - return ; +export const OutgoingByMe = Template.bind({}); +OutgoingByMe.args = { + isFromMe: true, }; - OutgoingByMe.story = { name: 'Outgoing by Me', }; -export const IncomingByAnotherAuthor = (): JSX.Element => { - const props = createProps({ - authorTitle: 'Terrence Malick', - isIncoming: true, - }); - - return ; +export const IncomingByAnotherAuthor = Template.bind({}); +IncomingByAnotherAuthor.args = { + authorTitle: getDefaultConversation().title, + isIncoming: true, }; - IncomingByAnotherAuthor.story = { name: 'Incoming by Another Author', }; -export const IncomingByMe = (): JSX.Element => { - const props = createProps({ - isFromMe: true, - isIncoming: true, - }); - - return ; +export const IncomingByMe = Template.bind({}); +IncomingByMe.args = { + isFromMe: true, + isIncoming: true, }; - IncomingByMe.story = { name: 'Incoming by Me', }; -export const IncomingOutgoingColors = (): JSX.Element => { - const props = createProps({}); +export const IncomingOutgoingColors = (args: Props): JSX.Element => { return ( <> {ConversationColors.map(color => - renderInMessage({ ...props, conversationColor: color }) + renderInMessage({ ...args, conversationColor: color }) )} ); }; - +IncomingOutgoingColors.args = {}; IncomingOutgoingColors.story = { name: 'Incoming/Outgoing Colors', }; -export const ImageOnly = (): JSX.Element => { - const props = createProps({ - text: '', - rawAttachment: { +export const ImageOnly = Template.bind({}); +ImageOnly.args = { + text: '', + rawAttachment: { + contentType: IMAGE_PNG, + fileName: 'sax.png', + isVoiceMessage: false, + thumbnail: { contentType: IMAGE_PNG, - fileName: 'sax.png', - isVoiceMessage: false, - thumbnail: { - contentType: IMAGE_PNG, - height: 100, - width: 100, - path: pngUrl, - objectUrl: pngUrl, - }, + height: 100, + width: 100, + path: pngUrl, + objectUrl: pngUrl, }, - }); - - return ; + }, }; -export const ImageAttachment = (): JSX.Element => { - const props = createProps({ - rawAttachment: { +export const ImageAttachment = Template.bind({}); +ImageAttachment.args = { + rawAttachment: { + contentType: IMAGE_PNG, + fileName: 'sax.png', + isVoiceMessage: false, + thumbnail: { contentType: IMAGE_PNG, - fileName: 'sax.png', - isVoiceMessage: false, - thumbnail: { - contentType: IMAGE_PNG, - height: 100, - width: 100, - path: pngUrl, - objectUrl: pngUrl, - }, + height: 100, + width: 100, + path: pngUrl, + objectUrl: pngUrl, }, - }); - - return ; + }, }; -export const ImageAttachmentWOThumbnail = (): JSX.Element => { - const props = createProps({ - rawAttachment: { - contentType: IMAGE_PNG, - fileName: 'sax.png', - isVoiceMessage: false, - }, - }); - - return ; +export const ImageAttachmentNoThumbnail = Template.bind({}); +ImageAttachmentNoThumbnail.args = { + rawAttachment: { + contentType: IMAGE_PNG, + fileName: 'sax.png', + isVoiceMessage: false, + }, }; - -ImageAttachmentWOThumbnail.story = { +ImageAttachmentNoThumbnail.story = { name: 'Image Attachment w/o Thumbnail', }; -export const ImageTapToView = (): JSX.Element => { - const props = createProps({ - text: '', - isViewOnce: true, - rawAttachment: { - contentType: IMAGE_PNG, - fileName: 'sax.png', - isVoiceMessage: false, - }, - }); - - return ; +export const ImageTapToView = Template.bind({}); +ImageTapToView.args = { + text: '', + isViewOnce: true, + rawAttachment: { + contentType: IMAGE_PNG, + fileName: 'sax.png', + isVoiceMessage: false, + }, }; - ImageTapToView.story = { name: 'Image Tap-to-View', }; -export const VideoOnly = (): JSX.Element => { - const props = createProps({ - rawAttachment: { - contentType: VIDEO_MP4, - fileName: 'great-video.mp4', - isVoiceMessage: false, - thumbnail: { - contentType: IMAGE_PNG, - height: 100, - width: 100, - path: pngUrl, - objectUrl: pngUrl, - }, +export const VideoOnly = Template.bind({}); +VideoOnly.args = { + rawAttachment: { + contentType: VIDEO_MP4, + fileName: 'great-video.mp4', + isVoiceMessage: false, + thumbnail: { + contentType: IMAGE_PNG, + height: 100, + width: 100, + path: pngUrl, + objectUrl: pngUrl, }, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - props.text = undefined as any; - - return ; + }, + text: undefined, }; -export const VideoAttachment = (): JSX.Element => { - const props = createProps({ - rawAttachment: { - contentType: VIDEO_MP4, - fileName: 'great-video.mp4', - isVoiceMessage: false, - thumbnail: { - contentType: IMAGE_PNG, - height: 100, - width: 100, - path: pngUrl, - objectUrl: pngUrl, - }, +export const VideoAttachment = Template.bind({}); +VideoAttachment.args = { + rawAttachment: { + contentType: VIDEO_MP4, + fileName: 'great-video.mp4', + isVoiceMessage: false, + thumbnail: { + contentType: IMAGE_PNG, + height: 100, + width: 100, + path: pngUrl, + objectUrl: pngUrl, }, - }); - - return ; + }, }; -export const VideoAttachmentWOThumbnail = (): JSX.Element => { - const props = createProps({ - rawAttachment: { - contentType: VIDEO_MP4, - fileName: 'great-video.mp4', - isVoiceMessage: false, - }, - }); - - return ; +export const VideoAttachmentNoThumbnail = Template.bind({}); +VideoAttachmentNoThumbnail.args = { + rawAttachment: { + contentType: VIDEO_MP4, + fileName: 'great-video.mp4', + isVoiceMessage: false, + }, }; - -VideoAttachmentWOThumbnail.story = { +VideoAttachmentNoThumbnail.story = { name: 'Video Attachment w/o Thumbnail', }; -export const VideoTapToView = (): JSX.Element => { - const props = createProps({ - text: '', - isViewOnce: true, - rawAttachment: { - contentType: VIDEO_MP4, - fileName: 'great-video.mp4', - isVoiceMessage: false, - }, - }); - - return ; +export const VideoTapToView = Template.bind({}); +VideoTapToView.args = { + text: '', + isViewOnce: true, + rawAttachment: { + contentType: VIDEO_MP4, + fileName: 'great-video.mp4', + isVoiceMessage: false, + }, }; - VideoTapToView.story = { name: 'Video Tap-to-View', }; -export const GiftBadge = (): JSX.Element => { - const props = createProps({ - text: "Some text which shouldn't be rendered", - isGiftBadge: true, - }); - - return renderInMessage(props); +export const GiftBadge = TemplateInMessage.bind({}); +GiftBadge.args = { + text: "Some text which shouldn't be rendered", + isGiftBadge: true, }; -export const AudioOnly = (): JSX.Element => { - const props = createProps({ - rawAttachment: { - contentType: AUDIO_MP3, - fileName: 'great-video.mp3', - isVoiceMessage: false, - }, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - props.text = undefined as any; - - return ; +export const AudioOnly = Template.bind({}); +AudioOnly.args = { + rawAttachment: { + contentType: AUDIO_MP3, + fileName: 'great-video.mp3', + isVoiceMessage: false, + }, + text: undefined, }; -export const AudioAttachment = (): JSX.Element => { - const props = createProps({ - rawAttachment: { - contentType: AUDIO_MP3, - fileName: 'great-video.mp3', - isVoiceMessage: false, - }, - }); - - return ; +export const AudioAttachment = Template.bind({}); +AudioAttachment.args = { + rawAttachment: { + contentType: AUDIO_MP3, + fileName: 'great-video.mp3', + isVoiceMessage: false, + }, }; -export const VoiceMessageOnly = (): JSX.Element => { - const props = createProps({ - rawAttachment: { - contentType: AUDIO_MP3, - fileName: 'great-video.mp3', - isVoiceMessage: true, - }, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - props.text = undefined as any; - - return ; +export const VoiceMessageOnly = Template.bind({}); +VoiceMessageOnly.args = { + rawAttachment: { + contentType: AUDIO_MP3, + fileName: 'great-video.mp3', + isVoiceMessage: true, + }, + text: undefined, }; -export const VoiceMessageAttachment = (): JSX.Element => { - const props = createProps({ - rawAttachment: { - contentType: AUDIO_MP3, - fileName: 'great-video.mp3', - isVoiceMessage: true, - }, - }); - - return ; +export const VoiceMessageAttachment = Template.bind({}); +VoiceMessageAttachment.args = { + rawAttachment: { + contentType: AUDIO_MP3, + fileName: 'great-video.mp3', + isVoiceMessage: true, + }, }; -export const OtherFileOnly = (): JSX.Element => { - const props = createProps({ - rawAttachment: { - contentType: stringToMIMEType('application/json'), - fileName: 'great-data.json', - isVoiceMessage: false, - }, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - props.text = undefined as any; - - return ; +export const OtherFileOnly = Template.bind({}); +OtherFileOnly.args = { + rawAttachment: { + contentType: stringToMIMEType('application/json'), + fileName: 'great-data.json', + isVoiceMessage: false, + }, + text: undefined, }; -export const MediaTapToView = (): JSX.Element => { - const props = createProps({ - text: '', - isViewOnce: true, - rawAttachment: { - contentType: AUDIO_MP3, - fileName: 'great-video.mp3', - isVoiceMessage: false, - }, - }); - - return ; +export const MediaTapToView = Template.bind({}); +MediaTapToView.args = { + text: '', + isViewOnce: true, + rawAttachment: { + contentType: AUDIO_MP3, + fileName: 'great-video.mp3', + isVoiceMessage: false, + }, }; - MediaTapToView.story = { name: 'Media Tap-to-View', }; -export const OtherFileAttachment = (): JSX.Element => { - const props = createProps({ - rawAttachment: { - contentType: stringToMIMEType('application/json'), - fileName: 'great-data.json', - isVoiceMessage: false, - }, - }); - - return ; +export const OtherFileAttachment = Template.bind({}); +OtherFileAttachment.args = { + rawAttachment: { + contentType: stringToMIMEType('application/json'), + fileName: 'great-data.json', + isVoiceMessage: false, + }, }; -export const LongMessageAttachmentShouldBeHidden = (): JSX.Element => { - const props = createProps({ - rawAttachment: { - contentType: LONG_MESSAGE, - fileName: 'signal-long-message-123.txt', - isVoiceMessage: false, - }, - }); - - return ; +export const LongMessageAttachmentShouldBeHidden = Template.bind({}); +LongMessageAttachmentShouldBeHidden.args = { + rawAttachment: { + contentType: LONG_MESSAGE, + fileName: 'signal-long-message-123.txt', + isVoiceMessage: false, + }, }; - LongMessageAttachmentShouldBeHidden.story = { name: 'Long message attachment (should be hidden)', }; -export const NoCloseButton = (): JSX.Element => { - const props = createProps(); - props.onClose = undefined; - - return ; +export const NoCloseButton = Template.bind({}); +NoCloseButton.args = { + onClose: undefined, }; -export const MessageNotFound = (): JSX.Element => { - const props = createProps({ - referencedMessageNotFound: true, - }); - - return renderInMessage(props); +export const MessageNotFound = TemplateInMessage.bind({}); +MessageNotFound.args = { + referencedMessageNotFound: true, }; -export const MissingTextAttachment = (): JSX.Element => { - const props = createProps(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - props.text = undefined as any; - - return ; +export const MissingTextAttachment = Template.bind({}); +MissingTextAttachment.args = { + text: undefined, }; - MissingTextAttachment.story = { name: 'Missing Text & Attachment', }; -export const MentionOutgoingAnotherAuthor = (): JSX.Element => { - const props = createProps({ - authorTitle: 'Tony Stark', - text: '@Captain America Lunch later?', - }); - - return ; +export const MentionOutgoingAnotherAuthor = Template.bind({}); +MentionOutgoingAnotherAuthor.args = { + authorTitle: 'Tony Stark', + text: '@Captain America Lunch later?', }; - MentionOutgoingAnotherAuthor.story = { name: '@mention + outgoing + another author', }; -export const MentionOutgoingMe = (): JSX.Element => { - const props = createProps({ - isFromMe: true, - text: '@Captain America Lunch later?', - }); - - return ; +export const MentionOutgoingMe = Template.bind({}); +MentionOutgoingMe.args = { + isFromMe: true, + text: '@Captain America Lunch later?', }; - MentionOutgoingMe.story = { name: '@mention + outgoing + me', }; -export const MentionIncomingAnotherAuthor = (): JSX.Element => { - const props = createProps({ - authorTitle: 'Captain America', - isIncoming: true, - text: '@Tony Stark sure', - }); - - return ; +export const MentionIncomingAnotherAuthor = Template.bind({}); +MentionIncomingAnotherAuthor.args = { + authorTitle: 'Captain America', + isIncoming: true, + text: '@Tony Stark sure', }; - MentionIncomingAnotherAuthor.story = { name: '@mention + incoming + another author', }; -export const MentionIncomingMe = (): JSX.Element => { - const props = createProps({ - isFromMe: true, - isIncoming: true, - text: '@Tony Stark sure', - }); - - return ; +export const MentionIncomingMe = Template.bind({}); +MentionIncomingMe.args = { + isFromMe: true, + isIncoming: true, + text: '@Tony Stark sure', }; - MentionIncomingMe.story = { name: '@mention + incoming + me', }; -export const CustomColor = (): JSX.Element => ( +export const CustomColor = (args: Props): JSX.Element => ( <> ( /> ); - -export const IsStoryReply = (): JSX.Element => { - const props = createProps({ - text: 'Wow!', - }); - - return ( - - ); +CustomColor.args = { + isIncoming: true, + text: 'Solid + Gradient', }; +export const IsStoryReply = Template.bind({}); +IsStoryReply.args = { + text: 'Wow!', + authorTitle: 'Amanda', + isStoryReply: true, + moduleClassName: 'StoryReplyQuote', + onClose: undefined, + rawAttachment: { + contentType: VIDEO_MP4, + fileName: 'great-video.mp4', + isVoiceMessage: false, + }, +}; IsStoryReply.story = { name: 'isStoryReply', }; -export const IsStoryReplyEmoji = (): JSX.Element => { - const props = createProps(); - - return ( - - ); +export const IsStoryReplyEmoji = Template.bind({}); +IsStoryReplyEmoji.args = { + authorTitle: getDefaultConversation().firstName, + isStoryReply: true, + moduleClassName: 'StoryReplyQuote', + onClose: undefined, + rawAttachment: { + contentType: IMAGE_PNG, + fileName: 'sax.png', + isVoiceMessage: false, + thumbnail: { + contentType: IMAGE_PNG, + height: 100, + width: 100, + path: pngUrl, + objectUrl: pngUrl, + }, + }, + reactionEmoji: '🏋️', }; - IsStoryReplyEmoji.story = { name: 'isStoryReply emoji', }; diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 4ba4cf161..9c2a3169c 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -418,6 +418,8 @@ const actions = () => ({ peekGroupCallForTheFirstTime: action('peekGroupCallForTheFirstTime'), peekGroupCallIfItHasMembers: action('peekGroupCallIfItHasMembers'), + + viewStory: action('viewStory'), }); const renderItem = ({ diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index 145f4aebe..308fa09b0 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -267,6 +267,8 @@ const getActions = createSelector( 'downloadNewVersion', 'contactSupport', + + 'viewStory', ]); const safe: AssertProps = unsafe; diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index 24300e8ed..1adc596a8 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -107,6 +107,7 @@ const getDefaultProps = () => ({ renderEmojiPicker, renderReactionPicker, renderAudioAttachment: () =>
*AudioAttachment*
, + viewStory: action('viewStory'), }); export default { diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts index 870c77478..ba0d50b78 100644 --- a/ts/state/ducks/stories.ts +++ b/ts/state/ducks/stories.ts @@ -1,7 +1,7 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { ThunkAction } from 'redux-thunk'; +import type { ThunkAction, ThunkDispatch } from 'redux-thunk'; import { isEqual, pick } from 'lodash'; import type { AttachmentType } from '../../types/Attachment'; import type { BodyRangeType } from '../../types/Util'; @@ -18,6 +18,7 @@ import * as log from '../../logging/log'; import dataInterface from '../../sql/Client'; import { DAY } from '../../util/durations'; import { ReadStatus } from '../../messages/MessageReadStatus'; +import { StoryViewDirectionType, StoryViewModeType } from '../../types/Stories'; import { ToastReactionFailed } from '../../components/ToastReactionFailed'; import { UUID } from '../../types/UUID'; import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend'; @@ -32,11 +33,12 @@ import { isDownloaded, isDownloading, } from '../../types/Attachment'; +import { getConversationSelector } from '../selectors/conversations'; +import { getStories } from '../selectors/stories'; +import { isGroup } from '../../util/whatTypeOfConversation'; import { useBoundActions } from '../../hooks/useBoundActions'; import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue'; import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue'; -import { isGroup } from '../../util/whatTypeOfConversation'; -import { getConversationSelector } from '../selectors/conversations'; export type StoryDataType = { attachment?: AttachmentType; @@ -56,6 +58,12 @@ export type StoryDataType = { | 'type' >; +export type SelectedStoryDataType = { + currentIndex: number; + numStories: number; + story: StoryDataType; +}; + // State export type StoriesStateType = { @@ -64,7 +72,9 @@ export type StoriesStateType = { messageId: string; replies: Array; }; + readonly selectedStoryData?: SelectedStoryDataType; readonly stories: Array; + readonly storyViewMode?: StoryViewModeType; }; // Actions @@ -76,6 +86,7 @@ const REPLY_TO_STORY = 'stories/REPLY_TO_STORY'; export const RESOLVE_ATTACHMENT_URL = 'stories/RESOLVE_ATTACHMENT_URL'; const STORY_CHANGED = 'stories/STORY_CHANGED'; const TOGGLE_VIEW = 'stories/TOGGLE_VIEW'; +const VIEW_STORY = 'stories/VIEW_STORY'; type DOEStoryActionType = { type: typeof DOE_STORY; @@ -117,6 +128,16 @@ type ToggleViewActionType = { type: typeof TOGGLE_VIEW; }; +type ViewStoryActionType = { + type: typeof VIEW_STORY; + payload: + | { + selectedStoryData: SelectedStoryDataType; + storyViewMode: StoryViewModeType; + } + | undefined; +}; + export type StoriesActionType = | DOEStoryActionType | LoadStoryRepliesActionType @@ -126,23 +147,11 @@ export type StoriesActionType = | ReplyToStoryActionType | ResolveAttachmentUrlActionType | StoryChangedActionType - | ToggleViewActionType; + | ToggleViewActionType + | ViewStoryActionType; // Action Creators -export const actions = { - deleteStoryForEveryone, - loadStoryReplies, - markStoryRead, - queueStoryDownload, - reactToStory, - replyToStory, - storyChanged, - toggleStoriesView, -}; - -export const useStoriesActions = (): typeof actions => useBoundActions(actions); - function deleteStoryForEveryone( story: StoryViewType ): ThunkAction { @@ -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; +} => { + 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 { + 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 => { + return (dispatch, getState) => { + if (!storyId || !storyViewMode) { + dispatch({ + type: VIEW_STORY, + payload: undefined, + }); + return; + } + + const state = getState(); + const { stories } = state.stories; + + // Spec: + // When opening the story viewer you should always be taken to the oldest + // un viewed story of the user you tapped on + // If all stories from a user are viewed, opening the viewer should take + // you to their oldest story + + const story = stories.find(item => item.messageId === storyId); + + if (!story) { + return; + } + + const { currentIndex, numStories, storiesByConversationId } = + getSelectedStoryDataForConversationId( + dispatch, + getState, + story.conversationId, + storyId + ); + + // Go directly to the storyId selected + if (!viewDirection) { + dispatch({ + type: VIEW_STORY, + payload: { + selectedStoryData: { + currentIndex, + numStories, + story, + }, + storyViewMode, + }, + }); + return; + } + + // Next story within the same user's stories + if ( + viewDirection === StoryViewDirectionType.Next && + currentIndex < numStories - 1 + ) { + const nextIndex = currentIndex + 1; + const nextStory = storiesByConversationId[nextIndex]; + + dispatch({ + type: VIEW_STORY, + payload: { + selectedStoryData: { + currentIndex: nextIndex, + numStories, + story: nextStory, + }, + storyViewMode, + }, + }); + return; + } + + // Prev story within the same user's stories + if (viewDirection === StoryViewDirectionType.Previous && currentIndex > 0) { + const nextIndex = currentIndex - 1; + const nextStory = storiesByConversationId[nextIndex]; + + dispatch({ + type: VIEW_STORY, + payload: { + selectedStoryData: { + currentIndex: nextIndex, + numStories, + story: nextStory, + }, + storyViewMode, + }, + }); + return; + } + + // Are there any unviewed stories left? If so we should play the unviewed + // stories first. But only if we're going "next" + if (viewDirection === StoryViewDirectionType.Next) { + const unreadStory = stories.find( + item => item.readStatus === ReadStatus.Unread + ); + if (unreadStory) { + const nextSelectedStoryData = getSelectedStoryDataForConversationId( + dispatch, + getState, + unreadStory.conversationId, + unreadStory.messageId + ); + dispatch({ + type: VIEW_STORY, + payload: { + selectedStoryData: { + currentIndex: nextSelectedStoryData.currentIndex, + numStories: nextSelectedStoryData.numStories, + story: unreadStory, + }, + storyViewMode, + }, + }); + return; + } + } + + const conversationStories = getStories(state).stories; + const conversationStoryIndex = conversationStories.findIndex( + item => item.conversationId === story.conversationId + ); + + if (conversationStoryIndex < 0) { + return; + } + + // Find the next user's stories + if ( + viewDirection === StoryViewDirectionType.Next && + conversationStoryIndex < conversationStories.length - 1 + ) { + // Spec: + // Tapping right advances you to the next un viewed story + // If all stories are viewed, advance to the next viewed story + // When you reach the newest story from a user, tapping right again + // should take you to the next user's oldest un viewed story or oldest + // story if all stories for the next user are viewed. + // When you reach the newest story from the last user in the story list, + // tapping right should close the viewer + // Touch area for tapping right should be 80% of width of the screen + const nextConversationStoryIndex = conversationStoryIndex + 1; + const conversationStory = conversationStories[nextConversationStoryIndex]; + + const nextSelectedStoryData = getSelectedStoryDataForConversationId( + dispatch, + getState, + conversationStory.conversationId + ); + + // Close the viewer if we were viewing unread stories only and we've + // reached the last unread story. + if ( + !nextSelectedStoryData.hasUnread && + storyViewMode === StoryViewModeType.Unread + ) { + dispatch({ + type: VIEW_STORY, + payload: undefined, + }); + return; + } + + dispatch({ + type: VIEW_STORY, + payload: { + selectedStoryData: { + currentIndex: 0, + numStories: nextSelectedStoryData.numStories, + story: nextSelectedStoryData.storiesByConversationId[0], + }, + storyViewMode, + }, + }); + return; + } + + // Find the previous user's stories + if ( + viewDirection === StoryViewDirectionType.Previous && + conversationStoryIndex > 0 + ) { + // Spec: + // Tapping left takes you back to the previous story + // When you reach the oldest story from a user, tapping left again takes + // you to the previous users oldest un viewed story or newest viewed + // story if all stories are viewed + // If you tap left on the oldest story from the first user in the story + // list, it should re-start playback on that story + // Touch area for tapping left should be 20% of width of the screen + const nextConversationStoryIndex = conversationStoryIndex - 1; + const conversationStory = conversationStories[nextConversationStoryIndex]; + + const nextSelectedStoryData = getSelectedStoryDataForConversationId( + dispatch, + getState, + conversationStory.conversationId + ); + + dispatch({ + type: VIEW_STORY, + payload: { + selectedStoryData: { + currentIndex: 0, + numStories: nextSelectedStoryData.numStories, + story: nextSelectedStoryData.storiesByConversationId[0], + }, + storyViewMode, + }, + }); + return; + } + + // Could not meet any criteria, close the viewer + dispatch({ + type: VIEW_STORY, + payload: undefined, + }); + }; +}; + +export const actions = { + deleteStoryForEveryone, + loadStoryReplies, + markStoryRead, + queueStoryDownload, + reactToStory, + replyToStory, + storyChanged, + toggleStoriesView, + viewUserStories, + viewStory, +}; + +export const useStoriesActions = (): typeof actions => useBoundActions(actions); + // Reducer export function getEmptyState( @@ -645,5 +986,15 @@ export function reducer( }; } + if (action.type === VIEW_STORY) { + const { selectedStoryData, storyViewMode } = action.payload || {}; + + return { + ...state, + selectedStoryData, + storyViewMode, + }; + } + return state; } diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 1963cc8e0..0a312125d 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -483,7 +483,7 @@ export const getPropsForStoryReplyContext = createSelectorCreator( rawAttachment: storyReplyContext.attachment ? processQuoteAttachment(storyReplyContext.attachment) : undefined, - referencedMessageNotFound: !storyReplyContext.messageId, + storyId: storyReplyContext.messageId, text: getStoryReplyText(window.i18n, storyReplyContext.attachment), }; }, diff --git a/ts/state/selectors/stories.ts b/ts/state/selectors/stories.ts index e180efe3f..4bed0c9d0 100644 --- a/ts/state/selectors/stories.ts +++ b/ts/state/selectors/stories.ts @@ -15,7 +15,12 @@ import type { StoryViewType, } from '../../types/Stories'; import type { StateType } from '../reducer'; -import type { StoryDataType, StoriesStateType } from '../ducks/stories'; +import type { + SelectedStoryDataType, + StoryDataType, + StoriesStateType, +} from '../ducks/stories'; +import { MY_STORIES_ID } from '../../types/Stories'; import { ReadStatus } from '../../messages/MessageReadStatus'; import { SendStatus } from '../../messages/MessageSendState'; import { canReply } from './message'; @@ -25,7 +30,6 @@ import { getMe, } from './conversations'; import { getDistributionListSelector } from './storyDistributionLists'; -import { getUserConversationId } from './user'; export const getStoriesState = (state: StateType): StoriesStateType => state.stories; @@ -35,36 +39,35 @@ export const shouldShowStoriesView = createSelector( ({ isShowingStoriesView }): boolean => isShowingStoriesView ); -function getNewestStory(x: ConversationStoryType | MyStoryType): StoryViewType { - return x.stories[x.stories.length - 1]; -} - -function sortByRecencyAndUnread( - a: ConversationStoryType | MyStoryType, - b: ConversationStoryType | MyStoryType -): number { - const storyA = getNewestStory(a); - const storyB = getNewestStory(b); - - if (storyA.isUnread && storyB.isUnread) { - return storyA.timestamp > storyB.timestamp ? -1 : 1; - } - - if (storyB.isUnread) { - return 1; - } - - if (storyA.isUnread) { - return -1; - } - - return storyA.timestamp > storyB.timestamp ? -1 : 1; -} +export const getSelectedStoryData = createSelector( + getStoriesState, + ({ selectedStoryData }): SelectedStoryDataType | undefined => + selectedStoryData +); function getReactionUniqueId(reaction: MessageReactionType): string { return `${reaction.fromId}:${reaction.targetAuthorUuid}:${reaction.timestamp}`; } +function sortByRecencyAndUnread( + storyA: ConversationStoryType, + storyB: ConversationStoryType +): number { + if (storyA.storyView.isUnread && storyB.storyView.isUnread) { + return storyA.storyView.timestamp > storyB.storyView.timestamp ? -1 : 1; + } + + if (storyB.storyView.isUnread) { + return 1; + } + + if (storyA.storyView.isUnread) { + return -1; + } + + return storyA.storyView.timestamp > storyB.storyView.timestamp ? -1 : 1; +} + function getAvatarData( conversation: ConversationType ): Pick< @@ -90,10 +93,9 @@ function getAvatarData( ]); } -function getStoryView( +export function getStoryView( conversationSelector: GetConversationByIdType, - story: StoryDataType, - ourConversationId?: string + story: StoryDataType ): StoryViewType { const sender = pick(conversationSelector(story.sourceUuid || story.source), [ 'acceptedMessageRequest', @@ -113,7 +115,7 @@ function getStoryView( return { attachment, - canReply: canReply(story, ourConversationId, conversationSelector), + canReply: canReply(story, undefined, conversationSelector), isUnread: story.readStatus === ReadStatus.Unread, messageId: story.messageId, sender, @@ -121,10 +123,9 @@ function getStoryView( }; } -function getConversationStory( +export function getConversationStory( conversationSelector: GetConversationByIdType, - story: StoryDataType, - ourConversationId?: string + story: StoryDataType ): ConversationStoryType { const sender = pick(conversationSelector(story.sourceUuid || story.source), [ 'hideStory', @@ -142,59 +143,16 @@ function getConversationStory( 'title', ]); - const storyView = getStoryView( - conversationSelector, - story, - ourConversationId - ); + const storyView = getStoryView(conversationSelector, story); return { conversationId: conversation.id, group: conversation.id !== sender.id ? conversation : undefined, isHidden: Boolean(sender.hideStory), - stories: [storyView], + storyView, }; } -export type GetStoriesByConversationIdType = ( - conversationId: string -) => ConversationStoryType; -export const getStoriesSelector = createSelector( - getConversationSelector, - getUserConversationId, - getStoriesState, - ( - conversationSelector, - ourConversationId, - { stories }: Readonly - ): GetStoriesByConversationIdType => { - return conversationId => { - const conversationStoryAcc: ConversationStoryType = { - conversationId, - stories: [], - }; - - return stories.reduce((acc, story) => { - if (story.conversationId !== conversationId) { - return acc; - } - - const conversationStory = getConversationStory( - conversationSelector, - story, - ourConversationId - ); - - return { - ...acc, - ...conversationStory, - stories: [...acc.stories, ...conversationStory.stories], - }; - }, conversationStoryAcc); - }; - } -); - export const getStoryReplies = createSelector( getConversationSelector, getContactNameColorSelector, @@ -262,13 +220,11 @@ export const getStories = createSelector( getConversationSelector, getDistributionListSelector, getStoriesState, - getUserConversationId, shouldShowStoriesView, ( conversationSelector, distributionListSelector, { stories }: Readonly, - ourConversationId, isShowingStoriesView ): { hiddenStories: Array; @@ -293,16 +249,16 @@ export const getStories = createSelector( } if (story.sendStateByConversationId && story.storyDistributionListId) { - const list = distributionListSelector(story.storyDistributionListId); + const list = + story.storyDistributionListId === MY_STORIES_ID + ? { id: MY_STORIES_ID, name: MY_STORIES_ID } + : distributionListSelector(story.storyDistributionListId); + if (!list) { return; } - const storyView = getStoryView( - conversationSelector, - story, - ourConversationId - ); + const storyView = getStoryView(conversationSelector, story); const sendState: Array = []; const { sendStateByConversationId } = story; @@ -352,8 +308,7 @@ export const getStories = createSelector( const conversationStory = getConversationStory( conversationSelector, - story, - ourConversationId + story ); let storiesMap: Map; @@ -366,25 +321,18 @@ export const getStories = createSelector( const existingConversationStory = storiesMap.get( conversationStory.conversationId - ) || { stories: [] }; + ); storiesMap.set(conversationStory.conversationId, { ...existingConversationStory, ...conversationStory, - stories: [ - ...existingConversationStory.stories, - ...conversationStory.stories, - ], + storyView: conversationStory.storyView, }); }); return { - hiddenStories: Array.from(hiddenStoriesById.values()).sort( - sortByRecencyAndUnread - ), - myStories: Array.from(myStoriesById.values()).sort( - sortByRecencyAndUnread - ), + hiddenStories: Array.from(hiddenStoriesById.values()), + myStories: Array.from(myStoriesById.values()), stories: Array.from(storiesById.values()).sort(sortByRecencyAndUnread), }; } diff --git a/ts/state/smart/App.tsx b/ts/state/smart/App.tsx index 47b50e3a6..0a14086d6 100644 --- a/ts/state/smart/App.tsx +++ b/ts/state/smart/App.tsx @@ -13,6 +13,7 @@ import { SmartGlobalModalContainer } from './GlobalModalContainer'; import { SmartLeftPane } from './LeftPane'; import { SmartSafetyNumberViewer } from './SafetyNumberViewer'; import { SmartStories } from './Stories'; +import { SmartStoryViewer } from './StoryViewer'; import type { StateType } from '../reducer'; import { getPreferredBadgeSelector } from '../selectors/badges'; import { @@ -23,7 +24,10 @@ import { getIsMainWindowFullScreen, getMenuOptions, } from '../selectors/user'; -import { shouldShowStoriesView } from '../selectors/stories'; +import { + getSelectedStoryData, + shouldShowStoriesView, +} from '../selectors/stories'; import { getHideMenuBar } from '../selectors/items'; import { getConversationsStoppingSend } from '../selectors/conversations'; import { getIsCustomizingPreferredReactions } from '../selectors/preferredReactions'; @@ -54,6 +58,8 @@ const mapStateToProps = (state: StateType) => { ), isShowingStoriesView: shouldShowStoriesView(state), renderStories: () => , + selectedStoryData: getSelectedStoryData(state), + renderStoryViewer: () => , requestVerification: ( type: 'sms' | 'voice', number: string, diff --git a/ts/state/smart/Stories.tsx b/ts/state/smart/Stories.tsx index d5e1cfaa5..fb451e7b9 100644 --- a/ts/state/smart/Stories.tsx +++ b/ts/state/smart/Stories.tsx @@ -7,12 +7,10 @@ import { useSelector } from 'react-redux'; import type { LocalizerType } from '../../types/Util'; import type { StateType } from '../reducer'; import type { PropsType as SmartStoryCreatorPropsType } from './StoryCreator'; -import type { PropsType as SmartStoryViewerPropsType } from './StoryViewer'; import { SmartStoryCreator } from './StoryCreator'; -import { SmartStoryViewer } from './StoryViewer'; import { Stories } from '../../components/Stories'; import { getMe } from '../selectors/conversations'; -import { getIntl, getUserConversationId } from '../selectors/user'; +import { getIntl } from '../selectors/user'; import { getPreferredLeftPaneWidth } from '../selectors/items'; import { getStories } from '../selectors/stories'; import { saveAttachment } from '../../util/saveAttachment'; @@ -26,24 +24,6 @@ function renderStoryCreator({ return ; } -function renderStoryViewer({ - conversationId, - onClose, - onNextUserStories, - onPrevUserStories, - storyToView, -}: SmartStoryViewerPropsType): JSX.Element { - return ( - - ); -} - export function SmartStories(): JSX.Element | null { const storiesActions = useStoriesActions(); const { showConversation, toggleHideStories } = useConversationsActions(); @@ -61,7 +41,6 @@ export function SmartStories(): JSX.Element | null { const { hiddenStories, myStories, stories } = useSelector(getStories); - const ourConversationId = useSelector(getUserConversationId); const me = useSelector(getMe); if (!isShowingStoriesView) { @@ -82,10 +61,8 @@ export function SmartStories(): JSX.Element | null { saveAttachment(story.attachment, story.timestamp); } }} - ourConversationId={String(ourConversationId)} preferredWidthFromStorage={preferredWidthFromStorage} renderStoryCreator={renderStoryCreator} - renderStoryViewer={renderStoryViewer} showConversation={showConversation} stories={stories} toggleHideStories={toggleHideStories} diff --git a/ts/state/smart/StoryViewer.tsx b/ts/state/smart/StoryViewer.tsx index 9708aabc3..040c6be8e 100644 --- a/ts/state/smart/StoryViewer.tsx +++ b/ts/state/smart/StoryViewer.tsx @@ -4,12 +4,14 @@ import React from 'react'; import { useSelector } from 'react-redux'; -import type { GetStoriesByConversationIdType } from '../selectors/stories'; +import type { GetConversationByIdType } from '../selectors/conversations'; import type { LocalizerType } from '../../types/Util'; +import type { StoryViewModeType } from '../../types/Stories'; import type { StateType } from '../reducer'; -import type { StoryViewType } from '../../types/Stories'; +import type { SelectedStoryDataType } from '../ducks/stories'; import { StoryViewer } from '../../components/StoryViewer'; import { ToastMessageBodyTooLong } from '../../components/ToastMessageBodyTooLong'; +import { getConversationSelector } from '../selectors/conversations'; import { getEmojiSkinTone, getHasAllStoriesMuted, @@ -17,30 +19,22 @@ import { } from '../selectors/items'; import { getIntl } from '../selectors/user'; import { getPreferredBadgeSelector } from '../selectors/badges'; -import { getStoriesSelector, getStoryReplies } from '../selectors/stories'; +import { + getConversationStory, + getSelectedStoryData, + getStoryReplies, + getStoryView, +} from '../selectors/stories'; import { renderEmojiPicker } from './renderEmojiPicker'; import { showToast } from '../../util/showToast'; +import { strictAssert } from '../../util/assert'; import { useActions as useEmojisActions } from '../ducks/emojis'; import { useActions as useItemsActions } from '../ducks/items'; import { useConversationsActions } from '../ducks/conversations'; import { useRecentEmojis } from '../selectors/emojis'; import { useStoriesActions } from '../ducks/stories'; -export type PropsType = { - conversationId: string; - onClose: () => unknown; - onNextUserStories?: () => unknown; - onPrevUserStories?: () => unknown; - storyToView?: StoryViewType; -}; - -export function SmartStoryViewer({ - conversationId, - onClose, - onNextUserStories, - onPrevUserStories, - storyToView, -}: PropsType): JSX.Element | null { +export function SmartStoryViewer(): JSX.Element | null { const storiesActions = useStoriesActions(); const { onSetSkinTone, toggleHasAllStoriesMuted } = useItemsActions(); const { onUseEmoji } = useEmojisActions(); @@ -52,14 +46,25 @@ export function SmartStoryViewer({ getPreferredReactionEmoji ); - const getStoriesByConversationId = useSelector< + const selectedStoryData = useSelector< StateType, - GetStoriesByConversationIdType - >(getStoriesSelector); + SelectedStoryDataType | undefined + >(getSelectedStoryData); - const { group, stories } = storyToView - ? { group: undefined, stories: [storyToView] } - : getStoriesByConversationId(conversationId); + strictAssert(selectedStoryData, 'StoryViewer: !selectedStoryData'); + + const conversationSelector = useSelector( + getConversationSelector + ); + + const storyView = getStoryView(conversationSelector, selectedStoryData.story); + const conversationStory = getConversationStory( + conversationSelector, + selectedStoryData.story + ); + const storyViewMode = useSelector( + state => state.stories.storyViewMode + ); const recentEmojis = useRecentEmojis(); const skinTone = useSelector(getEmojiSkinTone); @@ -70,26 +75,24 @@ export function SmartStoryViewer({ return ( { showConversation({ conversationId: senderId }); storiesActions.toggleStoriesView(); }} - onNextUserStories={onNextUserStories} - onPrevUserStories={onPrevUserStories} onReactToStory={async (emoji, story) => { const { messageId } = story; storiesActions.reactToStory(emoji, messageId); }} onReplyToStory={(message, mentions, timestamp, story) => { storiesActions.replyToStory( - conversationId, + conversationStory.conversationId, message, mentions, timestamp, @@ -103,8 +106,9 @@ export function SmartStoryViewer({ recentEmojis={recentEmojis} renderEmojiPicker={renderEmojiPicker} replyState={replyState} - stories={stories} skinTone={skinTone} + story={storyView} + storyViewMode={storyViewMode} toggleHasAllStoriesMuted={toggleHasAllStoriesMuted} {...storiesActions} /> diff --git a/ts/test-both/helpers/getFakeStory.tsx b/ts/test-both/helpers/getFakeStory.tsx index 39c9df48e..61eb926f0 100644 --- a/ts/test-both/helpers/getFakeStory.tsx +++ b/ts/test-both/helpers/getFakeStory.tsx @@ -69,6 +69,6 @@ export function getFakeStory({ return { conversationId: storyView.sender.id, group, - stories: [storyView], + storyView, }; } diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index e868c7762..196c0dc3a 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -1936,7 +1936,7 @@ export default class MessageReceiver distributionListToSentUuid.forEach((sentToUuids, listId) => { const ev = new SentEvent( { - destinationUuid: dropNull(sentMessage.destinationUuid), + destinationUuid: envelope.destinationUuid.toString(), timestamp: envelope.timestamp, serverTimestamp: envelope.serverTimestamp, unidentifiedStatus: Array.from(sentToUuids).map( diff --git a/ts/types/Stories.ts b/ts/types/Stories.ts index d0bfde195..54d5b2314 100644 --- a/ts/types/Stories.ts +++ b/ts/types/Stories.ts @@ -45,7 +45,7 @@ export type ConversationStoryType = { >; isHidden?: boolean; searchNames?: string; // This is just here to satisfy Fuse's types - stories: Array; + storyView: StoryViewType; }; export type StorySendStateType = { @@ -99,3 +99,14 @@ export type MyStoryType = { }; export const MY_STORIES_ID = '00000000-0000-0000-0000-000000000000'; + +export enum StoryViewDirectionType { + Next = 'Next', + Previous = 'Previous', +} + +export enum StoryViewModeType { + Unread = 'Unread', + All = 'All', + Single = 'Single', +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 313651c2f..094763953 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -9286,13 +9286,6 @@ "reasonCategory": "usageTrusted", "updated": "2022-04-29T23:54:21.656Z" }, - { - "rule": "React-useRef", - "path": "ts/components/StoryViewer.tsx", - "line": " const storiesRef = useRef(stories);", - "reasonCategory": "usageTrusted", - "updated": "2022-04-30T00:44:47.213Z" - }, { "rule": "React-useRef", "path": "ts/components/StoryViewsNRepliesModal.tsx",