Sync my stories with primary device
This commit is contained in:
parent
7554d8326a
commit
9155784d56
67 changed files with 2954 additions and 1238 deletions
|
@ -86,55 +86,63 @@ export function ContextMenuPopper<T>({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={theme ? themeClassName(theme) : undefined}>
|
||||
<div
|
||||
className="ContextMenu__popper"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
{title && <div className="ContextMenu__title">{title}</div>}
|
||||
{menuOptions.map((option, index) => (
|
||||
<button
|
||||
aria-label={option.label}
|
||||
className={classNames({
|
||||
ContextMenu__option: true,
|
||||
'ContextMenu__option--focused': focusedIndex === index,
|
||||
})}
|
||||
key={option.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
option.onClick(option.value);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className="ContextMenu__option--container">
|
||||
{option.icon && (
|
||||
<div
|
||||
className={classNames(
|
||||
'ContextMenu__option--icon',
|
||||
option.icon
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className="ContextMenu__option--title">{option.label}</div>
|
||||
{option.description && (
|
||||
<div className="ContextMenu__option--description">
|
||||
{option.description}
|
||||
</div>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
allowOutsideClick: true,
|
||||
}}
|
||||
>
|
||||
<div className={theme ? themeClassName(theme) : undefined}>
|
||||
<div
|
||||
className="ContextMenu__popper"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
{title && <div className="ContextMenu__title">{title}</div>}
|
||||
{menuOptions.map((option, index) => (
|
||||
<button
|
||||
aria-label={option.label}
|
||||
className={classNames({
|
||||
ContextMenu__option: true,
|
||||
'ContextMenu__option--focused': focusedIndex === index,
|
||||
})}
|
||||
key={option.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
option.onClick(option.value);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className="ContextMenu__option--container">
|
||||
{option.icon && (
|
||||
<div
|
||||
className={classNames(
|
||||
'ContextMenu__option--icon',
|
||||
option.icon
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className="ContextMenu__option--title">
|
||||
{option.label}
|
||||
</div>
|
||||
{option.description && (
|
||||
<div className="ContextMenu__option--description">
|
||||
{option.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{typeof value !== 'undefined' &&
|
||||
typeof option.value !== 'undefined' &&
|
||||
value === option.value ? (
|
||||
<div className="ContextMenu__option--selected" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
{typeof value !== 'undefined' &&
|
||||
typeof option.value !== 'undefined' &&
|
||||
value === option.value ? (
|
||||
<div className="ContextMenu__option--selected" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -214,22 +222,16 @@ export function ContextMenu<T>({
|
|||
type="button"
|
||||
/>
|
||||
{menuShowing && (
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
allowOutsideClick: true,
|
||||
}}
|
||||
>
|
||||
<ContextMenuPopper
|
||||
focusedIndex={focusedIndex}
|
||||
isMenuShowing={menuShowing}
|
||||
menuOptions={menuOptions}
|
||||
onClose={() => setMenuShowing(false)}
|
||||
popperOptions={popperOptions}
|
||||
referenceElement={referenceElement}
|
||||
title={title}
|
||||
value={value}
|
||||
/>
|
||||
</FocusTrap>
|
||||
<ContextMenuPopper
|
||||
focusedIndex={focusedIndex}
|
||||
isMenuShowing={menuShowing}
|
||||
menuOptions={menuOptions}
|
||||
onClose={() => setMenuShowing(false)}
|
||||
popperOptions={popperOptions}
|
||||
referenceElement={referenceElement}
|
||||
title={title}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
import React from 'react';
|
||||
import type {
|
||||
ContactModalStateType,
|
||||
ForwardMessagePropsType,
|
||||
UserNotFoundModalStateType,
|
||||
} from '../state/ducks/globalModals';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
|
@ -18,6 +19,9 @@ type PropsType = {
|
|||
// ContactModal
|
||||
contactModalState?: ContactModalStateType;
|
||||
renderContactModal: () => JSX.Element;
|
||||
// ForwardMessageModal
|
||||
forwardMessageProps?: ForwardMessagePropsType;
|
||||
renderForwardMessageModal: () => JSX.Element;
|
||||
// ProfileEditor
|
||||
isProfileEditorVisible: boolean;
|
||||
renderProfileEditor: () => JSX.Element;
|
||||
|
@ -37,6 +41,9 @@ export const GlobalModalContainer = ({
|
|||
// ContactModal
|
||||
contactModalState,
|
||||
renderContactModal,
|
||||
// ForwardMessageModal
|
||||
forwardMessageProps,
|
||||
renderForwardMessageModal,
|
||||
// ProfileEditor
|
||||
isProfileEditorVisible,
|
||||
renderProfileEditor,
|
||||
|
@ -94,5 +101,9 @@ export const GlobalModalContainer = ({
|
|||
return <WhatsNewModal hideWhatsNewModal={hideWhatsNewModal} i18n={i18n} />;
|
||||
}
|
||||
|
||||
if (forwardMessageProps) {
|
||||
return renderForwardMessageModal();
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
105
ts/components/MyStories.stories.tsx
Normal file
105
ts/components/MyStories.stories.tsx
Normal file
|
@ -0,0 +1,105 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { Meta, ReactFramework, Story } from '@storybook/react';
|
||||
import type { PlayFunction } from '@storybook/csf';
|
||||
import React from 'react';
|
||||
import { expect } from '@storybook/jest';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { within, userEvent } from '@storybook/testing-library';
|
||||
|
||||
import type { PropsType } from './MyStories';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { MY_STORIES_ID } from '../types/Stories';
|
||||
import { MyStories } from './MyStories';
|
||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||
import { getFakeMyStory } from '../test-both/helpers/getFakeStory';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
import { sleep } from '../util/sleep';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
export default {
|
||||
title: 'Components/MyStories',
|
||||
component: MyStories,
|
||||
argTypes: {
|
||||
i18n: {
|
||||
defaultValue: i18n,
|
||||
},
|
||||
onBack: {
|
||||
action: true,
|
||||
},
|
||||
onDelete: {
|
||||
action: true,
|
||||
},
|
||||
onForward: {
|
||||
action: true,
|
||||
},
|
||||
onSave: {
|
||||
action: true,
|
||||
},
|
||||
ourConversationId: {
|
||||
defaultValue: getDefaultConversation().id,
|
||||
},
|
||||
queueStoryDownload: {
|
||||
action: true,
|
||||
},
|
||||
renderStoryViewer: {
|
||||
action: true,
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
const Template: Story<PropsType> = args => <MyStories {...args} />;
|
||||
|
||||
export const NoStories = Template.bind({});
|
||||
NoStories.args = {
|
||||
myStories: [],
|
||||
};
|
||||
NoStories.story = {
|
||||
name: 'No Stories',
|
||||
};
|
||||
|
||||
const interactionTest: PlayFunction<ReactFramework, PropsType> = async ({
|
||||
args,
|
||||
canvasElement,
|
||||
}) => {
|
||||
const canvas = within(canvasElement);
|
||||
const [btnDownload] = canvas.getAllByLabelText('Download story');
|
||||
await userEvent.click(btnDownload);
|
||||
await expect(args.onSave).toHaveBeenCalled();
|
||||
|
||||
const [btnBack] = canvas.getAllByLabelText('Back');
|
||||
await userEvent.click(btnBack);
|
||||
await expect(args.onBack).toHaveBeenCalled();
|
||||
|
||||
const [btnCtxMenu] = canvas.getAllByLabelText('Context menu');
|
||||
|
||||
await userEvent.click(btnCtxMenu);
|
||||
await sleep(300);
|
||||
const [btnFwd] = canvas.getAllByLabelText('Forward');
|
||||
await userEvent.click(btnFwd);
|
||||
await expect(args.onForward).toHaveBeenCalled();
|
||||
};
|
||||
|
||||
export const SingleListStories = Template.bind({});
|
||||
SingleListStories.args = {
|
||||
myStories: [getFakeMyStory(MY_STORIES_ID)],
|
||||
};
|
||||
SingleListStories.play = interactionTest;
|
||||
SingleListStories.story = {
|
||||
name: 'One distribution list',
|
||||
};
|
||||
|
||||
export const MultiListStories = Template.bind({});
|
||||
MultiListStories.args = {
|
||||
myStories: [
|
||||
getFakeMyStory(MY_STORIES_ID),
|
||||
getFakeMyStory(uuid(), 'Cool Peeps'),
|
||||
getFakeMyStory(uuid(), 'Family'),
|
||||
],
|
||||
};
|
||||
MultiListStories.play = interactionTest;
|
||||
MultiListStories.story = {
|
||||
name: 'Multiple distribution lists',
|
||||
};
|
167
ts/components/MyStories.tsx
Normal file
167
ts/components/MyStories.tsx
Normal file
|
@ -0,0 +1,167 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
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 { ConfirmationDialog } from './ConfirmationDialog';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
import { MY_STORIES_ID } from '../types/Stories';
|
||||
import { MessageTimestamp } from './conversation/MessageTimestamp';
|
||||
import { StoryImage } from './StoryImage';
|
||||
import { Theme } from '../util/theme';
|
||||
|
||||
export type PropsType = {
|
||||
i18n: LocalizerType;
|
||||
myStories: Array<MyStoryType>;
|
||||
onBack: () => unknown;
|
||||
onDelete: (story: StoryViewType) => unknown;
|
||||
onForward: (storyId: string) => unknown;
|
||||
onSave: (story: StoryViewType) => unknown;
|
||||
ourConversationId: string;
|
||||
queueStoryDownload: (storyId: string) => unknown;
|
||||
renderStoryViewer: (props: SmartStoryViewerPropsType) => JSX.Element;
|
||||
};
|
||||
|
||||
export const MyStories = ({
|
||||
i18n,
|
||||
myStories,
|
||||
onBack,
|
||||
onDelete,
|
||||
onForward,
|
||||
onSave,
|
||||
ourConversationId,
|
||||
queueStoryDownload,
|
||||
renderStoryViewer,
|
||||
}: PropsType): JSX.Element => {
|
||||
const [confirmDeleteStory, setConfirmDeleteStory] = useState<
|
||||
StoryViewType | undefined
|
||||
>();
|
||||
|
||||
const [storyToView, setStoryToView] = useState<StoryViewType | undefined>();
|
||||
|
||||
return (
|
||||
<>
|
||||
{confirmDeleteStory && (
|
||||
<ConfirmationDialog
|
||||
actions={[
|
||||
{
|
||||
text: i18n('delete'),
|
||||
action: () => onDelete(confirmDeleteStory),
|
||||
style: 'negative',
|
||||
},
|
||||
]}
|
||||
i18n={i18n}
|
||||
onClose={() => setConfirmDeleteStory(undefined)}
|
||||
>
|
||||
{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')}
|
||||
className="Stories__pane__header--back"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
/>
|
||||
<div className="Stories__pane__header--title">
|
||||
{i18n('MyStories__title')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="Stories__pane__list">
|
||||
{myStories.map(list => (
|
||||
<div className="MyStories__distribution" key={list.distributionId}>
|
||||
<div className="MyStories__distribution__title">
|
||||
{list.distributionId === MY_STORIES_ID
|
||||
? i18n('Stories__mine')
|
||||
: list.distributionName}
|
||||
</div>
|
||||
{list.stories.map(story => (
|
||||
<div className="MyStories__story" key={story.timestamp}>
|
||||
{story.attachment && (
|
||||
<button
|
||||
aria-label={i18n('MyStories__story')}
|
||||
className="MyStories__story__preview"
|
||||
onClick={() => setStoryToView(story)}
|
||||
type="button"
|
||||
>
|
||||
<StoryImage
|
||||
attachment={story.attachment}
|
||||
i18n={i18n}
|
||||
isThumbnail
|
||||
label={i18n('MyStories__story')}
|
||||
moduleClassName="MyStories__story__preview"
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
storyId={story.messageId}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<div className="MyStories__story__details">
|
||||
{story.views === 1
|
||||
? i18n('MyStories__views--singular', [String(story.views)])
|
||||
: i18n('MyStories__views--plural', [
|
||||
String(story.views || 0),
|
||||
])}
|
||||
<MessageTimestamp
|
||||
i18n={i18n}
|
||||
module="MyStories__story__timestamp"
|
||||
timestamp={story.timestamp}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
aria-label={i18n('MyStories__download')}
|
||||
className="MyStories__story__download"
|
||||
onClick={() => {
|
||||
onSave(story);
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
<ContextMenu
|
||||
buttonClassName="MyStories__story__more"
|
||||
i18n={i18n}
|
||||
menuOptions={[
|
||||
{
|
||||
icon: 'MyStories__icon--save',
|
||||
label: i18n('save'),
|
||||
onClick: () => {
|
||||
onSave(story);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'MyStories__icon--forward',
|
||||
label: i18n('forward'),
|
||||
onClick: () => {
|
||||
onForward(story.messageId);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'MyStories__icon--delete',
|
||||
label: i18n('delete'),
|
||||
onClick: () => {
|
||||
setConfirmDeleteStory(story);
|
||||
},
|
||||
},
|
||||
]}
|
||||
theme={Theme.Dark}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!myStories.length && (
|
||||
<div className="Stories__pane__list--empty">
|
||||
{i18n('Stories__list-empty')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
81
ts/components/MyStoriesButton.stories.tsx
Normal file
81
ts/components/MyStoriesButton.stories.tsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { Meta, ReactFramework, Story } from '@storybook/react';
|
||||
import type { PlayFunction } from '@storybook/csf';
|
||||
import React from 'react';
|
||||
import { expect } from '@storybook/jest';
|
||||
import { within, userEvent } from '@storybook/testing-library';
|
||||
|
||||
import type { PropsType } from './MyStoriesButton';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { MyStoriesButton } from './MyStoriesButton';
|
||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||
import { getFakeStoryView } from '../test-both/helpers/getFakeStory';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
export default {
|
||||
title: 'Components/MyStoriesButton',
|
||||
component: MyStoriesButton,
|
||||
argTypes: {
|
||||
hasMultiple: {
|
||||
control: 'checkbox',
|
||||
defaultValue: false,
|
||||
},
|
||||
i18n: {
|
||||
defaultValue: i18n,
|
||||
},
|
||||
me: {
|
||||
defaultValue: getDefaultConversation(),
|
||||
},
|
||||
newestStory: {
|
||||
defaultValue: getFakeStoryView(),
|
||||
},
|
||||
onClick: {
|
||||
action: true,
|
||||
},
|
||||
queueStoryDownload: {
|
||||
action: true,
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
const Template: Story<PropsType> = args => <MyStoriesButton {...args} />;
|
||||
|
||||
const interactionTest: PlayFunction<ReactFramework, PropsType> = async ({
|
||||
args,
|
||||
canvasElement,
|
||||
}) => {
|
||||
const canvas = within(canvasElement);
|
||||
const [btnStory] = canvas.getAllByLabelText('Story');
|
||||
await userEvent.click(btnStory);
|
||||
await expect(args.onClick).toHaveBeenCalled();
|
||||
};
|
||||
|
||||
export const NoStory = Template.bind({});
|
||||
NoStory.args = {
|
||||
hasMultiple: false,
|
||||
newestStory: undefined,
|
||||
};
|
||||
NoStory.story = {
|
||||
name: 'No Story',
|
||||
};
|
||||
NoStory.play = interactionTest;
|
||||
|
||||
export const OneStory = Template.bind({});
|
||||
OneStory.args = {};
|
||||
OneStory.story = {
|
||||
name: 'One Story',
|
||||
};
|
||||
OneStory.play = interactionTest;
|
||||
|
||||
export const ManyStories = Template.bind({});
|
||||
ManyStories.args = {
|
||||
hasMultiple: true,
|
||||
};
|
||||
ManyStories.story = {
|
||||
name: 'Many Stories',
|
||||
};
|
||||
ManyStories.play = interactionTest;
|
103
ts/components/MyStoriesButton.tsx
Normal file
103
ts/components/MyStoriesButton.tsx
Normal file
|
@ -0,0 +1,103 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { StoryViewType } from '../types/Stories';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { StoryImage } from './StoryImage';
|
||||
import { getAvatarColor } from '../types/Colors';
|
||||
|
||||
export type PropsType = {
|
||||
hasMultiple: boolean;
|
||||
i18n: LocalizerType;
|
||||
me: ConversationType;
|
||||
newestStory?: StoryViewType;
|
||||
onClick: () => unknown;
|
||||
queueStoryDownload: (storyId: string) => unknown;
|
||||
};
|
||||
|
||||
export const MyStoriesButton = ({
|
||||
hasMultiple,
|
||||
i18n,
|
||||
me,
|
||||
newestStory,
|
||||
onClick,
|
||||
queueStoryDownload,
|
||||
}: PropsType): JSX.Element => {
|
||||
const {
|
||||
acceptedMessageRequest,
|
||||
avatarPath,
|
||||
color,
|
||||
isMe,
|
||||
name,
|
||||
profileName,
|
||||
sharedGroupNames,
|
||||
title,
|
||||
} = me;
|
||||
|
||||
return (
|
||||
<div className="Stories__my-stories">
|
||||
<button
|
||||
aria-label={i18n('StoryListItem__label')}
|
||||
className="StoryListItem"
|
||||
onClick={onClick}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
>
|
||||
<Avatar
|
||||
acceptedMessageRequest={acceptedMessageRequest}
|
||||
sharedGroupNames={sharedGroupNames}
|
||||
avatarPath={avatarPath}
|
||||
badge={undefined}
|
||||
color={getAvatarColor(color)}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe={Boolean(isMe)}
|
||||
name={name}
|
||||
profileName={profileName}
|
||||
size={AvatarSize.FORTY_EIGHT}
|
||||
title={title}
|
||||
/>
|
||||
<div className="StoryListItem__info">
|
||||
<>
|
||||
<div className="StoryListItem__info--title">
|
||||
{i18n('Stories__mine')}
|
||||
</div>
|
||||
{!newestStory && (
|
||||
<div className="StoryListItem__info--timestamp">
|
||||
{i18n('Stories__add')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames('StoryListItem__previews', {
|
||||
'StoryListItem__previews--multiple': hasMultiple,
|
||||
})}
|
||||
>
|
||||
{hasMultiple && <div className="StoryListItem__previews--more" />}
|
||||
{newestStory ? (
|
||||
<StoryImage
|
||||
attachment={newestStory.attachment}
|
||||
i18n={i18n}
|
||||
isThumbnail
|
||||
label=""
|
||||
moduleClassName="StoryListItem__previews--image"
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
storyId={newestStory.messageId}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
aria-label={i18n('Stories__add')}
|
||||
className="StoryListItem__previews--add StoryListItem__previews--image"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -3,20 +3,16 @@
|
|||
|
||||
import type { Meta, Story } from '@storybook/react';
|
||||
import React from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import type { AttachmentType } from '../types/Attachment';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { PropsType } from './Stories';
|
||||
import { Stories } from './Stories';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||
import {
|
||||
fakeAttachment,
|
||||
fakeThumbnail,
|
||||
} from '../test-both/helpers/fakeAttachment';
|
||||
getFakeMyStory,
|
||||
getFakeStory,
|
||||
} from '../test-both/helpers/getFakeStory';
|
||||
import * as durations from '../util/durations';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
@ -24,119 +20,136 @@ const i18n = setupI18n('en', enMessages);
|
|||
export default {
|
||||
title: 'Components/Stories',
|
||||
component: Stories,
|
||||
argTypes: {
|
||||
deleteStoryForEveryone: { action: true },
|
||||
hiddenStories: {
|
||||
defaultValue: [],
|
||||
},
|
||||
i18n: {
|
||||
defaultValue: i18n,
|
||||
},
|
||||
myStories: {
|
||||
defaultValue: [],
|
||||
},
|
||||
onForwardStory: { action: true },
|
||||
onSaveStory: { action: true },
|
||||
ourConversationId: {
|
||||
defaultValue: getDefaultConversation().id,
|
||||
},
|
||||
preferredWidthFromStorage: {
|
||||
defaultValue: 380,
|
||||
},
|
||||
queueStoryDownload: { action: true },
|
||||
renderStoryCreator: { action: true },
|
||||
renderStoryViewer: { action: true },
|
||||
showConversation: { action: true },
|
||||
stories: {
|
||||
defaultValue: [],
|
||||
},
|
||||
toggleHideStories: { action: true },
|
||||
toggleStoriesView: { action: true },
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
function createStory({
|
||||
attachment,
|
||||
group,
|
||||
timestamp,
|
||||
}: {
|
||||
attachment?: AttachmentType;
|
||||
group?: Pick<
|
||||
ConversationType,
|
||||
| 'acceptedMessageRequest'
|
||||
| 'avatarPath'
|
||||
| 'color'
|
||||
| 'id'
|
||||
| 'name'
|
||||
| 'profileName'
|
||||
| 'sharedGroupNames'
|
||||
| 'title'
|
||||
>;
|
||||
timestamp: number;
|
||||
}) {
|
||||
const replies = Math.random() > 0.5;
|
||||
let hasReplies = false;
|
||||
let hasRepliesFromSelf = false;
|
||||
if (replies) {
|
||||
hasReplies = true;
|
||||
hasRepliesFromSelf = Math.random() > 0.5;
|
||||
}
|
||||
|
||||
const sender = getDefaultConversation();
|
||||
|
||||
return {
|
||||
conversationId: sender.id,
|
||||
group,
|
||||
stories: [
|
||||
{
|
||||
attachment,
|
||||
hasReplies,
|
||||
hasRepliesFromSelf,
|
||||
isMe: false,
|
||||
isUnread: Math.random() > 0.5,
|
||||
messageId: uuid(),
|
||||
sender,
|
||||
timestamp,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getAttachmentWithThumbnail(url: string): AttachmentType {
|
||||
return fakeAttachment({
|
||||
url,
|
||||
thumbnail: fakeThumbnail(url),
|
||||
});
|
||||
}
|
||||
|
||||
const getDefaultProps = (): PropsType => ({
|
||||
hiddenStories: [],
|
||||
i18n,
|
||||
preferredWidthFromStorage: 380,
|
||||
queueStoryDownload: action('queueStoryDownload'),
|
||||
renderStoryCreator: () => <div />,
|
||||
renderStoryViewer: () => <div />,
|
||||
showConversation: action('showConversation'),
|
||||
stories: [
|
||||
createStory({
|
||||
attachment: getAttachmentWithThumbnail(
|
||||
'/fixtures/tina-rolf-269345-unsplash.jpg'
|
||||
),
|
||||
timestamp: Date.now() - 2 * durations.MINUTE,
|
||||
}),
|
||||
createStory({
|
||||
attachment: getAttachmentWithThumbnail(
|
||||
'/fixtures/koushik-chowdavarapu-105425-unsplash.jpg'
|
||||
),
|
||||
timestamp: Date.now() - 5 * durations.MINUTE,
|
||||
}),
|
||||
createStory({
|
||||
group: getDefaultConversation({ title: 'BBQ in the park' }),
|
||||
attachment: getAttachmentWithThumbnail(
|
||||
'/fixtures/nathan-anderson-316188-unsplash.jpg'
|
||||
),
|
||||
timestamp: Date.now() - 65 * durations.MINUTE,
|
||||
}),
|
||||
createStory({
|
||||
attachment: getAttachmentWithThumbnail('/fixtures/snow.jpg'),
|
||||
timestamp: Date.now() - 92 * durations.MINUTE,
|
||||
}),
|
||||
createStory({
|
||||
attachment: getAttachmentWithThumbnail('/fixtures/kitten-1-64-64.jpg'),
|
||||
timestamp: Date.now() - 164 * durations.MINUTE,
|
||||
}),
|
||||
createStory({
|
||||
group: getDefaultConversation({ title: 'Breaking Signal for Science' }),
|
||||
attachment: getAttachmentWithThumbnail('/fixtures/kitten-2-64-64.jpg'),
|
||||
timestamp: Date.now() - 380 * durations.MINUTE,
|
||||
}),
|
||||
createStory({
|
||||
attachment: getAttachmentWithThumbnail('/fixtures/kitten-3-64-64.jpg'),
|
||||
timestamp: Date.now() - 421 * durations.MINUTE,
|
||||
}),
|
||||
],
|
||||
toggleHideStories: action('toggleHideStories'),
|
||||
toggleStoriesView: action('toggleStoriesView'),
|
||||
});
|
||||
|
||||
const Template: Story<PropsType> = args => <Stories {...args} />;
|
||||
|
||||
export const Blank = Template.bind({});
|
||||
Blank.args = {
|
||||
...getDefaultProps(),
|
||||
stories: [],
|
||||
};
|
||||
Blank.args = {};
|
||||
|
||||
export const Many = Template.bind({});
|
||||
Many.args = getDefaultProps();
|
||||
Many.args = {
|
||||
stories: [
|
||||
getFakeStory({
|
||||
attachmentUrl: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
timestamp: Date.now() - 2 * durations.MINUTE,
|
||||
}),
|
||||
getFakeStory({
|
||||
attachmentUrl: '/fixtures/koushik-chowdavarapu-105425-unsplash.jpg',
|
||||
timestamp: Date.now() - 5 * durations.MINUTE,
|
||||
}),
|
||||
getFakeStory({
|
||||
attachmentUrl: '/fixtures/nathan-anderson-316188-unsplash.jpg',
|
||||
group: getDefaultConversation({ title: 'BBQ in the park' }),
|
||||
timestamp: Date.now() - 65 * durations.MINUTE,
|
||||
}),
|
||||
getFakeStory({
|
||||
attachmentUrl: '/fixtures/snow.jpg',
|
||||
timestamp: Date.now() - 92 * durations.MINUTE,
|
||||
}),
|
||||
getFakeStory({
|
||||
attachmentUrl: '/fixtures/kitten-1-64-64.jpg',
|
||||
timestamp: Date.now() - 164 * durations.MINUTE,
|
||||
}),
|
||||
getFakeStory({
|
||||
attachmentUrl: '/fixtures/kitten-2-64-64.jpg',
|
||||
group: getDefaultConversation({ title: 'Breaking Signal for Science' }),
|
||||
timestamp: Date.now() - 380 * durations.MINUTE,
|
||||
}),
|
||||
getFakeStory({
|
||||
attachmentUrl: '/fixtures/kitten-3-64-64.jpg',
|
||||
timestamp: Date.now() - 421 * durations.MINUTE,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export const HiddenStories = Template.bind({});
|
||||
HiddenStories.args = {
|
||||
hiddenStories: [
|
||||
getFakeStory({
|
||||
attachmentUrl: '/fixtures/kitten-1-64-64.jpg',
|
||||
timestamp: Date.now() - 164 * durations.MINUTE,
|
||||
}),
|
||||
getFakeStory({
|
||||
attachmentUrl: '/fixtures/kitten-2-64-64.jpg',
|
||||
group: getDefaultConversation({ title: 'Breaking Signal for Science' }),
|
||||
timestamp: Date.now() - 380 * durations.MINUTE,
|
||||
}),
|
||||
getFakeStory({
|
||||
attachmentUrl: '/fixtures/kitten-3-64-64.jpg',
|
||||
timestamp: Date.now() - 421 * durations.MINUTE,
|
||||
}),
|
||||
],
|
||||
stories: [
|
||||
getFakeStory({
|
||||
attachmentUrl: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
timestamp: Date.now() - 2 * durations.MINUTE,
|
||||
}),
|
||||
getFakeStory({
|
||||
attachmentUrl: '/fixtures/snow.jpg',
|
||||
timestamp: Date.now() - 92 * durations.MINUTE,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export const MyStories = Template.bind({});
|
||||
MyStories.args = {
|
||||
myStories: [
|
||||
getFakeMyStory(undefined, 'BFF'),
|
||||
getFakeMyStory(undefined, 'The Fun Group'),
|
||||
],
|
||||
hiddenStories: [
|
||||
getFakeStory({
|
||||
attachmentUrl: '/fixtures/kitten-1-64-64.jpg',
|
||||
timestamp: Date.now() - 164 * durations.MINUTE,
|
||||
}),
|
||||
getFakeStory({
|
||||
attachmentUrl: '/fixtures/kitten-2-64-64.jpg',
|
||||
group: getDefaultConversation({ title: 'Breaking Signal for Science' }),
|
||||
timestamp: Date.now() - 380 * durations.MINUTE,
|
||||
}),
|
||||
getFakeStory({
|
||||
attachmentUrl: '/fixtures/kitten-3-64-64.jpg',
|
||||
timestamp: Date.now() - 421 * durations.MINUTE,
|
||||
}),
|
||||
],
|
||||
stories: [
|
||||
getFakeStory({
|
||||
attachmentUrl: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
timestamp: Date.now() - 2 * durations.MINUTE,
|
||||
}),
|
||||
getFakeStory({
|
||||
attachmentUrl: '/fixtures/snow.jpg',
|
||||
timestamp: Date.now() - 92 * durations.MINUTE,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
|
|
@ -4,19 +4,33 @@
|
|||
import FocusTrap from 'focus-trap-react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import type { ConversationStoryType } from './StoryListItem';
|
||||
import type {
|
||||
ConversationType,
|
||||
ShowConversationType,
|
||||
} from '../state/ducks/conversations';
|
||||
import type {
|
||||
ConversationStoryType,
|
||||
MyStoryType,
|
||||
StoryViewType,
|
||||
} 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 type { ShowConversationType } from '../state/ducks/conversations';
|
||||
import * as log from '../logging/log';
|
||||
import { MyStories } from './MyStories';
|
||||
import { StoriesPane } from './StoriesPane';
|
||||
import { Theme, themeClassName } from '../util/theme';
|
||||
import { getWidthFromPreferredWidth } from '../util/leftPaneWidth';
|
||||
import * as log from '../logging/log';
|
||||
|
||||
export type PropsType = {
|
||||
deleteStoryForEveryone: (story: StoryViewType) => unknown;
|
||||
hiddenStories: Array<ConversationStoryType>;
|
||||
i18n: LocalizerType;
|
||||
me: ConversationType;
|
||||
myStories: Array<MyStoryType>;
|
||||
onForwardStory: (storyId: string) => unknown;
|
||||
onSaveStory: (story: StoryViewType) => unknown;
|
||||
ourConversationId: string;
|
||||
preferredWidthFromStorage: number;
|
||||
queueStoryDownload: (storyId: string) => unknown;
|
||||
renderStoryCreator: (props: SmartStoryCreatorPropsType) => JSX.Element;
|
||||
|
@ -28,8 +42,14 @@ export type PropsType = {
|
|||
};
|
||||
|
||||
export const Stories = ({
|
||||
deleteStoryForEveryone,
|
||||
hiddenStories,
|
||||
i18n,
|
||||
me,
|
||||
myStories,
|
||||
onForwardStory,
|
||||
onSaveStory,
|
||||
ourConversationId,
|
||||
preferredWidthFromStorage,
|
||||
queueStoryDownload,
|
||||
renderStoryCreator,
|
||||
|
@ -100,6 +120,7 @@ export const Stories = ({
|
|||
}, [conversationIdToView, stories]);
|
||||
|
||||
const [isShowingStoryCreator, setIsShowingStoryCreator] = useState(false);
|
||||
const [isMyStories, setIsMyStories] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={classNames('Stories', themeClassName(Theme.Dark))}>
|
||||
|
@ -116,26 +137,49 @@ export const Stories = ({
|
|||
})}
|
||||
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
|
||||
<div className="Stories__pane" style={{ width }}>
|
||||
<StoriesPane
|
||||
hiddenStories={hiddenStories}
|
||||
i18n={i18n}
|
||||
onAddStory={() => setIsShowingStoryCreator(true)}
|
||||
onStoryClicked={clickedIdToView => {
|
||||
const storyIndex = stories.findIndex(
|
||||
x => x.conversationId === clickedIdToView
|
||||
);
|
||||
log.info('stories.onStoryClicked', {
|
||||
storyIndex,
|
||||
length: stories.length,
|
||||
});
|
||||
setConversationIdToView(clickedIdToView);
|
||||
}}
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
showConversation={showConversation}
|
||||
stories={stories}
|
||||
toggleHideStories={toggleHideStories}
|
||||
toggleStoriesView={toggleStoriesView}
|
||||
/>
|
||||
{isMyStories && myStories.length ? (
|
||||
<MyStories
|
||||
i18n={i18n}
|
||||
myStories={myStories}
|
||||
onBack={() => setIsMyStories(false)}
|
||||
onDelete={deleteStoryForEveryone}
|
||||
onForward={onForwardStory}
|
||||
onSave={onSaveStory}
|
||||
ourConversationId={ourConversationId}
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
renderStoryViewer={renderStoryViewer}
|
||||
/>
|
||||
) : (
|
||||
<StoriesPane
|
||||
hiddenStories={hiddenStories}
|
||||
i18n={i18n}
|
||||
me={me}
|
||||
myStories={myStories}
|
||||
onAddStory={() => setIsShowingStoryCreator(true)}
|
||||
onMyStoriesClicked={() => {
|
||||
if (myStories.length) {
|
||||
setIsMyStories(true);
|
||||
} else {
|
||||
setIsShowingStoryCreator(true);
|
||||
}
|
||||
}}
|
||||
onStoryClicked={clickedIdToView => {
|
||||
const storyIndex = stories.findIndex(
|
||||
x => x.conversationId === clickedIdToView
|
||||
);
|
||||
log.info('stories.onStoryClicked[StoriesPane]', {
|
||||
storyIndex,
|
||||
length: stories.length,
|
||||
});
|
||||
setConversationIdToView(clickedIdToView);
|
||||
}}
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
showConversation={showConversation}
|
||||
stories={stories}
|
||||
toggleHideStories={toggleHideStories}
|
||||
toggleStoriesView={toggleStoriesView}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FocusTrap>
|
||||
<div className="Stories__placeholder">
|
||||
|
|
|
@ -5,9 +5,17 @@ import Fuse from 'fuse.js';
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { ConversationStoryType, StoryViewType } from './StoryListItem';
|
||||
import type {
|
||||
ConversationType,
|
||||
ShowConversationType,
|
||||
} from '../state/ducks/conversations';
|
||||
import type {
|
||||
ConversationStoryType,
|
||||
MyStoryType,
|
||||
StoryViewType,
|
||||
} from '../types/Stories';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { ShowConversationType } from '../state/ducks/conversations';
|
||||
import { MyStoriesButton } from './MyStoriesButton';
|
||||
import { SearchInput } from './SearchInput';
|
||||
import { StoryListItem } from './StoryListItem';
|
||||
import { isNotNil } from '../util/isNotNil';
|
||||
|
@ -47,14 +55,19 @@ function search(
|
|||
.map(result => result.item);
|
||||
}
|
||||
|
||||
function getNewestStory(story: ConversationStoryType): StoryViewType {
|
||||
function getNewestStory(
|
||||
story: ConversationStoryType | MyStoryType
|
||||
): StoryViewType {
|
||||
return story.stories[story.stories.length - 1];
|
||||
}
|
||||
|
||||
export type PropsType = {
|
||||
hiddenStories: Array<ConversationStoryType>;
|
||||
i18n: LocalizerType;
|
||||
me: ConversationType;
|
||||
myStories: Array<MyStoryType>;
|
||||
onAddStory: () => unknown;
|
||||
onMyStoriesClicked: () => unknown;
|
||||
onStoryClicked: (conversationId: string) => unknown;
|
||||
queueStoryDownload: (storyId: string) => unknown;
|
||||
showConversation: ShowConversationType;
|
||||
|
@ -66,7 +79,10 @@ export type PropsType = {
|
|||
export const StoriesPane = ({
|
||||
hiddenStories,
|
||||
i18n,
|
||||
me,
|
||||
myStories,
|
||||
onAddStory,
|
||||
onMyStoriesClicked,
|
||||
onStoryClicked,
|
||||
queueStoryDownload,
|
||||
showConversation,
|
||||
|
@ -116,6 +132,16 @@ export const StoriesPane = ({
|
|||
placeholder={i18n('search')}
|
||||
value={searchTerm}
|
||||
/>
|
||||
<MyStoriesButton
|
||||
hasMultiple={myStories.length ? myStories[0].stories.length > 1 : false}
|
||||
i18n={i18n}
|
||||
me={me}
|
||||
newestStory={
|
||||
myStories.length ? getNewestStory(myStories[0]) : undefined
|
||||
}
|
||||
onClick={onMyStoriesClicked}
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
/>
|
||||
<div
|
||||
className={classNames('Stories__pane__list', {
|
||||
'Stories__pane__list--empty': !stories.length,
|
||||
|
|
|
@ -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 './StoryListItem';
|
||||
import { StoryListItem } from './StoryListItem';
|
||||
|
@ -18,72 +18,41 @@ const i18n = setupI18n('en', enMessages);
|
|||
|
||||
export default {
|
||||
title: 'Components/StoryListItem',
|
||||
};
|
||||
|
||||
function getDefaultProps(): PropsType {
|
||||
return {
|
||||
i18n,
|
||||
onClick: action('onClick'),
|
||||
onGoToConversation: action('onGoToConversation'),
|
||||
onHideStory: action('onHideStory'),
|
||||
queueStoryDownload: action('queueStoryDownload'),
|
||||
story: {
|
||||
messageId: '123',
|
||||
sender: getDefaultConversation(),
|
||||
timestamp: Date.now(),
|
||||
component: StoryListItem,
|
||||
argTypes: {
|
||||
i18n: {
|
||||
defaultValue: i18n,
|
||||
},
|
||||
};
|
||||
}
|
||||
onClick: { action: true },
|
||||
onGoToConversation: { action: true },
|
||||
onHideStory: { action: true },
|
||||
queueStoryDownload: { action: true },
|
||||
story: {
|
||||
defaultValue: {
|
||||
messageId: '123',
|
||||
sender: getDefaultConversation(),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
export const MyStory = (): JSX.Element => (
|
||||
<StoryListItem
|
||||
{...getDefaultProps()}
|
||||
story={{
|
||||
messageId: '123',
|
||||
sender: getDefaultConversation({ isMe: true }),
|
||||
timestamp: Date.now(),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const Template: Story<PropsType> = args => <StoryListItem {...args} />;
|
||||
|
||||
export const MyStoryMany = (): JSX.Element => (
|
||||
<StoryListItem
|
||||
{...getDefaultProps()}
|
||||
story={{
|
||||
attachment: fakeAttachment({
|
||||
thumbnail: fakeThumbnail(
|
||||
'/fixtures/nathan-anderson-316188-unsplash.jpg'
|
||||
),
|
||||
}),
|
||||
messageId: '123',
|
||||
sender: getDefaultConversation({ isMe: true }),
|
||||
timestamp: Date.now(),
|
||||
}}
|
||||
hasMultiple
|
||||
/>
|
||||
);
|
||||
|
||||
MyStoryMany.story = {
|
||||
name: 'My Story (many)',
|
||||
export const SomeonesStory = Template.bind({});
|
||||
SomeonesStory.args = {
|
||||
group: getDefaultConversation({ title: 'Sports Group' }),
|
||||
story: {
|
||||
attachment: fakeAttachment({
|
||||
thumbnail: fakeThumbnail('/fixtures/tina-rolf-269345-unsplash.jpg'),
|
||||
}),
|
||||
hasReplies: true,
|
||||
isUnread: true,
|
||||
messageId: '123',
|
||||
sender: getDefaultConversation(),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
export const SomeonesStory = (): JSX.Element => (
|
||||
<StoryListItem
|
||||
{...getDefaultProps()}
|
||||
group={getDefaultConversation({ title: 'Sports Group' })}
|
||||
story={{
|
||||
attachment: fakeAttachment({
|
||||
thumbnail: fakeThumbnail('/fixtures/tina-rolf-269345-unsplash.jpg'),
|
||||
}),
|
||||
hasReplies: true,
|
||||
isUnread: true,
|
||||
messageId: '123',
|
||||
sender: getDefaultConversation(),
|
||||
timestamp: Date.now(),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
SomeonesStory.story = {
|
||||
name: "Someone's story",
|
||||
};
|
||||
|
|
|
@ -3,9 +3,8 @@
|
|||
|
||||
import React, { useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import type { AttachmentType } from '../types/Attachment';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { ConversationStoryType, StoryViewType } from '../types/Stories';
|
||||
import { Avatar, AvatarSize, AvatarStoryRing } from './Avatar';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
import { ContextMenuPopper } from './ContextMenu';
|
||||
|
@ -13,53 +12,7 @@ import { MessageTimestamp } from './conversation/MessageTimestamp';
|
|||
import { StoryImage } from './StoryImage';
|
||||
import { getAvatarColor } from '../types/Colors';
|
||||
|
||||
export type ConversationStoryType = {
|
||||
conversationId: string;
|
||||
group?: Pick<
|
||||
ConversationType,
|
||||
| 'acceptedMessageRequest'
|
||||
| 'avatarPath'
|
||||
| 'color'
|
||||
| 'id'
|
||||
| 'name'
|
||||
| 'profileName'
|
||||
| 'sharedGroupNames'
|
||||
| 'title'
|
||||
>;
|
||||
hasMultiple?: boolean;
|
||||
isHidden?: boolean;
|
||||
searchNames?: string; // This is just here to satisfy Fuse's types
|
||||
stories: Array<StoryViewType>;
|
||||
};
|
||||
|
||||
export type StoryViewType = {
|
||||
attachment?: AttachmentType;
|
||||
canReply?: boolean;
|
||||
hasReplies?: boolean;
|
||||
hasRepliesFromSelf?: boolean;
|
||||
isHidden?: boolean;
|
||||
isUnread?: boolean;
|
||||
messageId: string;
|
||||
sender: Pick<
|
||||
ConversationType,
|
||||
| 'acceptedMessageRequest'
|
||||
| 'avatarPath'
|
||||
| 'color'
|
||||
| 'firstName'
|
||||
| 'id'
|
||||
| 'isMe'
|
||||
| 'name'
|
||||
| 'profileName'
|
||||
| 'sharedGroupNames'
|
||||
| 'title'
|
||||
>;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type PropsType = Pick<
|
||||
ConversationStoryType,
|
||||
'group' | 'hasMultiple' | 'isHidden'
|
||||
> & {
|
||||
export type PropsType = Pick<ConversationStoryType, 'group' | 'isHidden'> & {
|
||||
i18n: LocalizerType;
|
||||
onClick: () => unknown;
|
||||
onGoToConversation: (conversationId: string) => unknown;
|
||||
|
@ -70,7 +23,6 @@ export type PropsType = Pick<
|
|||
|
||||
export const StoryListItem = ({
|
||||
group,
|
||||
hasMultiple,
|
||||
i18n,
|
||||
isHidden,
|
||||
onClick,
|
||||
|
@ -129,9 +81,7 @@ export const StoryListItem = ({
|
|||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (!isMe) {
|
||||
setIsShowingContextMenu(true);
|
||||
}
|
||||
setIsShowingContextMenu(true);
|
||||
}}
|
||||
ref={setReferenceElement}
|
||||
tabIndex={0}
|
||||
|
@ -153,49 +103,25 @@ export const StoryListItem = ({
|
|||
title={title}
|
||||
/>
|
||||
<div className="StoryListItem__info">
|
||||
{isMe ? (
|
||||
<>
|
||||
<div className="StoryListItem__info--title">
|
||||
{i18n('Stories__mine')}
|
||||
</div>
|
||||
{!attachment && (
|
||||
<div className="StoryListItem__info--timestamp">
|
||||
{i18n('Stories__add')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="StoryListItem__info--title">
|
||||
{group
|
||||
? i18n('Stories__from-to-group', {
|
||||
name: title,
|
||||
group: group.title,
|
||||
})
|
||||
: title}
|
||||
</div>
|
||||
<MessageTimestamp
|
||||
i18n={i18n}
|
||||
module="StoryListItem__info--timestamp"
|
||||
timestamp={timestamp}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
<div className="StoryListItem__info--title">
|
||||
{group
|
||||
? i18n('Stories__from-to-group', {
|
||||
name: title,
|
||||
group: group.title,
|
||||
})
|
||||
: title}
|
||||
</div>
|
||||
<MessageTimestamp
|
||||
i18n={i18n}
|
||||
module="StoryListItem__info--timestamp"
|
||||
timestamp={timestamp}
|
||||
/>
|
||||
</>
|
||||
{repliesElement}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames('StoryListItem__previews', {
|
||||
'StoryListItem__previews--multiple': hasMultiple,
|
||||
})}
|
||||
>
|
||||
{!attachment && isMe && (
|
||||
<div
|
||||
aria-label={i18n('Stories__add')}
|
||||
className="StoryListItem__previews--add StoryListItem__previews--image"
|
||||
/>
|
||||
)}
|
||||
{hasMultiple && <div className="StoryListItem__previews--more" />}
|
||||
<div className="StoryListItem__previews">
|
||||
<StoryImage
|
||||
attachment={attachment}
|
||||
i18n={i18n}
|
||||
|
|
|
@ -16,14 +16,14 @@ import type { ConversationType } from '../state/ducks/conversations';
|
|||
import type { EmojiPickDataType } from './emoji/EmojiPicker';
|
||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||
import type { RenderEmojiPickerProps } from './conversation/ReactionPicker';
|
||||
import type { ReplyStateType } from '../types/Stories';
|
||||
import type { StoryViewType } from './StoryListItem';
|
||||
import type { ReplyStateType, StoryViewType } from '../types/Stories';
|
||||
import { AnimatedEmojiGalore } from './AnimatedEmojiGalore';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
import { ContextMenuPopper } from './ContextMenu';
|
||||
import { Intl } from './Intl';
|
||||
import { MessageTimestamp } from './conversation/MessageTimestamp';
|
||||
import { SendStatus } from '../messages/MessageSendState';
|
||||
import { StoryImage } from './StoryImage';
|
||||
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
|
||||
import { Theme } from '../util/theme';
|
||||
|
@ -56,8 +56,8 @@ export type PropsType = {
|
|||
onClose: () => unknown;
|
||||
onGoToConversation: (conversationId: string) => unknown;
|
||||
onHideStory: (conversationId: string) => unknown;
|
||||
onNextUserStories: () => unknown;
|
||||
onPrevUserStories: () => unknown;
|
||||
onNextUserStories?: () => unknown;
|
||||
onPrevUserStories?: () => unknown;
|
||||
onSetSkinTone: (tone: number) => unknown;
|
||||
onTextTooLong: () => unknown;
|
||||
onReactToStory: (emoji: string, story: StoryViewType) => unknown;
|
||||
|
@ -76,7 +76,6 @@ export type PropsType = {
|
|||
skinTone?: number;
|
||||
stories: Array<StoryViewType>;
|
||||
toggleHasAllStoriesMuted: () => unknown;
|
||||
views?: Array<string>;
|
||||
};
|
||||
|
||||
const CAPTION_BUFFER = 20;
|
||||
|
@ -116,7 +115,6 @@ export const StoryViewer = ({
|
|||
skinTone,
|
||||
stories,
|
||||
toggleHasAllStoriesMuted,
|
||||
views,
|
||||
}: PropsType): JSX.Element => {
|
||||
const [currentStoryIndex, setCurrentStoryIndex] = useState(0);
|
||||
const [storyDuration, setStoryDuration] = useState<number | undefined>();
|
||||
|
@ -128,7 +126,8 @@ export const StoryViewer = ({
|
|||
|
||||
const visibleStory = stories[currentStoryIndex];
|
||||
|
||||
const { attachment, canReply, isHidden, messageId, timestamp } = visibleStory;
|
||||
const { attachment, canReply, isHidden, messageId, sendState, timestamp } =
|
||||
visibleStory;
|
||||
const {
|
||||
acceptedMessageRequest,
|
||||
avatarPath,
|
||||
|
@ -202,7 +201,7 @@ export const StoryViewer = ({
|
|||
setCurrentStoryIndex(currentStoryIndex + 1);
|
||||
} else {
|
||||
setCurrentStoryIndex(0);
|
||||
onNextUserStories();
|
||||
onNextUserStories?.();
|
||||
}
|
||||
}, [currentStoryIndex, onNextUserStories, stories.length]);
|
||||
|
||||
|
@ -210,7 +209,7 @@ export const StoryViewer = ({
|
|||
// for the prior user's stories.
|
||||
const showPrevStory = useCallback(() => {
|
||||
if (currentStoryIndex === 0) {
|
||||
onPrevUserStories();
|
||||
onPrevUserStories?.();
|
||||
} else {
|
||||
setCurrentStoryIndex(currentStoryIndex - 1);
|
||||
}
|
||||
|
@ -378,9 +377,13 @@ export const StoryViewer = ({
|
|||
|
||||
const replies =
|
||||
replyState && replyState.messageId === messageId ? replyState.replies : [];
|
||||
|
||||
const viewCount = (views || []).length;
|
||||
const views = sendState
|
||||
? sendState.filter(({ status }) => status === SendStatus.Viewed)
|
||||
: [];
|
||||
const replyCount = replies.length;
|
||||
const viewCount = views.length;
|
||||
|
||||
const shouldShowContextMenu = !sendState;
|
||||
|
||||
return (
|
||||
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
|
||||
|
@ -390,18 +393,20 @@ export const StoryViewer = ({
|
|||
style={{ background: getStoryBackground(attachment) }}
|
||||
/>
|
||||
<div className="StoryViewer__content">
|
||||
<button
|
||||
aria-label={i18n('back')}
|
||||
className={classNames(
|
||||
'StoryViewer__arrow StoryViewer__arrow--left',
|
||||
{
|
||||
'StoryViewer__arrow--visible': arrowToShow === Arrow.Left,
|
||||
}
|
||||
)}
|
||||
onClick={showPrevStory}
|
||||
onMouseMove={() => setArrowToShow(Arrow.Left)}
|
||||
type="button"
|
||||
/>
|
||||
{onPrevUserStories && (
|
||||
<button
|
||||
aria-label={i18n('back')}
|
||||
className={classNames(
|
||||
'StoryViewer__arrow StoryViewer__arrow--left',
|
||||
{
|
||||
'StoryViewer__arrow--visible': arrowToShow === Arrow.Left,
|
||||
}
|
||||
)}
|
||||
onClick={showPrevStory}
|
||||
onMouseMove={() => setArrowToShow(Arrow.Left)}
|
||||
type="button"
|
||||
/>
|
||||
)}
|
||||
<div className="StoryViewer__protection StoryViewer__protection--top" />
|
||||
<div className="StoryViewer__container">
|
||||
<StoryImage
|
||||
|
@ -532,13 +537,15 @@ export const StoryViewer = ({
|
|||
onClick={toggleHasAllStoriesMuted}
|
||||
type="button"
|
||||
/>
|
||||
<button
|
||||
aria-label={i18n('MyStories__more')}
|
||||
className="StoryViewer__more"
|
||||
onClick={() => setIsShowingContextMenu(true)}
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
/>
|
||||
{shouldShowContextMenu && (
|
||||
<button
|
||||
aria-label={i18n('MyStories__more')}
|
||||
className="StoryViewer__more"
|
||||
onClick={() => setIsShowingContextMenu(true)}
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="StoryViewer__progress">
|
||||
|
@ -619,18 +626,20 @@ export const StoryViewer = ({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
aria-label={i18n('forward')}
|
||||
className={classNames(
|
||||
'StoryViewer__arrow StoryViewer__arrow--right',
|
||||
{
|
||||
'StoryViewer__arrow--visible': arrowToShow === Arrow.Right,
|
||||
}
|
||||
)}
|
||||
onClick={showNextStory}
|
||||
onMouseMove={() => setArrowToShow(Arrow.Right)}
|
||||
type="button"
|
||||
/>
|
||||
{onNextUserStories && (
|
||||
<button
|
||||
aria-label={i18n('forward')}
|
||||
className={classNames(
|
||||
'StoryViewer__arrow StoryViewer__arrow--right',
|
||||
{
|
||||
'StoryViewer__arrow--visible': arrowToShow === Arrow.Right,
|
||||
}
|
||||
)}
|
||||
onClick={showNextStory}
|
||||
onMouseMove={() => setArrowToShow(Arrow.Right)}
|
||||
type="button"
|
||||
/>
|
||||
)}
|
||||
<div className="StoryViewer__protection StoryViewer__protection--bottom" />
|
||||
<button
|
||||
aria-label={i18n('close')}
|
||||
|
@ -696,7 +705,7 @@ export const StoryViewer = ({
|
|||
replies={replies}
|
||||
skinTone={skinTone}
|
||||
storyPreviewAttachment={attachment}
|
||||
views={[]}
|
||||
views={views}
|
||||
/>
|
||||
)}
|
||||
{hasConfirmHideStory && (
|
||||
|
|
|
@ -8,6 +8,7 @@ import type { PropsType } from './StoryViewsNRepliesModal';
|
|||
import * as durations from '../util/durations';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { IMAGE_JPEG } from '../types/MIME';
|
||||
import { SendStatus } from '../messages/MessageSendState';
|
||||
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
|
||||
import { fakeAttachment } from '../test-both/helpers/fakeAttachment';
|
||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||
|
@ -56,24 +57,29 @@ function getViewsAndReplies() {
|
|||
|
||||
const views = [
|
||||
{
|
||||
...p1,
|
||||
timestamp: Date.now() - 20 * durations.MINUTE,
|
||||
recipient: p1,
|
||||
status: SendStatus.Viewed,
|
||||
updatedAt: Date.now() - 20 * durations.MINUTE,
|
||||
},
|
||||
{
|
||||
...p2,
|
||||
timestamp: Date.now() - 25 * durations.MINUTE,
|
||||
recipient: p2,
|
||||
status: SendStatus.Viewed,
|
||||
updatedAt: Date.now() - 25 * durations.MINUTE,
|
||||
},
|
||||
{
|
||||
...p3,
|
||||
timestamp: Date.now() - 15 * durations.MINUTE,
|
||||
recipient: p3,
|
||||
status: SendStatus.Viewed,
|
||||
updatedAt: Date.now() - 15 * durations.MINUTE,
|
||||
},
|
||||
{
|
||||
...p4,
|
||||
timestamp: Date.now() - 5 * durations.MINUTE,
|
||||
recipient: p4,
|
||||
status: SendStatus.Viewed,
|
||||
updatedAt: Date.now() - 5 * durations.MINUTE,
|
||||
},
|
||||
{
|
||||
...p5,
|
||||
timestamp: Date.now() - 30 * durations.MINUTE,
|
||||
recipient: p5,
|
||||
status: SendStatus.Viewed,
|
||||
updatedAt: Date.now() - 30 * durations.MINUTE,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
@ -6,13 +6,11 @@ import classNames from 'classnames';
|
|||
import { usePopper } from 'react-popper';
|
||||
import type { AttachmentType } from '../types/Attachment';
|
||||
import type { BodyRangeType, LocalizerType } from '../types/Util';
|
||||
import type { ContactNameColorType } from '../types/Colors';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { EmojiPickDataType } from './emoji/EmojiPicker';
|
||||
import type { InputApi } from './CompositionInput';
|
||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||
import type { RenderEmojiPickerProps } from './conversation/ReactionPicker';
|
||||
import type { ReplyType } from '../types/Stories';
|
||||
import type { ReplyType, StorySendStateType } from '../types/Stories';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { CompositionInput } from './CompositionInput';
|
||||
import { ContactName } from './conversation/ContactName';
|
||||
|
@ -29,21 +27,6 @@ import { ThemeType } from '../types/Util';
|
|||
import { getAvatarColor } from '../types/Colors';
|
||||
import { getStoryReplyText } from '../util/getStoryReplyText';
|
||||
|
||||
type ViewType = Pick<
|
||||
ConversationType,
|
||||
| 'acceptedMessageRequest'
|
||||
| 'avatarPath'
|
||||
| 'color'
|
||||
| 'isMe'
|
||||
| 'name'
|
||||
| 'profileName'
|
||||
| 'sharedGroupNames'
|
||||
| 'title'
|
||||
> & {
|
||||
contactNameColor?: ContactNameColorType;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
enum Tab {
|
||||
Replies = 'Replies',
|
||||
Views = 'Views',
|
||||
|
@ -71,7 +54,7 @@ export type PropsType = {
|
|||
replies: Array<ReplyType>;
|
||||
skinTone?: number;
|
||||
storyPreviewAttachment?: AttachmentType;
|
||||
views: Array<ViewType>;
|
||||
views: Array<StorySendStateType>;
|
||||
};
|
||||
|
||||
export const StoryViewsNRepliesModal = ({
|
||||
|
@ -328,34 +311,33 @@ export const StoryViewsNRepliesModal = ({
|
|||
const viewsElement = views.length ? (
|
||||
<div className="StoryViewsNRepliesModal__views">
|
||||
{views.map(view => (
|
||||
<div className="StoryViewsNRepliesModal__view" key={view.timestamp}>
|
||||
<div className="StoryViewsNRepliesModal__view" key={view.recipient.id}>
|
||||
<div>
|
||||
<Avatar
|
||||
acceptedMessageRequest={view.acceptedMessageRequest}
|
||||
avatarPath={view.avatarPath}
|
||||
acceptedMessageRequest={view.recipient.acceptedMessageRequest}
|
||||
avatarPath={view.recipient.avatarPath}
|
||||
badge={undefined}
|
||||
color={getAvatarColor(view.color)}
|
||||
color={getAvatarColor(view.recipient.color)}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe={Boolean(view.isMe)}
|
||||
name={view.name}
|
||||
profileName={view.profileName}
|
||||
sharedGroupNames={view.sharedGroupNames || []}
|
||||
isMe={Boolean(view.recipient.isMe)}
|
||||
name={view.recipient.name}
|
||||
profileName={view.recipient.profileName}
|
||||
sharedGroupNames={view.recipient.sharedGroupNames || []}
|
||||
size={AvatarSize.TWENTY_EIGHT}
|
||||
title={view.title}
|
||||
title={view.recipient.title}
|
||||
/>
|
||||
<span className="StoryViewsNRepliesModal__view--name">
|
||||
<ContactName
|
||||
contactNameColor={view.contactNameColor}
|
||||
title={view.title}
|
||||
/>
|
||||
<ContactName title={view.recipient.title} />
|
||||
</span>
|
||||
</div>
|
||||
<MessageTimestamp
|
||||
i18n={i18n}
|
||||
module="StoryViewsNRepliesModal__view--timestamp"
|
||||
timestamp={view.timestamp}
|
||||
/>
|
||||
{view.updatedAt && (
|
||||
<MessageTimestamp
|
||||
i18n={i18n}
|
||||
module="StoryViewsNRepliesModal__view--timestamp"
|
||||
timestamp={view.updatedAt}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue