New contact popup when clicking on group member or avatar

This commit is contained in:
Chris Svenningsen 2020-11-11 09:36:05 -08:00 committed by GitHub
parent cd599f92c8
commit d593f74241
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 717 additions and 44 deletions

View file

@ -18,7 +18,7 @@ export type Props = {
name?: string;
phoneNumber?: string;
profileName?: string;
size: 28 | 32 | 52 | 80 | 112;
size: 28 | 32 | 52 | 80 | 96 | 112;
onClick?: () => unknown;
@ -147,7 +147,7 @@ export class Avatar extends React.Component<Props, State> {
const hasImage = !noteToSelf && avatarPath && !imageBroken;
if (![28, 32, 52, 80, 112].includes(size)) {
if (![28, 32, 52, 80, 96, 112].includes(size)) {
throw new Error(`Size ${size} is not supported!`);
}

View file

@ -0,0 +1,83 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import { boolean } from '@storybook/addon-knobs';
import { storiesOf } from '@storybook/react';
import { ContactModal, PropsType } from './ContactModal';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { ConversationType } from '../../state/ducks/conversations';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/Conversation/ContactModal', module);
const defaultContact: ConversationType = {
id: 'abcdef',
lastUpdated: Date.now(),
markedUnread: false,
areWeAdmin: false,
title: 'Pauline Oliveros',
type: 'direct',
phoneNumber: '(333) 444-5515',
};
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
areWeAdmin: boolean('areWeAdmin', overrideProps.areWeAdmin || false),
contact: overrideProps.contact || defaultContact,
i18n,
isMember: boolean('isMember', overrideProps.isMember || true),
onClose: action('onClose'),
openConversation: action('openConversation'),
removeMember: action('removeMember'),
showSafetyNumber: action('showSafetyNumber'),
});
story.add('As non-admin', () => {
const props = createProps({
areWeAdmin: false,
});
return <ContactModal {...props} />;
});
story.add('As admin', () => {
const props = createProps({
areWeAdmin: true,
});
return <ContactModal {...props} />;
});
story.add('As admin, viewing non-member of group', () => {
const props = createProps({
isMember: false,
});
return <ContactModal {...props} />;
});
story.add('Without phone number', () => {
const props = createProps({
contact: {
...defaultContact,
phoneNumber: undefined,
},
});
return <ContactModal {...props} />;
});
story.add('Viewing self', () => {
const props = createProps({
contact: {
...defaultContact,
isMe: true,
},
});
return <ContactModal {...props} />;
});

View file

@ -0,0 +1,159 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { ReactPortal } from 'react';
import { createPortal } from 'react-dom';
import { ConversationType } from '../../state/ducks/conversations';
import { Avatar } from '../Avatar';
import { LocalizerType } from '../../types/Util';
export type PropsType = {
areWeAdmin: boolean;
contact?: ConversationType;
readonly i18n: LocalizerType;
isMember: boolean;
onClose: () => void;
openConversation: (conversationId: string) => void;
removeMember: (conversationId: string) => void;
showSafetyNumber: (conversationId: string) => void;
};
export const ContactModal = ({
areWeAdmin,
contact,
i18n,
isMember,
onClose,
openConversation,
removeMember,
showSafetyNumber,
}: PropsType): ReactPortal | null => {
if (!contact) {
throw new Error('Contact modal opened without a matching contact');
}
const [root, setRoot] = React.useState<HTMLElement | null>(null);
const overlayRef = React.useRef<HTMLElement | null>(null);
const closeButtonRef = React.useRef<HTMLElement | null>(null);
React.useEffect(() => {
const div = document.createElement('div');
document.body.appendChild(div);
setRoot(div);
return () => {
document.body.removeChild(div);
setRoot(null);
};
}, []);
React.useEffect(() => {
if (root !== null && closeButtonRef.current) {
closeButtonRef.current.focus();
}
}, [root]);
React.useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
onClose();
}
};
document.addEventListener('keyup', handler);
return () => {
document.removeEventListener('keyup', handler);
};
}, [onClose]);
const onClickOverlay = (e: React.MouseEvent<HTMLElement>) => {
if (e.target === overlayRef.current) {
e.preventDefault();
e.stopPropagation();
onClose();
}
};
return root
? createPortal(
<div
ref={ref => {
overlayRef.current = ref;
}}
role="presentation"
className="module-contact-modal__overlay"
onClick={onClickOverlay}
>
<div className="module-contact-modal">
<button
ref={r => {
closeButtonRef.current = r;
}}
type="button"
className="module-contact-modal__close-button"
onClick={onClose}
aria-label={i18n('close')}
/>
<Avatar
avatarPath={contact.avatarPath}
color={contact.color}
conversationType="direct"
i18n={i18n}
name={contact.name}
profileName={contact.profileName}
size={96}
title={contact.title}
/>
<div className="module-contact-modal__name">{contact.title}</div>
{contact.phoneNumber && (
<div className="module-contact-modal__profile-and-number">
{contact.phoneNumber}
</div>
)}
<div className="module-contact-modal__button-container">
<button
type="button"
className="module-contact-modal__button module-contact-modal__send-message"
onClick={() => openConversation(contact.id)}
>
<div className="module-contact-modal__bubble-icon">
<div className="module-contact-modal__send-message__bubble-icon" />
</div>
<span>{i18n('ContactModal--message')}</span>
</button>
{!contact.isMe && (
<button
type="button"
className="module-contact-modal__button module-contact-modal__safety-number"
onClick={() => showSafetyNumber(contact.id)}
>
<div className="module-contact-modal__bubble-icon">
<div className="module-contact-modal__safety-number__bubble-icon" />
</div>
<span>{i18n('showSafetyNumber')}</span>
</button>
)}
{!contact.isMe && areWeAdmin && isMember && (
<button
type="button"
className="module-contact-modal__button module-contact-modal__remove-from-group"
onClick={() => removeMember(contact.id)}
>
<div className="module-contact-modal__bubble-icon">
<div className="module-contact-modal__remove-from-group__bubble-icon" />
</div>
<span>{i18n('ContactModal--remove-from-group')}</span>
</button>
)}
</div>
</div>
</div>,
root
)
: null;
};

View file

@ -43,6 +43,7 @@ const renderEmojiPicker: Props['renderEmojiPicker'] = ({
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
attachments: overrideProps.attachments,
authorId: overrideProps.authorId || 'some-id',
authorColor: overrideProps.authorColor || 'blue',
authorAvatarPath: overrideProps.authorAvatarPath,
authorTitle: text('authorTitle', overrideProps.authorTitle || ''),
@ -85,6 +86,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
scrollToQuotedMessage: action('scrollToQuotedMessage'),
selectMessage: action('selectMessage'),
showContactDetail: action('showContactDetail'),
showContactModal: action('showContactModal'),
showExpiredIncomingTapToViewToast: action(
'showExpiredIncomingTapToViewToast'
),

View file

@ -102,6 +102,7 @@ export type PropsData = {
timestamp: number;
status?: MessageStatusType;
contact?: ContactType;
authorId: string;
authorTitle: string;
authorName?: string;
authorProfileName?: string;
@ -170,6 +171,7 @@ export type PropsActions = {
contact: ContactType;
signalAccount?: string;
}) => void;
showContactModal: (contactId: string) => void;
showVisualAttachment: (options: {
attachment: AttachmentType;
@ -1055,6 +1057,7 @@ export class Message extends React.PureComponent<Props, State> {
public renderAvatar(): JSX.Element | undefined {
const {
authorAvatarPath,
authorId,
authorName,
authorPhoneNumber,
authorProfileName,
@ -1064,6 +1067,7 @@ export class Message extends React.PureComponent<Props, State> {
conversationType,
direction,
i18n,
showContactModal,
} = this.props;
if (
@ -1071,12 +1075,16 @@ export class Message extends React.PureComponent<Props, State> {
conversationType !== 'group' ||
direction === 'outgoing'
) {
return;
return undefined;
}
// eslint-disable-next-line consistent-return
return (
<div className="module-message__author-avatar">
<button
type="button"
className="module-message__author-avatar"
onClick={() => showContactModal(authorId)}
tabIndex={0}
>
<Avatar
avatarPath={authorAvatarPath}
color={authorColor}
@ -1088,7 +1096,7 @@ export class Message extends React.PureComponent<Props, State> {
title={authorTitle}
size={28}
/>
</div>
</button>
);
}

View file

@ -17,6 +17,7 @@ const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/Conversation/MessageDetail', module);
const defaultMessage: MessageProps = {
authorId: 'some-id',
authorTitle: 'Max',
canReply: true,
canDeleteForEveryone: true,
@ -41,6 +42,7 @@ const defaultMessage: MessageProps = {
retrySend: () => null,
scrollToQuotedMessage: () => null,
showContactDetail: () => null,
showContactModal: () => null,
showExpiredIncomingTapToViewToast: () => null,
showExpiredOutgoingTapToViewToast: () => null,
showMessageDetail: () => null,

View file

@ -20,6 +20,7 @@ const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/Conversation/Quote', module);
const defaultMessageProps: MessagesProps = {
authorId: 'some-id',
authorTitle: 'Person X',
canReply: true,
canDeleteForEveryone: true,
@ -45,6 +46,7 @@ const defaultMessageProps: MessagesProps = {
scrollToQuotedMessage: () => null,
selectMessage: () => null,
showContactDetail: () => null,
showContactModal: () => null,
showExpiredIncomingTapToViewToast: () => null,
showExpiredOutgoingTapToViewToast: () => null,
showMessageDetail: () => null,

View file

@ -230,6 +230,7 @@ const actions = () => ({
showMessageDetail: action('showMessageDetail'),
openConversation: action('openConversation'),
showContactDetail: action('showContactDetail'),
showContactModal: action('showContactModal'),
showVisualAttachment: action('showVisualAttachment'),
downloadAttachment: action('downloadAttachment'),
displayTapToViewMessage: action('displayTapToViewMessage'),

View file

@ -47,6 +47,7 @@ const getDefaultProps = () => ({
showMessageDetail: action('showMessageDetail'),
openConversation: action('openConversation'),
showContactDetail: action('showContactDetail'),
showContactModal: action('showContactModal'),
showVisualAttachment: action('showVisualAttachment'),
downloadAttachment: action('downloadAttachment'),
displayTapToViewMessage: action('displayTapToViewMessage'),