Sync my stories with primary device

This commit is contained in:
Josh Perez 2022-06-30 20:52:03 -04:00 committed by GitHub
parent 7554d8326a
commit 9155784d56
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 2954 additions and 1238 deletions

View file

@ -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>
);

View file

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

View 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
View 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>
)}
</>
);
};

View 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;

View 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>
);
};

View file

@ -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,
}),
],
};

View file

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

View file

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

View file

@ -1,8 +1,8 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, Story } from '@storybook/react';
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { PropsType } from './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",
};

View file

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

View file

@ -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 && (

View file

@ -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,
},
];

View file

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