Fixes story viewing behavior
This commit is contained in:
parent
c4b6eebcd6
commit
3e644f45cf
|
@ -17,6 +17,7 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
|
|||
import type { MenuOptionsType, MenuActionType } from '../types/menu';
|
||||
import { TitleBarContainer } from './TitleBarContainer';
|
||||
import type { ExecuteMenuRoleType } from './TitleBarContainer';
|
||||
import type { SelectedStoryDataType } from '../state/ducks/stories';
|
||||
|
||||
type PropsType = {
|
||||
appView: AppViewType;
|
||||
|
@ -27,6 +28,8 @@ type PropsType = {
|
|||
renderGlobalModalContainer: () => JSX.Element;
|
||||
isShowingStoriesView: boolean;
|
||||
renderStories: () => JSX.Element;
|
||||
selectedStoryData?: SelectedStoryDataType;
|
||||
renderStoryViewer: () => JSX.Element;
|
||||
requestVerification: (
|
||||
type: 'sms' | 'voice',
|
||||
number: string,
|
||||
|
@ -69,9 +72,11 @@ export const App = ({
|
|||
renderLeftPane,
|
||||
renderSafetyNumber,
|
||||
renderStories,
|
||||
renderStoryViewer,
|
||||
requestVerification,
|
||||
selectedConversationId,
|
||||
selectedMessage,
|
||||
selectedStoryData,
|
||||
showConversation,
|
||||
showWhatsNewModal,
|
||||
theme,
|
||||
|
@ -169,6 +174,7 @@ export const App = ({
|
|||
{renderGlobalModalContainer()}
|
||||
{renderCallManager()}
|
||||
{isShowingStoriesView && renderStories()}
|
||||
{selectedStoryData && renderStoryViewer()}
|
||||
{contents}
|
||||
</div>
|
||||
</TitleBarContainer>
|
||||
|
|
|
@ -47,6 +47,7 @@ export default {
|
|||
renderStoryViewer: {
|
||||
action: true,
|
||||
},
|
||||
viewStory: { action: true },
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
|
|
|
@ -4,10 +4,10 @@
|
|||
import React, { useState } from 'react';
|
||||
import type { MyStoryType, StoryViewType } from '../types/Stories';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { PropsType as SmartStoryViewerPropsType } from '../state/smart/StoryViewer';
|
||||
import type { ViewStoryActionCreatorType } from '../state/ducks/stories';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
import { MY_STORIES_ID } from '../types/Stories';
|
||||
import { MY_STORIES_ID, StoryViewModeType } from '../types/Stories';
|
||||
import { MessageTimestamp } from './conversation/MessageTimestamp';
|
||||
import { StoryImage } from './StoryImage';
|
||||
import { Theme } from '../util/theme';
|
||||
|
@ -19,9 +19,8 @@ export type PropsType = {
|
|||
onDelete: (story: StoryViewType) => unknown;
|
||||
onForward: (storyId: string) => unknown;
|
||||
onSave: (story: StoryViewType) => unknown;
|
||||
ourConversationId: string;
|
||||
queueStoryDownload: (storyId: string) => unknown;
|
||||
renderStoryViewer: (props: SmartStoryViewerPropsType) => JSX.Element;
|
||||
viewStory: ViewStoryActionCreatorType;
|
||||
};
|
||||
|
||||
export const MyStories = ({
|
||||
|
@ -31,16 +30,13 @@ export const MyStories = ({
|
|||
onDelete,
|
||||
onForward,
|
||||
onSave,
|
||||
ourConversationId,
|
||||
queueStoryDownload,
|
||||
renderStoryViewer,
|
||||
viewStory,
|
||||
}: PropsType): JSX.Element => {
|
||||
const [confirmDeleteStory, setConfirmDeleteStory] = useState<
|
||||
StoryViewType | undefined
|
||||
>();
|
||||
|
||||
const [storyToView, setStoryToView] = useState<StoryViewType | undefined>();
|
||||
|
||||
return (
|
||||
<>
|
||||
{confirmDeleteStory && (
|
||||
|
@ -58,12 +54,6 @@ export const MyStories = ({
|
|||
{i18n('MyStories__delete')}
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
{storyToView &&
|
||||
renderStoryViewer({
|
||||
conversationId: ourConversationId,
|
||||
onClose: () => setStoryToView(undefined),
|
||||
storyToView,
|
||||
})}
|
||||
<div className="Stories__pane__header Stories__pane__header--centered">
|
||||
<button
|
||||
aria-label={i18n('back')}
|
||||
|
@ -89,7 +79,9 @@ export const MyStories = ({
|
|||
<button
|
||||
aria-label={i18n('MyStories__story')}
|
||||
className="MyStories__story__preview"
|
||||
onClick={() => setStoryToView(story)}
|
||||
onClick={() =>
|
||||
viewStory(story.messageId, StoryViewModeType.Single)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<StoryImage
|
||||
|
|
|
@ -28,6 +28,9 @@ export default {
|
|||
i18n: {
|
||||
defaultValue: i18n,
|
||||
},
|
||||
me: {
|
||||
defaultValue: getDefaultConversation(),
|
||||
},
|
||||
myStories: {
|
||||
defaultValue: [],
|
||||
},
|
||||
|
@ -48,6 +51,8 @@ export default {
|
|||
},
|
||||
toggleHideStories: { action: true },
|
||||
toggleStoriesView: { action: true },
|
||||
viewUserStories: { action: true },
|
||||
viewStory: { action: true },
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import type {
|
||||
ConversationType,
|
||||
|
@ -15,8 +15,7 @@ import type {
|
|||
} from '../types/Stories';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { PropsType as SmartStoryCreatorPropsType } from '../state/smart/StoryCreator';
|
||||
import type { PropsType as SmartStoryViewerPropsType } from '../state/smart/StoryViewer';
|
||||
import * as log from '../logging/log';
|
||||
import type { ViewStoryActionCreatorType } from '../state/ducks/stories';
|
||||
import { MyStories } from './MyStories';
|
||||
import { StoriesPane } from './StoriesPane';
|
||||
import { Theme, themeClassName } from '../util/theme';
|
||||
|
@ -30,15 +29,15 @@ export type PropsType = {
|
|||
myStories: Array<MyStoryType>;
|
||||
onForwardStory: (storyId: string) => unknown;
|
||||
onSaveStory: (story: StoryViewType) => unknown;
|
||||
ourConversationId: string;
|
||||
preferredWidthFromStorage: number;
|
||||
queueStoryDownload: (storyId: string) => unknown;
|
||||
renderStoryCreator: (props: SmartStoryCreatorPropsType) => JSX.Element;
|
||||
renderStoryViewer: (props: SmartStoryViewerPropsType) => JSX.Element;
|
||||
showConversation: ShowConversationType;
|
||||
stories: Array<ConversationStoryType>;
|
||||
toggleHideStories: (conversationId: string) => unknown;
|
||||
toggleStoriesView: () => unknown;
|
||||
viewUserStories: (conversationId: string) => unknown;
|
||||
viewStory: ViewStoryActionCreatorType;
|
||||
};
|
||||
|
||||
export const Stories = ({
|
||||
|
@ -49,76 +48,20 @@ export const Stories = ({
|
|||
myStories,
|
||||
onForwardStory,
|
||||
onSaveStory,
|
||||
ourConversationId,
|
||||
preferredWidthFromStorage,
|
||||
queueStoryDownload,
|
||||
renderStoryCreator,
|
||||
renderStoryViewer,
|
||||
showConversation,
|
||||
stories,
|
||||
toggleHideStories,
|
||||
toggleStoriesView,
|
||||
viewUserStories,
|
||||
viewStory,
|
||||
}: PropsType): JSX.Element => {
|
||||
const [conversationIdToView, setConversationIdToView] = useState<
|
||||
undefined | string
|
||||
>();
|
||||
|
||||
const width = getWidthFromPreferredWidth(preferredWidthFromStorage, {
|
||||
requiresFullWidth: true,
|
||||
});
|
||||
|
||||
const onNextUserStories = useCallback(() => {
|
||||
// First find the next unread story if there are any
|
||||
const nextUnreadIndex = stories.findIndex(conversationStory =>
|
||||
conversationStory.stories.some(story => story.isUnread)
|
||||
);
|
||||
|
||||
log.info('stories.onNextUserStories', { nextUnreadIndex });
|
||||
|
||||
if (nextUnreadIndex >= 0) {
|
||||
const nextStory = stories[nextUnreadIndex];
|
||||
setConversationIdToView(nextStory.conversationId);
|
||||
return;
|
||||
}
|
||||
|
||||
// If not then play the next available story
|
||||
const storyIndex = stories.findIndex(
|
||||
x => x.conversationId === conversationIdToView
|
||||
);
|
||||
|
||||
log.info('stories.onNextUserStories', {
|
||||
storyIndex,
|
||||
length: stories.length,
|
||||
});
|
||||
|
||||
// If we've reached the end, close the viewer
|
||||
if (storyIndex >= stories.length - 1 || storyIndex === -1) {
|
||||
setConversationIdToView(undefined);
|
||||
return;
|
||||
}
|
||||
const nextStory = stories[storyIndex + 1];
|
||||
setConversationIdToView(nextStory.conversationId);
|
||||
}, [conversationIdToView, stories]);
|
||||
|
||||
const onPrevUserStories = useCallback(() => {
|
||||
const storyIndex = stories.findIndex(
|
||||
x => x.conversationId === conversationIdToView
|
||||
);
|
||||
|
||||
log.info('stories.onPrevUserStories', {
|
||||
storyIndex,
|
||||
length: stories.length,
|
||||
});
|
||||
|
||||
if (storyIndex <= 0) {
|
||||
// Restart playback on the story if it's the oldest
|
||||
setConversationIdToView(conversationIdToView);
|
||||
return;
|
||||
}
|
||||
const prevStory = stories[storyIndex - 1];
|
||||
setConversationIdToView(prevStory.conversationId);
|
||||
}, [conversationIdToView, stories]);
|
||||
|
||||
const [isShowingStoryCreator, setIsShowingStoryCreator] = useState(false);
|
||||
const [isMyStories, setIsMyStories] = useState(false);
|
||||
|
||||
|
@ -128,13 +71,6 @@ export const Stories = ({
|
|||
renderStoryCreator({
|
||||
onClose: () => setIsShowingStoryCreator(false),
|
||||
})}
|
||||
{conversationIdToView &&
|
||||
renderStoryViewer({
|
||||
conversationId: conversationIdToView,
|
||||
onClose: () => setConversationIdToView(undefined),
|
||||
onNextUserStories,
|
||||
onPrevUserStories,
|
||||
})}
|
||||
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
|
||||
<div className="Stories__pane" style={{ width }}>
|
||||
{isMyStories && myStories.length ? (
|
||||
|
@ -145,9 +81,8 @@ export const Stories = ({
|
|||
onDelete={deleteStoryForEveryone}
|
||||
onForward={onForwardStory}
|
||||
onSave={onSaveStory}
|
||||
ourConversationId={ourConversationId}
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
renderStoryViewer={renderStoryViewer}
|
||||
viewStory={viewStory}
|
||||
/>
|
||||
) : (
|
||||
<StoriesPane
|
||||
|
@ -163,16 +98,7 @@ export const Stories = ({
|
|||
setIsShowingStoryCreator(true);
|
||||
}
|
||||
}}
|
||||
onStoryClicked={clickedIdToView => {
|
||||
const storyIndex = stories.findIndex(
|
||||
x => x.conversationId === clickedIdToView
|
||||
);
|
||||
log.info('stories.onStoryClicked[StoriesPane]', {
|
||||
storyIndex,
|
||||
length: stories.length,
|
||||
});
|
||||
setConversationIdToView(clickedIdToView);
|
||||
}}
|
||||
onStoryClicked={viewUserStories}
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
showConversation={showConversation}
|
||||
stories={stories}
|
||||
|
|
|
@ -21,17 +21,14 @@ import { StoryListItem } from './StoryListItem';
|
|||
import { isNotNil } from '../util/isNotNil';
|
||||
|
||||
const FUSE_OPTIONS: Fuse.IFuseOptions<ConversationStoryType> = {
|
||||
getFn: (obj, path) => {
|
||||
getFn: (story, path) => {
|
||||
if (path === 'searchNames') {
|
||||
return obj.stories
|
||||
.flatMap((story: StoryViewType) => [
|
||||
story.sender.title,
|
||||
story.sender.name,
|
||||
])
|
||||
.filter(isNotNil);
|
||||
return [story.storyView.sender.title, story.storyView.sender.name].filter(
|
||||
isNotNil
|
||||
);
|
||||
}
|
||||
|
||||
return obj.group?.title ?? '';
|
||||
return story.group?.title ?? '';
|
||||
},
|
||||
keys: [
|
||||
{
|
||||
|
@ -55,9 +52,7 @@ function search(
|
|||
.map(result => result.item);
|
||||
}
|
||||
|
||||
function getNewestStory(
|
||||
story: ConversationStoryType | MyStoryType
|
||||
): StoryViewType {
|
||||
function getNewestMyStory(story: MyStoryType): StoryViewType {
|
||||
return story.stories[story.stories.length - 1];
|
||||
}
|
||||
|
||||
|
@ -137,7 +132,7 @@ export const StoriesPane = ({
|
|||
i18n={i18n}
|
||||
me={me}
|
||||
newestStory={
|
||||
myStories.length ? getNewestStory(myStories[0]) : undefined
|
||||
myStories.length ? getNewestMyStory(myStories[0]) : undefined
|
||||
}
|
||||
onClick={onMyStoriesClicked}
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
|
@ -151,7 +146,7 @@ export const StoriesPane = ({
|
|||
<StoryListItem
|
||||
group={story.group}
|
||||
i18n={i18n}
|
||||
key={getNewestStory(story).timestamp}
|
||||
key={story.storyView.timestamp}
|
||||
onClick={() => {
|
||||
onStoryClicked(story.conversationId);
|
||||
}}
|
||||
|
@ -161,7 +156,7 @@ export const StoriesPane = ({
|
|||
toggleStoriesView();
|
||||
}}
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
story={getNewestStory(story)}
|
||||
story={story.storyView}
|
||||
/>
|
||||
))}
|
||||
{Boolean(hiddenStories.length) && (
|
||||
|
@ -178,7 +173,7 @@ export const StoriesPane = ({
|
|||
{isShowingHiddenStories &&
|
||||
hiddenStories.map(story => (
|
||||
<StoryListItem
|
||||
key={getNewestStory(story).timestamp}
|
||||
key={story.storyView.timestamp}
|
||||
i18n={i18n}
|
||||
isHidden
|
||||
onClick={() => {
|
||||
|
@ -190,7 +185,7 @@ export const StoriesPane = ({
|
|||
toggleStoriesView();
|
||||
}}
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
story={getNewestStory(story)}
|
||||
story={story.storyView}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { Meta, Story } from '@storybook/react';
|
||||
import React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import type { PropsType } from './StoryViewer';
|
||||
import { StoryViewer } from './StoryViewer';
|
||||
|
@ -10,184 +10,114 @@ import enMessages from '../../_locales/en/messages.json';
|
|||
import { setupI18n } from '../util/setupI18n';
|
||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||
import { fakeAttachment } from '../test-both/helpers/fakeAttachment';
|
||||
import { getFakeStoryView } from '../test-both/helpers/getFakeStory';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
export default {
|
||||
title: 'Components/StoryViewer',
|
||||
};
|
||||
component: StoryViewer,
|
||||
argTypes: {
|
||||
currentIndex: {
|
||||
defaultvalue: 0,
|
||||
},
|
||||
getPreferredBadge: { action: true },
|
||||
group: {
|
||||
defaultValue: undefined,
|
||||
},
|
||||
hasAllStoriesMuted: {
|
||||
defaultValue: false,
|
||||
},
|
||||
i18n: {
|
||||
defaultValue: i18n,
|
||||
},
|
||||
loadStoryReplies: { action: true },
|
||||
markStoryRead: { action: true },
|
||||
numStories: {
|
||||
defaultValue: 1,
|
||||
},
|
||||
onGoToConversation: { action: true },
|
||||
onHideStory: { action: true },
|
||||
onReactToStory: { action: true },
|
||||
onReplyToStory: { action: true },
|
||||
onSetSkinTone: { action: true },
|
||||
onTextTooLong: { action: true },
|
||||
onUseEmoji: { action: true },
|
||||
preferredReactionEmoji: {
|
||||
defaultValue: ['❤️', '👍', '👎', '😂', '😮', '😢'],
|
||||
},
|
||||
queueStoryDownload: { action: true },
|
||||
renderEmojiPicker: { action: true },
|
||||
skinTone: {
|
||||
defaultValue: 0,
|
||||
},
|
||||
story: {
|
||||
defaultValue: getFakeStoryView(),
|
||||
},
|
||||
toggleHasAllStoriesMuted: { action: true },
|
||||
viewStory: { action: true },
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
function getDefaultProps(): PropsType {
|
||||
const sender = getDefaultConversation();
|
||||
|
||||
return {
|
||||
conversationId: sender.id,
|
||||
getPreferredBadge: () => undefined,
|
||||
group: undefined,
|
||||
hasAllStoriesMuted: false,
|
||||
i18n,
|
||||
loadStoryReplies: action('loadStoryReplies'),
|
||||
markStoryRead: action('markStoryRead'),
|
||||
onClose: action('onClose'),
|
||||
onGoToConversation: action('onGoToConversation'),
|
||||
onHideStory: action('onHideStory'),
|
||||
onNextUserStories: action('onNextUserStories'),
|
||||
onPrevUserStories: action('onPrevUserStories'),
|
||||
onReactToStory: action('onReactToStory'),
|
||||
onReplyToStory: action('onReplyToStory'),
|
||||
onSetSkinTone: action('onSetSkinTone'),
|
||||
onTextTooLong: action('onTextTooLong'),
|
||||
onUseEmoji: action('onUseEmoji'),
|
||||
preferredReactionEmoji: ['❤️', '👍', '👎', '😂', '😮', '😢'],
|
||||
queueStoryDownload: action('queueStoryDownload'),
|
||||
renderEmojiPicker: () => <div />,
|
||||
stories: [
|
||||
{
|
||||
attachment: fakeAttachment({
|
||||
path: 'snow.jpg',
|
||||
url: '/fixtures/snow.jpg',
|
||||
}),
|
||||
canReply: true,
|
||||
messageId: '123',
|
||||
sender,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
toggleHasAllStoriesMuted: action('toggleHasAllStoriesMuted'),
|
||||
};
|
||||
}
|
||||
|
||||
export const SomeonesStory = (): JSX.Element => (
|
||||
<StoryViewer {...getDefaultProps()} />
|
||||
);
|
||||
const Template: Story<PropsType> = args => <StoryViewer {...args} />;
|
||||
|
||||
export const SomeonesStory = Template.bind({});
|
||||
SomeonesStory.args = {};
|
||||
SomeonesStory.story = {
|
||||
name: "Someone's story",
|
||||
};
|
||||
|
||||
export const WideStory = (): JSX.Element => (
|
||||
<StoryViewer
|
||||
{...getDefaultProps()}
|
||||
stories={[
|
||||
{
|
||||
attachment: fakeAttachment({
|
||||
path: 'file.jpg',
|
||||
url: '/fixtures/nathan-anderson-316188-unsplash.jpg',
|
||||
}),
|
||||
canReply: true,
|
||||
messageId: '123',
|
||||
sender: getDefaultConversation(),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
export const WideStory = Template.bind({});
|
||||
WideStory.args = {
|
||||
story: getFakeStoryView('/fixtures/nathan-anderson-316188-unsplash.jpg'),
|
||||
};
|
||||
WideStory.story = {
|
||||
name: 'Wide story',
|
||||
};
|
||||
|
||||
export const InAGroup = (): JSX.Element => (
|
||||
<StoryViewer
|
||||
{...getDefaultProps()}
|
||||
group={getDefaultConversation({
|
||||
avatarPath: '/fixtures/kitten-4-112-112.jpg',
|
||||
title: 'Family Group',
|
||||
type: 'group',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
export const InAGroup = Template.bind({});
|
||||
InAGroup.args = {
|
||||
group: getDefaultConversation({
|
||||
avatarPath: '/fixtures/kitten-4-112-112.jpg',
|
||||
title: 'Family Group',
|
||||
type: 'group',
|
||||
}),
|
||||
};
|
||||
InAGroup.story = {
|
||||
name: 'In a group',
|
||||
};
|
||||
|
||||
export const MultiStory = (): JSX.Element => {
|
||||
const sender = getDefaultConversation();
|
||||
return (
|
||||
<StoryViewer
|
||||
{...getDefaultProps()}
|
||||
stories={[
|
||||
{
|
||||
attachment: fakeAttachment({
|
||||
path: 'snow.jpg',
|
||||
url: '/fixtures/snow.jpg',
|
||||
}),
|
||||
messageId: '123',
|
||||
sender,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
attachment: fakeAttachment({
|
||||
path: 'file.jpg',
|
||||
url: '/fixtures/nathan-anderson-316188-unsplash.jpg',
|
||||
}),
|
||||
canReply: true,
|
||||
messageId: '456',
|
||||
sender,
|
||||
timestamp: Date.now() - 3600,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
export const MultiStory = Template.bind({});
|
||||
MultiStory.args = {
|
||||
currentIndex: 2,
|
||||
numStories: 7,
|
||||
story: getFakeStoryView('/fixtures/snow.jpg'),
|
||||
};
|
||||
|
||||
MultiStory.story = {
|
||||
name: 'Multi story',
|
||||
};
|
||||
|
||||
export const Caption = (): JSX.Element => (
|
||||
<StoryViewer
|
||||
{...getDefaultProps()}
|
||||
group={getDefaultConversation({
|
||||
avatarPath: '/fixtures/kitten-4-112-112.jpg',
|
||||
title: 'Broskis',
|
||||
type: 'group',
|
||||
})}
|
||||
replyState={{
|
||||
messageId: '123',
|
||||
replies: [
|
||||
{
|
||||
...getDefaultConversation(),
|
||||
body: 'Cool',
|
||||
id: 'abc',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}}
|
||||
stories={[
|
||||
{
|
||||
attachment: fakeAttachment({
|
||||
caption: 'This place looks lovely',
|
||||
path: 'file.jpg',
|
||||
url: '/fixtures/nathan-anderson-316188-unsplash.jpg',
|
||||
}),
|
||||
canReply: true,
|
||||
messageId: '123',
|
||||
sender: getDefaultConversation(),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
export const Caption = Template.bind({});
|
||||
Caption.args = {
|
||||
story: {
|
||||
...getFakeStoryView(),
|
||||
attachment: fakeAttachment({
|
||||
caption: 'This place looks lovely',
|
||||
path: 'file.jpg',
|
||||
url: '/fixtures/nathan-anderson-316188-unsplash.jpg',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const LongCaption = (): JSX.Element => (
|
||||
<StoryViewer
|
||||
{...getDefaultProps()}
|
||||
hasAllStoriesMuted
|
||||
stories={[
|
||||
{
|
||||
attachment: fakeAttachment({
|
||||
caption:
|
||||
'Snowycle, snowycle, snowycle\nI want to ride my snowycle, snowycle, snowycle\nI want to ride my snowycle\nI want to ride my snow\nI want to ride my snowycle\nI want to ride it where I like\nSnowycle, snowycle, snowycle\nI want to ride my snowycle, snowycle, snowycle\nI want to ride my snowycle\nI want to ride my snow\nI want to ride my snowycle\nI want to ride it where I like\nSnowycle, snowycle, snowycle\nI want to ride my snowycle, snowycle, snowycle\nI want to ride my snowycle\nI want to ride my snow\nI want to ride my snowycle\nI want to ride it where I like',
|
||||
path: 'file.jpg',
|
||||
url: '/fixtures/snow.jpg',
|
||||
}),
|
||||
canReply: true,
|
||||
messageId: '123',
|
||||
sender: getDefaultConversation(),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
export const LongCaption = Template.bind({});
|
||||
LongCaption.args = {
|
||||
story: {
|
||||
...getFakeStoryView(),
|
||||
attachment: fakeAttachment({
|
||||
caption:
|
||||
'Snowycle, snowycle, snowycle\nI want to ride my snowycle, snowycle, snowycle\nI want to ride my snowycle\nI want to ride my snow\nI want to ride my snowycle\nI want to ride it where I like\nSnowycle, snowycle, snowycle\nI want to ride my snowycle, snowycle, snowycle\nI want to ride my snowycle\nI want to ride my snow\nI want to ride my snowycle\nI want to ride it where I like\nSnowycle, snowycle, snowycle\nI want to ride my snowycle, snowycle, snowycle\nI want to ride my snowycle\nI want to ride my snow\nI want to ride my snowycle\nI want to ride it where I like',
|
||||
path: 'file.jpg',
|
||||
url: '/fixtures/snow.jpg',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
|
|
@ -2,13 +2,7 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useSpring, animated, to } from '@react-spring/web';
|
||||
import type { BodyRangeType, LocalizerType } from '../types/Util';
|
||||
|
@ -17,6 +11,8 @@ import type { EmojiPickDataType } from './emoji/EmojiPicker';
|
|||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||
import type { RenderEmojiPickerProps } from './conversation/ReactionPicker';
|
||||
import type { ReplyStateType, StoryViewType } from '../types/Stories';
|
||||
import type { ViewStoryActionCreatorType } from '../state/ducks/stories';
|
||||
import * as log from '../logging/log';
|
||||
import { AnimatedEmojiGalore } from './AnimatedEmojiGalore';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
|
@ -25,18 +21,17 @@ import { Intl } from './Intl';
|
|||
import { MessageTimestamp } from './conversation/MessageTimestamp';
|
||||
import { SendStatus } from '../messages/MessageSendState';
|
||||
import { StoryImage } from './StoryImage';
|
||||
import { StoryViewDirectionType, StoryViewModeType } from '../types/Stories';
|
||||
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
|
||||
import { Theme } from '../util/theme';
|
||||
import { getAvatarColor } from '../types/Colors';
|
||||
import { getStoryBackground } from '../util/getStoryBackground';
|
||||
import { getStoryDuration } from '../util/getStoryDuration';
|
||||
import { graphemeAwareSlice } from '../util/graphemeAwareSlice';
|
||||
import { isDownloaded, isDownloading } from '../types/Attachment';
|
||||
import { useEscapeHandling } from '../hooks/useEscapeHandling';
|
||||
import * as log from '../logging/log';
|
||||
|
||||
export type PropsType = {
|
||||
conversationId: string;
|
||||
currentIndex: number;
|
||||
getPreferredBadge: PreferredBadgeSelectorType;
|
||||
group?: Pick<
|
||||
ConversationType,
|
||||
|
@ -53,11 +48,9 @@ export type PropsType = {
|
|||
i18n: LocalizerType;
|
||||
loadStoryReplies: (conversationId: string, messageId: string) => unknown;
|
||||
markStoryRead: (mId: string) => unknown;
|
||||
onClose: () => unknown;
|
||||
numStories: number;
|
||||
onGoToConversation: (conversationId: string) => unknown;
|
||||
onHideStory: (conversationId: string) => unknown;
|
||||
onNextUserStories?: () => unknown;
|
||||
onPrevUserStories?: () => unknown;
|
||||
onSetSkinTone: (tone: number) => unknown;
|
||||
onTextTooLong: () => unknown;
|
||||
onReactToStory: (emoji: string, story: StoryViewType) => unknown;
|
||||
|
@ -74,8 +67,10 @@ export type PropsType = {
|
|||
renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element;
|
||||
replyState?: ReplyStateType;
|
||||
skinTone?: number;
|
||||
stories: Array<StoryViewType>;
|
||||
story: StoryViewType;
|
||||
storyViewMode?: StoryViewModeType;
|
||||
toggleHasAllStoriesMuted: () => unknown;
|
||||
viewStory: ViewStoryActionCreatorType;
|
||||
};
|
||||
|
||||
const CAPTION_BUFFER = 20;
|
||||
|
@ -90,18 +85,16 @@ enum Arrow {
|
|||
}
|
||||
|
||||
export const StoryViewer = ({
|
||||
conversationId,
|
||||
currentIndex,
|
||||
getPreferredBadge,
|
||||
group,
|
||||
hasAllStoriesMuted,
|
||||
i18n,
|
||||
loadStoryReplies,
|
||||
markStoryRead,
|
||||
onClose,
|
||||
numStories,
|
||||
onGoToConversation,
|
||||
onHideStory,
|
||||
onNextUserStories,
|
||||
onPrevUserStories,
|
||||
onReactToStory,
|
||||
onReplyToStory,
|
||||
onSetSkinTone,
|
||||
|
@ -113,10 +106,11 @@ export const StoryViewer = ({
|
|||
renderEmojiPicker,
|
||||
replyState,
|
||||
skinTone,
|
||||
stories,
|
||||
story,
|
||||
storyViewMode,
|
||||
toggleHasAllStoriesMuted,
|
||||
viewStory,
|
||||
}: PropsType): JSX.Element => {
|
||||
const [currentStoryIndex, setCurrentStoryIndex] = useState(0);
|
||||
const [storyDuration, setStoryDuration] = useState<number | undefined>();
|
||||
const [isShowingContextMenu, setIsShowingContextMenu] = useState(false);
|
||||
const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false);
|
||||
|
@ -124,10 +118,8 @@ export const StoryViewer = ({
|
|||
useState<HTMLButtonElement | null>(null);
|
||||
const [reactionEmoji, setReactionEmoji] = useState<string | undefined>();
|
||||
|
||||
const visibleStory = stories[currentStoryIndex];
|
||||
|
||||
const { attachment, canReply, isHidden, messageId, sendState, timestamp } =
|
||||
visibleStory;
|
||||
story;
|
||||
const {
|
||||
acceptedMessageRequest,
|
||||
avatarPath,
|
||||
|
@ -139,10 +131,14 @@ export const StoryViewer = ({
|
|||
profileName,
|
||||
sharedGroupNames,
|
||||
title,
|
||||
} = visibleStory.sender;
|
||||
} = story.sender;
|
||||
|
||||
const [hasReplyModal, setHasReplyModal] = useState(false);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
viewStory();
|
||||
}, [viewStory]);
|
||||
|
||||
const onEscape = useCallback(() => {
|
||||
if (hasReplyModal) {
|
||||
setHasReplyModal(false);
|
||||
|
@ -173,48 +169,6 @@ export const StoryViewer = ({
|
|||
setHasExpandedCaption(false);
|
||||
}, [messageId]);
|
||||
|
||||
// These exist to change currentStoryIndex to the oldest unread story a user
|
||||
// has, or set to 0 whenever conversationId changes.
|
||||
// We use a ref so that we only depend on conversationId changing, since
|
||||
// the stories Array will change once we mark as story as viewed.
|
||||
const storiesRef = useRef(stories);
|
||||
|
||||
useEffect(() => {
|
||||
const unreadStoryIndex = storiesRef.current.findIndex(
|
||||
story => story.isUnread
|
||||
);
|
||||
log.info('stories.findUnreadStory', {
|
||||
unreadStoryIndex,
|
||||
stories: storiesRef.current.length,
|
||||
});
|
||||
setCurrentStoryIndex(unreadStoryIndex < 0 ? 0 : unreadStoryIndex);
|
||||
}, [conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
storiesRef.current = stories;
|
||||
}, [stories]);
|
||||
|
||||
// Either we show the next story in the current user's stories or we ask
|
||||
// for the next user's stories.
|
||||
const showNextStory = useCallback(() => {
|
||||
if (currentStoryIndex < stories.length - 1) {
|
||||
setCurrentStoryIndex(currentStoryIndex + 1);
|
||||
} else {
|
||||
setCurrentStoryIndex(0);
|
||||
onNextUserStories?.();
|
||||
}
|
||||
}, [currentStoryIndex, onNextUserStories, stories.length]);
|
||||
|
||||
// Either we show the previous story in the current user's stories or we ask
|
||||
// for the prior user's stories.
|
||||
const showPrevStory = useCallback(() => {
|
||||
if (currentStoryIndex === 0) {
|
||||
onPrevUserStories?.();
|
||||
} else {
|
||||
setCurrentStoryIndex(currentStoryIndex - 1);
|
||||
}
|
||||
}, [currentStoryIndex, onPrevUserStories]);
|
||||
|
||||
useEffect(() => {
|
||||
let shouldCancel = false;
|
||||
(async function hydrateStoryDuration() {
|
||||
|
@ -247,12 +201,16 @@ export const StoryViewer = ({
|
|||
onRest: {
|
||||
width: ({ value }) => {
|
||||
if (value === 100) {
|
||||
showNextStory();
|
||||
viewStory(
|
||||
story.messageId,
|
||||
storyViewMode,
|
||||
StoryViewDirectionType.Next
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
[showNextStory]
|
||||
[story.messageId, storyViewMode, viewStory]
|
||||
);
|
||||
|
||||
// We need to be careful about this effect refreshing, it should only run
|
||||
|
@ -274,7 +232,7 @@ export const StoryViewer = ({
|
|||
return () => {
|
||||
spring.stop();
|
||||
};
|
||||
}, [currentStoryIndex, spring, storyDuration]);
|
||||
}, [currentIndex, spring, storyDuration]);
|
||||
|
||||
const [pauseStory, setPauseStory] = useState(false);
|
||||
|
||||
|
@ -299,32 +257,23 @@ export const StoryViewer = ({
|
|||
log.info('stories.markStoryRead', { messageId });
|
||||
}, [markStoryRead, messageId]);
|
||||
|
||||
// Queue all undownloaded stories once we're viewing someone's stories
|
||||
const storiesToDownload = useMemo(() => {
|
||||
return stories
|
||||
.filter(
|
||||
story =>
|
||||
!isDownloaded(story.attachment) && !isDownloading(story.attachment)
|
||||
)
|
||||
.map(story => story.messageId);
|
||||
}, [stories]);
|
||||
useEffect(() => {
|
||||
storiesToDownload.forEach(storyId => queueStoryDownload(storyId));
|
||||
}, [queueStoryDownload, storiesToDownload]);
|
||||
|
||||
const navigateStories = useCallback(
|
||||
(ev: KeyboardEvent) => {
|
||||
if (ev.key === 'ArrowRight') {
|
||||
showNextStory();
|
||||
viewStory(story.messageId, storyViewMode, StoryViewDirectionType.Next);
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
} else if (ev.key === 'ArrowLeft') {
|
||||
showPrevStory();
|
||||
viewStory(
|
||||
story.messageId,
|
||||
storyViewMode,
|
||||
StoryViewDirectionType.Previous
|
||||
);
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
},
|
||||
[showPrevStory, showNextStory]
|
||||
[story.messageId, storyViewMode, viewStory]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -335,13 +284,14 @@ export const StoryViewer = ({
|
|||
};
|
||||
}, [navigateStories]);
|
||||
|
||||
const isGroupStory = Boolean(group?.id);
|
||||
const groupId = group?.id;
|
||||
const isGroupStory = Boolean(groupId);
|
||||
useEffect(() => {
|
||||
if (!isGroupStory) {
|
||||
if (!groupId) {
|
||||
return;
|
||||
}
|
||||
loadStoryReplies(conversationId, messageId);
|
||||
}, [conversationId, isGroupStory, loadStoryReplies, messageId]);
|
||||
loadStoryReplies(groupId, messageId);
|
||||
}, [groupId, loadStoryReplies, messageId]);
|
||||
|
||||
const [arrowToShow, setArrowToShow] = useState<Arrow>(Arrow.None);
|
||||
|
||||
|
@ -385,6 +335,8 @@ export const StoryViewer = ({
|
|||
|
||||
const shouldShowContextMenu = !sendState;
|
||||
|
||||
const hasPrevNextArrows = storyViewMode !== StoryViewModeType.Single;
|
||||
|
||||
return (
|
||||
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
|
||||
<div className="StoryViewer">
|
||||
|
@ -393,7 +345,7 @@ export const StoryViewer = ({
|
|||
style={{ background: getStoryBackground(attachment) }}
|
||||
/>
|
||||
<div className="StoryViewer__content">
|
||||
{onPrevUserStories && (
|
||||
{hasPrevNextArrows && (
|
||||
<button
|
||||
aria-label={i18n('back')}
|
||||
className={classNames(
|
||||
|
@ -402,7 +354,13 @@ export const StoryViewer = ({
|
|||
'StoryViewer__arrow--visible': arrowToShow === Arrow.Left,
|
||||
}
|
||||
)}
|
||||
onClick={showPrevStory}
|
||||
onClick={() =>
|
||||
viewStory(
|
||||
story.messageId,
|
||||
storyViewMode,
|
||||
StoryViewDirectionType.Previous
|
||||
)
|
||||
}
|
||||
onMouseMove={() => setArrowToShow(Arrow.Left)}
|
||||
type="button"
|
||||
/>
|
||||
|
@ -549,12 +507,9 @@ export const StoryViewer = ({
|
|||
</div>
|
||||
</div>
|
||||
<div className="StoryViewer__progress">
|
||||
{stories.map((story, index) => (
|
||||
<div
|
||||
className="StoryViewer__progress--container"
|
||||
key={story.messageId}
|
||||
>
|
||||
{currentStoryIndex === index ? (
|
||||
{Array.from(Array(numStories), (_, index) => (
|
||||
<div className="StoryViewer__progress--container" key={index}>
|
||||
{currentIndex === index ? (
|
||||
<animated.div
|
||||
className="StoryViewer__progress--bar"
|
||||
style={{
|
||||
|
@ -565,7 +520,7 @@ export const StoryViewer = ({
|
|||
<div
|
||||
className="StoryViewer__progress--bar"
|
||||
style={{
|
||||
width: currentStoryIndex < index ? '0%' : '100%',
|
||||
width: currentIndex < index ? '0%' : '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@ -626,7 +581,7 @@ export const StoryViewer = ({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onNextUserStories && (
|
||||
{hasPrevNextArrows && (
|
||||
<button
|
||||
aria-label={i18n('forward')}
|
||||
className={classNames(
|
||||
|
@ -635,7 +590,13 @@ export const StoryViewer = ({
|
|||
'StoryViewer__arrow--visible': arrowToShow === Arrow.Right,
|
||||
}
|
||||
)}
|
||||
onClick={showNextStory}
|
||||
onClick={() =>
|
||||
viewStory(
|
||||
story.messageId,
|
||||
storyViewMode,
|
||||
StoryViewDirectionType.Next
|
||||
)
|
||||
}
|
||||
onMouseMove={() => setArrowToShow(Arrow.Right)}
|
||||
type="button"
|
||||
/>
|
||||
|
@ -686,7 +647,7 @@ export const StoryViewer = ({
|
|||
isMyStory={isMe}
|
||||
onClose={() => setHasReplyModal(false)}
|
||||
onReact={emoji => {
|
||||
onReactToStory(emoji, visibleStory);
|
||||
onReactToStory(emoji, story);
|
||||
setHasReplyModal(false);
|
||||
setReactionEmoji(emoji);
|
||||
}}
|
||||
|
@ -694,7 +655,7 @@ export const StoryViewer = ({
|
|||
if (!isGroupStory) {
|
||||
setHasReplyModal(false);
|
||||
}
|
||||
onReplyToStory(message, mentions, replyTimestamp, visibleStory);
|
||||
onReplyToStory(message, mentions, replyTimestamp, story);
|
||||
}}
|
||||
onSetSkinTone={onSetSkinTone}
|
||||
onTextTooLong={onTextTooLong}
|
||||
|
|
|
@ -253,6 +253,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
},
|
||||
theme: ThemeType.light,
|
||||
timestamp: number('timestamp', overrideProps.timestamp || Date.now()),
|
||||
viewStory: action('viewStory'),
|
||||
});
|
||||
|
||||
const createTimelineItem = (data: undefined | Props) =>
|
||||
|
|
|
@ -16,6 +16,7 @@ import type {
|
|||
ConversationTypeType,
|
||||
InteractionModeType,
|
||||
} from '../../state/ducks/conversations';
|
||||
import type { ViewStoryActionCreatorType } from '../../state/ducks/stories';
|
||||
import type { TimelineItemType } from './TimelineItem';
|
||||
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||
import { Avatar, AvatarSize } from '../Avatar';
|
||||
|
@ -44,6 +45,7 @@ import { shouldUseFullSizeLinkPreviewImage } from '../../linkPreviews/shouldUseF
|
|||
import { WidthBreakpoint } from '../_util';
|
||||
import { OutgoingGiftBadgeModal } from '../OutgoingGiftBadgeModal';
|
||||
import * as log from '../../logging/log';
|
||||
import { StoryViewModeType } from '../../types/Stories';
|
||||
|
||||
import type { AttachmentType } from '../../types/Attachment';
|
||||
import {
|
||||
|
@ -252,7 +254,7 @@ export type PropsData = {
|
|||
emoji?: string;
|
||||
isFromMe: boolean;
|
||||
rawAttachment?: QuotedAttachmentType;
|
||||
referencedMessageNotFound?: boolean;
|
||||
storyId?: string;
|
||||
text: string;
|
||||
};
|
||||
previews: Array<LinkPreviewType>;
|
||||
|
@ -360,6 +362,7 @@ export type PropsActions = {
|
|||
|
||||
showExpiredIncomingTapToViewToast: () => unknown;
|
||||
showExpiredOutgoingTapToViewToast: () => unknown;
|
||||
viewStory: ViewStoryActionCreatorType;
|
||||
};
|
||||
|
||||
export type Props = PropsData &
|
||||
|
@ -1519,6 +1522,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
direction,
|
||||
i18n,
|
||||
storyReplyContext,
|
||||
viewStory,
|
||||
} = this.props;
|
||||
|
||||
if (!storyReplyContext) {
|
||||
|
@ -1546,13 +1550,11 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
isViewOnce={false}
|
||||
moduleClassName="StoryReplyQuote"
|
||||
onClick={() => {
|
||||
// TODO DESKTOP-3255
|
||||
viewStory(storyReplyContext.storyId, StoryViewModeType.Single);
|
||||
}}
|
||||
rawAttachment={storyReplyContext.rawAttachment}
|
||||
reactionEmoji={storyReplyContext.emoji}
|
||||
referencedMessageNotFound={Boolean(
|
||||
storyReplyContext.referencedMessageNotFound
|
||||
)}
|
||||
referencedMessageNotFound={!storyReplyContext.storyId}
|
||||
text={storyReplyContext.text}
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -103,6 +103,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
showForwardMessageModal: action('showForwardMessageModal'),
|
||||
showVisualAttachment: action('showVisualAttachment'),
|
||||
startConversation: action('startConversation'),
|
||||
viewStory: action('viewStory'),
|
||||
});
|
||||
|
||||
export const DeliveredIncoming = (): JSX.Element => {
|
||||
|
|
|
@ -96,6 +96,7 @@ export type PropsReduxActions = Pick<
|
|||
| 'clearSelectedMessage'
|
||||
| 'doubleCheckMissingQuoteReference'
|
||||
| 'checkForAccount'
|
||||
| 'viewStory'
|
||||
>;
|
||||
|
||||
export type ExternalProps = PropsData & PropsBackboneActions;
|
||||
|
@ -302,6 +303,7 @@ export class MessageDetail extends React.Component<Props> {
|
|||
showVisualAttachment,
|
||||
startConversation,
|
||||
theme,
|
||||
viewStory,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
|
@ -371,6 +373,7 @@ export class MessageDetail extends React.Component<Props> {
|
|||
showVisualAttachment={showVisualAttachment}
|
||||
startConversation={startConversation}
|
||||
theme={theme}
|
||||
viewStory={viewStory}
|
||||
/>
|
||||
</div>
|
||||
<table className="module-message-detail__info">
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
// Copyright 2020-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { Meta, Story } from '@storybook/react';
|
||||
import * as React from 'react';
|
||||
import { isString } from 'lodash';
|
||||
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { boolean, text } from '@storybook/addon-knobs';
|
||||
|
||||
import { ConversationColors } from '../../types/Colors';
|
||||
import { pngUrl } from '../../storybook/Fixtures';
|
||||
|
@ -30,8 +29,49 @@ import { ThemeType } from '../../types/Util';
|
|||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
export default {
|
||||
component: Quote,
|
||||
title: 'Components/Conversation/Quote',
|
||||
};
|
||||
argTypes: {
|
||||
authorTitle: {
|
||||
defaultValue: 'Default Sender',
|
||||
},
|
||||
conversationColor: {
|
||||
defaultValue: 'forest',
|
||||
},
|
||||
doubleCheckMissingQuoteReference: { action: true },
|
||||
i18n: {
|
||||
defaultValue: i18n,
|
||||
},
|
||||
isFromMe: {
|
||||
control: { type: 'checkbox' },
|
||||
defaultValue: false,
|
||||
},
|
||||
isGiftBadge: {
|
||||
control: { type: 'checkbox' },
|
||||
defaultValue: false,
|
||||
},
|
||||
isIncoming: {
|
||||
control: { type: 'checkbox' },
|
||||
defaultValue: false,
|
||||
},
|
||||
isViewOnce: {
|
||||
control: { type: 'checkbox' },
|
||||
defaultValue: false,
|
||||
},
|
||||
onClick: { action: true },
|
||||
onClose: { action: true },
|
||||
rawAttachment: {
|
||||
defaultValue: undefined,
|
||||
},
|
||||
referencedMessageNotFound: {
|
||||
control: { type: 'checkbox' },
|
||||
defaultValue: false,
|
||||
},
|
||||
text: {
|
||||
defaultValue: 'A sample message from a pal',
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
const defaultMessageProps: MessagesProps = {
|
||||
author: getDefaultConversation({
|
||||
|
@ -105,6 +145,7 @@ const defaultMessageProps: MessagesProps = {
|
|||
textDirection: TextDirection.Default,
|
||||
theme: ThemeType.light,
|
||||
timestamp: Date.now(),
|
||||
viewStory: action('viewStory'),
|
||||
};
|
||||
|
||||
const renderInMessage = ({
|
||||
|
@ -143,459 +184,332 @@ const renderInMessage = ({
|
|||
);
|
||||
};
|
||||
|
||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
authorTitle: text(
|
||||
'authorTitle',
|
||||
overrideProps.authorTitle || 'Default Sender'
|
||||
),
|
||||
conversationColor: overrideProps.conversationColor || 'forest',
|
||||
doubleCheckMissingQuoteReference:
|
||||
overrideProps.doubleCheckMissingQuoteReference ||
|
||||
action('doubleCheckMissingQuoteReference'),
|
||||
i18n,
|
||||
isFromMe: boolean('isFromMe', overrideProps.isFromMe || false),
|
||||
isIncoming: boolean('isIncoming', overrideProps.isIncoming || false),
|
||||
onClick: action('onClick'),
|
||||
onClose: action('onClose'),
|
||||
rawAttachment: overrideProps.rawAttachment || undefined,
|
||||
referencedMessageNotFound: boolean(
|
||||
'referencedMessageNotFound',
|
||||
overrideProps.referencedMessageNotFound || false
|
||||
),
|
||||
isGiftBadge: boolean('isGiftBadge', overrideProps.isGiftBadge || false),
|
||||
isViewOnce: boolean('isViewOnce', overrideProps.isViewOnce || false),
|
||||
text: text(
|
||||
'text',
|
||||
isString(overrideProps.text)
|
||||
? overrideProps.text
|
||||
: 'A sample message from a pal'
|
||||
),
|
||||
});
|
||||
const Template: Story<Props> = args => <Quote {...args} />;
|
||||
const TemplateInMessage: Story<Props> = args => renderInMessage(args);
|
||||
|
||||
export const OutgoingByAnotherAuthor = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
authorTitle: 'Terrence Malick',
|
||||
});
|
||||
|
||||
return <Quote {...props} />;
|
||||
export const OutgoingByAnotherAuthor = Template.bind({});
|
||||
OutgoingByAnotherAuthor.args = {
|
||||
authorTitle: getDefaultConversation().title,
|
||||
};
|
||||
|
||||
OutgoingByAnotherAuthor.story = {
|
||||
name: 'Outgoing by Another Author',
|
||||
};
|
||||
|
||||
export const OutgoingByMe = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
isFromMe: true,
|
||||
});
|
||||
|
||||
return <Quote {...props} />;
|
||||
export const OutgoingByMe = Template.bind({});
|
||||
OutgoingByMe.args = {
|
||||
isFromMe: true,
|
||||
};
|
||||
|
||||
OutgoingByMe.story = {
|
||||
name: 'Outgoing by Me',
|
||||
};
|
||||
|
||||
export const IncomingByAnotherAuthor = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
authorTitle: 'Terrence Malick',
|
||||
isIncoming: true,
|
||||
});
|
||||
|
||||
return <Quote {...props} />;
|
||||
export const IncomingByAnotherAuthor = Template.bind({});
|
||||
IncomingByAnotherAuthor.args = {
|
||||
authorTitle: getDefaultConversation().title,
|
||||
isIncoming: true,
|
||||
};
|
||||
|
||||
IncomingByAnotherAuthor.story = {
|
||||
name: 'Incoming by Another Author',
|
||||
};
|
||||
|
||||
export const IncomingByMe = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
isFromMe: true,
|
||||
isIncoming: true,
|
||||
});
|
||||
|
||||
return <Quote {...props} />;
|
||||
export const IncomingByMe = Template.bind({});
|
||||
IncomingByMe.args = {
|
||||
isFromMe: true,
|
||||
isIncoming: true,
|
||||
};
|
||||
|
||||
IncomingByMe.story = {
|
||||
name: 'Incoming by Me',
|
||||
};
|
||||
|
||||
export const IncomingOutgoingColors = (): JSX.Element => {
|
||||
const props = createProps({});
|
||||
export const IncomingOutgoingColors = (args: Props): JSX.Element => {
|
||||
return (
|
||||
<>
|
||||
{ConversationColors.map(color =>
|
||||
renderInMessage({ ...props, conversationColor: color })
|
||||
renderInMessage({ ...args, conversationColor: color })
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
IncomingOutgoingColors.args = {};
|
||||
IncomingOutgoingColors.story = {
|
||||
name: 'Incoming/Outgoing Colors',
|
||||
};
|
||||
|
||||
export const ImageOnly = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
text: '',
|
||||
rawAttachment: {
|
||||
export const ImageOnly = Template.bind({});
|
||||
ImageOnly.args = {
|
||||
text: '',
|
||||
rawAttachment: {
|
||||
contentType: IMAGE_PNG,
|
||||
fileName: 'sax.png',
|
||||
isVoiceMessage: false,
|
||||
thumbnail: {
|
||||
contentType: IMAGE_PNG,
|
||||
fileName: 'sax.png',
|
||||
isVoiceMessage: false,
|
||||
thumbnail: {
|
||||
contentType: IMAGE_PNG,
|
||||
height: 100,
|
||||
width: 100,
|
||||
path: pngUrl,
|
||||
objectUrl: pngUrl,
|
||||
},
|
||||
height: 100,
|
||||
width: 100,
|
||||
path: pngUrl,
|
||||
objectUrl: pngUrl,
|
||||
},
|
||||
});
|
||||
|
||||
return <Quote {...props} />;
|
||||
},
|
||||
};
|
||||
|
||||
export const ImageAttachment = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
rawAttachment: {
|
||||
export const ImageAttachment = Template.bind({});
|
||||
ImageAttachment.args = {
|
||||
rawAttachment: {
|
||||
contentType: IMAGE_PNG,
|
||||
fileName: 'sax.png',
|
||||
isVoiceMessage: false,
|
||||
thumbnail: {
|
||||
contentType: IMAGE_PNG,
|
||||
fileName: 'sax.png',
|
||||
isVoiceMessage: false,
|
||||
thumbnail: {
|
||||
contentType: IMAGE_PNG,
|
||||
height: 100,
|
||||
width: 100,
|
||||
path: pngUrl,
|
||||
objectUrl: pngUrl,
|
||||
},
|
||||
height: 100,
|
||||
width: 100,
|
||||
path: pngUrl,
|
||||
objectUrl: pngUrl,
|
||||
},
|
||||
});
|
||||
|
||||
return <Quote {...props} />;
|
||||
},
|
||||
};
|
||||
|
||||
export const ImageAttachmentWOThumbnail = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
rawAttachment: {
|
||||
contentType: IMAGE_PNG,
|
||||
fileName: 'sax.png',
|
||||
isVoiceMessage: false,
|
||||
},
|
||||
});
|
||||
|
||||
return <Quote {...props} />;
|
||||
export const ImageAttachmentNoThumbnail = Template.bind({});
|
||||
ImageAttachmentNoThumbnail.args = {
|
||||
rawAttachment: {
|
||||
contentType: IMAGE_PNG,
|
||||
fileName: 'sax.png',
|
||||
isVoiceMessage: false,
|
||||
},
|
||||
};
|
||||
|
||||
ImageAttachmentWOThumbnail.story = {
|
||||
ImageAttachmentNoThumbnail.story = {
|
||||
name: 'Image Attachment w/o Thumbnail',
|
||||
};
|
||||
|
||||
export const ImageTapToView = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
text: '',
|
||||
isViewOnce: true,
|
||||
rawAttachment: {
|
||||
contentType: IMAGE_PNG,
|
||||
fileName: 'sax.png',
|
||||
isVoiceMessage: false,
|
||||
},
|
||||
});
|
||||
|
||||
return <Quote {...props} />;
|
||||
export const ImageTapToView = Template.bind({});
|
||||
ImageTapToView.args = {
|
||||
text: '',
|
||||
isViewOnce: true,
|
||||
rawAttachment: {
|
||||
contentType: IMAGE_PNG,
|
||||
fileName: 'sax.png',
|
||||
isVoiceMessage: false,
|
||||
},
|
||||
};
|
||||
|
||||
ImageTapToView.story = {
|
||||
name: 'Image Tap-to-View',
|
||||
};
|
||||
|
||||
export const VideoOnly = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
rawAttachment: {
|
||||
contentType: VIDEO_MP4,
|
||||
fileName: 'great-video.mp4',
|
||||
isVoiceMessage: false,
|
||||
thumbnail: {
|
||||
contentType: IMAGE_PNG,
|
||||
height: 100,
|
||||
width: 100,
|
||||
path: pngUrl,
|
||||
objectUrl: pngUrl,
|
||||
},
|
||||
export const VideoOnly = Template.bind({});
|
||||
VideoOnly.args = {
|
||||
rawAttachment: {
|
||||
contentType: VIDEO_MP4,
|
||||
fileName: 'great-video.mp4',
|
||||
isVoiceMessage: false,
|
||||
thumbnail: {
|
||||
contentType: IMAGE_PNG,
|
||||
height: 100,
|
||||
width: 100,
|
||||
path: pngUrl,
|
||||
objectUrl: pngUrl,
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
props.text = undefined as any;
|
||||
|
||||
return <Quote {...props} />;
|
||||
},
|
||||
text: undefined,
|
||||
};
|
||||
|
||||
export const VideoAttachment = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
rawAttachment: {
|
||||
contentType: VIDEO_MP4,
|
||||
fileName: 'great-video.mp4',
|
||||
isVoiceMessage: false,
|
||||
thumbnail: {
|
||||
contentType: IMAGE_PNG,
|
||||
height: 100,
|
||||
width: 100,
|
||||
path: pngUrl,
|
||||
objectUrl: pngUrl,
|
||||
},
|
||||
export const VideoAttachment = Template.bind({});
|
||||
VideoAttachment.args = {
|
||||
rawAttachment: {
|
||||
contentType: VIDEO_MP4,
|
||||
fileName: 'great-video.mp4',
|
||||
isVoiceMessage: false,
|
||||
thumbnail: {
|
||||
contentType: IMAGE_PNG,
|
||||
height: 100,
|
||||
width: 100,
|
||||
path: pngUrl,
|
||||
objectUrl: pngUrl,
|
||||
},
|
||||
});
|
||||
|
||||
return <Quote {...props} />;
|
||||
},
|
||||
};
|
||||
|
||||
export const VideoAttachmentWOThumbnail = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
rawAttachment: {
|
||||
contentType: VIDEO_MP4,
|
||||
fileName: 'great-video.mp4',
|
||||
isVoiceMessage: false,
|
||||
},
|
||||
});
|
||||
|
||||
return <Quote {...props} />;
|
||||
export const VideoAttachmentNoThumbnail = Template.bind({});
|
||||
VideoAttachmentNoThumbnail.args = {
|
||||
rawAttachment: {
|
||||
contentType: VIDEO_MP4,
|
||||
fileName: 'great-video.mp4',
|
||||
isVoiceMessage: false,
|
||||
},
|
||||
};
|
||||
|
||||
VideoAttachmentWOThumbnail.story = {
|
||||
VideoAttachmentNoThumbnail.story = {
|
||||
name: 'Video Attachment w/o Thumbnail',
|
||||
};
|
||||
|
||||
export const VideoTapToView = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
text: '',
|
||||
isViewOnce: true,
|
||||
rawAttachment: {
|
||||
contentType: VIDEO_MP4,
|
||||
fileName: 'great-video.mp4',
|
||||
isVoiceMessage: false,
|
||||
},
|
||||
});
|
||||
|
||||
return <Quote {...props} />;
|
||||
export const VideoTapToView = Template.bind({});
|
||||
VideoTapToView.args = {
|
||||
text: '',
|
||||
isViewOnce: true,
|
||||
rawAttachment: {
|
||||
contentType: VIDEO_MP4,
|
||||
fileName: 'great-video.mp4',
|
||||
isVoiceMessage: false,
|
||||
},
|
||||
};
|
||||
|
||||
VideoTapToView.story = {
|
||||
name: 'Video Tap-to-View',
|
||||
};
|
||||
|
||||
export const GiftBadge = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
text: "Some text which shouldn't be rendered",
|
||||
isGiftBadge: true,
|
||||
});
|
||||
|
||||
return renderInMessage(props);
|
||||
export const GiftBadge = TemplateInMessage.bind({});
|
||||
GiftBadge.args = {
|
||||
text: "Some text which shouldn't be rendered",
|
||||
isGiftBadge: true,
|
||||
};
|
||||
|
||||
export const AudioOnly = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
rawAttachment: {
|
||||
contentType: AUDIO_MP3,
|
||||
fileName: 'great-video.mp3',
|
||||
isVoiceMessage: false,
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
props.text = undefined as any;
|
||||
|
||||
return <Quote {...props} />;
|
||||
export const AudioOnly = Template.bind({});
|
||||
AudioOnly.args = {
|
||||
rawAttachment: {
|
||||
contentType: AUDIO_MP3,
|
||||
fileName: 'great-video.mp3',
|
||||
isVoiceMessage: false,
|
||||
},
|
||||
text: undefined,
|
||||
};
|
||||
|
||||
export const AudioAttachment = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
rawAttachment: {
|
||||
contentType: AUDIO_MP3,
|
||||
fileName: 'great-video.mp3',
|
||||
isVoiceMessage: false,
|
||||
},
|
||||
});
|
||||
|
||||
return <Quote {...props} />;
|
||||
export const AudioAttachment = Template.bind({});
|
||||
AudioAttachment.args = {
|
||||
rawAttachment: {
|
||||
contentType: AUDIO_MP3,
|
||||
fileName: 'great-video.mp3',
|
||||
isVoiceMessage: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const VoiceMessageOnly = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
rawAttachment: {
|
||||
contentType: AUDIO_MP3,
|
||||
fileName: 'great-video.mp3',
|
||||
isVoiceMessage: true,
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
props.text = undefined as any;
|
||||
|
||||
return <Quote {...props} />;
|
||||
export const VoiceMessageOnly = Template.bind({});
|
||||
VoiceMessageOnly.args = {
|
||||
rawAttachment: {
|
||||
contentType: AUDIO_MP3,
|
||||
fileName: 'great-video.mp3',
|
||||
isVoiceMessage: true,
|
||||
},
|
||||
text: undefined,
|
||||
};
|
||||
|
||||
export const VoiceMessageAttachment = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
rawAttachment: {
|
||||
contentType: AUDIO_MP3,
|
||||
fileName: 'great-video.mp3',
|
||||
isVoiceMessage: true,
|
||||
},
|
||||
});
|
||||
|
||||
return <Quote {...props} />;
|
||||
export const VoiceMessageAttachment = Template.bind({});
|
||||
VoiceMessageAttachment.args = {
|
||||
rawAttachment: {
|
||||
contentType: AUDIO_MP3,
|
||||
fileName: 'great-video.mp3',
|
||||
isVoiceMessage: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const OtherFileOnly = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
rawAttachment: {
|
||||
contentType: stringToMIMEType('application/json'),
|
||||
fileName: 'great-data.json',
|
||||
isVoiceMessage: false,
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
props.text = undefined as any;
|
||||
|
||||
return <Quote {...props} />;
|
||||
export const OtherFileOnly = Template.bind({});
|
||||
OtherFileOnly.args = {
|
||||
rawAttachment: {
|
||||
contentType: stringToMIMEType('application/json'),
|
||||
fileName: 'great-data.json',
|
||||
isVoiceMessage: false,
|
||||
},
|
||||
text: undefined,
|
||||
};
|
||||
|
||||
export const MediaTapToView = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
text: '',
|
||||
isViewOnce: true,
|
||||
rawAttachment: {
|
||||
contentType: AUDIO_MP3,
|
||||
fileName: 'great-video.mp3',
|
||||
isVoiceMessage: false,
|
||||
},
|
||||
});
|
||||
|
||||
return <Quote {...props} />;
|
||||
export const MediaTapToView = Template.bind({});
|
||||
MediaTapToView.args = {
|
||||
text: '',
|
||||
isViewOnce: true,
|
||||
rawAttachment: {
|
||||
contentType: AUDIO_MP3,
|
||||
fileName: 'great-video.mp3',
|
||||
isVoiceMessage: false,
|
||||
},
|
||||
};
|
||||
|
||||
MediaTapToView.story = {
|
||||
name: 'Media Tap-to-View',
|
||||
};
|
||||
|
||||
export const OtherFileAttachment = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
rawAttachment: {
|
||||
contentType: stringToMIMEType('application/json'),
|
||||
fileName: 'great-data.json',
|
||||
isVoiceMessage: false,
|
||||
},
|
||||
});
|
||||
|
||||
return <Quote {...props} />;
|
||||
export const OtherFileAttachment = Template.bind({});
|
||||
OtherFileAttachment.args = {
|
||||
rawAttachment: {
|
||||
contentType: stringToMIMEType('application/json'),
|
||||
fileName: 'great-data.json',
|
||||
isVoiceMessage: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const LongMessageAttachmentShouldBeHidden = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
rawAttachment: {
|
||||
contentType: LONG_MESSAGE,
|
||||
fileName: 'signal-long-message-123.txt',
|
||||
isVoiceMessage: false,
|
||||
},
|
||||
});
|
||||
|
||||
return <Quote {...props} />;
|
||||
export const LongMessageAttachmentShouldBeHidden = Template.bind({});
|
||||
LongMessageAttachmentShouldBeHidden.args = {
|
||||
rawAttachment: {
|
||||
contentType: LONG_MESSAGE,
|
||||
fileName: 'signal-long-message-123.txt',
|
||||
isVoiceMessage: false,
|
||||
},
|
||||
};
|
||||
|
||||
LongMessageAttachmentShouldBeHidden.story = {
|
||||
name: 'Long message attachment (should be hidden)',
|
||||
};
|
||||
|
||||
export const NoCloseButton = (): JSX.Element => {
|
||||
const props = createProps();
|
||||
props.onClose = undefined;
|
||||
|
||||
return <Quote {...props} />;
|
||||
export const NoCloseButton = Template.bind({});
|
||||
NoCloseButton.args = {
|
||||
onClose: undefined,
|
||||
};
|
||||
|
||||
export const MessageNotFound = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
referencedMessageNotFound: true,
|
||||
});
|
||||
|
||||
return renderInMessage(props);
|
||||
export const MessageNotFound = TemplateInMessage.bind({});
|
||||
MessageNotFound.args = {
|
||||
referencedMessageNotFound: true,
|
||||
};
|
||||
|
||||
export const MissingTextAttachment = (): JSX.Element => {
|
||||
const props = createProps();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
props.text = undefined as any;
|
||||
|
||||
return <Quote {...props} />;
|
||||
export const MissingTextAttachment = Template.bind({});
|
||||
MissingTextAttachment.args = {
|
||||
text: undefined,
|
||||
};
|
||||
|
||||
MissingTextAttachment.story = {
|
||||
name: 'Missing Text & Attachment',
|
||||
};
|
||||
|
||||
export const MentionOutgoingAnotherAuthor = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
authorTitle: 'Tony Stark',
|
||||
text: '@Captain America Lunch later?',
|
||||
});
|
||||
|
||||
return <Quote {...props} />;
|
||||
export const MentionOutgoingAnotherAuthor = Template.bind({});
|
||||
MentionOutgoingAnotherAuthor.args = {
|
||||
authorTitle: 'Tony Stark',
|
||||
text: '@Captain America Lunch later?',
|
||||
};
|
||||
|
||||
MentionOutgoingAnotherAuthor.story = {
|
||||
name: '@mention + outgoing + another author',
|
||||
};
|
||||
|
||||
export const MentionOutgoingMe = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
isFromMe: true,
|
||||
text: '@Captain America Lunch later?',
|
||||
});
|
||||
|
||||
return <Quote {...props} />;
|
||||
export const MentionOutgoingMe = Template.bind({});
|
||||
MentionOutgoingMe.args = {
|
||||
isFromMe: true,
|
||||
text: '@Captain America Lunch later?',
|
||||
};
|
||||
|
||||
MentionOutgoingMe.story = {
|
||||
name: '@mention + outgoing + me',
|
||||
};
|
||||
|
||||
export const MentionIncomingAnotherAuthor = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
authorTitle: 'Captain America',
|
||||
isIncoming: true,
|
||||
text: '@Tony Stark sure',
|
||||
});
|
||||
|
||||
return <Quote {...props} />;
|
||||
export const MentionIncomingAnotherAuthor = Template.bind({});
|
||||
MentionIncomingAnotherAuthor.args = {
|
||||
authorTitle: 'Captain America',
|
||||
isIncoming: true,
|
||||
text: '@Tony Stark sure',
|
||||
};
|
||||
|
||||
MentionIncomingAnotherAuthor.story = {
|
||||
name: '@mention + incoming + another author',
|
||||
};
|
||||
|
||||
export const MentionIncomingMe = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
isFromMe: true,
|
||||
isIncoming: true,
|
||||
text: '@Tony Stark sure',
|
||||
});
|
||||
|
||||
return <Quote {...props} />;
|
||||
export const MentionIncomingMe = Template.bind({});
|
||||
MentionIncomingMe.args = {
|
||||
isFromMe: true,
|
||||
isIncoming: true,
|
||||
text: '@Tony Stark sure',
|
||||
};
|
||||
|
||||
MentionIncomingMe.story = {
|
||||
name: '@mention + incoming + me',
|
||||
};
|
||||
|
||||
export const CustomColor = (): JSX.Element => (
|
||||
export const CustomColor = (args: Props): JSX.Element => (
|
||||
<>
|
||||
<Quote
|
||||
{...createProps({ isIncoming: true, text: 'Solid + Gradient' })}
|
||||
{...args}
|
||||
customColor={{
|
||||
start: { hue: 82, saturation: 35 },
|
||||
}}
|
||||
/>
|
||||
<Quote
|
||||
{...createProps()}
|
||||
{...args}
|
||||
isIncoming={false}
|
||||
text="A gradient"
|
||||
customColor={{
|
||||
deg: 192,
|
||||
start: { hue: 304, saturation: 85 },
|
||||
|
@ -604,59 +518,48 @@ export const CustomColor = (): JSX.Element => (
|
|||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
export const IsStoryReply = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
text: 'Wow!',
|
||||
});
|
||||
|
||||
return (
|
||||
<Quote
|
||||
{...props}
|
||||
authorTitle="Amanda"
|
||||
isStoryReply
|
||||
moduleClassName="StoryReplyQuote"
|
||||
onClose={undefined}
|
||||
rawAttachment={{
|
||||
contentType: VIDEO_MP4,
|
||||
fileName: 'great-video.mp4',
|
||||
isVoiceMessage: false,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
CustomColor.args = {
|
||||
isIncoming: true,
|
||||
text: 'Solid + Gradient',
|
||||
};
|
||||
|
||||
export const IsStoryReply = Template.bind({});
|
||||
IsStoryReply.args = {
|
||||
text: 'Wow!',
|
||||
authorTitle: 'Amanda',
|
||||
isStoryReply: true,
|
||||
moduleClassName: 'StoryReplyQuote',
|
||||
onClose: undefined,
|
||||
rawAttachment: {
|
||||
contentType: VIDEO_MP4,
|
||||
fileName: 'great-video.mp4',
|
||||
isVoiceMessage: false,
|
||||
},
|
||||
};
|
||||
IsStoryReply.story = {
|
||||
name: 'isStoryReply',
|
||||
};
|
||||
|
||||
export const IsStoryReplyEmoji = (): JSX.Element => {
|
||||
const props = createProps();
|
||||
|
||||
return (
|
||||
<Quote
|
||||
{...props}
|
||||
authorTitle="Charlie"
|
||||
isStoryReply
|
||||
moduleClassName="StoryReplyQuote"
|
||||
onClose={undefined}
|
||||
rawAttachment={{
|
||||
contentType: IMAGE_PNG,
|
||||
fileName: 'sax.png',
|
||||
isVoiceMessage: false,
|
||||
thumbnail: {
|
||||
contentType: IMAGE_PNG,
|
||||
height: 100,
|
||||
width: 100,
|
||||
path: pngUrl,
|
||||
objectUrl: pngUrl,
|
||||
},
|
||||
}}
|
||||
reactionEmoji="🏋️"
|
||||
/>
|
||||
);
|
||||
export const IsStoryReplyEmoji = Template.bind({});
|
||||
IsStoryReplyEmoji.args = {
|
||||
authorTitle: getDefaultConversation().firstName,
|
||||
isStoryReply: true,
|
||||
moduleClassName: 'StoryReplyQuote',
|
||||
onClose: undefined,
|
||||
rawAttachment: {
|
||||
contentType: IMAGE_PNG,
|
||||
fileName: 'sax.png',
|
||||
isVoiceMessage: false,
|
||||
thumbnail: {
|
||||
contentType: IMAGE_PNG,
|
||||
height: 100,
|
||||
width: 100,
|
||||
path: pngUrl,
|
||||
objectUrl: pngUrl,
|
||||
},
|
||||
},
|
||||
reactionEmoji: '🏋️',
|
||||
};
|
||||
|
||||
IsStoryReplyEmoji.story = {
|
||||
name: 'isStoryReply emoji',
|
||||
};
|
||||
|
|
|
@ -418,6 +418,8 @@ const actions = () => ({
|
|||
|
||||
peekGroupCallForTheFirstTime: action('peekGroupCallForTheFirstTime'),
|
||||
peekGroupCallIfItHasMembers: action('peekGroupCallIfItHasMembers'),
|
||||
|
||||
viewStory: action('viewStory'),
|
||||
});
|
||||
|
||||
const renderItem = ({
|
||||
|
|
|
@ -267,6 +267,8 @@ const getActions = createSelector(
|
|||
'downloadNewVersion',
|
||||
|
||||
'contactSupport',
|
||||
|
||||
'viewStory',
|
||||
]);
|
||||
|
||||
const safe: AssertProps<PropsActionsType, typeof unsafe> = unsafe;
|
||||
|
|
|
@ -107,6 +107,7 @@ const getDefaultProps = () => ({
|
|||
renderEmojiPicker,
|
||||
renderReactionPicker,
|
||||
renderAudioAttachment: () => <div>*AudioAttachment*</div>,
|
||||
viewStory: action('viewStory'),
|
||||
});
|
||||
|
||||
export default {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ThunkAction } from 'redux-thunk';
|
||||
import type { ThunkAction, ThunkDispatch } from 'redux-thunk';
|
||||
import { isEqual, pick } from 'lodash';
|
||||
import type { AttachmentType } from '../../types/Attachment';
|
||||
import type { BodyRangeType } from '../../types/Util';
|
||||
|
@ -18,6 +18,7 @@ import * as log from '../../logging/log';
|
|||
import dataInterface from '../../sql/Client';
|
||||
import { DAY } from '../../util/durations';
|
||||
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||
import { StoryViewDirectionType, StoryViewModeType } from '../../types/Stories';
|
||||
import { ToastReactionFailed } from '../../components/ToastReactionFailed';
|
||||
import { UUID } from '../../types/UUID';
|
||||
import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend';
|
||||
|
@ -32,11 +33,12 @@ import {
|
|||
isDownloaded,
|
||||
isDownloading,
|
||||
} from '../../types/Attachment';
|
||||
import { getConversationSelector } from '../selectors/conversations';
|
||||
import { getStories } from '../selectors/stories';
|
||||
import { isGroup } from '../../util/whatTypeOfConversation';
|
||||
import { useBoundActions } from '../../hooks/useBoundActions';
|
||||
import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue';
|
||||
import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue';
|
||||
import { isGroup } from '../../util/whatTypeOfConversation';
|
||||
import { getConversationSelector } from '../selectors/conversations';
|
||||
|
||||
export type StoryDataType = {
|
||||
attachment?: AttachmentType;
|
||||
|
@ -56,6 +58,12 @@ export type StoryDataType = {
|
|||
| 'type'
|
||||
>;
|
||||
|
||||
export type SelectedStoryDataType = {
|
||||
currentIndex: number;
|
||||
numStories: number;
|
||||
story: StoryDataType;
|
||||
};
|
||||
|
||||
// State
|
||||
|
||||
export type StoriesStateType = {
|
||||
|
@ -64,7 +72,9 @@ export type StoriesStateType = {
|
|||
messageId: string;
|
||||
replies: Array<MessageAttributesType>;
|
||||
};
|
||||
readonly selectedStoryData?: SelectedStoryDataType;
|
||||
readonly stories: Array<StoryDataType>;
|
||||
readonly storyViewMode?: StoryViewModeType;
|
||||
};
|
||||
|
||||
// Actions
|
||||
|
@ -76,6 +86,7 @@ const REPLY_TO_STORY = 'stories/REPLY_TO_STORY';
|
|||
export const RESOLVE_ATTACHMENT_URL = 'stories/RESOLVE_ATTACHMENT_URL';
|
||||
const STORY_CHANGED = 'stories/STORY_CHANGED';
|
||||
const TOGGLE_VIEW = 'stories/TOGGLE_VIEW';
|
||||
const VIEW_STORY = 'stories/VIEW_STORY';
|
||||
|
||||
type DOEStoryActionType = {
|
||||
type: typeof DOE_STORY;
|
||||
|
@ -117,6 +128,16 @@ type ToggleViewActionType = {
|
|||
type: typeof TOGGLE_VIEW;
|
||||
};
|
||||
|
||||
type ViewStoryActionType = {
|
||||
type: typeof VIEW_STORY;
|
||||
payload:
|
||||
| {
|
||||
selectedStoryData: SelectedStoryDataType;
|
||||
storyViewMode: StoryViewModeType;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export type StoriesActionType =
|
||||
| DOEStoryActionType
|
||||
| LoadStoryRepliesActionType
|
||||
|
@ -126,23 +147,11 @@ export type StoriesActionType =
|
|||
| ReplyToStoryActionType
|
||||
| ResolveAttachmentUrlActionType
|
||||
| StoryChangedActionType
|
||||
| ToggleViewActionType;
|
||||
| ToggleViewActionType
|
||||
| ViewStoryActionType;
|
||||
|
||||
// Action Creators
|
||||
|
||||
export const actions = {
|
||||
deleteStoryForEveryone,
|
||||
loadStoryReplies,
|
||||
markStoryRead,
|
||||
queueStoryDownload,
|
||||
reactToStory,
|
||||
replyToStory,
|
||||
storyChanged,
|
||||
toggleStoriesView,
|
||||
};
|
||||
|
||||
export const useStoriesActions = (): typeof actions => useBoundActions(actions);
|
||||
|
||||
function deleteStoryForEveryone(
|
||||
story: StoryViewType
|
||||
): ThunkAction<void, RootStateType, unknown, DOEStoryActionType> {
|
||||
|
@ -414,6 +423,338 @@ function toggleStoriesView(): ToggleViewActionType {
|
|||
};
|
||||
}
|
||||
|
||||
const getSelectedStoryDataForConversationId = (
|
||||
dispatch: ThunkDispatch<
|
||||
RootStateType,
|
||||
unknown,
|
||||
NoopActionType | ResolveAttachmentUrlActionType
|
||||
>,
|
||||
getState: () => RootStateType,
|
||||
conversationId: string,
|
||||
selectedStoryId?: string
|
||||
): {
|
||||
currentIndex: number;
|
||||
hasUnread: boolean;
|
||||
numStories: number;
|
||||
storiesByConversationId: Array<StoryDataType>;
|
||||
} => {
|
||||
const state = getState();
|
||||
const { stories } = state.stories;
|
||||
|
||||
const storiesByConversationId = stories.filter(
|
||||
item => item.conversationId === conversationId
|
||||
);
|
||||
|
||||
// Find the index of the storyId provided, or if none provided then find the
|
||||
// oldest unread story from the user. If all stories are read then we can
|
||||
// start at the first story.
|
||||
let currentIndex = 0;
|
||||
let hasUnread = false;
|
||||
storiesByConversationId.forEach((item, index) => {
|
||||
if (selectedStoryId && item.messageId === selectedStoryId) {
|
||||
currentIndex = index;
|
||||
}
|
||||
|
||||
if (
|
||||
!selectedStoryId &&
|
||||
!currentIndex &&
|
||||
item.readStatus === ReadStatus.Unread
|
||||
) {
|
||||
hasUnread = true;
|
||||
currentIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
const numStories = storiesByConversationId.length;
|
||||
|
||||
// Queue all undownloaded stories once we're viewing someone's stories
|
||||
storiesByConversationId.forEach(item => {
|
||||
if (isDownloaded(item.attachment) || isDownloading(item.attachment)) {
|
||||
return;
|
||||
}
|
||||
|
||||
queueStoryDownload(item.messageId)(dispatch, getState, null);
|
||||
});
|
||||
|
||||
return {
|
||||
currentIndex,
|
||||
hasUnread,
|
||||
numStories,
|
||||
storiesByConversationId,
|
||||
};
|
||||
};
|
||||
|
||||
function viewUserStories(
|
||||
conversationId: string
|
||||
): ThunkAction<void, RootStateType, unknown, ViewStoryActionType> {
|
||||
return (dispatch, getState) => {
|
||||
const { currentIndex, hasUnread, numStories, storiesByConversationId } =
|
||||
getSelectedStoryDataForConversationId(dispatch, getState, conversationId);
|
||||
|
||||
const story = storiesByConversationId[currentIndex];
|
||||
|
||||
dispatch({
|
||||
type: VIEW_STORY,
|
||||
payload: {
|
||||
selectedStoryData: {
|
||||
currentIndex,
|
||||
numStories,
|
||||
story,
|
||||
},
|
||||
storyViewMode: hasUnread
|
||||
? StoryViewModeType.Unread
|
||||
: StoryViewModeType.All,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export type ViewStoryActionCreatorType = (
|
||||
storyId?: string,
|
||||
storyViewMode?: StoryViewModeType,
|
||||
viewDirection?: StoryViewDirectionType
|
||||
) => unknown;
|
||||
|
||||
const viewStory: ViewStoryActionCreatorType = (
|
||||
storyId,
|
||||
storyViewMode,
|
||||
viewDirection
|
||||
): ThunkAction<void, RootStateType, unknown, ViewStoryActionType> => {
|
||||
return (dispatch, getState) => {
|
||||
if (!storyId || !storyViewMode) {
|
||||
dispatch({
|
||||
type: VIEW_STORY,
|
||||
payload: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const state = getState();
|
||||
const { stories } = state.stories;
|
||||
|
||||
// Spec:
|
||||
// When opening the story viewer you should always be taken to the oldest
|
||||
// un viewed story of the user you tapped on
|
||||
// If all stories from a user are viewed, opening the viewer should take
|
||||
// you to their oldest story
|
||||
|
||||
const story = stories.find(item => item.messageId === storyId);
|
||||
|
||||
if (!story) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { currentIndex, numStories, storiesByConversationId } =
|
||||
getSelectedStoryDataForConversationId(
|
||||
dispatch,
|
||||
getState,
|
||||
story.conversationId,
|
||||
storyId
|
||||
);
|
||||
|
||||
// Go directly to the storyId selected
|
||||
if (!viewDirection) {
|
||||
dispatch({
|
||||
type: VIEW_STORY,
|
||||
payload: {
|
||||
selectedStoryData: {
|
||||
currentIndex,
|
||||
numStories,
|
||||
story,
|
||||
},
|
||||
storyViewMode,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Next story within the same user's stories
|
||||
if (
|
||||
viewDirection === StoryViewDirectionType.Next &&
|
||||
currentIndex < numStories - 1
|
||||
) {
|
||||
const nextIndex = currentIndex + 1;
|
||||
const nextStory = storiesByConversationId[nextIndex];
|
||||
|
||||
dispatch({
|
||||
type: VIEW_STORY,
|
||||
payload: {
|
||||
selectedStoryData: {
|
||||
currentIndex: nextIndex,
|
||||
numStories,
|
||||
story: nextStory,
|
||||
},
|
||||
storyViewMode,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Prev story within the same user's stories
|
||||
if (viewDirection === StoryViewDirectionType.Previous && currentIndex > 0) {
|
||||
const nextIndex = currentIndex - 1;
|
||||
const nextStory = storiesByConversationId[nextIndex];
|
||||
|
||||
dispatch({
|
||||
type: VIEW_STORY,
|
||||
payload: {
|
||||
selectedStoryData: {
|
||||
currentIndex: nextIndex,
|
||||
numStories,
|
||||
story: nextStory,
|
||||
},
|
||||
storyViewMode,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Are there any unviewed stories left? If so we should play the unviewed
|
||||
// stories first. But only if we're going "next"
|
||||
if (viewDirection === StoryViewDirectionType.Next) {
|
||||
const unreadStory = stories.find(
|
||||
item => item.readStatus === ReadStatus.Unread
|
||||
);
|
||||
if (unreadStory) {
|
||||
const nextSelectedStoryData = getSelectedStoryDataForConversationId(
|
||||
dispatch,
|
||||
getState,
|
||||
unreadStory.conversationId,
|
||||
unreadStory.messageId
|
||||
);
|
||||
dispatch({
|
||||
type: VIEW_STORY,
|
||||
payload: {
|
||||
selectedStoryData: {
|
||||
currentIndex: nextSelectedStoryData.currentIndex,
|
||||
numStories: nextSelectedStoryData.numStories,
|
||||
story: unreadStory,
|
||||
},
|
||||
storyViewMode,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const conversationStories = getStories(state).stories;
|
||||
const conversationStoryIndex = conversationStories.findIndex(
|
||||
item => item.conversationId === story.conversationId
|
||||
);
|
||||
|
||||
if (conversationStoryIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the next user's stories
|
||||
if (
|
||||
viewDirection === StoryViewDirectionType.Next &&
|
||||
conversationStoryIndex < conversationStories.length - 1
|
||||
) {
|
||||
// Spec:
|
||||
// Tapping right advances you to the next un viewed story
|
||||
// If all stories are viewed, advance to the next viewed story
|
||||
// When you reach the newest story from a user, tapping right again
|
||||
// should take you to the next user's oldest un viewed story or oldest
|
||||
// story if all stories for the next user are viewed.
|
||||
// When you reach the newest story from the last user in the story list,
|
||||
// tapping right should close the viewer
|
||||
// Touch area for tapping right should be 80% of width of the screen
|
||||
const nextConversationStoryIndex = conversationStoryIndex + 1;
|
||||
const conversationStory = conversationStories[nextConversationStoryIndex];
|
||||
|
||||
const nextSelectedStoryData = getSelectedStoryDataForConversationId(
|
||||
dispatch,
|
||||
getState,
|
||||
conversationStory.conversationId
|
||||
);
|
||||
|
||||
// Close the viewer if we were viewing unread stories only and we've
|
||||
// reached the last unread story.
|
||||
if (
|
||||
!nextSelectedStoryData.hasUnread &&
|
||||
storyViewMode === StoryViewModeType.Unread
|
||||
) {
|
||||
dispatch({
|
||||
type: VIEW_STORY,
|
||||
payload: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: VIEW_STORY,
|
||||
payload: {
|
||||
selectedStoryData: {
|
||||
currentIndex: 0,
|
||||
numStories: nextSelectedStoryData.numStories,
|
||||
story: nextSelectedStoryData.storiesByConversationId[0],
|
||||
},
|
||||
storyViewMode,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the previous user's stories
|
||||
if (
|
||||
viewDirection === StoryViewDirectionType.Previous &&
|
||||
conversationStoryIndex > 0
|
||||
) {
|
||||
// Spec:
|
||||
// Tapping left takes you back to the previous story
|
||||
// When you reach the oldest story from a user, tapping left again takes
|
||||
// you to the previous users oldest un viewed story or newest viewed
|
||||
// story if all stories are viewed
|
||||
// If you tap left on the oldest story from the first user in the story
|
||||
// list, it should re-start playback on that story
|
||||
// Touch area for tapping left should be 20% of width of the screen
|
||||
const nextConversationStoryIndex = conversationStoryIndex - 1;
|
||||
const conversationStory = conversationStories[nextConversationStoryIndex];
|
||||
|
||||
const nextSelectedStoryData = getSelectedStoryDataForConversationId(
|
||||
dispatch,
|
||||
getState,
|
||||
conversationStory.conversationId
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: VIEW_STORY,
|
||||
payload: {
|
||||
selectedStoryData: {
|
||||
currentIndex: 0,
|
||||
numStories: nextSelectedStoryData.numStories,
|
||||
story: nextSelectedStoryData.storiesByConversationId[0],
|
||||
},
|
||||
storyViewMode,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Could not meet any criteria, close the viewer
|
||||
dispatch({
|
||||
type: VIEW_STORY,
|
||||
payload: undefined,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
deleteStoryForEveryone,
|
||||
loadStoryReplies,
|
||||
markStoryRead,
|
||||
queueStoryDownload,
|
||||
reactToStory,
|
||||
replyToStory,
|
||||
storyChanged,
|
||||
toggleStoriesView,
|
||||
viewUserStories,
|
||||
viewStory,
|
||||
};
|
||||
|
||||
export const useStoriesActions = (): typeof actions => useBoundActions(actions);
|
||||
|
||||
// Reducer
|
||||
|
||||
export function getEmptyState(
|
||||
|
@ -645,5 +986,15 @@ export function reducer(
|
|||
};
|
||||
}
|
||||
|
||||
if (action.type === VIEW_STORY) {
|
||||
const { selectedStoryData, storyViewMode } = action.payload || {};
|
||||
|
||||
return {
|
||||
...state,
|
||||
selectedStoryData,
|
||||
storyViewMode,
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
},
|
||||
|
|
|
@ -15,7 +15,12 @@ import type {
|
|||
StoryViewType,
|
||||
} from '../../types/Stories';
|
||||
import type { StateType } from '../reducer';
|
||||
import type { StoryDataType, StoriesStateType } from '../ducks/stories';
|
||||
import type {
|
||||
SelectedStoryDataType,
|
||||
StoryDataType,
|
||||
StoriesStateType,
|
||||
} from '../ducks/stories';
|
||||
import { MY_STORIES_ID } from '../../types/Stories';
|
||||
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||
import { SendStatus } from '../../messages/MessageSendState';
|
||||
import { canReply } from './message';
|
||||
|
@ -25,7 +30,6 @@ import {
|
|||
getMe,
|
||||
} from './conversations';
|
||||
import { getDistributionListSelector } from './storyDistributionLists';
|
||||
import { getUserConversationId } from './user';
|
||||
|
||||
export const getStoriesState = (state: StateType): StoriesStateType =>
|
||||
state.stories;
|
||||
|
@ -35,36 +39,35 @@ export const shouldShowStoriesView = createSelector(
|
|||
({ isShowingStoriesView }): boolean => isShowingStoriesView
|
||||
);
|
||||
|
||||
function getNewestStory(x: ConversationStoryType | MyStoryType): StoryViewType {
|
||||
return x.stories[x.stories.length - 1];
|
||||
}
|
||||
|
||||
function sortByRecencyAndUnread(
|
||||
a: ConversationStoryType | MyStoryType,
|
||||
b: ConversationStoryType | MyStoryType
|
||||
): number {
|
||||
const storyA = getNewestStory(a);
|
||||
const storyB = getNewestStory(b);
|
||||
|
||||
if (storyA.isUnread && storyB.isUnread) {
|
||||
return storyA.timestamp > storyB.timestamp ? -1 : 1;
|
||||
}
|
||||
|
||||
if (storyB.isUnread) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (storyA.isUnread) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return storyA.timestamp > storyB.timestamp ? -1 : 1;
|
||||
}
|
||||
export const getSelectedStoryData = createSelector(
|
||||
getStoriesState,
|
||||
({ selectedStoryData }): SelectedStoryDataType | undefined =>
|
||||
selectedStoryData
|
||||
);
|
||||
|
||||
function getReactionUniqueId(reaction: MessageReactionType): string {
|
||||
return `${reaction.fromId}:${reaction.targetAuthorUuid}:${reaction.timestamp}`;
|
||||
}
|
||||
|
||||
function sortByRecencyAndUnread(
|
||||
storyA: ConversationStoryType,
|
||||
storyB: ConversationStoryType
|
||||
): number {
|
||||
if (storyA.storyView.isUnread && storyB.storyView.isUnread) {
|
||||
return storyA.storyView.timestamp > storyB.storyView.timestamp ? -1 : 1;
|
||||
}
|
||||
|
||||
if (storyB.storyView.isUnread) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (storyA.storyView.isUnread) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return storyA.storyView.timestamp > storyB.storyView.timestamp ? -1 : 1;
|
||||
}
|
||||
|
||||
function getAvatarData(
|
||||
conversation: ConversationType
|
||||
): Pick<
|
||||
|
@ -90,10 +93,9 @@ function getAvatarData(
|
|||
]);
|
||||
}
|
||||
|
||||
function getStoryView(
|
||||
export function getStoryView(
|
||||
conversationSelector: GetConversationByIdType,
|
||||
story: StoryDataType,
|
||||
ourConversationId?: string
|
||||
story: StoryDataType
|
||||
): StoryViewType {
|
||||
const sender = pick(conversationSelector(story.sourceUuid || story.source), [
|
||||
'acceptedMessageRequest',
|
||||
|
@ -113,7 +115,7 @@ function getStoryView(
|
|||
|
||||
return {
|
||||
attachment,
|
||||
canReply: canReply(story, ourConversationId, conversationSelector),
|
||||
canReply: canReply(story, undefined, conversationSelector),
|
||||
isUnread: story.readStatus === ReadStatus.Unread,
|
||||
messageId: story.messageId,
|
||||
sender,
|
||||
|
@ -121,10 +123,9 @@ function getStoryView(
|
|||
};
|
||||
}
|
||||
|
||||
function getConversationStory(
|
||||
export function getConversationStory(
|
||||
conversationSelector: GetConversationByIdType,
|
||||
story: StoryDataType,
|
||||
ourConversationId?: string
|
||||
story: StoryDataType
|
||||
): ConversationStoryType {
|
||||
const sender = pick(conversationSelector(story.sourceUuid || story.source), [
|
||||
'hideStory',
|
||||
|
@ -142,59 +143,16 @@ function getConversationStory(
|
|||
'title',
|
||||
]);
|
||||
|
||||
const storyView = getStoryView(
|
||||
conversationSelector,
|
||||
story,
|
||||
ourConversationId
|
||||
);
|
||||
const storyView = getStoryView(conversationSelector, story);
|
||||
|
||||
return {
|
||||
conversationId: conversation.id,
|
||||
group: conversation.id !== sender.id ? conversation : undefined,
|
||||
isHidden: Boolean(sender.hideStory),
|
||||
stories: [storyView],
|
||||
storyView,
|
||||
};
|
||||
}
|
||||
|
||||
export type GetStoriesByConversationIdType = (
|
||||
conversationId: string
|
||||
) => ConversationStoryType;
|
||||
export const getStoriesSelector = createSelector(
|
||||
getConversationSelector,
|
||||
getUserConversationId,
|
||||
getStoriesState,
|
||||
(
|
||||
conversationSelector,
|
||||
ourConversationId,
|
||||
{ stories }: Readonly<StoriesStateType>
|
||||
): GetStoriesByConversationIdType => {
|
||||
return conversationId => {
|
||||
const conversationStoryAcc: ConversationStoryType = {
|
||||
conversationId,
|
||||
stories: [],
|
||||
};
|
||||
|
||||
return stories.reduce((acc, story) => {
|
||||
if (story.conversationId !== conversationId) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const conversationStory = getConversationStory(
|
||||
conversationSelector,
|
||||
story,
|
||||
ourConversationId
|
||||
);
|
||||
|
||||
return {
|
||||
...acc,
|
||||
...conversationStory,
|
||||
stories: [...acc.stories, ...conversationStory.stories],
|
||||
};
|
||||
}, conversationStoryAcc);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const getStoryReplies = createSelector(
|
||||
getConversationSelector,
|
||||
getContactNameColorSelector,
|
||||
|
@ -262,13 +220,11 @@ export const getStories = createSelector(
|
|||
getConversationSelector,
|
||||
getDistributionListSelector,
|
||||
getStoriesState,
|
||||
getUserConversationId,
|
||||
shouldShowStoriesView,
|
||||
(
|
||||
conversationSelector,
|
||||
distributionListSelector,
|
||||
{ stories }: Readonly<StoriesStateType>,
|
||||
ourConversationId,
|
||||
isShowingStoriesView
|
||||
): {
|
||||
hiddenStories: Array<ConversationStoryType>;
|
||||
|
@ -293,16 +249,16 @@ export const getStories = createSelector(
|
|||
}
|
||||
|
||||
if (story.sendStateByConversationId && story.storyDistributionListId) {
|
||||
const list = distributionListSelector(story.storyDistributionListId);
|
||||
const list =
|
||||
story.storyDistributionListId === MY_STORIES_ID
|
||||
? { id: MY_STORIES_ID, name: MY_STORIES_ID }
|
||||
: distributionListSelector(story.storyDistributionListId);
|
||||
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storyView = getStoryView(
|
||||
conversationSelector,
|
||||
story,
|
||||
ourConversationId
|
||||
);
|
||||
const storyView = getStoryView(conversationSelector, story);
|
||||
|
||||
const sendState: Array<StorySendStateType> = [];
|
||||
const { sendStateByConversationId } = story;
|
||||
|
@ -352,8 +308,7 @@ export const getStories = createSelector(
|
|||
|
||||
const conversationStory = getConversationStory(
|
||||
conversationSelector,
|
||||
story,
|
||||
ourConversationId
|
||||
story
|
||||
);
|
||||
|
||||
let storiesMap: Map<string, ConversationStoryType>;
|
||||
|
@ -366,25 +321,18 @@ export const getStories = createSelector(
|
|||
|
||||
const existingConversationStory = storiesMap.get(
|
||||
conversationStory.conversationId
|
||||
) || { stories: [] };
|
||||
);
|
||||
|
||||
storiesMap.set(conversationStory.conversationId, {
|
||||
...existingConversationStory,
|
||||
...conversationStory,
|
||||
stories: [
|
||||
...existingConversationStory.stories,
|
||||
...conversationStory.stories,
|
||||
],
|
||||
storyView: conversationStory.storyView,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
hiddenStories: Array.from(hiddenStoriesById.values()).sort(
|
||||
sortByRecencyAndUnread
|
||||
),
|
||||
myStories: Array.from(myStoriesById.values()).sort(
|
||||
sortByRecencyAndUnread
|
||||
),
|
||||
hiddenStories: Array.from(hiddenStoriesById.values()),
|
||||
myStories: Array.from(myStoriesById.values()),
|
||||
stories: Array.from(storiesById.values()).sort(sortByRecencyAndUnread),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import { SmartGlobalModalContainer } from './GlobalModalContainer';
|
|||
import { SmartLeftPane } from './LeftPane';
|
||||
import { SmartSafetyNumberViewer } from './SafetyNumberViewer';
|
||||
import { SmartStories } from './Stories';
|
||||
import { SmartStoryViewer } from './StoryViewer';
|
||||
import type { StateType } from '../reducer';
|
||||
import { getPreferredBadgeSelector } from '../selectors/badges';
|
||||
import {
|
||||
|
@ -23,7 +24,10 @@ import {
|
|||
getIsMainWindowFullScreen,
|
||||
getMenuOptions,
|
||||
} from '../selectors/user';
|
||||
import { shouldShowStoriesView } from '../selectors/stories';
|
||||
import {
|
||||
getSelectedStoryData,
|
||||
shouldShowStoriesView,
|
||||
} from '../selectors/stories';
|
||||
import { getHideMenuBar } from '../selectors/items';
|
||||
import { getConversationsStoppingSend } from '../selectors/conversations';
|
||||
import { getIsCustomizingPreferredReactions } from '../selectors/preferredReactions';
|
||||
|
@ -54,6 +58,8 @@ const mapStateToProps = (state: StateType) => {
|
|||
),
|
||||
isShowingStoriesView: shouldShowStoriesView(state),
|
||||
renderStories: () => <SmartStories />,
|
||||
selectedStoryData: getSelectedStoryData(state),
|
||||
renderStoryViewer: () => <SmartStoryViewer />,
|
||||
requestVerification: (
|
||||
type: 'sms' | 'voice',
|
||||
number: string,
|
||||
|
|
|
@ -7,12 +7,10 @@ import { useSelector } from 'react-redux';
|
|||
import type { LocalizerType } from '../../types/Util';
|
||||
import type { StateType } from '../reducer';
|
||||
import type { PropsType as SmartStoryCreatorPropsType } from './StoryCreator';
|
||||
import type { PropsType as SmartStoryViewerPropsType } from './StoryViewer';
|
||||
import { SmartStoryCreator } from './StoryCreator';
|
||||
import { SmartStoryViewer } from './StoryViewer';
|
||||
import { Stories } from '../../components/Stories';
|
||||
import { getMe } from '../selectors/conversations';
|
||||
import { getIntl, getUserConversationId } from '../selectors/user';
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { getPreferredLeftPaneWidth } from '../selectors/items';
|
||||
import { getStories } from '../selectors/stories';
|
||||
import { saveAttachment } from '../../util/saveAttachment';
|
||||
|
@ -26,24 +24,6 @@ function renderStoryCreator({
|
|||
return <SmartStoryCreator onClose={onClose} />;
|
||||
}
|
||||
|
||||
function renderStoryViewer({
|
||||
conversationId,
|
||||
onClose,
|
||||
onNextUserStories,
|
||||
onPrevUserStories,
|
||||
storyToView,
|
||||
}: SmartStoryViewerPropsType): JSX.Element {
|
||||
return (
|
||||
<SmartStoryViewer
|
||||
conversationId={conversationId}
|
||||
onClose={onClose}
|
||||
onNextUserStories={onNextUserStories}
|
||||
onPrevUserStories={onPrevUserStories}
|
||||
storyToView={storyToView}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SmartStories(): JSX.Element | null {
|
||||
const storiesActions = useStoriesActions();
|
||||
const { showConversation, toggleHideStories } = useConversationsActions();
|
||||
|
@ -61,7 +41,6 @@ export function SmartStories(): JSX.Element | null {
|
|||
|
||||
const { hiddenStories, myStories, stories } = useSelector(getStories);
|
||||
|
||||
const ourConversationId = useSelector(getUserConversationId);
|
||||
const me = useSelector(getMe);
|
||||
|
||||
if (!isShowingStoriesView) {
|
||||
|
@ -82,10 +61,8 @@ export function SmartStories(): JSX.Element | null {
|
|||
saveAttachment(story.attachment, story.timestamp);
|
||||
}
|
||||
}}
|
||||
ourConversationId={String(ourConversationId)}
|
||||
preferredWidthFromStorage={preferredWidthFromStorage}
|
||||
renderStoryCreator={renderStoryCreator}
|
||||
renderStoryViewer={renderStoryViewer}
|
||||
showConversation={showConversation}
|
||||
stories={stories}
|
||||
toggleHideStories={toggleHideStories}
|
||||
|
|
|
@ -4,12 +4,14 @@
|
|||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import type { GetStoriesByConversationIdType } from '../selectors/stories';
|
||||
import type { GetConversationByIdType } from '../selectors/conversations';
|
||||
import type { LocalizerType } from '../../types/Util';
|
||||
import type { StoryViewModeType } from '../../types/Stories';
|
||||
import type { StateType } from '../reducer';
|
||||
import type { StoryViewType } from '../../types/Stories';
|
||||
import type { SelectedStoryDataType } from '../ducks/stories';
|
||||
import { StoryViewer } from '../../components/StoryViewer';
|
||||
import { ToastMessageBodyTooLong } from '../../components/ToastMessageBodyTooLong';
|
||||
import { getConversationSelector } from '../selectors/conversations';
|
||||
import {
|
||||
getEmojiSkinTone,
|
||||
getHasAllStoriesMuted,
|
||||
|
@ -17,30 +19,22 @@ import {
|
|||
} from '../selectors/items';
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { getPreferredBadgeSelector } from '../selectors/badges';
|
||||
import { getStoriesSelector, getStoryReplies } from '../selectors/stories';
|
||||
import {
|
||||
getConversationStory,
|
||||
getSelectedStoryData,
|
||||
getStoryReplies,
|
||||
getStoryView,
|
||||
} from '../selectors/stories';
|
||||
import { renderEmojiPicker } from './renderEmojiPicker';
|
||||
import { showToast } from '../../util/showToast';
|
||||
import { strictAssert } from '../../util/assert';
|
||||
import { useActions as useEmojisActions } from '../ducks/emojis';
|
||||
import { useActions as useItemsActions } from '../ducks/items';
|
||||
import { useConversationsActions } from '../ducks/conversations';
|
||||
import { useRecentEmojis } from '../selectors/emojis';
|
||||
import { useStoriesActions } from '../ducks/stories';
|
||||
|
||||
export type PropsType = {
|
||||
conversationId: string;
|
||||
onClose: () => unknown;
|
||||
onNextUserStories?: () => unknown;
|
||||
onPrevUserStories?: () => unknown;
|
||||
storyToView?: StoryViewType;
|
||||
};
|
||||
|
||||
export function SmartStoryViewer({
|
||||
conversationId,
|
||||
onClose,
|
||||
onNextUserStories,
|
||||
onPrevUserStories,
|
||||
storyToView,
|
||||
}: PropsType): JSX.Element | null {
|
||||
export function SmartStoryViewer(): JSX.Element | null {
|
||||
const storiesActions = useStoriesActions();
|
||||
const { onSetSkinTone, toggleHasAllStoriesMuted } = useItemsActions();
|
||||
const { onUseEmoji } = useEmojisActions();
|
||||
|
@ -52,14 +46,25 @@ export function SmartStoryViewer({
|
|||
getPreferredReactionEmoji
|
||||
);
|
||||
|
||||
const getStoriesByConversationId = useSelector<
|
||||
const selectedStoryData = useSelector<
|
||||
StateType,
|
||||
GetStoriesByConversationIdType
|
||||
>(getStoriesSelector);
|
||||
SelectedStoryDataType | undefined
|
||||
>(getSelectedStoryData);
|
||||
|
||||
const { group, stories } = storyToView
|
||||
? { group: undefined, stories: [storyToView] }
|
||||
: getStoriesByConversationId(conversationId);
|
||||
strictAssert(selectedStoryData, 'StoryViewer: !selectedStoryData');
|
||||
|
||||
const conversationSelector = useSelector<StateType, GetConversationByIdType>(
|
||||
getConversationSelector
|
||||
);
|
||||
|
||||
const storyView = getStoryView(conversationSelector, selectedStoryData.story);
|
||||
const conversationStory = getConversationStory(
|
||||
conversationSelector,
|
||||
selectedStoryData.story
|
||||
);
|
||||
const storyViewMode = useSelector<StateType, StoryViewModeType | undefined>(
|
||||
state => state.stories.storyViewMode
|
||||
);
|
||||
|
||||
const recentEmojis = useRecentEmojis();
|
||||
const skinTone = useSelector<StateType, number>(getEmojiSkinTone);
|
||||
|
@ -70,26 +75,24 @@ export function SmartStoryViewer({
|
|||
|
||||
return (
|
||||
<StoryViewer
|
||||
conversationId={conversationId}
|
||||
currentIndex={selectedStoryData.currentIndex}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
group={group}
|
||||
group={conversationStory.group}
|
||||
hasAllStoriesMuted={hasAllStoriesMuted}
|
||||
i18n={i18n}
|
||||
onClose={onClose}
|
||||
numStories={selectedStoryData.numStories}
|
||||
onHideStory={toggleHideStories}
|
||||
onGoToConversation={senderId => {
|
||||
showConversation({ conversationId: senderId });
|
||||
storiesActions.toggleStoriesView();
|
||||
}}
|
||||
onNextUserStories={onNextUserStories}
|
||||
onPrevUserStories={onPrevUserStories}
|
||||
onReactToStory={async (emoji, story) => {
|
||||
const { messageId } = story;
|
||||
storiesActions.reactToStory(emoji, messageId);
|
||||
}}
|
||||
onReplyToStory={(message, mentions, timestamp, story) => {
|
||||
storiesActions.replyToStory(
|
||||
conversationId,
|
||||
conversationStory.conversationId,
|
||||
message,
|
||||
mentions,
|
||||
timestamp,
|
||||
|
@ -103,8 +106,9 @@ export function SmartStoryViewer({
|
|||
recentEmojis={recentEmojis}
|
||||
renderEmojiPicker={renderEmojiPicker}
|
||||
replyState={replyState}
|
||||
stories={stories}
|
||||
skinTone={skinTone}
|
||||
story={storyView}
|
||||
storyViewMode={storyViewMode}
|
||||
toggleHasAllStoriesMuted={toggleHasAllStoriesMuted}
|
||||
{...storiesActions}
|
||||
/>
|
||||
|
|
|
@ -69,6 +69,6 @@ export function getFakeStory({
|
|||
return {
|
||||
conversationId: storyView.sender.id,
|
||||
group,
|
||||
stories: [storyView],
|
||||
storyView,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -45,7 +45,7 @@ export type ConversationStoryType = {
|
|||
>;
|
||||
isHidden?: boolean;
|
||||
searchNames?: string; // This is just here to satisfy Fuse's types
|
||||
stories: Array<StoryViewType>;
|
||||
storyView: StoryViewType;
|
||||
};
|
||||
|
||||
export type StorySendStateType = {
|
||||
|
@ -99,3 +99,14 @@ export type MyStoryType = {
|
|||
};
|
||||
|
||||
export const MY_STORIES_ID = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
export enum StoryViewDirectionType {
|
||||
Next = 'Next',
|
||||
Previous = 'Previous',
|
||||
}
|
||||
|
||||
export enum StoryViewModeType {
|
||||
Unread = 'Unread',
|
||||
All = 'All',
|
||||
Single = 'Single',
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue