Display user badges

This commit is contained in:
Evan Hahn 2021-11-02 18:01:13 -05:00 committed by GitHub
parent 927c22ef73
commit f647c4e053
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
95 changed files with 2891 additions and 424 deletions

View file

@ -4,7 +4,7 @@
import React, { useState } from 'react';
import type { ConversationType } from '../state/ducks/conversations';
import { Intl } from './Intl';
import type { LocalizerType } from '../types/Util';
import type { LocalizerType, ThemeType } from '../types/Util';
import { Modal } from './Modal';
import { ConversationListItem } from './conversationList/ConversationListItem';
@ -12,12 +12,14 @@ type PropsType = {
groupAdmins: Array<ConversationType>;
i18n: LocalizerType;
openConversation: (conversationId: string) => unknown;
theme: ThemeType;
};
export const AnnouncementsOnlyGroupBanner = ({
groupAdmins,
i18n,
openConversation,
theme,
}: PropsType): JSX.Element => {
const [isShowingAdmins, setIsShowingAdmins] = useState(false);
@ -40,6 +42,7 @@ export const AnnouncementsOnlyGroupBanner = ({
lastMessage={undefined}
lastUpdated={undefined}
typingContact={undefined}
theme={theme}
/>
))}
</Modal>

View file

@ -14,6 +14,8 @@ import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
import type { AvatarColorType } from '../types/Colors';
import { AvatarColors } from '../types/Colors';
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
import { getFakeBadge } from '../test-both/helpers/getFakeBadge';
const i18n = setupI18n('en', enMessages);
@ -37,6 +39,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
? overrideProps.acceptedMessageRequest
: true,
avatarPath: text('avatarPath', overrideProps.avatarPath || ''),
badge: overrideProps.badge,
blur: overrideProps.blur,
color: select('color', colorMap, overrideProps.color || AvatarColors[0]),
conversationType: select(
@ -66,6 +69,27 @@ story.add('Avatar', () => {
return sizes.map(size => <Avatar key={size} {...props} size={size} />);
});
story.add('With badge', () => {
const Wrapper = () => {
const theme = React.useContext(StorybookThemeContext);
const props = createProps({
avatarPath: '/fixtures/kitten-3-64-64.jpg',
badge: getFakeBadge(),
theme,
});
return (
<>
{sizes.map(size => (
<Avatar key={size} {...props} size={size} />
))}
</>
);
};
return <Wrapper />;
});
story.add('Wide image', () => {
const props = createProps({
avatarPath: '/fixtures/wide.jpg',

View file

@ -15,10 +15,14 @@ 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 * as log from '../logging/log';
import { assert } from '../util/assert';
import { shouldBlurAvatar } from '../util/shouldBlurAvatar';
import { getBadgeImageFileLocalPath } from '../badges/getBadgeImageFileLocalPath';
import { BadgeImageTheme } from '../badges/BadgeImageTheme';
export enum AvatarBlur {
NoBlur,
@ -40,6 +44,7 @@ export enum AvatarSize {
export type Props = {
avatarPath?: string;
badge?: BadgeType;
blur?: AvatarBlur;
color?: AvatarColorType;
loading?: boolean;
@ -53,6 +58,7 @@ export type Props = {
profileName?: string;
sharedGroupNames: Array<string>;
size: AvatarSize;
theme?: ThemeType;
title: string;
unblurredAvatarPath?: string;
@ -72,6 +78,7 @@ const getDefaultBlur = (
export const Avatar: FunctionComponent<Props> = ({
acceptedMessageRequest,
avatarPath,
badge,
className,
color = 'A200',
conversationType,
@ -83,6 +90,7 @@ export const Avatar: FunctionComponent<Props> = ({
onClick,
sharedGroupNames,
size,
theme,
title,
unblurredAvatarPath,
blur = getDefaultBlur({
@ -203,6 +211,33 @@ export const Avatar: FunctionComponent<Props> = ({
contents = <div className={contentsClassName}>{contentsChildren}</div>;
}
let badgeNode: ReactNode;
if (badge && theme && !isMe) {
const badgeSize = Math.ceil(size * 0.425);
const badgeTheme =
theme === ThemeType.light ? BadgeImageTheme.Light : BadgeImageTheme.Dark;
const badgeImagePath = getBadgeImageFileLocalPath(
badge,
badgeSize,
badgeTheme
);
if (badgeImagePath) {
badgeNode = (
<img
alt={badge.name}
className="module-Avatar__badge"
src={badgeImagePath}
style={{
width: badgeSize,
height: badgeSize,
}}
/>
);
}
} else if (badge && !theme) {
log.error('<Avatar> requires a theme if a badge is provided');
}
return (
<div
aria-label={i18n('contactAvatarAlt', [title])}
@ -219,6 +254,7 @@ export const Avatar: FunctionComponent<Props> = ({
ref={innerRef}
>
{contents}
{badgeNode}
</div>
);
};

View file

@ -0,0 +1,40 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import classNames from 'classnames';
import { times } from 'lodash';
import { strictAssert } from '../util/assert';
export function BadgeCarouselIndex({
currentIndex,
totalCount,
}: Readonly<{
currentIndex: number;
totalCount: number;
}>): JSX.Element | null {
strictAssert(totalCount >= 1, 'Expected 1 or more items');
strictAssert(
currentIndex < totalCount,
'Expected current index to be in range'
);
if (totalCount < 2) {
return null;
}
return (
<div aria-hidden className="BadgeCarouselIndex">
{times(totalCount, index => (
<div
key={index}
className={classNames(
'BadgeCarouselIndex__dot',
currentIndex === index && 'BadgeCarouselIndex__dot--selected'
)}
/>
))}
</div>
);
}

View file

@ -0,0 +1,24 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { BadgeDescription } from './BadgeDescription';
const story = storiesOf('Components/BadgeDescription', module);
story.add('Normal name', () => (
<BadgeDescription
template="{short_name} is here! Hello, {short_name}! {short_name}, I think you're great. This is not replaced: {not_replaced}"
firstName="Alice"
title="Should not be seen"
/>
));
story.add('Name with RTL overrides', () => (
<BadgeDescription
template="Hello, {short_name}! {short_name}, I think you're great."
title={'Flip-\u202eflop'}
/>
));

View file

@ -0,0 +1,42 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactChild, ReactElement } from 'react';
import React from 'react';
import { ContactName } from './conversation/ContactName';
export function BadgeDescription({
firstName,
template,
title,
}: Readonly<{
firstName?: string;
template: string;
title: string;
}>): ReactElement {
const result: Array<ReactChild> = [];
let lastIndex = 0;
const matches = template.matchAll(/\{short_name\}/g);
for (const match of matches) {
const matchIndex = match.index || 0;
result.push(template.slice(lastIndex, matchIndex));
result.push(
<ContactName
key={matchIndex}
firstName={firstName}
title={title}
preferFirstName
/>
);
lastIndex = matchIndex + 12;
}
result.push(template.slice(lastIndex));
return <>{result}</>;
}

View file

@ -0,0 +1,97 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ComponentProps } from 'react';
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
import { getFakeBadge, getFakeBadges } from '../test-both/helpers/getFakeBadge';
import { repeat, zipObject } from '../util/iterables';
import { BadgeImageTheme } from '../badges/BadgeImageTheme';
import { BadgeDialog } from './BadgeDialog';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/BadgeDialog', module);
const defaultProps: ComponentProps<typeof BadgeDialog> = {
badges: getFakeBadges(3),
firstName: 'Alice',
i18n,
onClose: action('onClose'),
title: 'Alice Levine',
};
story.add('No badges (closed immediately)', () => (
<BadgeDialog {...defaultProps} badges={[]} />
));
story.add('One badge', () => (
<BadgeDialog {...defaultProps} badges={getFakeBadges(1)} />
));
story.add('Badge with no image (should be impossible)', () => (
<BadgeDialog
{...defaultProps}
badges={[
{
...getFakeBadge(),
images: [],
},
]}
/>
));
story.add('Badge with pending image', () => (
<BadgeDialog
{...defaultProps}
badges={[
{
...getFakeBadge(),
images: Array(4).fill(
zipObject(
Object.values(BadgeImageTheme),
repeat({ url: 'https://example.com/ignored.svg' })
)
),
},
]}
/>
));
story.add('Badge with only one, low-detail image', () => (
<BadgeDialog
{...defaultProps}
badges={[
{
...getFakeBadge(),
images: [
zipObject(
Object.values(BadgeImageTheme),
repeat({
localPath: '/fixtures/orange-heart.svg',
url: 'https://example.com/ignored.svg',
})
),
...Array(3).fill(
zipObject(
Object.values(BadgeImageTheme),
repeat({ url: 'https://example.com/ignored.svg' })
)
),
],
},
]}
/>
));
story.add('Five badges', () => (
<BadgeDialog {...defaultProps} badges={getFakeBadges(5)} />
));
story.add('Many badges', () => (
<BadgeDialog {...defaultProps} badges={getFakeBadges(50)} />
));

View file

@ -0,0 +1,109 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useState } from 'react';
import { strictAssert } from '../util/assert';
import type { LocalizerType } from '../types/Util';
import type { BadgeType } from '../badges/types';
import { Modal } from './Modal';
import { BadgeDescription } from './BadgeDescription';
import { BadgeImage } from './BadgeImage';
import { BadgeCarouselIndex } from './BadgeCarouselIndex';
type PropsType = Readonly<{
badges: ReadonlyArray<BadgeType>;
firstName?: string;
i18n: LocalizerType;
onClose: () => unknown;
title: string;
}>;
export function BadgeDialog(props: PropsType): null | JSX.Element {
const { badges, onClose } = props;
const hasBadges = badges.length > 0;
useEffect(() => {
if (!hasBadges) {
onClose();
}
}, [hasBadges, onClose]);
return hasBadges ? <BadgeDialogWithBadges {...props} /> : null;
}
function BadgeDialogWithBadges({
badges,
firstName,
i18n,
onClose,
title,
}: PropsType): JSX.Element {
const firstBadge = badges[0];
strictAssert(
firstBadge,
'<BadgeDialogWithBadges> got an empty array of badges'
);
const [currentBadgeId, setCurrentBadgeId] = useState(firstBadge.id);
let currentBadge: BadgeType;
let currentBadgeIndex: number = badges.findIndex(
b => b.id === currentBadgeId
);
if (currentBadgeIndex === -1) {
currentBadgeIndex = 0;
currentBadge = firstBadge;
} else {
currentBadge = badges[currentBadgeIndex];
}
const setCurrentBadgeIndex = (index: number): void => {
const newBadge = badges[index];
strictAssert(newBadge, '<BadgeDialog> tried to select a nonexistent badge');
setCurrentBadgeId(newBadge.id);
};
const navigate = (change: number): void => {
setCurrentBadgeIndex(currentBadgeIndex + change);
};
return (
<Modal
hasXButton
moduleClassName="BadgeDialog"
i18n={i18n}
onClose={onClose}
>
<button
aria-label={i18n('previous')}
className="BadgeDialog__nav BadgeDialog__nav--previous"
disabled={currentBadgeIndex === 0}
onClick={() => navigate(-1)}
type="button"
/>
<div className="BadgeDialog__main">
<BadgeImage badge={currentBadge} size={200} />
<div className="BadgeDialog__name">{currentBadge.name}</div>
<div className="BadgeDialog__description">
<BadgeDescription
firstName={firstName}
template={currentBadge.descriptionTemplate}
title={title}
/>
</div>
<BadgeCarouselIndex
currentIndex={currentBadgeIndex}
totalCount={badges.length}
/>
</div>
<button
aria-label={i18n('next')}
className="BadgeDialog__nav BadgeDialog__nav--next"
disabled={currentBadgeIndex === badges.length - 1}
onClick={() => navigate(1)}
type="button"
/>
</Modal>
);
}

View file

@ -0,0 +1,48 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { BadgeType } from '../badges/types';
import { Spinner } from './Spinner';
import { getBadgeImageFileLocalPath } from '../badges/getBadgeImageFileLocalPath';
import { BadgeImageTheme } from '../badges/BadgeImageTheme';
export function BadgeImage({
badge,
size,
}: Readonly<{
badge: BadgeType;
size: number;
}>): JSX.Element {
const { name } = badge;
const imagePath = getBadgeImageFileLocalPath(
badge,
size,
BadgeImageTheme.Transparent
);
if (!imagePath) {
return (
<Spinner
ariaLabel={name}
moduleClassName="BadgeImage BadgeImage__loading"
size={`${size}px`}
svgSize="normal"
/>
);
}
return (
<img
alt={name}
className="BadgeImage"
src={imagePath}
style={{
width: size,
height: size,
}}
/>
);
}

View file

@ -15,6 +15,7 @@ import enMessages from '../../_locales/en/messages.json';
import { fakeAttachment } from '../test-both/helpers/fakeAttachment';
import { landscapeGreenUrl } from '../storybook/Fixtures';
import { ThemeType } from '../types/Util';
const i18n = setupI18n('en', enMessages);
@ -31,6 +32,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
onSendMessage: action('onSendMessage'),
processAttachments: action('processAttachments'),
removeAttachment: action('removeAttachment'),
theme: ThemeType.light,
// AttachmentList
draftAttachments: overrideProps.draftAttachments || [],

View file

@ -9,6 +9,7 @@ import type {
BodyRangeType,
BodyRangesType,
LocalizerType,
ThemeType,
} from '../types/Util';
import type { ErrorDialogAudioRecorderType } from '../state/ducks/audioRecorder';
import type { HandleAttachmentsProcessingArgsType } from '../util/handleAttachmentsProcessing';
@ -117,6 +118,7 @@ export type OwnProps = Readonly<{
setQuotedMessage(message: undefined): unknown;
shouldSendHighQualityAttachments: boolean;
startRecording: () => unknown;
theme: ThemeType;
}>;
export type Props = Pick<
@ -162,6 +164,7 @@ export const CompositionArea = ({
onSendMessage,
processAttachments,
removeAttachment,
theme,
// AttachmentList
draftAttachments,
@ -542,6 +545,7 @@ export const CompositionArea = ({
groupAdmins={groupAdmins}
i18n={i18n}
openConversation={openConversation}
theme={theme}
/>
);
}

View file

@ -1,14 +1,14 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { useContext } from 'react';
import { times, omit } from 'lodash';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { boolean, date, select, text } from '@storybook/addon-knobs';
import type { PropsType, Row } from './ConversationList';
import type { Row } from './ConversationList';
import { ConversationList, RowType } from './ConversationList';
import { MessageSearchResult } from './conversationList/MessageSearchResult';
import type { PropsData as ConversationListItemPropsType } from './conversationList/ConversationListItem';
@ -17,6 +17,7 @@ import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbo
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
const i18n = setupI18n('en', enMessages);
@ -46,52 +47,58 @@ const defaultConversations: Array<ConversationListItemPropsType> = [
getDefaultConversation(),
];
const createProps = (rows: ReadonlyArray<Row>): PropsType => ({
dimensions: {
width: 300,
height: 350,
},
rowCount: rows.length,
getRow: (index: number) => rows[index],
shouldRecomputeRowHeights: false,
i18n,
onSelectConversation: action('onSelectConversation'),
onClickArchiveButton: action('onClickArchiveButton'),
onClickContactCheckbox: action('onClickContactCheckbox'),
renderMessageSearchResult: (id: string) => (
<MessageSearchResult
body="Lorem ipsum wow"
bodyRanges={[]}
conversationId="marc-convo"
from={defaultConversations[0]}
const Wrapper = ({
rows,
scrollable,
}: Readonly<{ rows: ReadonlyArray<Row>; scrollable?: boolean }>) => {
const theme = useContext(StorybookThemeContext);
return (
<ConversationList
dimensions={{
width: 300,
height: 350,
}}
rowCount={rows.length}
getRow={(index: number) => rows[index]}
shouldRecomputeRowHeights={false}
i18n={i18n}
id={id}
openConversationInternal={action('openConversationInternal')}
sentAt={1587358800000}
snippet="Lorem <<left>>ipsum<<right>> wow"
to={defaultConversations[1]}
onSelectConversation={action('onSelectConversation')}
onClickArchiveButton={action('onClickArchiveButton')}
onClickContactCheckbox={action('onClickContactCheckbox')}
renderMessageSearchResult={(id: string) => (
<MessageSearchResult
body="Lorem ipsum wow"
bodyRanges={[]}
conversationId="marc-convo"
from={defaultConversations[0]}
i18n={i18n}
id={id}
openConversationInternal={action('openConversationInternal')}
sentAt={1587358800000}
snippet="Lorem <<left>>ipsum<<right>> wow"
to={defaultConversations[1]}
/>
)}
scrollable={scrollable}
showChooseGroupMembers={action('showChooseGroupMembers')}
startNewConversationFromPhoneNumber={action(
'startNewConversationFromPhoneNumber'
)}
theme={theme}
/>
),
showChooseGroupMembers: action('showChooseGroupMembers'),
startNewConversationFromPhoneNumber: action(
'startNewConversationFromPhoneNumber'
),
});
);
};
story.add('Archive button', () => (
<ConversationList
{...createProps([
{
type: RowType.ArchiveButton,
archivedConversationsCount: 123,
},
])}
<Wrapper
rows={[{ type: RowType.ArchiveButton, archivedConversationsCount: 123 }]}
/>
));
story.add('Contact: note to self', () => (
<ConversationList
{...createProps([
<Wrapper
rows={[
{
type: RowType.Contact,
contact: {
@ -100,35 +107,30 @@ story.add('Contact: note to self', () => (
about: '🤠 should be ignored',
},
},
])}
]}
/>
));
story.add('Contact: direct', () => (
<ConversationList
{...createProps([
{
type: RowType.Contact,
contact: defaultConversations[0],
},
])}
<Wrapper
rows={[{ type: RowType.Contact, contact: defaultConversations[0] }]}
/>
));
story.add('Contact: direct with short about', () => (
<ConversationList
{...createProps([
<Wrapper
rows={[
{
type: RowType.Contact,
contact: { ...defaultConversations[0], about: '🤠 yee haw' },
},
])}
]}
/>
));
story.add('Contact: direct with long about', () => (
<ConversationList
{...createProps([
<Wrapper
rows={[
{
type: RowType.Contact,
contact: {
@ -137,24 +139,24 @@ story.add('Contact: direct with long about', () => (
'🤠 Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris. Maecenas congue ligula ac quam viverra nec consectetur ante hendrerit. Donec et mollis dolor. Praesent et diam eget libero egestas mattis sit amet vitae augue.',
},
},
])}
]}
/>
));
story.add('Contact: group', () => (
<ConversationList
{...createProps([
<Wrapper
rows={[
{
type: RowType.Contact,
contact: { ...defaultConversations[0], type: 'group' },
},
])}
]}
/>
));
story.add('Contact checkboxes', () => (
<ConversationList
{...createProps([
<Wrapper
rows={[
{
type: RowType.ContactCheckbox,
contact: defaultConversations[0],
@ -173,13 +175,13 @@ story.add('Contact checkboxes', () => (
},
isChecked: true,
},
])}
]}
/>
));
story.add('Contact checkboxes: disabled', () => (
<ConversationList
{...createProps([
<Wrapper
rows={[
{
type: RowType.ContactCheckbox,
contact: defaultConversations[0],
@ -204,7 +206,7 @@ story.add('Contact checkboxes: disabled', () => (
isChecked: true,
disabledReason: ContactCheckboxDisabledReason.AlreadyAdded,
},
])}
]}
/>
));
@ -219,6 +221,7 @@ story.add('Contact checkboxes: disabled', () => (
? overrideProps.acceptedMessageRequest
: true
),
badges: [],
isMe: boolean('isMe', overrideProps.isMe || false),
avatarPath: text('avatarPath', overrideProps.avatarPath || ''),
id: overrideProps.id || '',
@ -246,13 +249,13 @@ story.add('Contact checkboxes: disabled', () => (
const renderConversation = (
overrideProps: Partial<ConversationListItemPropsType> = {}
) => (
<ConversationList
{...createProps([
<Wrapper
rows={[
{
type: RowType.Conversation,
conversation: createConversation(overrideProps),
},
])}
]}
/>
);
@ -278,15 +281,13 @@ story.add('Contact checkboxes: disabled', () => (
);
story.add('Conversations: Message Statuses', () => (
<ConversationList
{...createProps(
MessageStatuses.map(status => ({
type: RowType.Conversation,
conversation: createConversation({
lastMessage: { text: status, status, deletedForEveryone: false },
}),
}))
)}
<Wrapper
rows={MessageStatuses.map(status => ({
type: RowType.Conversation,
conversation: createConversation({
lastMessage: { text: status, status, deletedForEveryone: false },
}),
}))}
/>
));
@ -324,20 +325,18 @@ story.add('Contact checkboxes: disabled', () => (
);
story.add('Conversations: unread count', () => (
<ConversationList
{...createProps(
[4, 10, 34, 250].map(unreadCount => ({
type: RowType.Conversation,
conversation: createConversation({
lastMessage: {
text: 'Hey there!',
status: 'delivered',
deletedForEveryone: false,
},
unreadCount,
}),
}))
)}
<Wrapper
rows={[4, 10, 34, 250].map(unreadCount => ({
type: RowType.Conversation,
conversation: createConversation({
lastMessage: {
text: 'Hey there!',
status: 'delivered',
deletedForEveryone: false,
},
unreadCount,
}),
}))}
/>
));
@ -396,19 +395,17 @@ Line 4, well.`,
];
return (
<ConversationList
{...createProps(
messages.map(messageText => ({
type: RowType.Conversation,
conversation: createConversation({
lastMessage: {
text: messageText,
status: 'read',
deletedForEveryone: false,
},
}),
}))
)}
<Wrapper
rows={messages.map(messageText => ({
type: RowType.Conversation,
conversation: createConversation({
lastMessage: {
text: messageText,
status: 'read',
deletedForEveryone: false,
},
}),
}))}
/>
);
});
@ -422,20 +419,18 @@ Line 4, well.`,
];
return (
<ConversationList
{...createProps(
pairs.map(([lastUpdated, messageText]) => ({
type: RowType.Conversation,
conversation: createConversation({
lastUpdated,
lastMessage: {
text: messageText,
status: 'read',
deletedForEveryone: false,
},
}),
}))
)}
<Wrapper
rows={pairs.map(([lastUpdated, messageText]) => ({
type: RowType.Conversation,
conversation: createConversation({
lastUpdated,
lastMessage: {
text: messageText,
status: 'read',
deletedForEveryone: false,
},
}),
}))}
/>
);
});
@ -446,7 +441,7 @@ Line 4, well.`,
conversation: omit(createConversation(), 'lastUpdated'),
};
return <ConversationList {...createProps([row])} />;
return <Wrapper rows={[row]} />;
});
story.add('Conversation: Missing Message', () => {
@ -455,7 +450,7 @@ Line 4, well.`,
conversation: omit(createConversation(), 'lastMessage'),
};
return <ConversationList {...createProps([row])} />;
return <Wrapper rows={[row]} />;
});
story.add('Conversation: Missing Text', () =>
@ -488,8 +483,8 @@ Line 4, well.`,
}
story.add('Headers', () => (
<ConversationList
{...createProps([
<Wrapper
rows={[
{
type: RowType.Header,
i18nKey: 'conversationsHeader',
@ -498,36 +493,36 @@ story.add('Headers', () => (
type: RowType.Header,
i18nKey: 'messagesHeader',
},
])}
]}
/>
));
story.add('Start new conversation', () => (
<ConversationList
{...createProps([
<Wrapper
rows={[
{
type: RowType.StartNewConversation,
phoneNumber: '+12345559876',
},
])}
]}
/>
));
story.add('Search results loading skeleton', () => (
<ConversationList
<Wrapper
scrollable={false}
{...createProps([
rows={[
{ type: RowType.SearchResultsLoadingFakeHeader },
...times(99, () => ({
type: RowType.SearchResultsLoadingFakeRow as const,
})),
])}
]}
/>
));
story.add('Kitchen sink', () => (
<ConversationList
{...createProps([
<Wrapper
rows={[
{
type: RowType.StartNewConversation,
phoneNumber: '+12345559876',
@ -552,6 +547,6 @@ story.add('Kitchen sink', () => (
type: RowType.ArchiveButton,
archivedConversationsCount: 123,
},
])}
]}
/>
));

View file

@ -8,11 +8,13 @@ import { List } from 'react-virtualized';
import classNames from 'classnames';
import { get, pick } from 'lodash';
import { getOwn } from '../util/getOwn';
import { missingCaseError } from '../util/missingCaseError';
import { assert } from '../util/assert';
import type { LocalizerType } from '../types/Util';
import type { LocalizerType, ThemeType } from '../types/Util';
import { ScrollBehavior } from '../types/Util';
import { getConversationListWidthBreakpoint } from './_util';
import type { BadgeType } from '../badges/types';
import type { PropsData as ConversationListItemPropsType } from './conversationList/ConversationListItem';
import { ConversationListItem } from './conversationList/ConversationListItem';
@ -105,6 +107,7 @@ export type Row =
| StartNewConversationRowType;
export type PropsType = {
badgesById?: Record<string, BadgeType>;
dimensions?: {
width: number;
height: number;
@ -120,6 +123,7 @@ export type PropsType = {
scrollable?: boolean;
i18n: LocalizerType;
theme: ThemeType;
onClickArchiveButton: () => void;
onClickContactCheckbox: (
@ -136,6 +140,7 @@ const NORMAL_ROW_HEIGHT = 76;
const HEADER_ROW_HEIGHT = 40;
export const ConversationList: React.FC<PropsType> = ({
badgesById,
dimensions,
getRow,
i18n,
@ -150,6 +155,7 @@ export const ConversationList: React.FC<PropsType> = ({
shouldRecomputeRowHeights,
showChooseGroupMembers,
startNewConversationFromPhoneNumber,
theme,
}) => {
const listRef = useRef<null | List>(null);
@ -235,6 +241,7 @@ export const ConversationList: React.FC<PropsType> = ({
const itemProps = pick(row.conversation, [
'acceptedMessageRequest',
'avatarPath',
'badges',
'color',
'draftPreview',
'id',
@ -255,7 +262,12 @@ export const ConversationList: React.FC<PropsType> = ({
'unblurredAvatarPath',
'unreadCount',
]);
const { title, unreadCount, lastMessage } = itemProps;
const { badges, title, unreadCount, lastMessage } = itemProps;
let badge: undefined | BadgeType;
if (badgesById && badges[0]) {
badge = getOwn(badgesById, badges[0].id);
}
result = (
<div
@ -270,8 +282,10 @@ export const ConversationList: React.FC<PropsType> = ({
<ConversationListItem
{...itemProps}
key={key}
badge={badge}
onClick={onSelectConversation}
i18n={i18n}
theme={theme}
/>
</div>
);
@ -326,6 +340,7 @@ export const ConversationList: React.FC<PropsType> = ({
);
},
[
badgesById,
getRow,
i18n,
onClickArchiveButton,
@ -334,6 +349,7 @@ export const ConversationList: React.FC<PropsType> = ({
renderMessageSearchResult,
showChooseGroupMembers,
startNewConversationFromPhoneNumber,
theme,
]
);

View file

@ -14,6 +14,7 @@ import { ForwardMessageModal } from './ForwardMessageModal';
import { IMAGE_JPEG, VIDEO_MP4, stringToMIMEType } from '../types/MIME';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { setupI18n } from '../util/setupI18n';
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
const createAttachment = (
props: Partial<AttachmentType> = {}
@ -39,7 +40,7 @@ const candidateConversations = Array.from(Array(100), () =>
getDefaultConversation()
);
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
attachments: overrideProps.attachments,
candidateConversations,
doForwardMessage: action('doForwardMessage'),
@ -55,24 +56,25 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
recentEmojis: [],
removeLinkPreview: action('removeLinkPreview'),
skinTone: 0,
theme: React.useContext(StorybookThemeContext),
});
story.add('Modal', () => {
return <ForwardMessageModal {...createProps()} />;
return <ForwardMessageModal {...useProps()} />;
});
story.add('with text', () => {
return <ForwardMessageModal {...createProps({ messageBody: 'sup' })} />;
return <ForwardMessageModal {...useProps({ messageBody: 'sup' })} />;
});
story.add('a sticker', () => {
return <ForwardMessageModal {...createProps({ isSticker: true })} />;
return <ForwardMessageModal {...useProps({ isSticker: true })} />;
});
story.add('link preview', () => {
return (
<ForwardMessageModal
{...createProps({
{...useProps({
linkPreview: {
description: LONG_DESCRIPTION,
date: Date.now(),
@ -94,7 +96,7 @@ story.add('link preview', () => {
story.add('media attachments', () => {
return (
<ForwardMessageModal
{...createProps({
{...useProps({
attachments: [
createAttachment({
contentType: IMAGE_JPEG,
@ -122,7 +124,7 @@ story.add('media attachments', () => {
story.add('announcement only groups non-admin', () => (
<ForwardMessageModal
{...createProps()}
{...useProps()}
candidateConversations={[
getDefaultConversation({
announcementsOnly: true,

View file

@ -29,7 +29,7 @@ import type { Props as EmojiButtonProps } from './emoji/EmojiButton';
import { EmojiButton } from './emoji/EmojiButton';
import type { EmojiPickDataType } from './emoji/EmojiPicker';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import type { BodyRangeType, LocalizerType } from '../types/Util';
import type { BodyRangeType, LocalizerType, ThemeType } from '../types/Util';
import { ModalHost } from './ModalHost';
import { SearchInput } from './SearchInput';
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
@ -57,6 +57,7 @@ export type DataPropsType = {
caretLocation?: number
) => unknown;
onTextTooLong: () => void;
theme: ThemeType;
} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
type ActionPropsType = Pick<
@ -86,6 +87,7 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
recentEmojis,
removeLinkPreview,
skinTone,
theme,
}) => {
const inputRef = useRef<null | HTMLInputElement>(null);
const inputApiRef = React.useRef<InputApi | undefined>();
@ -412,6 +414,7 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
startNewConversationFromPhoneNumber={
shouldNeverBeCalled
}
theme={theme}
/>
</div>
);

View file

@ -15,6 +15,7 @@ import { MessageSearchResult } from './conversationList/MessageSearchResult';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
const i18n = setupI18n('en', enMessages);
@ -79,7 +80,8 @@ const defaultModeSpecificProps = {
const emptySearchResultsGroup = { isLoading: false, results: [] };
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
badgesById: {},
cantAddContactToGroup: action('cantAddContactToGroup'),
canResizeLeftPane: true,
clearGroupCreationError: action('clearGroupCreationError'),
@ -146,6 +148,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
),
startSearch: action('startSearch'),
startSettingGroupMetadata: action('startSettingGroupMetadata'),
theme: React.useContext(StorybookThemeContext),
toggleComposeEditingAvatar: action('toggleComposeEditingAvatar'),
toggleConversationInChooseMembers: action(
'toggleConversationInChooseMembers'
@ -159,7 +162,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
story.add('Inbox: no conversations', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Inbox,
pinnedConversations: [],
@ -174,7 +177,7 @@ story.add('Inbox: no conversations', () => (
story.add('Inbox: only pinned conversations', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Inbox,
pinnedConversations,
@ -189,7 +192,7 @@ story.add('Inbox: only pinned conversations', () => (
story.add('Inbox: only non-pinned conversations', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Inbox,
pinnedConversations: [],
@ -204,7 +207,7 @@ story.add('Inbox: only non-pinned conversations', () => (
story.add('Inbox: only archived conversations', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Inbox,
pinnedConversations: [],
@ -219,7 +222,7 @@ story.add('Inbox: only archived conversations', () => (
story.add('Inbox: pinned and archived conversations', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Inbox,
pinnedConversations,
@ -234,7 +237,7 @@ story.add('Inbox: pinned and archived conversations', () => (
story.add('Inbox: non-pinned and archived conversations', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Inbox,
pinnedConversations: [],
@ -249,7 +252,7 @@ story.add('Inbox: non-pinned and archived conversations', () => (
story.add('Inbox: pinned and non-pinned conversations', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Inbox,
pinnedConversations,
@ -263,14 +266,14 @@ story.add('Inbox: pinned and non-pinned conversations', () => (
));
story.add('Inbox: pinned, non-pinned, and archived conversations', () => (
<LeftPane {...createProps()} />
<LeftPane {...useProps()} />
));
// Search stories
story.add('Search: no results when searching everywhere', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Search,
conversationResults: emptySearchResultsGroup,
@ -285,7 +288,7 @@ story.add('Search: no results when searching everywhere', () => (
story.add('Search: no results when searching everywhere (SMS)', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Search,
conversationResults: emptySearchResultsGroup,
@ -300,7 +303,7 @@ story.add('Search: no results when searching everywhere (SMS)', () => (
story.add('Search: no results when searching in a conversation', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Search,
conversationResults: emptySearchResultsGroup,
@ -316,7 +319,7 @@ story.add('Search: no results when searching in a conversation', () => (
story.add('Search: all results loading', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Search,
conversationResults: { isLoading: true },
@ -331,7 +334,7 @@ story.add('Search: all results loading', () => (
story.add('Search: some results loading', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Search,
conversationResults: {
@ -349,7 +352,7 @@ story.add('Search: some results loading', () => (
story.add('Search: has conversations and contacts, but not messages', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Search,
conversationResults: {
@ -367,7 +370,7 @@ story.add('Search: has conversations and contacts, but not messages', () => (
story.add('Search: all results', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Search,
conversationResults: {
@ -393,7 +396,7 @@ story.add('Search: all results', () => (
story.add('Archive: no archived conversations', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Archive,
archivedConversations: [],
@ -406,7 +409,7 @@ story.add('Archive: no archived conversations', () => (
story.add('Archive: archived conversations', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Archive,
archivedConversations: defaultConversations,
@ -419,7 +422,7 @@ story.add('Archive: archived conversations', () => (
story.add('Archive: searching a conversation', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Archive,
archivedConversations: defaultConversations,
@ -438,7 +441,7 @@ story.add('Archive: searching a conversation', () => (
story.add('Compose: no contacts or groups', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: [],
@ -452,7 +455,7 @@ story.add('Compose: no contacts or groups', () => (
story.add('Compose: some contacts, no groups, no search term', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: defaultConversations,
@ -466,7 +469,7 @@ story.add('Compose: some contacts, no groups, no search term', () => (
story.add('Compose: some contacts, no groups, with a search term', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: defaultConversations,
@ -480,7 +483,7 @@ story.add('Compose: some contacts, no groups, with a search term', () => (
story.add('Compose: some groups, no contacts, no search term', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: [],
@ -494,7 +497,7 @@ story.add('Compose: some groups, no contacts, no search term', () => (
story.add('Compose: some groups, no contacts, with search term', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: [],
@ -508,7 +511,7 @@ story.add('Compose: some groups, no contacts, with search term', () => (
story.add('Compose: some contacts, some groups, no search term', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: defaultConversations,
@ -522,7 +525,7 @@ story.add('Compose: some contacts, some groups, no search term', () => (
story.add('Compose: some contacts, some groups, with a search term', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: defaultConversations,
@ -538,7 +541,7 @@ story.add('Compose: some contacts, some groups, with a search term', () => (
story.add('Captcha dialog: required', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Inbox,
pinnedConversations,
@ -554,7 +557,7 @@ story.add('Captcha dialog: required', () => (
story.add('Captcha dialog: pending', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Inbox,
pinnedConversations,
@ -572,7 +575,7 @@ story.add('Captcha dialog: pending', () => (
story.add('Group Metadata: No Timer', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.SetGroupMetadata,
groupAvatar: undefined,
@ -590,7 +593,7 @@ story.add('Group Metadata: No Timer', () => (
story.add('Group Metadata: Regular Timer', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.SetGroupMetadata,
groupAvatar: undefined,
@ -608,7 +611,7 @@ story.add('Group Metadata: Regular Timer', () => (
story.add('Group Metadata: Custom Timer', () => (
<LeftPane
{...createProps({
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.SetGroupMetadata,
groupAvatar: undefined,

View file

@ -23,8 +23,9 @@ import type { LeftPaneSetGroupMetadataPropsType } from './leftPane/LeftPaneSetGr
import { LeftPaneSetGroupMetadataHelper } from './leftPane/LeftPaneSetGroupMetadataHelper';
import * as OS from '../OS';
import type { LocalizerType } from '../types/Util';
import type { LocalizerType, ThemeType } from '../types/Util';
import { ScrollBehavior } from '../types/Util';
import type { BadgeType } from '../badges/types';
import { usePrevious } from '../hooks/usePrevious';
import { missingCaseError } from '../util/missingCaseError';
import { strictAssert } from '../util/assert';
@ -83,6 +84,7 @@ export type PropsType = {
mode: LeftPaneMode.SetGroupMetadata;
} & LeftPaneSetGroupMetadataPropsType);
i18n: LocalizerType;
badgesById: Record<string, BadgeType>;
preferredWidthFromStorage: number;
selectedConversationId: undefined | string;
selectedMessageId: undefined | string;
@ -90,6 +92,7 @@ export type PropsType = {
canResizeLeftPane: boolean;
challengeStatus: 'idle' | 'required' | 'pending';
setChallengeStatus: (status: 'idle') => void;
theme: ThemeType;
// Action Creators
cantAddContactToGroup: (conversationId: string) => void;
@ -143,6 +146,7 @@ export type PropsType = {
};
export const LeftPane: React.FC<PropsType> = ({
badgesById,
cantAddContactToGroup,
canResizeLeftPane,
challengeStatus,
@ -182,6 +186,7 @@ export const LeftPane: React.FC<PropsType> = ({
startSearch,
startNewConversationFromPhoneNumber,
startSettingGroupMetadata,
theme,
toggleComposeEditingAvatar,
toggleConversationInChooseMembers,
updateSearchTerm,
@ -565,6 +570,7 @@ export const LeftPane: React.FC<PropsType> = ({
tabIndex={-1}
>
<ConversationList
badgesById={badgesById}
dimensions={{
width,
height: contentRect.bounds?.height || 0,
@ -602,6 +608,7 @@ export const LeftPane: React.FC<PropsType> = ({
startNewConversationFromPhoneNumber={
startNewConversationFromPhoneNumber
}
theme={theme}
/>
</div>
</div>

View file

@ -280,6 +280,7 @@ story.add('Conversation Header', () => (
getConversation={() => ({
acceptedMessageRequest: true,
avatarPath: '/fixtures/kitten-1-64-64.jpg',
badges: [],
id: '1234',
isMe: false,
name: 'Test',

View file

@ -13,6 +13,7 @@ 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';
const i18n = setupI18n('en', enMessages);
@ -28,6 +29,7 @@ const defaultContact: ConversationType = getDefaultConversation({
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
areWeAdmin: boolean('areWeAdmin', overrideProps.areWeAdmin || false),
badges: overrideProps.badges || [],
contact: overrideProps.contact || defaultContact,
hideContactModal: action('hideContactModal'),
i18n,
@ -86,3 +88,11 @@ story.add('Viewing self', () => {
return <ContactModal {...props} />;
});
story.add('With badges', () => {
const props = createProps({
badges: getFakeBadges(2),
});
return <ContactModal {...props} />;
});

View file

@ -3,17 +3,21 @@
import React, { useEffect, useState } from 'react';
import { missingCaseError } from '../../util/missingCaseError';
import { About } from './About';
import { Avatar } from '../Avatar';
import { AvatarLightbox } from '../AvatarLightbox';
import type { ConversationType } from '../../state/ducks/conversations';
import { Modal } from '../Modal';
import type { LocalizerType } from '../../types/Util';
import { BadgeDialog } from '../BadgeDialog';
import type { BadgeType } from '../../badges/types';
import { SharedGroupNames } from '../SharedGroupNames';
import { ConfirmationDialog } from '../ConfirmationDialog';
export type PropsDataType = {
areWeAdmin: boolean;
badges: ReadonlyArray<BadgeType>;
contact?: ConversationType;
conversationId?: string;
readonly i18n: LocalizerType;
@ -38,8 +42,15 @@ type PropsActionType = {
export type PropsType = PropsDataType & PropsActionType;
enum ContactModalView {
Default,
ShowingAvatar,
ShowingBadges,
}
export const ContactModal = ({
areWeAdmin,
badges,
contact,
conversationId,
hideContactModal,
@ -56,7 +67,7 @@ export const ContactModal = ({
throw new Error('Contact modal opened without a matching contact');
}
const [showingAvatar, setShowingAvatar] = useState(false);
const [view, setView] = useState(ContactModalView.Default);
const [confirmToggleAdmin, setConfirmToggleAdmin] = useState(false);
useEffect(() => {
@ -66,135 +77,158 @@ export const ContactModal = ({
}
}, [conversationId, updateConversationModelSharedGroups]);
if (showingAvatar) {
return (
<AvatarLightbox
avatarColor={contact.color}
avatarPath={contact.avatarPath}
conversationTitle={contact.title}
i18n={i18n}
onClose={() => setShowingAvatar(false)}
/>
);
}
switch (view) {
case ContactModalView.Default: {
const preferredBadge: undefined | BadgeType = badges[0];
return (
<Modal
moduleClassName="ContactModal__modal"
hasXButton
i18n={i18n}
onClose={hideContactModal}
>
<div className="ContactModal">
<Avatar
acceptedMessageRequest={contact.acceptedMessageRequest}
avatarPath={contact.avatarPath}
color={contact.color}
conversationType="direct"
return (
<Modal
moduleClassName="ContactModal__modal"
hasXButton
i18n={i18n}
isMe={contact.isMe}
name={contact.name}
profileName={contact.profileName}
sharedGroupNames={contact.sharedGroupNames}
size={96}
title={contact.title}
unblurredAvatarPath={contact.unblurredAvatarPath}
onClick={() => setShowingAvatar(true)}
/>
<div className="ContactModal__name">{contact.title}</div>
<div className="module-about__container">
<About text={contact.about} />
</div>
{contact.phoneNumber && (
<div className="ContactModal__info">{contact.phoneNumber}</div>
)}
{!contact.isMe && (
<div className="ContactModal__info">
<SharedGroupNames
onClose={hideContactModal}
>
<div className="ContactModal">
<Avatar
acceptedMessageRequest={contact.acceptedMessageRequest}
avatarPath={contact.avatarPath}
badge={preferredBadge}
color={contact.color}
conversationType="direct"
i18n={i18n}
sharedGroupNames={contact.sharedGroupNames || []}
/>
</div>
)}
<div className="ContactModal__button-container">
<button
type="button"
className="ContactModal__button ContactModal__send-message"
onClick={() => {
hideContactModal();
openConversationInternal({ conversationId: contact.id });
}}
>
<div className="ContactModal__bubble-icon">
<div className="ContactModal__send-message__bubble-icon" />
</div>
<span>{i18n('ContactModal--message')}</span>
</button>
{!contact.isMe && (
<button
type="button"
className="ContactModal__button ContactModal__safety-number"
isMe={contact.isMe}
name={contact.name}
profileName={contact.profileName}
sharedGroupNames={contact.sharedGroupNames}
size={96}
title={contact.title}
unblurredAvatarPath={contact.unblurredAvatarPath}
onClick={() => {
hideContactModal();
toggleSafetyNumberModal(contact.id);
setView(
preferredBadge
? ContactModalView.ShowingBadges
: ContactModalView.ShowingAvatar
);
}}
>
<div className="ContactModal__bubble-icon">
<div className="ContactModal__safety-number__bubble-icon" />
/>
<div className="ContactModal__name">{contact.title}</div>
<div className="module-about__container">
<About text={contact.about} />
</div>
{contact.phoneNumber && (
<div className="ContactModal__info">{contact.phoneNumber}</div>
)}
{!contact.isMe && (
<div className="ContactModal__info">
<SharedGroupNames
i18n={i18n}
sharedGroupNames={contact.sharedGroupNames || []}
/>
</div>
<span>{i18n('showSafetyNumber')}</span>
</button>
)}
{!contact.isMe && areWeAdmin && isMember && conversationId && (
<>
)}
<div className="ContactModal__button-container">
<button
type="button"
className="ContactModal__button ContactModal__make-admin"
onClick={() => setConfirmToggleAdmin(true)}
className="ContactModal__button ContactModal__send-message"
onClick={() => {
hideContactModal();
openConversationInternal({ conversationId: contact.id });
}}
>
<div className="ContactModal__bubble-icon">
<div className="ContactModal__make-admin__bubble-icon" />
<div className="ContactModal__send-message__bubble-icon" />
</div>
{isAdmin ? (
<span>{i18n('ContactModal--rm-admin')}</span>
) : (
<span>{i18n('ContactModal--make-admin')}</span>
)}
<span>{i18n('ContactModal--message')}</span>
</button>
<button
type="button"
className="ContactModal__button ContactModal__remove-from-group"
onClick={() =>
removeMemberFromGroup(conversationId, contact.id)
}
{!contact.isMe && (
<button
type="button"
className="ContactModal__button ContactModal__safety-number"
onClick={() => {
hideContactModal();
toggleSafetyNumberModal(contact.id);
}}
>
<div className="ContactModal__bubble-icon">
<div className="ContactModal__safety-number__bubble-icon" />
</div>
<span>{i18n('showSafetyNumber')}</span>
</button>
)}
{!contact.isMe && areWeAdmin && isMember && conversationId && (
<>
<button
type="button"
className="ContactModal__button ContactModal__make-admin"
onClick={() => setConfirmToggleAdmin(true)}
>
<div className="ContactModal__bubble-icon">
<div className="ContactModal__make-admin__bubble-icon" />
</div>
{isAdmin ? (
<span>{i18n('ContactModal--rm-admin')}</span>
) : (
<span>{i18n('ContactModal--make-admin')}</span>
)}
</button>
<button
type="button"
className="ContactModal__button ContactModal__remove-from-group"
onClick={() =>
removeMemberFromGroup(conversationId, contact.id)
}
>
<div className="ContactModal__bubble-icon">
<div className="ContactModal__remove-from-group__bubble-icon" />
</div>
<span>{i18n('ContactModal--remove-from-group')}</span>
</button>
</>
)}
</div>
{confirmToggleAdmin && conversationId && (
<ConfirmationDialog
actions={[
{
action: () => toggleAdmin(conversationId, contact.id),
text: isAdmin
? i18n('ContactModal--rm-admin')
: i18n('ContactModal--make-admin'),
},
]}
i18n={i18n}
onClose={() => setConfirmToggleAdmin(false)}
>
<div className="ContactModal__bubble-icon">
<div className="ContactModal__remove-from-group__bubble-icon" />
</div>
<span>{i18n('ContactModal--remove-from-group')}</span>
</button>
</>
)}
</div>
{confirmToggleAdmin && conversationId && (
<ConfirmationDialog
actions={[
{
action: () => toggleAdmin(conversationId, contact.id),
text: isAdmin
? i18n('ContactModal--rm-admin')
: i18n('ContactModal--make-admin'),
},
]}
i18n={i18n}
onClose={() => setConfirmToggleAdmin(false)}
>
{isAdmin
? i18n('ContactModal--rm-admin-info', [contact.title])
: i18n('ContactModal--make-admin-info', [contact.title])}
</ConfirmationDialog>
)}
</div>
</Modal>
);
{isAdmin
? i18n('ContactModal--rm-admin-info', [contact.title])
: i18n('ContactModal--make-admin-info', [contact.title])}
</ConfirmationDialog>
)}
</div>
</Modal>
);
}
case ContactModalView.ShowingAvatar:
return (
<AvatarLightbox
avatarColor={contact.color}
avatarPath={contact.avatarPath}
conversationTitle={contact.title}
i18n={i18n}
onClose={() => setView(ContactModalView.Default)}
/>
);
case ContactModalView.ShowingBadges:
return (
<BadgeDialog
badges={badges}
firstName={contact.firstName}
i18n={i18n}
onClose={() => setView(ContactModalView.Default)}
title={contact.title}
/>
);
default:
throw missingCaseError(view);
}
};

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { ComponentProps } from 'react';
import React from 'react';
import React, { useContext } from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
@ -11,6 +11,7 @@ import { getDefaultConversation } from '../../test-both/helpers/getDefaultConver
import { getRandomColor } from '../../test-both/helpers/getRandomColor';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
import { StorybookThemeContext } from '../../../.storybook/StorybookThemeContext';
import {
ConversationHeader,
OutgoingCallButtonStyle,
@ -25,7 +26,7 @@ type ConversationHeaderStory = {
description: string;
items: Array<{
title: string;
props: ComponentProps<typeof ConversationHeader>;
props: Omit<ComponentProps<typeof ConversationHeader>, 'theme'>;
}>;
};
@ -317,15 +318,18 @@ const stories: Array<ConversationHeaderStory> = [
stories.forEach(({ title, description, items }) =>
book.add(
title,
() =>
items.map(({ title: subtitle, props }, i) => {
() => {
const theme = useContext(StorybookThemeContext);
return items.map(({ title: subtitle, props }, i) => {
return (
<div key={i}>
{subtitle ? <h3>{subtitle}</h3> : null}
<ConversationHeader {...props} />
<ConversationHeader {...props} theme={theme} />
</div>
);
}),
});
},
{
docs: description,
}

View file

@ -17,8 +17,9 @@ import { DisappearingTimeDialog } from '../DisappearingTimeDialog';
import { Avatar, AvatarSize } from '../Avatar';
import { InContactsIcon } from '../InContactsIcon';
import type { LocalizerType } from '../../types/Util';
import type { LocalizerType, ThemeType } from '../../types/Util';
import type { ConversationType } from '../../state/ducks/conversations';
import type { BadgeType } from '../../badges/types';
import { getMuteOptions } from '../../util/getMuteOptions';
import * as expirationTimer from '../../util/expirationTimer';
import { missingCaseError } from '../../util/missingCaseError';
@ -32,11 +33,13 @@ export enum OutgoingCallButtonStyle {
}
export type PropsDataType = {
badge?: BadgeType;
conversationTitle?: string;
isMissingMandatoryProfileSharing?: boolean;
outgoingCallButtonStyle: OutgoingCallButtonStyle;
showBackButton?: boolean;
isSMSOnly?: boolean;
theme: ThemeType;
} & Pick<
ConversationType,
| 'acceptedMessageRequest'
@ -190,6 +193,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
const {
acceptedMessageRequest,
avatarPath,
badge,
color,
i18n,
type,
@ -198,6 +202,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
phoneNumber,
profileName,
sharedGroupNames,
theme,
title,
unblurredAvatarPath,
} = this.props;
@ -207,6 +212,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
badge={badge}
color={color}
conversationType={type}
i18n={i18n}
@ -218,6 +224,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
profileName={profileName}
sharedGroupNames={sharedGroupNames}
size={AvatarSize.THIRTY_TWO}
theme={theme}
unblurredAvatarPath={unblurredAvatarPath}
/>
</span>

View file

@ -9,6 +9,7 @@ import { action } from '@storybook/addon-actions';
import { ConversationHero } from './ConversationHero';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
import { StorybookThemeContext } from '../../../.storybook/StorybookThemeContext';
const i18n = setupI18n('en', enMessages);
@ -22,11 +23,18 @@ 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} />;
};
storiesOf('Components/Conversation/ConversationHero', module)
.add('Direct (Five Other Groups)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
<Wrapper
about={getAbout()}
acceptedMessageRequest
i18n={i18n}
@ -54,7 +62,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
.add('Direct (Four Other Groups)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
<Wrapper
about={getAbout()}
acceptedMessageRequest
i18n={i18n}
@ -81,7 +89,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
.add('Direct (Three Other Groups)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
<Wrapper
about={getAbout()}
acceptedMessageRequest
i18n={i18n}
@ -103,7 +111,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
.add('Direct (Two Other Groups)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
<Wrapper
about={getAbout()}
acceptedMessageRequest
i18n={i18n}
@ -125,7 +133,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
.add('Direct (One Other Group)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
<Wrapper
about={getAbout()}
acceptedMessageRequest
i18n={i18n}
@ -147,7 +155,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
.add('Direct (No Groups, Name)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
<Wrapper
about={getAbout()}
acceptedMessageRequest
i18n={i18n}
@ -169,7 +177,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
.add('Direct (No Groups, Just Profile)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
<Wrapper
about={getAbout()}
acceptedMessageRequest
i18n={i18n}
@ -191,7 +199,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
.add('Direct (No Groups, Just Phone Number)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
<Wrapper
about={getAbout()}
acceptedMessageRequest
i18n={i18n}
@ -213,7 +221,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
.add('Direct (No Groups, No Data)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
<Wrapper
i18n={i18n}
isMe={false}
title={text('title', 'Unknown contact')}
@ -234,7 +242,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
.add('Direct (No Groups, No Data, Not Accepted)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
<Wrapper
i18n={i18n}
isMe={false}
title={text('title', 'Unknown contact')}
@ -255,7 +263,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
.add('Group (many members)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
<Wrapper
acceptedMessageRequest
i18n={i18n}
isMe={false}
@ -274,7 +282,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
.add('Group (one member)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
<Wrapper
acceptedMessageRequest
i18n={i18n}
isMe={false}
@ -293,7 +301,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
.add('Group (zero members)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
<Wrapper
acceptedMessageRequest
i18n={i18n}
isMe={false}
@ -313,7 +321,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
.add('Group (long group description)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
<Wrapper
acceptedMessageRequest
i18n={i18n}
isMe={false}
@ -333,7 +341,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
.add('Group (No name)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
<Wrapper
acceptedMessageRequest
i18n={i18n}
isMe={false}
@ -352,7 +360,7 @@ storiesOf('Components/Conversation/ConversationHero', module)
.add('Note to Self', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
<Wrapper
acceptedMessageRequest
i18n={i18n}
isMe

View file

@ -8,7 +8,7 @@ import { ContactName } from './ContactName';
import { About } from './About';
import { GroupDescription } from './GroupDescription';
import { SharedGroupNames } from '../SharedGroupNames';
import type { LocalizerType } from '../../types/Util';
import type { LocalizerType, ThemeType } from '../../types/Util';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { Button, ButtonSize, ButtonVariant } from '../Button';
import { shouldBlurAvatar } from '../../util/shouldBlurAvatar';
@ -28,6 +28,7 @@ export type Props = {
unblurAvatar: () => void;
unblurredAvatarPath?: string;
updateSharedGroups: () => unknown;
theme: ThemeType;
} & Omit<AvatarProps, 'onClick' | 'size' | 'noteToSelf'>;
const renderMembershipRow = ({
@ -98,6 +99,7 @@ export const ConversationHero = ({
about,
acceptedMessageRequest,
avatarPath,
badge,
color,
conversationType,
groupDescription,
@ -107,6 +109,7 @@ export const ConversationHero = ({
name,
phoneNumber,
profileName,
theme,
title,
onHeightChange,
unblurAvatar,
@ -180,6 +183,7 @@ export const ConversationHero = ({
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
badge={badge}
blur={avatarBlur}
className="module-conversation-hero__avatar"
color={color}
@ -192,6 +196,7 @@ export const ConversationHero = ({
profileName={profileName}
sharedGroupNames={sharedGroupNames}
size={112}
theme={theme}
title={title}
/>
<h1 className="module-conversation-hero__profile-name">

View file

@ -15,6 +15,7 @@ import type { PropsType } from './Timeline';
import { Timeline } from './Timeline';
import type { TimelineItemType } from './TimelineItem';
import { TimelineItem } from './TimelineItem';
import { StorybookThemeContext } from '../../../.storybook/StorybookThemeContext';
import { ConversationHero } from './ConversationHero';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { getRandomColor } from '../../test-both/helpers/getRandomColor';
@ -412,24 +413,31 @@ const getAvatarPath = () =>
text('avatarPath', '/fixtures/kitten-4-112-112.jpg');
const getPhoneNumber = () => text('phoneNumber', '+1 (808) 555-1234');
const renderHeroRow = () => (
<ConversationHero
about={getAbout()}
acceptedMessageRequest
i18n={i18n}
isMe={false}
title={getTitle()}
avatarPath={getAvatarPath()}
name={getName()}
profileName={getProfileName()}
phoneNumber={getPhoneNumber()}
conversationType="direct"
onHeightChange={action('onHeightChange in ConversationHero')}
sharedGroupNames={['NYC Rock Climbers', 'Dinner Party']}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={noop}
/>
);
const renderHeroRow = () => {
const Wrapper = () => {
const theme = React.useContext(StorybookThemeContext);
return (
<ConversationHero
about={getAbout()}
acceptedMessageRequest
i18n={i18n}
isMe={false}
title={getTitle()}
avatarPath={getAvatarPath()}
name={getName()}
profileName={getProfileName()}
phoneNumber={getPhoneNumber()}
conversationType="direct"
onHeightChange={action('onHeightChange in ConversationHero')}
sharedGroupNames={['NYC Rock Climbers', 'Dinner Party']}
theme={theme}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={noop}
/>
);
};
return <Wrapper />;
};
const renderLoadingRow = () => <TimelineLoadingRow state="loading" />;
const renderTypingBubble = () => (
<TypingBubble

View file

@ -14,6 +14,7 @@ import enMessages from '../../../../_locales/en/messages.json';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
import { AddGroupMembersModal } from './AddGroupMembersModal';
import { RequestState } from './util';
import { ThemeType } from '../../../types/Util';
const i18n = setupI18n('en', enMessages);
@ -37,6 +38,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
action('onMakeRequest')(conversationIds);
},
requestState: RequestState.Inactive,
theme: ThemeType.light,
...overrideProps,
});

View file

@ -5,7 +5,7 @@ import type { FunctionComponent } from 'react';
import React, { useMemo, useReducer } from 'react';
import { without } from 'lodash';
import type { LocalizerType } from '../../../types/Util';
import type { LocalizerType, ThemeType } from '../../../types/Util';
import {
AddGroupMemberErrorDialog,
AddGroupMemberErrorDialogMode,
@ -35,6 +35,7 @@ type PropsType = {
makeRequest: (conversationIds: ReadonlyArray<string>) => Promise<void>;
onClose: () => void;
requestState: RequestState;
theme: ThemeType;
};
enum Stage {
@ -151,6 +152,7 @@ export const AddGroupMembersModal: FunctionComponent<PropsType> = ({
onClose,
makeRequest,
requestState,
theme,
}) => {
const maxGroupSize = getMaximumNumberOfContacts();
const maxRecommendedGroupSize = getRecommendedMaximumNumberOfContacts();
@ -284,6 +286,7 @@ export const AddGroupMembersModal: FunctionComponent<PropsType> = ({
selectedContacts={selectedContacts}
setCantAddContactForModal={setCantAddContactForModal}
setSearchTerm={setSearchTerm}
theme={theme}
toggleSelectedContact={toggleSelectedContact}
/>
);

View file

@ -6,7 +6,7 @@ import React, { useEffect, useMemo, useState, useRef } from 'react';
import type { MeasuredComponentProps } from 'react-measure';
import Measure from 'react-measure';
import type { LocalizerType } from '../../../../types/Util';
import type { LocalizerType, ThemeType } from '../../../../types/Util';
import { assert } from '../../../../util/assert';
import { getOwn } from '../../../../util/getOwn';
import { refMerger } from '../../../../util/refMerger';
@ -38,6 +38,7 @@ type PropsType = {
_: Readonly<undefined | ConversationType>
) => void;
setSearchTerm: (_: string) => void;
theme: ThemeType;
toggleSelectedContact: (conversationId: string) => void;
};
@ -55,6 +56,7 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
selectedContacts,
setCantAddContactForModal,
setSearchTerm,
theme,
toggleSelectedContact,
}) => {
const [focusRef] = useRestoreFocus();
@ -227,6 +229,7 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
shouldRecomputeRowHeights={false}
showChooseGroupMembers={shouldNeverBeCalled}
startNewConversationFromPhoneNumber={shouldNeverBeCalled}
theme={theme}
/>
</div>
);

View file

@ -14,6 +14,7 @@ import type { Props } from './ConversationDetails';
import { ConversationDetails } from './ConversationDetails';
import type { ConversationType } from '../../../state/ducks/conversations';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
import { ThemeType } from '../../../types/Util';
const i18n = setupI18n('en', enMessages);
@ -55,6 +56,7 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({
isMe: i === 2,
}),
})),
preferredBadgeByConversation: {},
pendingApprovalMemberships: times(8, () => ({
member: getDefaultConversation(),
})),
@ -92,6 +94,7 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({
'onOutgoingVideoCallInConversation'
),
searchInConversation: action('searchInConversation'),
theme: ThemeType.light,
});
story.add('Basic', () => {

View file

@ -9,8 +9,9 @@ import type { ConversationType } from '../../../state/ducks/conversations';
import { assert } from '../../../util/assert';
import { getMutedUntilText } from '../../../util/getMutedUntilText';
import type { LocalizerType } from '../../../types/Util';
import type { LocalizerType, ThemeType } from '../../../types/Util';
import type { MediaItemType } from '../../../types/MediaItem';
import type { BadgeType } from '../../../badges/types';
import { CapabilityError } from '../../../types/errors';
import { missingCaseError } from '../../../util/missingCaseError';
@ -53,6 +54,7 @@ enum ModalState {
export type StateProps = {
addMembers: (conversationIds: ReadonlyArray<string>) => Promise<void>;
badges?: ReadonlyArray<BadgeType>;
canEditGroupInfo: boolean;
candidateContactsToAdd: Array<ConversationType>;
conversation?: ConversationType;
@ -62,6 +64,7 @@ export type StateProps = {
isGroup: boolean;
loadRecentMediaItems: (limit: number) => void;
memberships: Array<GroupV2Membership>;
preferredBadgeByConversation: Record<string, BadgeType>;
pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>;
pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
setDisappearingMessages: (seconds: number) => void;
@ -85,6 +88,7 @@ export type StateProps = {
onBlock: () => void;
onLeave: () => void;
onUnblock: () => void;
theme: ThemeType;
userAvatarData: Array<AvatarDataType>;
setMuteExpiration: (muteExpiresAt: undefined | number) => unknown;
onOutgoingAudioCallInConversation: () => unknown;
@ -104,6 +108,7 @@ export type Props = StateProps & ActionProps;
export const ConversationDetails: React.ComponentType<Props> = ({
addMembers,
badges,
canEditGroupInfo,
candidateContactsToAdd,
conversation,
@ -121,6 +126,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
onUnblock,
pendingApprovalMemberships,
pendingMemberships,
preferredBadgeByConversation,
replaceAvatar,
saveAvatarToDisk,
searchInConversation,
@ -134,6 +140,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
showGroupV2Permissions,
showLightboxForMedia,
showPendingInvites,
theme,
toggleSafetyNumberModal,
updateGroupAttributes,
userAvatarData,
@ -256,6 +263,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
setEditGroupAttributesRequestState(RequestState.Inactive);
}}
requestState={addGroupMembersRequestState}
theme={theme}
/>
);
break;
@ -311,6 +319,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
)}
<ConversationDetailsHeader
badges={badges}
canEdit={canEditGroupInfo}
conversation={conversation}
i18n={i18n}
@ -324,6 +333,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
: ModalState.EditingGroupDescription
);
}}
theme={theme}
/>
<div className="ConversationDetails__header-buttons">
@ -456,10 +466,12 @@ export const ConversationDetails: React.ComponentType<Props> = ({
conversationId={conversation.id}
i18n={i18n}
memberships={memberships}
preferredBadgeByConversation={preferredBadgeByConversation}
showContactModal={showContactModal}
startAddingNewMembers={() => {
setModalState(ModalState.AddingGroupMembers);
}}
theme={theme}
/>
)}

View file

@ -8,8 +8,10 @@ import { action } from '@storybook/addon-actions';
import { number, text } from '@storybook/addon-knobs';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
import { getFakeBadges } from '../../../test-both/helpers/getFakeBadge';
import { setupI18n } from '../../../util/setupI18n';
import enMessages from '../../../../_locales/en/messages.json';
import { StorybookThemeContext } from '../../../../.storybook/StorybookThemeContext';
import type { ConversationType } from '../../../state/ducks/conversations';
import type { Props } from './ConversationDetailsHeader';
@ -34,61 +36,46 @@ const createConversation = (): ConversationType =>
),
});
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
conversation: createConversation(),
i18n,
canEdit: false,
startEditing: action('startEditing'),
memberships: new Array(number('conversation members length', 0)),
isGroup: true,
isMe: false,
...overrideProps,
});
story.add('Basic', () => {
const props = createProps();
return <ConversationDetailsHeader {...props} />;
});
story.add('Editable', () => {
const props = createProps({ canEdit: true });
return <ConversationDetailsHeader {...props} />;
});
story.add('Basic no-description', () => {
const props = createProps();
const Wrapper = (overrideProps: Partial<Props>) => {
const theme = React.useContext(StorybookThemeContext);
return (
<ConversationDetailsHeader
{...props}
conversation={getDefaultConversation({
title: 'My Group',
type: 'group',
})}
conversation={createConversation()}
i18n={i18n}
canEdit={false}
startEditing={action('startEditing')}
memberships={new Array(number('conversation members length', 0))}
isGroup
isMe={false}
theme={theme}
{...overrideProps}
/>
);
});
};
story.add('Editable no-description', () => {
const props = createProps({ canEdit: true });
story.add('Basic', () => <Wrapper />);
return (
<ConversationDetailsHeader
{...props}
conversation={getDefaultConversation({
title: 'My Group',
type: 'group',
})}
/>
);
});
story.add('Editable', () => <Wrapper canEdit />);
story.add('1:1', () => (
<ConversationDetailsHeader {...createProps()} isGroup={false} />
story.add('Basic no-description', () => (
<Wrapper
conversation={getDefaultConversation({
title: 'My Group',
type: 'group',
})}
/>
));
story.add('Note to self', () => (
<ConversationDetailsHeader {...createProps()} isMe />
story.add('Editable no-description', () => (
<Wrapper
conversation={getDefaultConversation({
title: 'My Group',
type: 'group',
})}
/>
));
story.add('1:1', () => <Wrapper isGroup={false} badges={getFakeBadges(3)} />);
story.add('Note to self', () => <Wrapper isMe />);

View file

@ -11,10 +11,13 @@ import { Emojify } from '../Emojify';
import { GroupDescription } from '../GroupDescription';
import { About } from '../About';
import type { GroupV2Membership } from './ConversationDetailsMembershipList';
import type { LocalizerType } from '../../../types/Util';
import type { LocalizerType, ThemeType } from '../../../types/Util';
import { bemGenerator } from './util';
import { BadgeDialog } from '../../BadgeDialog';
import type { BadgeType } from '../../../badges/types';
export type Props = {
badges?: ReadonlyArray<BadgeType>;
canEdit: boolean;
conversation: ConversationType;
i18n: LocalizerType;
@ -22,11 +25,18 @@ export type Props = {
isMe: boolean;
memberships: Array<GroupV2Membership>;
startEditing: (isGroupTitle: boolean) => void;
theme: ThemeType;
};
enum ConversationDetailsHeaderActiveModal {
ShowingAvatar,
ShowingBadges,
}
const bem = bemGenerator('ConversationDetails-header');
export const ConversationDetailsHeader: React.ComponentType<Props> = ({
badges,
canEdit,
conversation,
i18n,
@ -34,9 +44,13 @@ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
isMe,
memberships,
startEditing,
theme,
}) => {
const [showingAvatar, setShowingAvatar] = useState(false);
const [activeModal, setActiveModal] = useState<
undefined | ConversationDetailsHeaderActiveModal
>();
let preferredBadge: undefined | BadgeType;
let subtitle: ReactNode;
if (isGroup) {
if (conversation.groupDescription) {
@ -65,17 +79,26 @@ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
</div>
</>
);
preferredBadge = badges?.[0];
}
const avatar = (
<Avatar
badge={preferredBadge}
conversationType={conversation.type}
i18n={i18n}
size={80}
{...conversation}
noteToSelf={isMe}
onClick={() => setShowingAvatar(true)}
onClick={() => {
setActiveModal(
preferredBadge
? ConversationDetailsHeaderActiveModal.ShowingBadges
: ConversationDetailsHeaderActiveModal.ShowingAvatar
);
}}
sharedGroupNames={[]}
theme={theme}
/>
);
@ -87,22 +110,44 @@ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
</div>
);
const avatarLightbox =
showingAvatar && !isMe ? (
<AvatarLightbox
avatarColor={conversation.color}
avatarPath={conversation.avatarPath}
conversationTitle={conversation.title}
i18n={i18n}
isGroup={isGroup}
onClose={() => setShowingAvatar(false)}
/>
) : null;
let modal: ReactNode;
switch (activeModal) {
case ConversationDetailsHeaderActiveModal.ShowingAvatar:
modal = (
<AvatarLightbox
avatarColor={conversation.color}
avatarPath={conversation.avatarPath}
conversationTitle={conversation.title}
i18n={i18n}
isGroup={isGroup}
onClose={() => {
setActiveModal(undefined);
}}
/>
);
break;
case ConversationDetailsHeaderActiveModal.ShowingBadges:
modal = (
<BadgeDialog
badges={badges || []}
firstName={conversation.firstName}
i18n={i18n}
onClose={() => {
setActiveModal(undefined);
}}
title={conversation.title}
/>
);
break;
default:
modal = null;
break;
}
if (canEdit) {
return (
<div className={bem('root')}>
{avatarLightbox}
{modal}
{avatar}
<button
type="button"
@ -136,7 +181,7 @@ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
return (
<div className={bem('root')}>
{avatarLightbox}
{modal}
{avatar}
{contents}
<div className={bem('subtitle')}>{subtitle}</div>

View file

@ -11,6 +11,9 @@ import { number } from '@storybook/addon-knobs';
import { setupI18n } from '../../../util/setupI18n';
import enMessages from '../../../../_locales/en/messages.json';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
import { getFakeBadge } from '../../../test-both/helpers/getFakeBadge';
import { ThemeType } from '../../../types/Util';
import type { BadgeType } from '../../../badges/types';
import type {
Props,
@ -47,8 +50,21 @@ const createProps = (overrideProps: Partial<Props>): Props => ({
conversationId: '123',
i18n,
memberships: overrideProps.memberships || [],
preferredBadgeByConversation:
overrideProps.preferredBadgeByConversation ||
(overrideProps.memberships || []).reduce(
(result: Record<string, BadgeType>, { member }, index) =>
(index + 1) % 3 === 0
? {
...result,
[member.id]: getFakeBadge({ alternate: index % 2 !== 0 }),
}
: result,
{}
),
showContactModal: action('showContactModal'),
startAddingNewMembers: action('startAddingNewMembers'),
theme: ThemeType.light,
});
story.add('Few', () => {

View file

@ -3,7 +3,10 @@
import React from 'react';
import type { LocalizerType } from '../../../types/Util';
import type { LocalizerType, ThemeType } from '../../../types/Util';
import { getOwn } from '../../../util/getOwn';
import type { BadgeType } from '../../../badges/types';
import { Avatar } from '../../Avatar';
import { Emojify } from '../Emojify';
@ -23,8 +26,10 @@ export type Props = {
i18n: LocalizerType;
maxShownMemberCount?: number;
memberships: Array<GroupV2Membership>;
preferredBadgeByConversation: Record<string, BadgeType>;
showContactModal: (contactId: string, conversationId: string) => void;
startAddingNewMembers?: () => void;
theme: ThemeType;
};
const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
@ -72,8 +77,10 @@ export const ConversationDetailsMembershipList: React.ComponentType<Props> = ({
i18n,
maxShownMemberCount = 5,
memberships,
preferredBadgeByConversation,
showContactModal,
startAddingNewMembers,
theme,
}) => {
const [showAllMembers, setShowAllMembers] = React.useState<boolean>(false);
const sortedMemberships = sortMemberships(memberships);
@ -107,8 +114,10 @@ export const ConversationDetailsMembershipList: React.ComponentType<Props> = ({
icon={
<Avatar
conversationType="direct"
badge={getOwn(preferredBadgeByConversation, member.id)}
i18n={i18n}
size={32}
theme={theme}
{...member}
/>
}

View file

@ -31,6 +31,7 @@ const sortedGroupMembers = Array.from(Array(32)).map((_, i) =>
const conversation: ConversationType = {
acceptedMessageRequest: true,
areWeAdmin: true,
badges: [],
id: '',
lastUpdated: 0,
markedUnread: false,

View file

@ -8,10 +8,11 @@ import { isBoolean, isNumber } from 'lodash';
import { v4 as uuid } from 'uuid';
import { Avatar, AvatarSize } from '../Avatar';
import type { BadgeType } from '../../badges/types';
import { Timestamp } from '../conversation/Timestamp';
import { isConversationUnread } from '../../util/isConversationUnread';
import { cleanId } from '../_util';
import type { LocalizerType } from '../../types/Util';
import type { LocalizerType, ThemeType } from '../../types/Util';
import type { ConversationType } from '../../state/ducks/conversations';
const BASE_CLASS_NAME =
@ -27,6 +28,7 @@ export const MESSAGE_TEXT_CLASS_NAME = `${MESSAGE_CLASS_NAME}__text`;
const CHECKBOX_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox`;
type PropsType = {
badge?: BadgeType;
checked?: boolean;
conversationType: 'group' | 'direct';
disabled?: boolean;
@ -42,6 +44,7 @@ type PropsType = {
messageText?: ReactNode;
messageTextIsAlwaysFullSize?: boolean;
onClick?: () => void;
theme?: ThemeType;
unreadCount?: number;
} & Pick<
ConversationType,
@ -62,6 +65,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
function BaseConversationListItem({
acceptedMessageRequest,
avatarPath,
badge,
checked,
color,
conversationType,
@ -82,6 +86,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
phoneNumber,
profileName,
sharedGroupNames,
theme,
title,
unblurredAvatarPath,
unreadCount,
@ -129,6 +134,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
badge={badge}
color={color}
conversationType={conversationType}
noteToSelf={isAvatarNoteToSelf}
@ -137,6 +143,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
theme={theme}
title={title}
sharedGroupNames={sharedGroupNames}
size={AvatarSize.FORTY_EIGHT}

View file

@ -15,8 +15,9 @@ import { MessageBody } from '../conversation/MessageBody';
import { ContactName } from '../conversation/ContactName';
import { TypingAnimation } from '../conversation/TypingAnimation';
import type { LocalizerType } from '../../types/Util';
import type { LocalizerType, ThemeType } from '../../types/Util';
import type { ConversationType } from '../../state/ducks/conversations';
import type { BadgeType } from '../../badges/types';
const MESSAGE_STATUS_ICON_CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__status-icon`;
@ -36,6 +37,7 @@ export type PropsData = Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'badges'
| 'color'
| 'draftPreview'
| 'id'
@ -56,11 +58,14 @@ export type PropsData = Pick<
| 'typingContact'
| 'unblurredAvatarPath'
| 'unreadCount'
>;
> & {
badge?: BadgeType;
};
type PropsHousekeeping = {
i18n: LocalizerType;
onClick: (id: string) => void;
theme: ThemeType;
};
export type Props = PropsData & PropsHousekeeping;
@ -69,6 +74,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
function ConversationListItem({
acceptedMessageRequest,
avatarPath,
badge,
color,
draftPreview,
i18n,
@ -85,6 +91,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
profileName,
sharedGroupNames,
shouldShowDraft,
theme,
title,
type,
typingContact,
@ -163,6 +170,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
<BaseConversationListItem
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
badge={badge}
color={color}
conversationType={type}
headerDate={lastUpdated}
@ -180,6 +188,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
phoneNumber={phoneNumber}
profileName={profileName}
sharedGroupNames={sharedGroupNames}
theme={theme}
title={title}
unreadCount={unreadCount}
unblurredAvatarPath={unblurredAvatarPath}