Modernize ContactModal

This commit is contained in:
Josh Perez 2021-09-21 18:37:10 -04:00 committed by GitHub
parent 1d2fcde49f
commit c05d23e628
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 426 additions and 493 deletions

View file

@ -28,15 +28,17 @@ const defaultContact: ConversationType = getDefaultConversation({
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
areWeAdmin: boolean('areWeAdmin', overrideProps.areWeAdmin || false),
contact: overrideProps.contact || defaultContact,
hideContactModal: action('hideContactModal'),
i18n,
isAdmin: boolean('isAdmin', overrideProps.isAdmin || false),
isMember: boolean('isMember', overrideProps.isMember || true),
onClose: action('onClose'),
openConversation: action('openConversation'),
removeMember: action('removeMember'),
showSafetyNumber: action('showSafetyNumber'),
openConversationInternal: action('openConversationInternal'),
removeMemberFromGroup: action('removeMemberFromGroup'),
showSafetyNumberInConversation: action('showSafetyNumberInConversation'),
toggleAdmin: action('toggleAdmin'),
updateSharedGroups: action('updateSharedGroups'),
updateConversationModelSharedGroups: action(
'updateConversationModelSharedGroups'
),
});
story.add('As non-admin', () => {

View file

@ -1,103 +1,73 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { ReactPortal, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import React, { useEffect, useState } from 'react';
import { About } from './About';
import { Avatar } from '../Avatar';
import { AvatarLightbox } from '../AvatarLightbox';
import { ConversationType } from '../../state/ducks/conversations';
import { Modal } from '../Modal';
import { LocalizerType } from '../../types/Util';
import { SharedGroupNames } from '../SharedGroupNames';
import { ConfirmationDialog } from '../ConfirmationDialog';
export type PropsType = {
export type PropsDataType = {
areWeAdmin: boolean;
contact?: ConversationType;
conversationId?: string;
readonly i18n: LocalizerType;
isAdmin: boolean;
isMember: boolean;
onClose: () => void;
openConversation: (conversationId: string) => void;
removeMember: (conversationId: string) => void;
showSafetyNumber: (conversationId: string) => void;
toggleAdmin: (conversationId: string) => void;
updateSharedGroups: () => void;
};
type PropsActionType = {
hideContactModal: () => void;
openConversationInternal: (
options: Readonly<{
conversationId: string;
messageId?: string;
switchToAssociatedView?: boolean;
}>
) => void;
removeMemberFromGroup: (conversationId: string, contactId: string) => void;
showSafetyNumberInConversation: (conversationId: string) => void;
toggleAdmin: (conversationId: string, contactId: string) => void;
updateConversationModelSharedGroups: (conversationId: string) => void;
};
export type PropsType = PropsDataType & PropsActionType;
export const ContactModal = ({
areWeAdmin,
contact,
conversationId,
hideContactModal,
i18n,
isAdmin,
isMember,
onClose,
openConversation,
removeMember,
showSafetyNumber,
openConversationInternal,
removeMemberFromGroup,
showSafetyNumberInConversation,
toggleAdmin,
updateSharedGroups,
}: PropsType): ReactPortal | null => {
updateConversationModelSharedGroups,
}: PropsType): JSX.Element => {
if (!contact) {
throw new Error('Contact modal opened without a matching contact');
}
const [root, setRoot] = useState<HTMLElement | null>(null);
const overlayRef = useRef<HTMLElement | null>(null);
const closeButtonRef = useRef<HTMLElement | null>(null);
const [showingAvatar, setShowingAvatar] = useState(false);
const [confirmToggleAdmin, setConfirmToggleAdmin] = useState(false);
useEffect(() => {
const div = document.createElement('div');
document.body.appendChild(div);
setRoot(div);
return () => {
document.body.removeChild(div);
setRoot(null);
};
}, []);
useEffect(() => {
// Kick off the expensive hydration of the current sharedGroupNames
updateSharedGroups();
}, [updateSharedGroups]);
useEffect(() => {
if (root !== null && closeButtonRef.current) {
closeButtonRef.current.focus();
if (conversationId) {
// Kick off the expensive hydration of the current sharedGroupNames
updateConversationModelSharedGroups(conversationId);
}
}, [root]);
}, [conversationId, updateConversationModelSharedGroups]);
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();
}
};
let content: JSX.Element;
if (showingAvatar) {
content = (
return (
<AvatarLightbox
avatarColor={contact.color}
avatarPath={contact.avatarPath}
@ -106,18 +76,16 @@ export const ContactModal = ({
onClose={() => setShowingAvatar(false)}
/>
);
} else {
content = (
<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')}
/>
}
return (
<Modal
moduleClassName="ContactModal__modal"
hasXButton
i18n={i18n}
onClose={hideContactModal}
>
<div className="ContactModal">
<Avatar
acceptedMessageRequest={contact.acceptedMessageRequest}
avatarPath={contact.avatarPath}
@ -133,53 +101,59 @@ export const ContactModal = ({
unblurredAvatarPath={contact.unblurredAvatarPath}
onClick={() => setShowingAvatar(true)}
/>
<div className="module-contact-modal__name">{contact.title}</div>
<div className="ContactModal__name">{contact.title}</div>
<div className="module-about__container">
<About text={contact.about} />
</div>
{contact.phoneNumber && (
<div className="module-contact-modal__info">
{contact.phoneNumber}
<div className="ContactModal__info">{contact.phoneNumber}</div>
)}
{!contact.isMe && (
<div className="ContactModal__info">
<SharedGroupNames
i18n={i18n}
sharedGroupNames={contact.sharedGroupNames || []}
/>
</div>
)}
<div className="module-contact-modal__info">
<SharedGroupNames
i18n={i18n}
sharedGroupNames={contact.sharedGroupNames || []}
/>
</div>
<div className="module-contact-modal__button-container">
<div className="ContactModal__button-container">
<button
type="button"
className="module-contact-modal__button module-contact-modal__send-message"
onClick={() => openConversation(contact.id)}
className="ContactModal__button ContactModal__send-message"
onClick={() => {
hideContactModal();
openConversationInternal({ conversationId: contact.id });
}}
>
<div className="module-contact-modal__bubble-icon">
<div className="module-contact-modal__send-message__bubble-icon" />
<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="module-contact-modal__button module-contact-modal__safety-number"
onClick={() => showSafetyNumber(contact.id)}
className="ContactModal__button ContactModal__safety-number"
onClick={() => {
hideContactModal();
showSafetyNumberInConversation(contact.id);
}}
>
<div className="module-contact-modal__bubble-icon">
<div className="module-contact-modal__safety-number__bubble-icon" />
<div className="ContactModal__bubble-icon">
<div className="ContactModal__safety-number__bubble-icon" />
</div>
<span>{i18n('showSafetyNumber')}</span>
</button>
)}
{!contact.isMe && areWeAdmin && isMember && (
{!contact.isMe && areWeAdmin && isMember && conversationId && (
<>
<button
type="button"
className="module-contact-modal__button module-contact-modal__make-admin"
onClick={() => toggleAdmin(contact.id)}
className="ContactModal__button ContactModal__make-admin"
onClick={() => setConfirmToggleAdmin(true)}
>
<div className="module-contact-modal__bubble-icon">
<div className="module-contact-modal__make-admin__bubble-icon" />
<div className="ContactModal__bubble-icon">
<div className="ContactModal__make-admin__bubble-icon" />
</div>
{isAdmin ? (
<span>{i18n('ContactModal--rm-admin')}</span>
@ -189,34 +163,38 @@ export const ContactModal = ({
</button>
<button
type="button"
className="module-contact-modal__button module-contact-modal__remove-from-group"
onClick={() => removeMember(contact.id)}
className="ContactModal__button ContactModal__remove-from-group"
onClick={() =>
removeMemberFromGroup(conversationId, contact.id)
}
>
<div className="module-contact-modal__bubble-icon">
<div className="module-contact-modal__remove-from-group__bubble-icon" />
<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>
);
}
return root
? createPortal(
<div
ref={ref => {
overlayRef.current = ref;
}}
role="presentation"
className="module-contact-modal__overlay"
onClick={onClickOverlay}
>
{content}
</div>,
root
)
: null;
</Modal>
);
};

View file

@ -60,7 +60,6 @@ export type StateProps = {
pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
setDisappearingMessages: (seconds: number) => void;
showAllMedia: () => void;
showContactModal: (conversationId: string) => void;
showGroupChatColorEditor: () => void;
showGroupLinkManagement: () => void;
showGroupV2Permissions: () => void;
@ -86,6 +85,7 @@ type ActionProps = {
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
replaceAvatar: ReplaceAvatarActionType;
saveAvatarToDisk: SaveAvatarToDiskActionType;
showContactModal: (contactId: string, conversationId: string) => void;
};
export type Props = StateProps & ActionProps;
@ -329,6 +329,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
<ConversationDetailsMembershipList
canAddNewMembers={canEditGroupInfo}
conversationId={conversation.id}
i18n={i18n}
memberships={memberships}
showContactModal={showContactModal}

View file

@ -44,6 +44,7 @@ const createProps = (overrideProps: Partial<Props>): Props => ({
canAddNewMembers: isBoolean(overrideProps.canAddNewMembers)
? overrideProps.canAddNewMembers
: false,
conversationId: '123',
i18n,
memberships: overrideProps.memberships || [],
showContactModal: action('showContactModal'),

View file

@ -19,10 +19,11 @@ export type GroupV2Membership = {
export type Props = {
canAddNewMembers: boolean;
conversationId: string;
i18n: LocalizerType;
maxShownMemberCount?: number;
memberships: Array<GroupV2Membership>;
showContactModal: (conversationId: string) => void;
showContactModal: (contactId: string, conversationId: string) => void;
startAddingNewMembers?: () => void;
};
@ -67,6 +68,7 @@ function sortMemberships(
export const ConversationDetailsMembershipList: React.ComponentType<Props> = ({
canAddNewMembers,
conversationId,
i18n,
maxShownMemberCount = 5,
memberships,
@ -101,7 +103,7 @@ export const ConversationDetailsMembershipList: React.ComponentType<Props> = ({
{sortedMemberships.slice(0, membersToShow).map(({ isAdmin, member }) => (
<PanelRow
key={member.id}
onClick={() => showContactModal(member.id)}
onClick={() => showContactModal(member.id, conversationId)}
icon={
<Avatar
conversationType="direct"