Add story entry points around the app

This commit is contained in:
Josh Perez 2022-07-21 20:44:35 -04:00 committed by GitHub
parent 1d5b361159
commit 5dfe30d235
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 367 additions and 533 deletions

View file

@ -119,6 +119,7 @@
margin-left: 4px; margin-left: 4px;
margin-right: var(--button-spacing); margin-right: var(--button-spacing);
padding: $padding; padding: $padding;
padding-left: 0;
@include keyboard-mode { @include keyboard-mode {
&:focus { &:focus {

View file

@ -3,20 +3,20 @@
import type { Meta, Story } from '@storybook/react'; import type { Meta, Story } from '@storybook/react';
import * as React from 'react'; import * as React from 'react';
import { isBoolean } from 'lodash'; import { action } from '@storybook/addon-actions';
import { expect } from '@storybook/jest'; import { expect } from '@storybook/jest';
import { isBoolean } from 'lodash';
import { within, userEvent } from '@storybook/testing-library'; import { within, userEvent } from '@storybook/testing-library';
import { action } from '@storybook/addon-actions';
import type { Props } from './Avatar';
import { Avatar, AvatarBlur, AvatarSize, AvatarStoryRing } from './Avatar';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
import type { AvatarColorType } from '../types/Colors'; import type { AvatarColorType } from '../types/Colors';
import type { Props } from './Avatar';
import enMessages from '../../_locales/en/messages.json';
import { Avatar, AvatarBlur, AvatarSize } from './Avatar';
import { AvatarColors } from '../types/Colors'; import { AvatarColors } from '../types/Colors';
import { getFakeBadge } from '../test-both/helpers/getFakeBadge'; import { HasStories } from '../types/Stories';
import { ThemeType } from '../types/Util'; import { ThemeType } from '../types/Util';
import { getFakeBadge } from '../test-both/helpers/getFakeBadge';
import { setupI18n } from '../util/setupI18n';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
@ -63,7 +63,7 @@ export default {
}, },
storyRing: { storyRing: {
control: { type: 'radio' }, control: { type: 'radio' },
options: [undefined, ...Object.values(AvatarStoryRing)], options: [undefined, ...Object.values(HasStories)],
}, },
theme: { theme: {
control: { type: 'radio' }, control: { type: 'radio' },
@ -263,7 +263,7 @@ BlurredWithClickToView.story = {
export const StoryUnread = TemplateSingle.bind({}); export const StoryUnread = TemplateSingle.bind({});
StoryUnread.args = createProps({ StoryUnread.args = createProps({
avatarPath: '/fixtures/kitten-3-64-64.jpg', avatarPath: '/fixtures/kitten-3-64-64.jpg',
storyRing: AvatarStoryRing.Unread, storyRing: HasStories.Unread,
}); });
StoryUnread.story = { StoryUnread.story = {
name: 'Story: unread', name: 'Story: unread',
@ -272,7 +272,7 @@ StoryUnread.story = {
export const StoryRead = TemplateSingle.bind({}); export const StoryRead = TemplateSingle.bind({});
StoryRead.args = createProps({ StoryRead.args = createProps({
avatarPath: '/fixtures/kitten-3-64-64.jpg', avatarPath: '/fixtures/kitten-3-64-64.jpg',
storyRing: AvatarStoryRing.Read, storyRing: HasStories.Read,
}); });
StoryRead.story = { StoryRead.story = {
name: 'Story: read', name: 'Story: read',

View file

@ -12,19 +12,19 @@ import React, { useEffect, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { noop } from 'lodash'; import { noop } from 'lodash';
import { Spinner } from './Spinner';
import { getInitials } from '../util/getInitials';
import type { LocalizerType } from '../types/Util';
import { ThemeType } from '../types/Util';
import type { AvatarColorType } from '../types/Colors'; import type { AvatarColorType } from '../types/Colors';
import type { BadgeType } from '../badges/types'; import type { BadgeType } from '../badges/types';
import type { LocalizerType } from '../types/Util';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { assert } from '../util/assert';
import { shouldBlurAvatar } from '../util/shouldBlurAvatar';
import { getBadgeImageFileLocalPath } from '../badges/getBadgeImageFileLocalPath';
import { isBadgeVisible } from '../badges/isBadgeVisible';
import { BadgeImageTheme } from '../badges/BadgeImageTheme'; import { BadgeImageTheme } from '../badges/BadgeImageTheme';
import { HasStories } from '../types/Stories';
import { Spinner } from './Spinner';
import { ThemeType } from '../types/Util';
import { assert } from '../util/assert';
import { getBadgeImageFileLocalPath } from '../badges/getBadgeImageFileLocalPath';
import { getInitials } from '../util/getInitials';
import { isBadgeVisible } from '../badges/isBadgeVisible';
import { shouldBlurAvatar } from '../util/shouldBlurAvatar';
import { shouldShowBadges } from '../badges/shouldShowBadges'; import { shouldShowBadges } from '../badges/shouldShowBadges';
export enum AvatarBlur { export enum AvatarBlur {
@ -45,11 +45,6 @@ export enum AvatarSize {
ONE_HUNDRED_TWELVE = 112, ONE_HUNDRED_TWELVE = 112,
} }
export enum AvatarStoryRing {
Unread = 'Unread',
Read = 'Read',
}
type BadgePlacementType = { bottom: number; right: number }; type BadgePlacementType = { bottom: number; right: number };
export type Props = { export type Props = {
@ -70,7 +65,7 @@ export type Props = {
title: string; title: string;
unblurredAvatarPath?: string; unblurredAvatarPath?: string;
searchResult?: boolean; searchResult?: boolean;
storyRing?: AvatarStoryRing; storyRing?: HasStories;
onClick?: (event: MouseEvent<HTMLButtonElement>) => unknown; onClick?: (event: MouseEvent<HTMLButtonElement>) => unknown;
onClickBadge?: (event: MouseEvent<HTMLButtonElement>) => unknown; onClickBadge?: (event: MouseEvent<HTMLButtonElement>) => unknown;
@ -308,9 +303,8 @@ export const Avatar: FunctionComponent<Props> = ({
className={classNames( className={classNames(
'module-Avatar', 'module-Avatar',
hasImage ? 'module-Avatar--with-image' : 'module-Avatar--no-image', hasImage ? 'module-Avatar--with-image' : 'module-Avatar--no-image',
storyRing && 'module-Avatar--with-story', Boolean(storyRing) && 'module-Avatar--with-story',
storyRing === AvatarStoryRing.Unread && storyRing === HasStories.Unread && 'module-Avatar--with-story--unread',
'module-Avatar--with-story--unread',
className className
)} )}
style={{ style={{

View file

@ -5,9 +5,10 @@ import React, { useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import type { ConversationStoryType, StoryViewType } from '../types/Stories'; import type { ConversationStoryType, StoryViewType } from '../types/Stories';
import { Avatar, AvatarSize, AvatarStoryRing } from './Avatar'; import { Avatar, AvatarSize } from './Avatar';
import { ConfirmationDialog } from './ConfirmationDialog'; import { ConfirmationDialog } from './ConfirmationDialog';
import { ContextMenuPopper } from './ContextMenu'; import { ContextMenuPopper } from './ContextMenu';
import { HasStories } from '../types/Stories';
import { MessageTimestamp } from './conversation/MessageTimestamp'; import { MessageTimestamp } from './conversation/MessageTimestamp';
import { StoryImage } from './StoryImage'; import { StoryImage } from './StoryImage';
import { getAvatarColor } from '../types/Colors'; import { getAvatarColor } from '../types/Colors';
@ -57,9 +58,9 @@ export const StoryListItem = ({
title, title,
} = sender; } = sender;
let avatarStoryRing: AvatarStoryRing | undefined; let avatarStoryRing: HasStories | undefined;
if (attachment) { if (attachment) {
avatarStoryRing = isUnread ? AvatarStoryRing.Unread : AvatarStoryRing.Read; avatarStoryRing = isUnread ? HasStories.Unread : HasStories.Read;
} }
let repliesElement: JSX.Element | undefined; let repliesElement: JSX.Element | undefined;

View file

@ -1,147 +1,147 @@
// Copyright 2020-2021 Signal Messenger, LLC // Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, Story } from '@storybook/react';
import * as React from 'react'; import * as React from 'react';
import casual from 'casual';
import { action } from '@storybook/addon-actions';
import { boolean } from '@storybook/addon-knobs';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import type { PropsType } from './ContactModal';
import { ContactModal } from './ContactModal';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
import type { ConversationType } from '../../state/ducks/conversations'; import type { ConversationType } from '../../state/ducks/conversations';
import { getFakeBadges } from '../../test-both/helpers/getFakeBadge'; import type { PropsType } from './ContactModal';
import enMessages from '../../../_locales/en/messages.json';
import { ContactModal } from './ContactModal';
import { HasStories } from '../../types/Stories';
import { ThemeType } from '../../types/Util'; import { ThemeType } from '../../types/Util';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { getFakeBadges } from '../../test-both/helpers/getFakeBadge';
import { setupI18n } from '../../util/setupI18n';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/Conversation/ContactModal',
};
const defaultContact: ConversationType = getDefaultConversation({ const defaultContact: ConversationType = getDefaultConversation({
id: 'abcdef',
title: 'Pauline Oliveros',
phoneNumber: '(333) 444-5515',
about: '👍 Free to chat', about: '👍 Free to chat',
}); });
const defaultGroup: ConversationType = getDefaultConversation({ const defaultGroup: ConversationType = getDefaultConversation({
id: 'abcdef',
areWeAdmin: true, areWeAdmin: true,
title: "It's a group", groupLink: casual.url,
groupLink: 'something', title: casual.title,
type: 'group',
}); });
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({ export default {
areWeASubscriber: false, title: 'Components/Conversation/ContactModal',
areWeAdmin: boolean('areWeAdmin', overrideProps.areWeAdmin || false), component: ContactModal,
badges: overrideProps.badges || [], argTypes: {
contact: overrideProps.contact || defaultContact, i18n: {
conversation: overrideProps.conversation || defaultGroup, defaultValue: i18n,
hideContactModal: action('hideContactModal'), },
i18n, areWeASubscriber: {
isAdmin: boolean('isAdmin', overrideProps.isAdmin || false), defaultValue: false,
isMember: boolean('isMember', overrideProps.isMember || true), },
removeMemberFromGroup: action('removeMemberFromGroup'), areWeAdmin: {
showConversation: action('showConversation'), defaultValue: false,
theme: ThemeType.light, },
toggleSafetyNumberModal: action('toggleSafetyNumberModal'), badges: {
toggleAdmin: action('toggleAdmin'), defaultValue: [],
updateConversationModelSharedGroups: action( },
'updateConversationModelSharedGroups' contact: {
), defaultValue: defaultContact,
}); },
conversation: {
defaultValue: defaultGroup,
},
hasStories: {
defaultValue: undefined,
},
hideContactModal: { action: true },
isAdmin: {
defaultValue: false,
},
isMember: {
defaultValue: true,
},
removeMemberFromGroup: { action: true },
showConversation: { action: true },
theme: {
defaultValue: ThemeType.light,
},
toggleAdmin: { action: true },
toggleSafetyNumberModal: { action: true },
updateConversationModelSharedGroups: { action: true },
viewUserStories: { action: true },
},
} as Meta;
export const AsNonAdmin = (): JSX.Element => { const Template: Story<PropsType> = args => <ContactModal {...args} />;
const props = createProps({
areWeAdmin: false,
});
return <ContactModal {...props} />; export const AsNonAdmin = Template.bind({});
AsNonAdmin.args = {
areWeAdmin: false,
}; };
AsNonAdmin.story = { AsNonAdmin.story = {
name: 'As non-admin', name: 'As non-admin',
}; };
export const AsAdmin = (): JSX.Element => { export const AsAdmin = Template.bind({});
const props = createProps({ AsAdmin.args = {
areWeAdmin: true, areWeAdmin: true,
});
return <ContactModal {...props} />;
}; };
AsAdmin.story = { AsAdmin.story = {
name: 'As admin', name: 'As admin',
}; };
export const AsAdminWithNoGroupLink = (): JSX.Element => { export const AsAdminWithNoGroupLink = Template.bind({});
const props = createProps({ AsAdminWithNoGroupLink.args = {
areWeAdmin: true, areWeAdmin: true,
conversation: { conversation: {
...defaultGroup, ...defaultGroup,
groupLink: undefined, groupLink: undefined,
}, },
});
return <ContactModal {...props} />;
}; };
AsAdminWithNoGroupLink.story = { AsAdminWithNoGroupLink.story = {
name: 'As admin with no group link', name: 'As admin with no group link',
}; };
export const AsAdminViewingNonMemberOfGroup = (): JSX.Element => { export const AsAdminViewingNonMemberOfGroup = Template.bind({});
const props = createProps({ AsAdminViewingNonMemberOfGroup.args = {
isMember: false, isMember: false,
});
return <ContactModal {...props} />;
}; };
AsAdminViewingNonMemberOfGroup.story = { AsAdminViewingNonMemberOfGroup.story = {
name: 'As admin, viewing non-member of group', name: 'As admin, viewing non-member of group',
}; };
export const WithoutPhoneNumber = (): JSX.Element => { export const WithoutPhoneNumber = Template.bind({});
const props = createProps({ WithoutPhoneNumber.args = {
contact: { contact: {
...defaultContact, ...defaultContact,
phoneNumber: undefined, phoneNumber: undefined,
}, },
});
return <ContactModal {...props} />;
}; };
WithoutPhoneNumber.story = { WithoutPhoneNumber.story = {
name: 'Without phone number', name: 'Without phone number',
}; };
export const ViewingSelf = (): JSX.Element => { export const ViewingSelf = Template.bind({});
const props = createProps({ ViewingSelf.args = {
contact: { contact: {
...defaultContact, ...defaultContact,
isMe: true, isMe: true,
}, },
});
return <ContactModal {...props} />;
}; };
ViewingSelf.story = { ViewingSelf.story = {
name: 'Viewing self', name: 'Viewing self',
}; };
export const WithBadges = (): JSX.Element => { export const WithBadges = Template.bind({});
const props = createProps({ WithBadges.args = {
badges: getFakeBadges(2), badges: getFakeBadges(2),
});
return <ContactModal {...props} />;
}; };
WithBadges.story = { WithBadges.story = {
name: 'With badges', name: 'With badges',
}; };
export const WithUnreadStories = Template.bind({});
WithUnreadStories.args = {
hasStories: HasStories.Unread,
};
WithUnreadStories.storyName = 'Unread Stories';

View file

@ -1,25 +1,26 @@
// Copyright 2020-2021 Signal Messenger, LLC // Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import * as log from '../../logging/log';
import { missingCaseError } from '../../util/missingCaseError';
import { About } from './About';
import { Avatar } from '../Avatar';
import { AvatarLightbox } from '../AvatarLightbox';
import type { import type {
ConversationType, ConversationType,
ShowConversationType, ShowConversationType,
} from '../../state/ducks/conversations'; } from '../../state/ducks/conversations';
import { Modal } from '../Modal';
import type { LocalizerType, ThemeType } from '../../types/Util';
import { BadgeDialog } from '../BadgeDialog';
import type { BadgeType } from '../../badges/types'; import type { BadgeType } from '../../badges/types';
import { SharedGroupNames } from '../SharedGroupNames'; import type { HasStories } from '../../types/Stories';
import type { LocalizerType, ThemeType } from '../../types/Util';
import * as log from '../../logging/log';
import { About } from './About';
import { Avatar } from '../Avatar';
import { AvatarLightbox } from '../AvatarLightbox';
import { BadgeDialog } from '../BadgeDialog';
import { ConfirmationDialog } from '../ConfirmationDialog'; import { ConfirmationDialog } from '../ConfirmationDialog';
import { Modal } from '../Modal';
import { RemoveGroupMemberConfirmationDialog } from './RemoveGroupMemberConfirmationDialog'; import { RemoveGroupMemberConfirmationDialog } from './RemoveGroupMemberConfirmationDialog';
import { SharedGroupNames } from '../SharedGroupNames';
import { missingCaseError } from '../../util/missingCaseError';
export type PropsDataType = { export type PropsDataType = {
areWeASubscriber: boolean; areWeASubscriber: boolean;
@ -27,6 +28,7 @@ export type PropsDataType = {
badges: ReadonlyArray<BadgeType>; badges: ReadonlyArray<BadgeType>;
contact?: ConversationType; contact?: ConversationType;
conversation?: ConversationType; conversation?: ConversationType;
hasStories?: HasStories;
readonly i18n: LocalizerType; readonly i18n: LocalizerType;
isAdmin: boolean; isAdmin: boolean;
isMember: boolean; isMember: boolean;
@ -40,6 +42,7 @@ type PropsActionType = {
toggleAdmin: (conversationId: string, contactId: string) => void; toggleAdmin: (conversationId: string, contactId: string) => void;
toggleSafetyNumberModal: (conversationId: string) => unknown; toggleSafetyNumberModal: (conversationId: string) => unknown;
updateConversationModelSharedGroups: (conversationId: string) => void; updateConversationModelSharedGroups: (conversationId: string) => void;
viewUserStories: (cid: string) => unknown;
}; };
export type PropsType = PropsDataType & PropsActionType; export type PropsType = PropsDataType & PropsActionType;
@ -62,6 +65,7 @@ export const ContactModal = ({
badges, badges,
contact, contact,
conversation, conversation,
hasStories,
hideContactModal, hideContactModal,
i18n, i18n,
isAdmin, isAdmin,
@ -72,6 +76,7 @@ export const ContactModal = ({
toggleAdmin, toggleAdmin,
toggleSafetyNumberModal, toggleSafetyNumberModal,
updateConversationModelSharedGroups, updateConversationModelSharedGroups,
viewUserStories,
}: PropsType): JSX.Element => { }: PropsType): JSX.Element => {
if (!contact) { if (!contact) {
throw new Error('Contact modal opened without a matching contact'); throw new Error('Contact modal opened without a matching contact');
@ -172,14 +177,21 @@ export const ContactModal = ({
i18n={i18n} i18n={i18n}
isMe={contact.isMe} isMe={contact.isMe}
name={contact.name} name={contact.name}
onClick={() => {
if (conversation && hasStories) {
viewUserStories(conversation.id);
} else {
setView(ContactModalView.ShowingAvatar);
}
}}
onClickBadge={() => setView(ContactModalView.ShowingBadges)}
profileName={contact.profileName} profileName={contact.profileName}
sharedGroupNames={contact.sharedGroupNames} sharedGroupNames={contact.sharedGroupNames}
size={96} size={96}
storyRing={hasStories}
theme={theme} theme={theme}
title={contact.title} title={contact.title}
unblurredAvatarPath={contact.unblurredAvatarPath} unblurredAvatarPath={contact.unblurredAvatarPath}
onClick={() => setView(ContactModalView.ShowingAvatar)}
onClickBadge={() => setView(ContactModalView.ShowingBadges)}
/> />
<div className="ContactModal__name">{contact.title}</div> <div className="ContactModal__name">{contact.title}</div>
<div className="module-about__container"> <div className="module-about__container">

View file

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC // Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
@ -56,6 +56,7 @@ const commonProps = {
onMarkUnread: action('onMarkUnread'), onMarkUnread: action('onMarkUnread'),
onMoveToInbox: action('onMoveToInbox'), onMoveToInbox: action('onMoveToInbox'),
onSetPin: action('onSetPin'), onSetPin: action('onSetPin'),
viewUserStories: action('viewUserStories'),
}; };
export const PrivateConvo = (): JSX.Element => { export const PrivateConvo = (): JSX.Element => {

View file

@ -1,4 +1,4 @@
// Copyright 2018-2021 Signal Messenger, LLC // Copyright 2018-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
@ -20,6 +20,7 @@ import { InContactsIcon } from '../InContactsIcon';
import type { LocalizerType, ThemeType } from '../../types/Util'; import type { LocalizerType, ThemeType } from '../../types/Util';
import type { ConversationType } from '../../state/ducks/conversations'; import type { ConversationType } from '../../state/ducks/conversations';
import type { BadgeType } from '../../badges/types'; import type { BadgeType } from '../../badges/types';
import type { HasStories } from '../../types/Stories';
import { getMuteOptions } from '../../util/getMuteOptions'; import { getMuteOptions } from '../../util/getMuteOptions';
import * as expirationTimer from '../../util/expirationTimer'; import * as expirationTimer from '../../util/expirationTimer';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
@ -39,6 +40,7 @@ export enum OutgoingCallButtonStyle {
export type PropsDataType = { export type PropsDataType = {
badge?: BadgeType; badge?: BadgeType;
conversationTitle?: string; conversationTitle?: string;
hasStories?: HasStories;
isMissingMandatoryProfileSharing?: boolean; isMissingMandatoryProfileSharing?: boolean;
outgoingCallButtonStyle: OutgoingCallButtonStyle; outgoingCallButtonStyle: OutgoingCallButtonStyle;
showBackButton?: boolean; showBackButton?: boolean;
@ -88,6 +90,7 @@ export type PropsActionsType = {
onArchive: () => void; onArchive: () => void;
onMarkUnread: () => void; onMarkUnread: () => void;
onMoveToInbox: () => void; onMoveToInbox: () => void;
viewUserStories: (cid: string) => unknown;
}; };
export type PropsHousekeepingType = { export type PropsHousekeepingType = {
@ -199,6 +202,8 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
avatarPath, avatarPath,
badge, badge,
color, color,
hasStories,
id,
i18n, i18n,
type, type,
isMe, isMe,
@ -209,6 +214,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
theme, theme,
title, title,
unblurredAvatarPath, unblurredAvatarPath,
viewUserStories,
} = this.props; } = this.props;
return ( return (
@ -221,14 +227,22 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
conversationType={type} conversationType={type}
i18n={i18n} i18n={i18n}
isMe={isMe} isMe={isMe}
noteToSelf={isMe}
title={title}
name={name} name={name}
noteToSelf={isMe}
onClick={
hasStories
? () => {
viewUserStories(id);
}
: undefined
}
phoneNumber={phoneNumber} phoneNumber={phoneNumber}
profileName={profileName} profileName={profileName}
sharedGroupNames={sharedGroupNames} sharedGroupNames={sharedGroupNames}
size={AvatarSize.THIRTY_TWO} size={AvatarSize.THIRTY_TWO}
storyRing={hasStories}
theme={theme} theme={theme}
title={title}
unblurredAvatarPath={unblurredAvatarPath} unblurredAvatarPath={unblurredAvatarPath}
/> />
</span> </span>
@ -488,30 +502,32 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
throw missingCaseError(type); throw missingCaseError(type);
} }
const avatar = this.renderAvatar();
const contents = ( const contents = (
<> <div className="module-ConversationHeader__header__info">
{this.renderAvatar()} {this.renderHeaderInfoTitle()}
<div className="module-ConversationHeader__header__info"> {this.renderHeaderInfoSubtitle()}
{this.renderHeaderInfoTitle()} </div>
{this.renderHeaderInfoSubtitle()}
</div>
</>
); );
if (onClick) { if (onClick) {
return ( return (
<button <div className="module-ConversationHeader__header">
type="button" {avatar}
className="module-ConversationHeader__header module-ConversationHeader__header--clickable" <button
onClick={onClick} type="button"
> className="module-ConversationHeader__header--clickable"
{contents} onClick={onClick}
</button> >
{contents}
</button>
</div>
); );
} }
return ( return (
<div className="module-ConversationHeader__header" ref={this.headerRef}> <div className="module-ConversationHeader__header" ref={this.headerRef}>
{avatar}
{contents} {contents}
</div> </div>
); );

View file

@ -1,458 +1,213 @@
// Copyright 2020-2022 Signal Messenger, LLC // Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import type { Meta, Story } from '@storybook/react';
import { number as numberKnob, text } from '@storybook/addon-knobs'; import React, { useContext } from 'react';
import { action } from '@storybook/addon-actions'; import casual from 'casual';
import { ConversationHero } from './ConversationHero';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json'; import enMessages from '../../../_locales/en/messages.json';
import type { Props } from './ConversationHero';
import { ConversationHero } from './ConversationHero';
import { HasStories } from '../../types/Stories';
import { StorybookThemeContext } from '../../../.storybook/StorybookThemeContext'; import { StorybookThemeContext } from '../../../.storybook/StorybookThemeContext';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { setupI18n } from '../../util/setupI18n';
import { ThemeType } from '../../types/Util';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
const getAbout = () => text('about', '👍 Free to chat');
const getTitle = () => text('name', 'Cayce Bollard');
const getName = () => text('name', 'Cayce Bollard');
const getProfileName = () => text('profileName', 'Cayce Bollard (profile)');
const getAvatarPath = () =>
text('avatarPath', '/fixtures/kitten-4-112-112.jpg');
const getPhoneNumber = () => text('phoneNumber', '+1 (646) 327-2700');
const updateSharedGroups = action('updateSharedGroups');
const Wrapper = (
props: Omit<React.ComponentProps<typeof ConversationHero>, 'theme'>
) => {
const theme = React.useContext(StorybookThemeContext);
return <ConversationHero {...props} theme={theme} />;
};
export default { export default {
title: 'Components/Conversation/ConversationHero', title: 'Components/Conversation/ConversationHero',
}; component: ConversationHero,
argTypes: {
conversationType: {
defaultValue: 'direct',
},
i18n: {
defaultValue: i18n,
},
theme: {
defaultValue: ThemeType.light,
},
unblurAvatar: { action: true },
updateSharedGroups: { action: true },
viewUserStories: { action: true },
},
} as Meta;
export const DirectFiveOtherGroups = (): JSX.Element => { const Template: Story<Props> = args => {
const theme = useContext(StorybookThemeContext);
return ( return (
<div style={{ width: '480px' }}> <div style={{ width: '480px' }}>
<Wrapper <ConversationHero {...getDefaultConversation()} {...args} theme={theme} />
about={getAbout()}
acceptedMessageRequest
badge={undefined}
i18n={i18n}
isMe={false}
title={getTitle()}
avatarPath={getAvatarPath()}
name={getName()}
profileName={getProfileName()}
phoneNumber={getPhoneNumber()}
conversationType="direct"
updateSharedGroups={updateSharedGroups}
sharedGroupNames={[
'NYC Rock Climbers',
'Dinner Party',
'Friends 🌿',
'Fourth',
'Fifth',
]}
unblurAvatar={action('unblurAvatar')}
/>
</div> </div>
); );
}; };
export const DirectFiveOtherGroups = Template.bind({});
DirectFiveOtherGroups.args = {
sharedGroupNames: Array.from(Array(5), () => casual.title),
};
DirectFiveOtherGroups.story = { DirectFiveOtherGroups.story = {
name: 'Direct (Five Other Groups)', name: 'Direct (Five Other Groups)',
}; };
export const DirectFourOtherGroups = (): JSX.Element => { export const DirectFourOtherGroups = Template.bind({});
return ( DirectFourOtherGroups.args = {
<div style={{ width: '480px' }}> sharedGroupNames: Array.from(Array(4), () => casual.title),
<Wrapper
about={getAbout()}
acceptedMessageRequest
badge={undefined}
i18n={i18n}
isMe={false}
title={getTitle()}
avatarPath={getAvatarPath()}
name={getName()}
profileName={getProfileName()}
phoneNumber={getPhoneNumber()}
conversationType="direct"
updateSharedGroups={updateSharedGroups}
sharedGroupNames={[
'NYC Rock Climbers',
'Dinner Party',
'Friends 🌿',
'Fourth',
]}
unblurAvatar={action('unblurAvatar')}
/>
</div>
);
}; };
DirectFourOtherGroups.story = { DirectFourOtherGroups.story = {
name: 'Direct (Four Other Groups)', name: 'Direct (Four Other Groups)',
}; };
export const DirectThreeOtherGroups = (): JSX.Element => { export const DirectThreeOtherGroups = Template.bind({});
return ( DirectThreeOtherGroups.args = {
<div style={{ width: '480px' }}> sharedGroupNames: Array.from(Array(3), () => casual.title),
<Wrapper
about={getAbout()}
acceptedMessageRequest
badge={undefined}
i18n={i18n}
isMe={false}
title={getTitle()}
avatarPath={getAvatarPath()}
name={getName()}
profileName={getProfileName()}
phoneNumber={getPhoneNumber()}
conversationType="direct"
updateSharedGroups={updateSharedGroups}
sharedGroupNames={['NYC Rock Climbers', 'Dinner Party', 'Friends 🌿']}
unblurAvatar={action('unblurAvatar')}
/>
</div>
);
}; };
DirectThreeOtherGroups.story = { DirectThreeOtherGroups.story = {
name: 'Direct (Three Other Groups)', name: 'Direct (Three Other Groups)',
}; };
export const DirectTwoOtherGroups = (): JSX.Element => { export const DirectTwoOtherGroups = Template.bind({});
return ( DirectTwoOtherGroups.args = {
<div style={{ width: '480px' }}> sharedGroupNames: Array.from(Array(2), () => casual.title),
<Wrapper
about={getAbout()}
acceptedMessageRequest
badge={undefined}
i18n={i18n}
isMe={false}
title={getTitle()}
avatarPath={getAvatarPath()}
name={getName()}
profileName={getProfileName()}
phoneNumber={getPhoneNumber()}
conversationType="direct"
updateSharedGroups={updateSharedGroups}
sharedGroupNames={['NYC Rock Climbers', 'Dinner Party']}
unblurAvatar={action('unblurAvatar')}
/>
</div>
);
}; };
DirectTwoOtherGroups.story = { DirectTwoOtherGroups.story = {
name: 'Direct (Two Other Groups)', name: 'Direct (Two Other Groups)',
}; };
export const DirectOneOtherGroup = (): JSX.Element => { export const DirectOneOtherGroup = Template.bind({});
return ( DirectOneOtherGroup.args = {
<div style={{ width: '480px' }}> sharedGroupNames: [casual.title],
<Wrapper
about={getAbout()}
acceptedMessageRequest
badge={undefined}
i18n={i18n}
isMe={false}
title={getTitle()}
avatarPath={getAvatarPath()}
name={getName()}
profileName={getProfileName()}
phoneNumber={getPhoneNumber()}
conversationType="direct"
updateSharedGroups={updateSharedGroups}
sharedGroupNames={['NYC Rock Climbers']}
unblurAvatar={action('unblurAvatar')}
/>
</div>
);
}; };
DirectOneOtherGroup.story = { DirectOneOtherGroup.story = {
name: 'Direct (One Other Group)', name: 'Direct (One Other Group)',
}; };
export const DirectNoGroupsName = (): JSX.Element => { export const DirectNoGroupsName = Template.bind({});
return ( DirectNoGroupsName.args = {
<div style={{ width: '480px' }}> about: '👍 Free to chat',
<Wrapper
about={getAbout()}
acceptedMessageRequest
badge={undefined}
i18n={i18n}
isMe={false}
title={getTitle()}
avatarPath={getAvatarPath()}
name={getName()}
profileName={text('profileName', '')}
phoneNumber={getPhoneNumber()}
conversationType="direct"
updateSharedGroups={updateSharedGroups}
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
/>
</div>
);
}; };
DirectNoGroupsName.story = { DirectNoGroupsName.story = {
name: 'Direct (No Groups, Name)', name: 'Direct (No Groups, Name)',
}; };
export const DirectNoGroupsJustProfile = (): JSX.Element => { export const DirectNoGroupsJustProfile = Template.bind({});
return ( DirectNoGroupsJustProfile.args = {
<div style={{ width: '480px' }}> phoneNumber: casual.phone,
<Wrapper
about={getAbout()}
acceptedMessageRequest
badge={undefined}
i18n={i18n}
isMe={false}
title={text('title', 'Cayce Bollard (profile)')}
avatarPath={getAvatarPath()}
name={text('name', '')}
profileName={getProfileName()}
phoneNumber={getPhoneNumber()}
conversationType="direct"
updateSharedGroups={updateSharedGroups}
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
/>
</div>
);
}; };
DirectNoGroupsJustProfile.story = { DirectNoGroupsJustProfile.story = {
name: 'Direct (No Groups, Just Profile)', name: 'Direct (No Groups, Just Profile)',
}; };
export const DirectNoGroupsJustPhoneNumber = (): JSX.Element => { export const DirectNoGroupsJustPhoneNumber = Template.bind({});
return ( DirectNoGroupsJustPhoneNumber.args = {
<div style={{ width: '480px' }}> name: '',
<Wrapper phoneNumber: casual.phone,
about={getAbout()} profileName: '',
acceptedMessageRequest title: '',
badge={undefined}
i18n={i18n}
isMe={false}
title={text('title', '+1 (646) 327-2700')}
avatarPath={getAvatarPath()}
name={text('name', '')}
profileName={text('profileName', '')}
phoneNumber={getPhoneNumber()}
conversationType="direct"
updateSharedGroups={updateSharedGroups}
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
/>
</div>
);
}; };
DirectNoGroupsJustPhoneNumber.story = { DirectNoGroupsJustPhoneNumber.story = {
name: 'Direct (No Groups, Just Phone Number)', name: 'Direct (No Groups, Just Phone Number)',
}; };
export const DirectNoGroupsNoData = (): JSX.Element => { export const DirectNoGroupsNoData = Template.bind({});
return ( DirectNoGroupsNoData.args = {
<div style={{ width: '480px' }}> avatarPath: undefined,
<Wrapper name: '',
i18n={i18n} phoneNumber: '',
isMe={false} profileName: '',
title={text('title', 'Unknown contact')} title: '',
acceptedMessageRequest
badge={undefined}
avatarPath={getAvatarPath()}
name={text('name', '')}
profileName={text('profileName', '')}
phoneNumber={text('phoneNumber', '')}
conversationType="direct"
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
/>
</div>
);
}; };
DirectNoGroupsNoData.story = { DirectNoGroupsNoData.story = {
name: 'Direct (No Groups, No Data)', name: 'Direct (No Groups, No Data)',
}; };
export const DirectNoGroupsNoDataNotAccepted = (): JSX.Element => { export const DirectNoGroupsNoDataNotAccepted = Template.bind({});
return ( DirectNoGroupsNoDataNotAccepted.args = {
<div style={{ width: '480px' }}> acceptedMessageRequest: false,
<Wrapper avatarPath: undefined,
i18n={i18n} name: '',
isMe={false} phoneNumber: '',
title={text('title', 'Unknown contact')} profileName: '',
acceptedMessageRequest={false} title: '',
badge={undefined}
avatarPath={getAvatarPath()}
name={text('name', '')}
profileName={text('profileName', '')}
phoneNumber={text('phoneNumber', '')}
conversationType="direct"
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
/>
</div>
);
}; };
DirectNoGroupsNoDataNotAccepted.story = { DirectNoGroupsNoDataNotAccepted.story = {
name: 'Direct (No Groups, No Data, Not Accepted)', name: 'Direct (No Groups, No Data, Not Accepted)',
}; };
export const GroupManyMembers = (): JSX.Element => { export const GroupManyMembers = Template.bind({});
return ( GroupManyMembers.args = {
<div style={{ width: '480px' }}> conversationType: 'group',
<Wrapper groupDescription: casual.sentence,
acceptedMessageRequest membersCount: casual.integer(20, 100),
badge={undefined} title: casual.title,
i18n={i18n}
isMe={false}
title={text('title', 'NYC Rock Climbers')}
name={text('groupName', 'NYC Rock Climbers')}
conversationType="group"
membersCount={numberKnob('membersCount', 22)}
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
/>
</div>
);
}; };
GroupManyMembers.story = { GroupManyMembers.story = {
name: 'Group (many members)', name: 'Group (many members)',
}; };
export const GroupOneMember = (): JSX.Element => { export const GroupOneMember = Template.bind({});
return ( GroupOneMember.args = {
<div style={{ width: '480px' }}> avatarPath: undefined,
<Wrapper conversationType: 'group',
acceptedMessageRequest groupDescription: casual.sentence,
badge={undefined} membersCount: 1,
i18n={i18n} title: casual.title,
isMe={false}
title={text('title', 'NYC Rock Climbers')}
name={text('groupName', 'NYC Rock Climbers')}
conversationType="group"
membersCount={1}
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
/>
</div>
);
}; };
GroupOneMember.story = { GroupOneMember.story = {
name: 'Group (one member)', name: 'Group (one member)',
}; };
export const GroupZeroMembers = (): JSX.Element => { export const GroupZeroMembers = Template.bind({});
return ( GroupZeroMembers.args = {
<div style={{ width: '480px' }}> avatarPath: undefined,
<Wrapper conversationType: 'group',
acceptedMessageRequest groupDescription: casual.sentence,
badge={undefined} membersCount: 0,
i18n={i18n} title: casual.title,
isMe={false}
title={text('title', 'NYC Rock Climbers')}
name={text('groupName', 'NYC Rock Climbers')}
conversationType="group"
groupDescription="This is a group for all the rock climbers of NYC"
membersCount={0}
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
/>
</div>
);
}; };
GroupZeroMembers.story = { GroupZeroMembers.story = {
name: 'Group (zero members)', name: 'Group (zero members)',
}; };
export const GroupLongGroupDescription = (): JSX.Element => { export const GroupLongGroupDescription = Template.bind({});
return ( GroupLongGroupDescription.args = {
<div style={{ width: '480px' }}> conversationType: 'group',
<Wrapper groupDescription:
acceptedMessageRequest "This is a group for all the rock climbers of NYC. We really like to climb rocks and these NYC people climb any rock. No rock is too small or too big to be climbed. We will ascend upon all rocks, and not just in NYC, in the whole world. We are just getting started, NYC is just the beginning, watch out rocks in the galaxy. Kuiper belt I'm looking at you. We will put on a space suit and climb all your rocks. No rock is near nor far for the rock climbers of NYC.",
badge={undefined} membersCount: casual.integer(1, 10),
i18n={i18n} title: casual.title,
isMe={false}
title={text('title', 'NYC Rock Climbers')}
name={text('groupName', 'NYC Rock Climbers')}
conversationType="group"
groupDescription="This is a group for all the rock climbers of NYC. We really like to climb rocks and these NYC people climb any rock. No rock is too small or too big to be climbed. We will ascend upon all rocks, and not just in NYC, in the whole world. We are just getting started, NYC is just the beginning, watch out rocks in the galaxy. Kuiper belt I'm looking at you. We will put on a space suit and climb all your rocks. No rock is near nor far for the rock climbers of NYC."
membersCount={0}
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
/>
</div>
);
}; };
GroupLongGroupDescription.story = { GroupLongGroupDescription.story = {
name: 'Group (long group description)', name: 'Group (long group description)',
}; };
export const GroupNoName = (): JSX.Element => { export const GroupNoName = Template.bind({});
return ( GroupNoName.args = {
<div style={{ width: '480px' }}> conversationType: 'group',
<Wrapper membersCount: 0,
acceptedMessageRequest name: '',
badge={undefined} title: '',
i18n={i18n}
isMe={false}
title={text('title', 'Unknown group')}
name={text('groupName', '')}
conversationType="group"
membersCount={0}
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
/>
</div>
);
}; };
GroupNoName.story = { GroupNoName.story = {
name: 'Group (No name)', name: 'Group (No name)',
}; };
export const NoteToSelf = (): JSX.Element => { export const NoteToSelf = Template.bind({});
return ( NoteToSelf.args = {
<div style={{ width: '480px' }}> isMe: true,
<Wrapper
acceptedMessageRequest
badge={undefined}
i18n={i18n}
isMe
title={getTitle()}
conversationType="direct"
phoneNumber={getPhoneNumber()}
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
/>
</div>
);
}; };
NoteToSelf.story = { NoteToSelf.story = {
name: 'Note to Self', name: 'Note to Self',
}; };
export const UnreadStories = Template.bind({});
UnreadStories.args = {
hasStories: HasStories.Unread,
};
export const ReadStories = Template.bind({});
ReadStories.args = {
hasStories: HasStories.Read,
};

View file

@ -9,6 +9,7 @@ import { About } from './About';
import { GroupDescription } from './GroupDescription'; import { GroupDescription } from './GroupDescription';
import { SharedGroupNames } from '../SharedGroupNames'; import { SharedGroupNames } from '../SharedGroupNames';
import type { LocalizerType, ThemeType } from '../../types/Util'; import type { LocalizerType, ThemeType } from '../../types/Util';
import type { HasStories } from '../../types/Stories';
import { ConfirmationDialog } from '../ConfirmationDialog'; import { ConfirmationDialog } from '../ConfirmationDialog';
import { Button, ButtonSize, ButtonVariant } from '../Button'; import { Button, ButtonSize, ButtonVariant } from '../Button';
import { shouldBlurAvatar } from '../../util/shouldBlurAvatar'; import { shouldBlurAvatar } from '../../util/shouldBlurAvatar';
@ -18,6 +19,8 @@ export type Props = {
about?: string; about?: string;
acceptedMessageRequest?: boolean; acceptedMessageRequest?: boolean;
groupDescription?: string; groupDescription?: string;
hasStories?: HasStories;
id: string;
i18n: LocalizerType; i18n: LocalizerType;
isMe: boolean; isMe: boolean;
membersCount?: number; membersCount?: number;
@ -27,6 +30,7 @@ export type Props = {
unblurredAvatarPath?: string; unblurredAvatarPath?: string;
updateSharedGroups: () => unknown; updateSharedGroups: () => unknown;
theme: ThemeType; theme: ThemeType;
viewUserStories: (cid: string) => unknown;
} & Omit<AvatarProps, 'onClick' | 'size' | 'noteToSelf'>; } & Omit<AvatarProps, 'onClick' | 'size' | 'noteToSelf'>;
const renderMembershipRow = ({ const renderMembershipRow = ({
@ -101,6 +105,8 @@ export const ConversationHero = ({
color, color,
conversationType, conversationType,
groupDescription, groupDescription,
hasStories,
id,
isMe, isMe,
membersCount, membersCount,
sharedGroupNames = [], sharedGroupNames = [],
@ -112,6 +118,7 @@ export const ConversationHero = ({
unblurAvatar, unblurAvatar,
unblurredAvatarPath, unblurredAvatarPath,
updateSharedGroups, updateSharedGroups,
viewUserStories,
}: Props): JSX.Element => { }: Props): JSX.Element => {
const [isShowingMessageRequestWarning, setIsShowingMessageRequestWarning] = const [isShowingMessageRequestWarning, setIsShowingMessageRequestWarning] =
useState(false); useState(false);
@ -124,7 +131,7 @@ export const ConversationHero = ({
updateSharedGroups(); updateSharedGroups();
}, [updateSharedGroups]); }, [updateSharedGroups]);
let avatarBlur: AvatarBlur; let avatarBlur: AvatarBlur = AvatarBlur.NoBlur;
let avatarOnClick: undefined | (() => void); let avatarOnClick: undefined | (() => void);
if ( if (
shouldBlurAvatar({ shouldBlurAvatar({
@ -137,8 +144,10 @@ export const ConversationHero = ({
) { ) {
avatarBlur = AvatarBlur.BlurPictureWithClickToView; avatarBlur = AvatarBlur.BlurPictureWithClickToView;
avatarOnClick = unblurAvatar; avatarOnClick = unblurAvatar;
} else { } else if (hasStories) {
avatarBlur = AvatarBlur.NoBlur; avatarOnClick = () => {
viewUserStories(id);
};
} }
const phoneNumberOnly = Boolean( const phoneNumberOnly = Boolean(
@ -165,6 +174,7 @@ export const ConversationHero = ({
profileName={profileName} profileName={profileName}
sharedGroupNames={sharedGroupNames} sharedGroupNames={sharedGroupNames}
size={112} size={112}
storyRing={hasStories}
theme={theme} theme={theme}
title={title} title={title}
/> />

View file

@ -491,19 +491,21 @@ const renderHeroRow = () => {
<ConversationHero <ConversationHero
about={getAbout()} about={getAbout()}
acceptedMessageRequest acceptedMessageRequest
avatarPath={getAvatarPath()}
badge={undefined} badge={undefined}
conversationType="direct"
id={getDefaultConversation().id}
i18n={i18n} i18n={i18n}
isMe={false} isMe={false}
title={getTitle()}
avatarPath={getAvatarPath()}
name={getName()} name={getName()}
profileName={getProfileName()}
phoneNumber={getPhoneNumber()} phoneNumber={getPhoneNumber()}
conversationType="direct" profileName={getProfileName()}
sharedGroupNames={['NYC Rock Climbers', 'Dinner Party']} sharedGroupNames={['NYC Rock Climbers', 'Dinner Party']}
theme={theme} theme={theme}
title={getTitle()}
unblurAvatar={action('unblurAvatar')} unblurAvatar={action('unblurAvatar')}
updateSharedGroups={noop} updateSharedGroups={noop}
viewUserStories={action('viewUserStories')}
/> />
); );
}; };

View file

@ -20,7 +20,7 @@ import type {
StoryDataType, StoryDataType,
StoriesStateType, StoriesStateType,
} from '../ducks/stories'; } from '../ducks/stories';
import { MY_STORIES_ID } from '../../types/Stories'; import { HasStories, MY_STORIES_ID } from '../../types/Stories';
import { ReadStatus } from '../../messages/MessageReadStatus'; import { ReadStatus } from '../../messages/MessageReadStatus';
import { SendStatus } from '../../messages/MessageSendState'; import { SendStatus } from '../../messages/MessageSendState';
import { canReply } from './message'; import { canReply } from './message';
@ -30,6 +30,7 @@ import {
getMe, getMe,
} from './conversations'; } from './conversations';
import { getDistributionListSelector } from './storyDistributionLists'; import { getDistributionListSelector } from './storyDistributionLists';
import { getStoriesEnabled } from './items';
export const getStoriesState = (state: StateType): StoriesStateType => export const getStoriesState = (state: StateType): StoriesStateType =>
state.stories; state.stories;
@ -348,3 +349,28 @@ export const getUnreadStoriesCount = createSelector(
.length; .length;
} }
); );
export const getHasStoriesSelector = createSelector(
getStoriesEnabled,
getStoriesState,
(isEnabled, { stories }) =>
(conversationId?: string): HasStories | undefined => {
if (!isEnabled || !conversationId) {
return;
}
const conversationStories = stories.filter(
story => story.conversationId === conversationId
);
if (!conversationStories.length) {
return;
}
return conversationStories.some(
story => story.readStatus === ReadStatus.Unread
)
? HasStories.Unread
: HasStories.Read;
}
);

View file

@ -11,6 +11,7 @@ import { getAreWeASubscriber } from '../selectors/items';
import { getIntl, getTheme } from '../selectors/user'; import { getIntl, getTheme } from '../selectors/user';
import { getBadgesSelector } from '../selectors/badges'; import { getBadgesSelector } from '../selectors/badges';
import { getConversationSelector } from '../selectors/conversations'; import { getConversationSelector } from '../selectors/conversations';
import { getHasStoriesSelector } from '../selectors/stories';
const mapStateToProps = (state: StateType): PropsDataType => { const mapStateToProps = (state: StateType): PropsDataType => {
const { contactId, conversationId } = const { contactId, conversationId } =
@ -35,12 +36,15 @@ const mapStateToProps = (state: StateType): PropsDataType => {
}); });
} }
const hasStories = getHasStoriesSelector(state)(conversationId);
return { return {
areWeASubscriber: getAreWeASubscriber(state), areWeASubscriber: getAreWeASubscriber(state),
areWeAdmin, areWeAdmin,
badges: getBadgesSelector(state)(contact.badges), badges: getBadgesSelector(state)(contact.badges),
contact, contact,
conversation: currentConversation, conversation: currentConversation,
hasStories,
i18n: getIntl(state), i18n: getIntl(state),
isAdmin, isAdmin,
isMember, isMember,

View file

@ -3,6 +3,8 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { pick } from 'lodash'; import { pick } from 'lodash';
import type { ConversationType } from '../ducks/conversations';
import type { StateType } from '../reducer';
import { import {
ConversationHeader, ConversationHeader,
OutgoingCallButtonStyle, OutgoingCallButtonStyle,
@ -12,15 +14,15 @@ import {
getConversationSelector, getConversationSelector,
isMissingRequiredProfileSharing, isMissingRequiredProfileSharing,
} from '../selectors/conversations'; } from '../selectors/conversations';
import type { StateType } from '../reducer';
import { CallMode } from '../../types/Calling'; import { CallMode } from '../../types/Calling';
import type { ConversationType } from '../ducks/conversations';
import { getConversationCallMode } from '../ducks/conversations';
import { getActiveCall, isAnybodyElseInGroupCall } from '../ducks/calling'; import { getActiveCall, isAnybodyElseInGroupCall } from '../ducks/calling';
import { getUserACI, getIntl, getTheme } from '../selectors/user'; import { getConversationCallMode } from '../ducks/conversations';
import { getHasStoriesSelector } from '../selectors/stories';
import { getOwn } from '../../util/getOwn'; import { getOwn } from '../../util/getOwn';
import { missingCaseError } from '../../util/missingCaseError'; import { getUserACI, getIntl, getTheme } from '../selectors/user';
import { isConversationSMSOnly } from '../../util/isConversationSMSOnly'; import { isConversationSMSOnly } from '../../util/isConversationSMSOnly';
import { mapDispatchToProps } from '../actions';
import { missingCaseError } from '../../util/missingCaseError';
import { strictAssert } from '../../util/assert'; import { strictAssert } from '../../util/assert';
export type OwnProps = { export type OwnProps = {
@ -83,6 +85,8 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => {
throw new Error('Could not find conversation'); throw new Error('Could not find conversation');
} }
const hasStories = getHasStoriesSelector(state)(id);
return { return {
...pick(conversation, [ ...pick(conversation, [
'acceptedMessageRequest', 'acceptedMessageRequest',
@ -110,6 +114,7 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => {
]), ]),
badge: getPreferredBadgeSelector(state)(conversation.badges), badge: getPreferredBadgeSelector(state)(conversation.badges),
conversationTitle: state.conversations.selectedConversationTitle, conversationTitle: state.conversations.selectedConversationTitle,
hasStories,
isMissingMandatoryProfileSharing: isMissingMandatoryProfileSharing:
isMissingRequiredProfileSharing(conversation), isMissingRequiredProfileSharing(conversation),
isSMSOnly: isConversationSMSOnly(conversation), isSMSOnly: isConversationSMSOnly(conversation),
@ -120,6 +125,6 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => {
}; };
}; };
const smart = connect(mapStateToProps, {}); const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartConversationHeader = smart(ConversationHeader); export const SmartConversationHeader = smart(ConversationHeader);

View file

@ -9,6 +9,7 @@ import { ConversationHero } from '../../components/conversation/ConversationHero
import type { StateType } from '../reducer'; import type { StateType } from '../reducer';
import { getPreferredBadgeSelector } from '../selectors/badges'; import { getPreferredBadgeSelector } from '../selectors/badges';
import { getIntl, getTheme } from '../selectors/user'; import { getIntl, getTheme } from '../selectors/user';
import { getHasStoriesSelector } from '../selectors/stories';
type ExternalProps = { type ExternalProps = {
id: string; id: string;
@ -27,6 +28,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
i18n: getIntl(state), i18n: getIntl(state),
...conversation, ...conversation,
conversationType: conversation.type, conversationType: conversation.type,
hasStories: getHasStoriesSelector(state)(id),
badge: getPreferredBadgeSelector(state)(conversation.badges), badge: getPreferredBadgeSelector(state)(conversation.badges),
theme: getTheme(state), theme: getTheme(state),
}; };

View file

@ -127,3 +127,8 @@ export function getStoryDistributionListName(
): string { ): string {
return id === MY_STORIES_ID ? i18n('Stories__mine') : name; return id === MY_STORIES_ID ? i18n('Stories__mine') : name;
} }
export enum HasStories {
Read = 'Read',
Unread = 'Unread',
}