From 5dfe30d235cbd73a3ae5c0a4029427e231313a11 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Thu, 21 Jul 2022 20:44:35 -0400 Subject: [PATCH] Add story entry points around the app --- .../components/ConversationHeader.scss | 1 + ts/components/Avatar.stories.tsx | 22 +- ts/components/Avatar.tsx | 30 +- ts/components/StoryListItem.tsx | 7 +- .../conversation/ContactModal.stories.tsx | 190 +++---- ts/components/conversation/ContactModal.tsx | 36 +- .../ConversationHeader.stories.tsx | 3 +- .../conversation/ConversationHeader.tsx | 50 +- .../conversation/ConversationHero.stories.tsx | 479 +++++------------- .../conversation/ConversationHero.tsx | 16 +- .../conversation/Timeline.stories.tsx | 10 +- ts/state/selectors/stories.ts | 28 +- ts/state/smart/ContactModal.tsx | 4 + ts/state/smart/ConversationHeader.tsx | 17 +- ts/state/smart/HeroRow.tsx | 2 + ts/types/Stories.ts | 5 + 16 files changed, 367 insertions(+), 533 deletions(-) diff --git a/stylesheets/components/ConversationHeader.scss b/stylesheets/components/ConversationHeader.scss index 6588f94ad..dc734f836 100644 --- a/stylesheets/components/ConversationHeader.scss +++ b/stylesheets/components/ConversationHeader.scss @@ -119,6 +119,7 @@ margin-left: 4px; margin-right: var(--button-spacing); padding: $padding; + padding-left: 0; @include keyboard-mode { &:focus { diff --git a/ts/components/Avatar.stories.tsx b/ts/components/Avatar.stories.tsx index 4f626139b..8a6c47587 100644 --- a/ts/components/Avatar.stories.tsx +++ b/ts/components/Avatar.stories.tsx @@ -3,20 +3,20 @@ import type { Meta, Story } from '@storybook/react'; import * as React from 'react'; -import { isBoolean } from 'lodash'; +import { action } from '@storybook/addon-actions'; import { expect } from '@storybook/jest'; +import { isBoolean } from 'lodash'; 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 { Props } from './Avatar'; +import enMessages from '../../_locales/en/messages.json'; +import { Avatar, AvatarBlur, AvatarSize } from './Avatar'; import { AvatarColors } from '../types/Colors'; -import { getFakeBadge } from '../test-both/helpers/getFakeBadge'; +import { HasStories } from '../types/Stories'; import { ThemeType } from '../types/Util'; +import { getFakeBadge } from '../test-both/helpers/getFakeBadge'; +import { setupI18n } from '../util/setupI18n'; const i18n = setupI18n('en', enMessages); @@ -63,7 +63,7 @@ export default { }, storyRing: { control: { type: 'radio' }, - options: [undefined, ...Object.values(AvatarStoryRing)], + options: [undefined, ...Object.values(HasStories)], }, theme: { control: { type: 'radio' }, @@ -263,7 +263,7 @@ BlurredWithClickToView.story = { export const StoryUnread = TemplateSingle.bind({}); StoryUnread.args = createProps({ avatarPath: '/fixtures/kitten-3-64-64.jpg', - storyRing: AvatarStoryRing.Unread, + storyRing: HasStories.Unread, }); StoryUnread.story = { name: 'Story: unread', @@ -272,7 +272,7 @@ StoryUnread.story = { export const StoryRead = TemplateSingle.bind({}); StoryRead.args = createProps({ avatarPath: '/fixtures/kitten-3-64-64.jpg', - storyRing: AvatarStoryRing.Read, + storyRing: HasStories.Read, }); StoryRead.story = { name: 'Story: read', diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index 75b6a9901..980a8e04f 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -12,19 +12,19 @@ import React, { useEffect, useState } from 'react'; import classNames from 'classnames'; 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 { BadgeType } from '../badges/types'; +import type { LocalizerType } from '../types/Util'; 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 { 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'; export enum AvatarBlur { @@ -45,11 +45,6 @@ export enum AvatarSize { ONE_HUNDRED_TWELVE = 112, } -export enum AvatarStoryRing { - Unread = 'Unread', - Read = 'Read', -} - type BadgePlacementType = { bottom: number; right: number }; export type Props = { @@ -70,7 +65,7 @@ export type Props = { title: string; unblurredAvatarPath?: string; searchResult?: boolean; - storyRing?: AvatarStoryRing; + storyRing?: HasStories; onClick?: (event: MouseEvent) => unknown; onClickBadge?: (event: MouseEvent) => unknown; @@ -308,9 +303,8 @@ export const Avatar: FunctionComponent = ({ className={classNames( 'module-Avatar', hasImage ? 'module-Avatar--with-image' : 'module-Avatar--no-image', - storyRing && 'module-Avatar--with-story', - storyRing === AvatarStoryRing.Unread && - 'module-Avatar--with-story--unread', + Boolean(storyRing) && 'module-Avatar--with-story', + storyRing === HasStories.Unread && 'module-Avatar--with-story--unread', className )} style={{ diff --git a/ts/components/StoryListItem.tsx b/ts/components/StoryListItem.tsx index 6561aaec6..d5c9aab41 100644 --- a/ts/components/StoryListItem.tsx +++ b/ts/components/StoryListItem.tsx @@ -5,9 +5,10 @@ import React, { useState } from 'react'; import classNames from 'classnames'; import type { LocalizerType } from '../types/Util'; import type { ConversationStoryType, StoryViewType } from '../types/Stories'; -import { Avatar, AvatarSize, AvatarStoryRing } from './Avatar'; +import { Avatar, AvatarSize } from './Avatar'; import { ConfirmationDialog } from './ConfirmationDialog'; import { ContextMenuPopper } from './ContextMenu'; +import { HasStories } from '../types/Stories'; import { MessageTimestamp } from './conversation/MessageTimestamp'; import { StoryImage } from './StoryImage'; import { getAvatarColor } from '../types/Colors'; @@ -57,9 +58,9 @@ export const StoryListItem = ({ title, } = sender; - let avatarStoryRing: AvatarStoryRing | undefined; + let avatarStoryRing: HasStories | undefined; if (attachment) { - avatarStoryRing = isUnread ? AvatarStoryRing.Unread : AvatarStoryRing.Read; + avatarStoryRing = isUnread ? HasStories.Unread : HasStories.Read; } let repliesElement: JSX.Element | undefined; diff --git a/ts/components/conversation/ContactModal.stories.tsx b/ts/components/conversation/ContactModal.stories.tsx index 2dbc60f91..9f383117e 100644 --- a/ts/components/conversation/ContactModal.stories.tsx +++ b/ts/components/conversation/ContactModal.stories.tsx @@ -1,147 +1,147 @@ -// Copyright 2020-2021 Signal Messenger, LLC +// Copyright 2020-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import type { Meta, Story } from '@storybook/react'; import * as React from 'react'; +import 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 { 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 { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; +import { getFakeBadges } from '../../test-both/helpers/getFakeBadge'; +import { setupI18n } from '../../util/setupI18n'; const i18n = setupI18n('en', enMessages); -export default { - title: 'Components/Conversation/ContactModal', -}; - const defaultContact: ConversationType = getDefaultConversation({ - id: 'abcdef', - title: 'Pauline Oliveros', - phoneNumber: '(333) 444-5515', about: '👍 Free to chat', }); + const defaultGroup: ConversationType = getDefaultConversation({ - id: 'abcdef', areWeAdmin: true, - title: "It's a group", - groupLink: 'something', + groupLink: casual.url, + title: casual.title, + type: 'group', }); -const createProps = (overrideProps: Partial = {}): PropsType => ({ - areWeASubscriber: false, - areWeAdmin: boolean('areWeAdmin', overrideProps.areWeAdmin || false), - badges: overrideProps.badges || [], - contact: overrideProps.contact || defaultContact, - conversation: overrideProps.conversation || defaultGroup, - hideContactModal: action('hideContactModal'), - i18n, - isAdmin: boolean('isAdmin', overrideProps.isAdmin || false), - isMember: boolean('isMember', overrideProps.isMember || true), - removeMemberFromGroup: action('removeMemberFromGroup'), - showConversation: action('showConversation'), - theme: ThemeType.light, - toggleSafetyNumberModal: action('toggleSafetyNumberModal'), - toggleAdmin: action('toggleAdmin'), - updateConversationModelSharedGroups: action( - 'updateConversationModelSharedGroups' - ), -}); +export default { + title: 'Components/Conversation/ContactModal', + component: ContactModal, + argTypes: { + i18n: { + defaultValue: i18n, + }, + areWeASubscriber: { + defaultValue: false, + }, + areWeAdmin: { + defaultValue: false, + }, + badges: { + defaultValue: [], + }, + 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 props = createProps({ - areWeAdmin: false, - }); +const Template: Story = args => ; - return ; +export const AsNonAdmin = Template.bind({}); +AsNonAdmin.args = { + areWeAdmin: false, }; - AsNonAdmin.story = { name: 'As non-admin', }; -export const AsAdmin = (): JSX.Element => { - const props = createProps({ - areWeAdmin: true, - }); - return ; +export const AsAdmin = Template.bind({}); +AsAdmin.args = { + areWeAdmin: true, }; - AsAdmin.story = { name: 'As admin', }; -export const AsAdminWithNoGroupLink = (): JSX.Element => { - const props = createProps({ - areWeAdmin: true, - conversation: { - ...defaultGroup, - groupLink: undefined, - }, - }); - return ; +export const AsAdminWithNoGroupLink = Template.bind({}); +AsAdminWithNoGroupLink.args = { + areWeAdmin: true, + conversation: { + ...defaultGroup, + groupLink: undefined, + }, }; - AsAdminWithNoGroupLink.story = { name: 'As admin with no group link', }; -export const AsAdminViewingNonMemberOfGroup = (): JSX.Element => { - const props = createProps({ - isMember: false, - }); - - return ; +export const AsAdminViewingNonMemberOfGroup = Template.bind({}); +AsAdminViewingNonMemberOfGroup.args = { + isMember: false, }; - AsAdminViewingNonMemberOfGroup.story = { name: 'As admin, viewing non-member of group', }; -export const WithoutPhoneNumber = (): JSX.Element => { - const props = createProps({ - contact: { - ...defaultContact, - phoneNumber: undefined, - }, - }); - - return ; +export const WithoutPhoneNumber = Template.bind({}); +WithoutPhoneNumber.args = { + contact: { + ...defaultContact, + phoneNumber: undefined, + }, }; - WithoutPhoneNumber.story = { name: 'Without phone number', }; -export const ViewingSelf = (): JSX.Element => { - const props = createProps({ - contact: { - ...defaultContact, - isMe: true, - }, - }); - - return ; +export const ViewingSelf = Template.bind({}); +ViewingSelf.args = { + contact: { + ...defaultContact, + isMe: true, + }, }; - ViewingSelf.story = { name: 'Viewing self', }; -export const WithBadges = (): JSX.Element => { - const props = createProps({ - badges: getFakeBadges(2), - }); - - return ; +export const WithBadges = Template.bind({}); +WithBadges.args = { + badges: getFakeBadges(2), }; - WithBadges.story = { name: 'With badges', }; + +export const WithUnreadStories = Template.bind({}); +WithUnreadStories.args = { + hasStories: HasStories.Unread, +}; +WithUnreadStories.storyName = 'Unread Stories'; diff --git a/ts/components/conversation/ContactModal.tsx b/ts/components/conversation/ContactModal.tsx index 92e939237..8e289c11d 100644 --- a/ts/components/conversation/ContactModal.tsx +++ b/ts/components/conversation/ContactModal.tsx @@ -1,25 +1,26 @@ -// Copyright 2020-2021 Signal Messenger, LLC +// Copyright 2020-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { useEffect, useState } 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 { ConversationType, ShowConversationType, } 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 { 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 { Modal } from '../Modal'; import { RemoveGroupMemberConfirmationDialog } from './RemoveGroupMemberConfirmationDialog'; +import { SharedGroupNames } from '../SharedGroupNames'; +import { missingCaseError } from '../../util/missingCaseError'; export type PropsDataType = { areWeASubscriber: boolean; @@ -27,6 +28,7 @@ export type PropsDataType = { badges: ReadonlyArray; contact?: ConversationType; conversation?: ConversationType; + hasStories?: HasStories; readonly i18n: LocalizerType; isAdmin: boolean; isMember: boolean; @@ -40,6 +42,7 @@ type PropsActionType = { toggleAdmin: (conversationId: string, contactId: string) => void; toggleSafetyNumberModal: (conversationId: string) => unknown; updateConversationModelSharedGroups: (conversationId: string) => void; + viewUserStories: (cid: string) => unknown; }; export type PropsType = PropsDataType & PropsActionType; @@ -62,6 +65,7 @@ export const ContactModal = ({ badges, contact, conversation, + hasStories, hideContactModal, i18n, isAdmin, @@ -72,6 +76,7 @@ export const ContactModal = ({ toggleAdmin, toggleSafetyNumberModal, updateConversationModelSharedGroups, + viewUserStories, }: PropsType): JSX.Element => { if (!contact) { throw new Error('Contact modal opened without a matching contact'); @@ -172,14 +177,21 @@ export const ContactModal = ({ i18n={i18n} isMe={contact.isMe} name={contact.name} + onClick={() => { + if (conversation && hasStories) { + viewUserStories(conversation.id); + } else { + setView(ContactModalView.ShowingAvatar); + } + }} + onClickBadge={() => setView(ContactModalView.ShowingBadges)} profileName={contact.profileName} sharedGroupNames={contact.sharedGroupNames} size={96} + storyRing={hasStories} theme={theme} title={contact.title} unblurredAvatarPath={contact.unblurredAvatarPath} - onClick={() => setView(ContactModalView.ShowingAvatar)} - onClickBadge={() => setView(ContactModalView.ShowingBadges)} />
{contact.title}
diff --git a/ts/components/conversation/ConversationHeader.stories.tsx b/ts/components/conversation/ConversationHeader.stories.tsx index 199e2392a..24fd3324c 100644 --- a/ts/components/conversation/ConversationHeader.stories.tsx +++ b/ts/components/conversation/ConversationHeader.stories.tsx @@ -1,4 +1,4 @@ -// Copyright 2020-2021 Signal Messenger, LLC +// Copyright 2020-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ComponentProps } from 'react'; @@ -56,6 +56,7 @@ const commonProps = { onMarkUnread: action('onMarkUnread'), onMoveToInbox: action('onMoveToInbox'), onSetPin: action('onSetPin'), + viewUserStories: action('viewUserStories'), }; export const PrivateConvo = (): JSX.Element => { diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index 180865d44..a2bd2b132 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -1,4 +1,4 @@ -// Copyright 2018-2021 Signal Messenger, LLC +// Copyright 2018-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ReactNode } from 'react'; @@ -20,6 +20,7 @@ import { InContactsIcon } from '../InContactsIcon'; import type { LocalizerType, ThemeType } from '../../types/Util'; import type { ConversationType } from '../../state/ducks/conversations'; import type { BadgeType } from '../../badges/types'; +import type { HasStories } from '../../types/Stories'; import { getMuteOptions } from '../../util/getMuteOptions'; import * as expirationTimer from '../../util/expirationTimer'; import { missingCaseError } from '../../util/missingCaseError'; @@ -39,6 +40,7 @@ export enum OutgoingCallButtonStyle { export type PropsDataType = { badge?: BadgeType; conversationTitle?: string; + hasStories?: HasStories; isMissingMandatoryProfileSharing?: boolean; outgoingCallButtonStyle: OutgoingCallButtonStyle; showBackButton?: boolean; @@ -88,6 +90,7 @@ export type PropsActionsType = { onArchive: () => void; onMarkUnread: () => void; onMoveToInbox: () => void; + viewUserStories: (cid: string) => unknown; }; export type PropsHousekeepingType = { @@ -199,6 +202,8 @@ export class ConversationHeader extends React.Component { avatarPath, badge, color, + hasStories, + id, i18n, type, isMe, @@ -209,6 +214,7 @@ export class ConversationHeader extends React.Component { theme, title, unblurredAvatarPath, + viewUserStories, } = this.props; return ( @@ -221,14 +227,22 @@ export class ConversationHeader extends React.Component { conversationType={type} i18n={i18n} isMe={isMe} - noteToSelf={isMe} - title={title} name={name} + noteToSelf={isMe} + onClick={ + hasStories + ? () => { + viewUserStories(id); + } + : undefined + } phoneNumber={phoneNumber} profileName={profileName} sharedGroupNames={sharedGroupNames} size={AvatarSize.THIRTY_TWO} + storyRing={hasStories} theme={theme} + title={title} unblurredAvatarPath={unblurredAvatarPath} /> @@ -488,30 +502,32 @@ export class ConversationHeader extends React.Component { throw missingCaseError(type); } + const avatar = this.renderAvatar(); const contents = ( - <> - {this.renderAvatar()} -
- {this.renderHeaderInfoTitle()} - {this.renderHeaderInfoSubtitle()} -
- +
+ {this.renderHeaderInfoTitle()} + {this.renderHeaderInfoSubtitle()} +
); if (onClick) { return ( - +
+ {avatar} + +
); } return (
+ {avatar} {contents}
); diff --git a/ts/components/conversation/ConversationHero.stories.tsx b/ts/components/conversation/ConversationHero.stories.tsx index cd1077328..43cefd78f 100644 --- a/ts/components/conversation/ConversationHero.stories.tsx +++ b/ts/components/conversation/ConversationHero.stories.tsx @@ -1,458 +1,213 @@ // Copyright 2020-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import * as React from 'react'; -import { number as numberKnob, text } from '@storybook/addon-knobs'; -import { action } from '@storybook/addon-actions'; +import type { Meta, Story } from '@storybook/react'; +import React, { useContext } from 'react'; +import casual from 'casual'; -import { ConversationHero } from './ConversationHero'; -import { setupI18n } from '../../util/setupI18n'; 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 { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; +import { setupI18n } from '../../util/setupI18n'; +import { ThemeType } from '../../types/Util'; 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, 'theme'> -) => { - const theme = React.useContext(StorybookThemeContext); - return ; -}; - export default { 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 = args => { + const theme = useContext(StorybookThemeContext); return (
- +
); }; +export const DirectFiveOtherGroups = Template.bind({}); +DirectFiveOtherGroups.args = { + sharedGroupNames: Array.from(Array(5), () => casual.title), +}; DirectFiveOtherGroups.story = { name: 'Direct (Five Other Groups)', }; -export const DirectFourOtherGroups = (): JSX.Element => { - return ( -
- -
- ); +export const DirectFourOtherGroups = Template.bind({}); +DirectFourOtherGroups.args = { + sharedGroupNames: Array.from(Array(4), () => casual.title), }; - DirectFourOtherGroups.story = { name: 'Direct (Four Other Groups)', }; -export const DirectThreeOtherGroups = (): JSX.Element => { - return ( -
- -
- ); +export const DirectThreeOtherGroups = Template.bind({}); +DirectThreeOtherGroups.args = { + sharedGroupNames: Array.from(Array(3), () => casual.title), }; - DirectThreeOtherGroups.story = { name: 'Direct (Three Other Groups)', }; -export const DirectTwoOtherGroups = (): JSX.Element => { - return ( -
- -
- ); +export const DirectTwoOtherGroups = Template.bind({}); +DirectTwoOtherGroups.args = { + sharedGroupNames: Array.from(Array(2), () => casual.title), }; - DirectTwoOtherGroups.story = { name: 'Direct (Two Other Groups)', }; -export const DirectOneOtherGroup = (): JSX.Element => { - return ( -
- -
- ); +export const DirectOneOtherGroup = Template.bind({}); +DirectOneOtherGroup.args = { + sharedGroupNames: [casual.title], }; - DirectOneOtherGroup.story = { name: 'Direct (One Other Group)', }; -export const DirectNoGroupsName = (): JSX.Element => { - return ( -
- -
- ); +export const DirectNoGroupsName = Template.bind({}); +DirectNoGroupsName.args = { + about: '👍 Free to chat', }; - DirectNoGroupsName.story = { name: 'Direct (No Groups, Name)', }; -export const DirectNoGroupsJustProfile = (): JSX.Element => { - return ( -
- -
- ); +export const DirectNoGroupsJustProfile = Template.bind({}); +DirectNoGroupsJustProfile.args = { + phoneNumber: casual.phone, }; - DirectNoGroupsJustProfile.story = { name: 'Direct (No Groups, Just Profile)', }; -export const DirectNoGroupsJustPhoneNumber = (): JSX.Element => { - return ( -
- -
- ); +export const DirectNoGroupsJustPhoneNumber = Template.bind({}); +DirectNoGroupsJustPhoneNumber.args = { + name: '', + phoneNumber: casual.phone, + profileName: '', + title: '', }; - DirectNoGroupsJustPhoneNumber.story = { name: 'Direct (No Groups, Just Phone Number)', }; -export const DirectNoGroupsNoData = (): JSX.Element => { - return ( -
- -
- ); +export const DirectNoGroupsNoData = Template.bind({}); +DirectNoGroupsNoData.args = { + avatarPath: undefined, + name: '', + phoneNumber: '', + profileName: '', + title: '', }; - DirectNoGroupsNoData.story = { name: 'Direct (No Groups, No Data)', }; -export const DirectNoGroupsNoDataNotAccepted = (): JSX.Element => { - return ( -
- -
- ); +export const DirectNoGroupsNoDataNotAccepted = Template.bind({}); +DirectNoGroupsNoDataNotAccepted.args = { + acceptedMessageRequest: false, + avatarPath: undefined, + name: '', + phoneNumber: '', + profileName: '', + title: '', }; - DirectNoGroupsNoDataNotAccepted.story = { name: 'Direct (No Groups, No Data, Not Accepted)', }; -export const GroupManyMembers = (): JSX.Element => { - return ( -
- -
- ); +export const GroupManyMembers = Template.bind({}); +GroupManyMembers.args = { + conversationType: 'group', + groupDescription: casual.sentence, + membersCount: casual.integer(20, 100), + title: casual.title, }; - GroupManyMembers.story = { name: 'Group (many members)', }; -export const GroupOneMember = (): JSX.Element => { - return ( -
- -
- ); +export const GroupOneMember = Template.bind({}); +GroupOneMember.args = { + avatarPath: undefined, + conversationType: 'group', + groupDescription: casual.sentence, + membersCount: 1, + title: casual.title, }; - GroupOneMember.story = { name: 'Group (one member)', }; -export const GroupZeroMembers = (): JSX.Element => { - return ( -
- -
- ); +export const GroupZeroMembers = Template.bind({}); +GroupZeroMembers.args = { + avatarPath: undefined, + conversationType: 'group', + groupDescription: casual.sentence, + membersCount: 0, + title: casual.title, }; - GroupZeroMembers.story = { name: 'Group (zero members)', }; -export const GroupLongGroupDescription = (): JSX.Element => { - return ( -
- -
- ); +export const GroupLongGroupDescription = Template.bind({}); +GroupLongGroupDescription.args = { + 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: casual.integer(1, 10), + title: casual.title, }; - GroupLongGroupDescription.story = { name: 'Group (long group description)', }; -export const GroupNoName = (): JSX.Element => { - return ( -
- -
- ); +export const GroupNoName = Template.bind({}); +GroupNoName.args = { + conversationType: 'group', + membersCount: 0, + name: '', + title: '', }; - GroupNoName.story = { name: 'Group (No name)', }; -export const NoteToSelf = (): JSX.Element => { - return ( -
- -
- ); +export const NoteToSelf = Template.bind({}); +NoteToSelf.args = { + isMe: true, }; - NoteToSelf.story = { name: 'Note to Self', }; + +export const UnreadStories = Template.bind({}); +UnreadStories.args = { + hasStories: HasStories.Unread, +}; + +export const ReadStories = Template.bind({}); +ReadStories.args = { + hasStories: HasStories.Read, +}; diff --git a/ts/components/conversation/ConversationHero.tsx b/ts/components/conversation/ConversationHero.tsx index afff7d68c..087e7fae5 100644 --- a/ts/components/conversation/ConversationHero.tsx +++ b/ts/components/conversation/ConversationHero.tsx @@ -9,6 +9,7 @@ import { About } from './About'; import { GroupDescription } from './GroupDescription'; import { SharedGroupNames } from '../SharedGroupNames'; import type { LocalizerType, ThemeType } from '../../types/Util'; +import type { HasStories } from '../../types/Stories'; import { ConfirmationDialog } from '../ConfirmationDialog'; import { Button, ButtonSize, ButtonVariant } from '../Button'; import { shouldBlurAvatar } from '../../util/shouldBlurAvatar'; @@ -18,6 +19,8 @@ export type Props = { about?: string; acceptedMessageRequest?: boolean; groupDescription?: string; + hasStories?: HasStories; + id: string; i18n: LocalizerType; isMe: boolean; membersCount?: number; @@ -27,6 +30,7 @@ export type Props = { unblurredAvatarPath?: string; updateSharedGroups: () => unknown; theme: ThemeType; + viewUserStories: (cid: string) => unknown; } & Omit; const renderMembershipRow = ({ @@ -101,6 +105,8 @@ export const ConversationHero = ({ color, conversationType, groupDescription, + hasStories, + id, isMe, membersCount, sharedGroupNames = [], @@ -112,6 +118,7 @@ export const ConversationHero = ({ unblurAvatar, unblurredAvatarPath, updateSharedGroups, + viewUserStories, }: Props): JSX.Element => { const [isShowingMessageRequestWarning, setIsShowingMessageRequestWarning] = useState(false); @@ -124,7 +131,7 @@ export const ConversationHero = ({ updateSharedGroups(); }, [updateSharedGroups]); - let avatarBlur: AvatarBlur; + let avatarBlur: AvatarBlur = AvatarBlur.NoBlur; let avatarOnClick: undefined | (() => void); if ( shouldBlurAvatar({ @@ -137,8 +144,10 @@ export const ConversationHero = ({ ) { avatarBlur = AvatarBlur.BlurPictureWithClickToView; avatarOnClick = unblurAvatar; - } else { - avatarBlur = AvatarBlur.NoBlur; + } else if (hasStories) { + avatarOnClick = () => { + viewUserStories(id); + }; } const phoneNumberOnly = Boolean( @@ -165,6 +174,7 @@ export const ConversationHero = ({ profileName={profileName} sharedGroupNames={sharedGroupNames} size={112} + storyRing={hasStories} theme={theme} title={title} /> diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 9c2a3169c..8843778a8 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -491,19 +491,21 @@ const renderHeroRow = () => { ); }; diff --git a/ts/state/selectors/stories.ts b/ts/state/selectors/stories.ts index abd934cf2..c5a64ee34 100644 --- a/ts/state/selectors/stories.ts +++ b/ts/state/selectors/stories.ts @@ -20,7 +20,7 @@ import type { StoryDataType, StoriesStateType, } from '../ducks/stories'; -import { MY_STORIES_ID } from '../../types/Stories'; +import { HasStories, MY_STORIES_ID } from '../../types/Stories'; import { ReadStatus } from '../../messages/MessageReadStatus'; import { SendStatus } from '../../messages/MessageSendState'; import { canReply } from './message'; @@ -30,6 +30,7 @@ import { getMe, } from './conversations'; import { getDistributionListSelector } from './storyDistributionLists'; +import { getStoriesEnabled } from './items'; export const getStoriesState = (state: StateType): StoriesStateType => state.stories; @@ -348,3 +349,28 @@ export const getUnreadStoriesCount = createSelector( .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; + } +); diff --git a/ts/state/smart/ContactModal.tsx b/ts/state/smart/ContactModal.tsx index 636980d92..32e2e1d0b 100644 --- a/ts/state/smart/ContactModal.tsx +++ b/ts/state/smart/ContactModal.tsx @@ -11,6 +11,7 @@ import { getAreWeASubscriber } from '../selectors/items'; import { getIntl, getTheme } from '../selectors/user'; import { getBadgesSelector } from '../selectors/badges'; import { getConversationSelector } from '../selectors/conversations'; +import { getHasStoriesSelector } from '../selectors/stories'; const mapStateToProps = (state: StateType): PropsDataType => { const { contactId, conversationId } = @@ -35,12 +36,15 @@ const mapStateToProps = (state: StateType): PropsDataType => { }); } + const hasStories = getHasStoriesSelector(state)(conversationId); + return { areWeASubscriber: getAreWeASubscriber(state), areWeAdmin, badges: getBadgesSelector(state)(contact.badges), contact, conversation: currentConversation, + hasStories, i18n: getIntl(state), isAdmin, isMember, diff --git a/ts/state/smart/ConversationHeader.tsx b/ts/state/smart/ConversationHeader.tsx index c6a2fd9fe..f7f6d4526 100644 --- a/ts/state/smart/ConversationHeader.tsx +++ b/ts/state/smart/ConversationHeader.tsx @@ -3,6 +3,8 @@ import { connect } from 'react-redux'; import { pick } from 'lodash'; +import type { ConversationType } from '../ducks/conversations'; +import type { StateType } from '../reducer'; import { ConversationHeader, OutgoingCallButtonStyle, @@ -12,15 +14,15 @@ import { getConversationSelector, isMissingRequiredProfileSharing, } from '../selectors/conversations'; -import type { StateType } from '../reducer'; import { CallMode } from '../../types/Calling'; -import type { ConversationType } from '../ducks/conversations'; -import { getConversationCallMode } from '../ducks/conversations'; 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 { missingCaseError } from '../../util/missingCaseError'; +import { getUserACI, getIntl, getTheme } from '../selectors/user'; import { isConversationSMSOnly } from '../../util/isConversationSMSOnly'; +import { mapDispatchToProps } from '../actions'; +import { missingCaseError } from '../../util/missingCaseError'; import { strictAssert } from '../../util/assert'; export type OwnProps = { @@ -83,6 +85,8 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => { throw new Error('Could not find conversation'); } + const hasStories = getHasStoriesSelector(state)(id); + return { ...pick(conversation, [ 'acceptedMessageRequest', @@ -110,6 +114,7 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => { ]), badge: getPreferredBadgeSelector(state)(conversation.badges), conversationTitle: state.conversations.selectedConversationTitle, + hasStories, isMissingMandatoryProfileSharing: isMissingRequiredProfileSharing(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); diff --git a/ts/state/smart/HeroRow.tsx b/ts/state/smart/HeroRow.tsx index 632250b3b..9274cc29e 100644 --- a/ts/state/smart/HeroRow.tsx +++ b/ts/state/smart/HeroRow.tsx @@ -9,6 +9,7 @@ import { ConversationHero } from '../../components/conversation/ConversationHero import type { StateType } from '../reducer'; import { getPreferredBadgeSelector } from '../selectors/badges'; import { getIntl, getTheme } from '../selectors/user'; +import { getHasStoriesSelector } from '../selectors/stories'; type ExternalProps = { id: string; @@ -27,6 +28,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { i18n: getIntl(state), ...conversation, conversationType: conversation.type, + hasStories: getHasStoriesSelector(state)(id), badge: getPreferredBadgeSelector(state)(conversation.badges), theme: getTheme(state), }; diff --git a/ts/types/Stories.ts b/ts/types/Stories.ts index 68c236c0e..17571fff7 100644 --- a/ts/types/Stories.ts +++ b/ts/types/Stories.ts @@ -127,3 +127,8 @@ export function getStoryDistributionListName( ): string { return id === MY_STORIES_ID ? i18n('Stories__mine') : name; } + +export enum HasStories { + Read = 'Read', + Unread = 'Unread', +}