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

@ -67,9 +67,6 @@ const {
const { const {
createCompositionArea, createCompositionArea,
} = require('../../ts/state/roots/createCompositionArea'); } = require('../../ts/state/roots/createCompositionArea');
const {
createContactModal,
} = require('../../ts/state/roots/createContactModal');
const { const {
createConversationDetails, createConversationDetails,
} = require('../../ts/state/roots/createConversationDetails'); } = require('../../ts/state/roots/createConversationDetails');
@ -363,7 +360,6 @@ exports.setup = (options = {}) => {
createApp, createApp,
createChatColorPicker, createChatColorPicker,
createCompositionArea, createCompositionArea,
createContactModal,
createConversationDetails, createConversationDetails,
createConversationHeader, createConversationHeader,
createForwardMessageModal, createForwardMessageModal,

View File

@ -8812,213 +8812,6 @@ button.module-image__border-overlay:focus {
} }
} }
// Module: Group Contact Details
$contact-modal-padding: 18px;
.module-contact-modal {
@include font-body-2;
min-width: 280px;
padding: $contact-modal-padding;
border-radius: 8px;
overflow: hidden;
@include popper-shadow();
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
@include light-theme() {
background: $color-white;
color: $color-gray-90;
}
@include dark-theme() {
background: $color-gray-75;
color: $color-gray-05;
}
&__overlay {
background: $color-black-alpha-40;
position: fixed;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
z-index: 5;
}
}
.module-contact-modal__name {
@include font-title-2;
margin-top: 6px;
}
.module-contact-modal__info {
text-align: center;
max-width: 248px;
margin-top: 8px;
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-25;
}
}
.module-contact-modal__button-container {
display: flex;
flex-direction: column;
align-items: flex-start;
margin: 12px 0 15px -$contact-modal-padding;
width: calc(100% + (#{$contact-modal-padding} * 2));
}
.module-contact-modal__button {
@include button-reset;
display: flex;
align-items: center;
padding: 7px $contact-modal-padding;
width: 100%;
&:last-child {
margin-bottom: 0;
}
&:hover {
background-color: $color-gray-15;
@include dark-theme {
background-color: $color-gray-60;
}
}
&:focus {
@include keyboard-mode {
background-color: $color-gray-15;
}
@include dark-keyboard-mode {
background-color: $color-gray-60;
}
}
}
.module-contact-modal__bubble-icon {
display: flex;
justify-content: center;
align-items: center;
margin-right: 10px;
width: 20px;
}
.module-contact-modal__send-message__bubble-icon {
height: 16px;
width: 18px;
@include light-theme {
@include color-svg(
'../images/icons/v2/message-outline-24.svg',
$color-gray-75
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/message-outline-24.svg',
$color-gray-15
);
}
}
.module-contact-modal__safety-number__bubble-icon {
height: 18px;
width: 17px;
@include light-theme {
@include color-svg(
'../images/icons/v2/safety-number-outline-24.svg',
$color-gray-75
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/safety-number-outline-24.svg',
$color-gray-15
);
}
}
.module-contact-modal__make-admin__bubble-icon {
height: 16px;
width: 18px;
@include light-theme {
@include color-svg(
'../images/icons/v2/group-outline-24.svg',
$color-gray-75
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/group-outline-24.svg',
$color-gray-15
);
}
}
.module-contact-modal__remove-from-group__bubble-icon {
height: 16px;
width: 16px;
@include light-theme {
@include color-svg(
'../images/icons/v2/leave-group-outline-16.svg',
$color-gray-75
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/leave-group-outline-16.svg',
$color-gray-15
);
}
}
.module-contact-modal__close-button {
@include button-reset;
position: absolute;
top: 10px;
right: 12px;
width: 24px;
height: 24px;
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-75);
@include dark-theme() {
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-25);
}
&:focus {
@include keyboard-mode {
background-color: $color-ultramarine;
}
}
}
.module-background-color { .module-background-color {
&__default { &__default {
background-color: $color-black-alpha-40; background-color: $color-black-alpha-40;

View File

@ -0,0 +1,148 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.ContactModal {
align-items: center;
display: flex;
flex-direction: column;
justify-content: center;
&__name {
@include font-title-2;
margin-top: 6px;
}
&__info {
text-align: center;
max-width: 248px;
margin-top: 8px;
}
&__button-container {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-top: 12px;
width: 100%;
}
&__button {
@include button-reset;
display: flex;
align-items: center;
padding: 7px 16px;
width: 100%;
&:last-child {
margin-bottom: 0;
}
&:hover {
background-color: $color-gray-02;
@include dark-theme {
background-color: $color-gray-80;
}
}
&:focus {
@include keyboard-mode {
background-color: $color-gray-02;
}
@include dark-keyboard-mode {
background-color: $color-gray-80;
}
}
}
&__bubble-icon {
display: flex;
justify-content: center;
align-items: center;
margin-right: 10px;
width: 20px;
}
&__send-message__bubble-icon {
height: 16px;
width: 18px;
@include light-theme {
@include color-svg(
'../images/icons/v2/message-outline-24.svg',
$color-gray-75
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/message-outline-24.svg',
$color-gray-15
);
}
}
&__safety-number__bubble-icon {
height: 18px;
width: 17px;
@include light-theme {
@include color-svg(
'../images/icons/v2/safety-number-outline-24.svg',
$color-gray-75
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/safety-number-outline-24.svg',
$color-gray-15
);
}
}
&__make-admin__bubble-icon {
height: 16px;
width: 18px;
@include light-theme {
@include color-svg(
'../images/icons/v2/group-outline-24.svg',
$color-gray-75
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/group-outline-24.svg',
$color-gray-15
);
}
}
&__remove-from-group__bubble-icon {
height: 16px;
width: 16px;
@include light-theme {
@include color-svg(
'../images/icons/v2/leave-group-outline-16.svg',
$color-gray-75
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/leave-group-outline-16.svg',
$color-gray-15
);
}
}
}
.module-Modal.ContactModal__modal .ContactModal__modal__body {
padding-left: 0;
padding-right: 0;
}

View File

@ -87,7 +87,7 @@
padding: 0 16px 16px 16px; padding: 0 16px 16px 16px;
border-top: 1px solid transparent; border-top: 1px solid transparent;
// If there's a header, just the body scrolls // If there's a header, just the body scrolls
overflow-y: scroll; // scroll so that the padding is always there overflow-y: overlay;
overflow-x: auto; overflow-x: auto;
&--scrolled { &--scrolled {
@ -105,7 +105,7 @@
&--no-header { &--no-header {
padding: 16px; padding: 16px;
// If there's no header, the whole thing scrolls // If there's no header, the whole thing scrolls
overflow-y: scroll; // scroll so that the padding is always there overflow-y: overlay;
overflow-x: auto; overflow-x: auto;
} }

View File

@ -45,6 +45,7 @@
@import './components/ChatColorPicker.scss'; @import './components/ChatColorPicker.scss';
@import './components/Checkbox.scss'; @import './components/Checkbox.scss';
@import './components/CompositionArea.scss'; @import './components/CompositionArea.scss';
@import './components/ContactModal.scss';
@import './components/ContactName.scss'; @import './components/ContactName.scss';
@import './components/ContactPill.scss'; @import './components/ContactPill.scss';
@import './components/ContactPills.scss'; @import './components/ContactPills.scss';

View File

@ -1,17 +1,29 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { ContactModalStateType } from '../state/ducks/globalModals';
type PropsType = { type PropsType = {
// ContactModal
contactModalState?: ContactModalStateType;
renderContactModal: () => JSX.Element;
// ProfileEditor // ProfileEditor
isProfileEditorVisible: boolean; isProfileEditorVisible: boolean;
renderProfileEditor: () => JSX.Element; renderProfileEditor: () => JSX.Element;
}; };
export const GlobalModalContainer = ({ export const GlobalModalContainer = ({
// ContactModal
contactModalState,
renderContactModal,
// ProfileEditor // ProfileEditor
isProfileEditorVisible, isProfileEditorVisible,
renderProfileEditor, renderProfileEditor,
}: PropsType): JSX.Element | null => { }: PropsType): JSX.Element | null => {
if (contactModalState) {
return renderContactModal();
}
if (isProfileEditorVisible) { if (isProfileEditorVisible) {
return renderProfileEditor(); return renderProfileEditor();
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -58,6 +58,7 @@ import {
import { AvatarDataType, getDefaultAvatars } from '../../types/Avatar'; import { AvatarDataType, getDefaultAvatars } from '../../types/Avatar';
import { getAvatarData } from '../../util/getAvatarData'; import { getAvatarData } from '../../util/getAvatarData';
import { isSameAvatarData } from '../../util/isSameAvatarData'; import { isSameAvatarData } from '../../util/isSameAvatarData';
import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper';
import { NoopActionType } from './noop'; import { NoopActionType } from './noop';
@ -780,6 +781,7 @@ export const actions = {
openConversationInternal, openConversationInternal,
removeAllConversations, removeAllConversations,
removeCustomColorOnConversations, removeCustomColorOnConversations,
removeMemberFromGroup,
repairNewestMessage, repairNewestMessage,
repairOldestMessage, repairOldestMessage,
replaceAvatar, replaceAvatar,
@ -803,11 +805,14 @@ export const actions = {
showArchivedConversations, showArchivedConversations,
showChooseGroupMembers, showChooseGroupMembers,
showInbox, showInbox,
showSafetyNumberInConversation,
startComposing, startComposing,
startNewConversationFromPhoneNumber, startNewConversationFromPhoneNumber,
startSettingGroupMetadata, startSettingGroupMetadata,
toggleAdmin,
toggleConversationInChooseMembers, toggleConversationInChooseMembers,
toggleComposeEditingAvatar, toggleComposeEditingAvatar,
updateConversationModelSharedGroups,
verifyConversationsStoppingMessageSend, verifyConversationsStoppingMessageSend,
}; };
@ -1720,6 +1725,73 @@ function openConversationExternal(
}; };
} }
function removeMemberFromGroup(
conversationId: string,
contactId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return dispatch => {
const conversationModel = window.ConversationController.get(conversationId);
if (conversationModel) {
const idForLogging = conversationModel.idForLogging();
longRunningTaskWrapper({
name: 'removeMemberFromGroup',
idForLogging,
task: () => conversationModel.removeFromGroupV2(contactId),
});
}
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function toggleAdmin(
conversationId: string,
contactId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return dispatch => {
const conversationModel = window.ConversationController.get(conversationId);
if (conversationModel) {
conversationModel.toggleAdmin(contactId);
}
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function updateConversationModelSharedGroups(
conversationId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (conversation && conversation.throttledUpdateSharedGroups) {
conversation.throttledUpdateSharedGroups();
}
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function showSafetyNumberInConversation(
conversationId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return dispatch => {
window.Whisper.events.trigger(
'showSafetyNumberInConversation',
conversationId
);
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function showInbox(): ShowInboxActionType { function showInbox(): ShowInboxActionType {
return { return {
type: 'SHOW_INBOX', type: 'SHOW_INBOX',

View File

@ -4,16 +4,33 @@
// State // State
export type GlobalModalsStateType = { export type GlobalModalsStateType = {
readonly contactModalState?: ContactModalStateType;
readonly isProfileEditorVisible: boolean; readonly isProfileEditorVisible: boolean;
readonly profileEditorHasError: boolean; readonly profileEditorHasError: boolean;
}; };
// Actions // Actions
const HIDE_CONTACT_MODAL = 'globalModals/HIDE_CONTACT_MODAL';
const SHOW_CONTACT_MODAL = 'globalModals/SHOW_CONTACT_MODAL';
const TOGGLE_PROFILE_EDITOR = 'globalModals/TOGGLE_PROFILE_EDITOR'; const TOGGLE_PROFILE_EDITOR = 'globalModals/TOGGLE_PROFILE_EDITOR';
export const TOGGLE_PROFILE_EDITOR_ERROR = export const TOGGLE_PROFILE_EDITOR_ERROR =
'globalModals/TOGGLE_PROFILE_EDITOR_ERROR'; 'globalModals/TOGGLE_PROFILE_EDITOR_ERROR';
export type ContactModalStateType = {
contactId: string;
conversationId?: string;
};
type HideContactModalActionType = {
type: typeof HIDE_CONTACT_MODAL;
};
type ShowContactModalActionType = {
type: typeof SHOW_CONTACT_MODAL;
payload: ContactModalStateType;
};
type ToggleProfileEditorActionType = { type ToggleProfileEditorActionType = {
type: typeof TOGGLE_PROFILE_EDITOR; type: typeof TOGGLE_PROFILE_EDITOR;
}; };
@ -23,16 +40,39 @@ export type ToggleProfileEditorErrorActionType = {
}; };
export type GlobalModalsActionType = export type GlobalModalsActionType =
| HideContactModalActionType
| ShowContactModalActionType
| ToggleProfileEditorActionType | ToggleProfileEditorActionType
| ToggleProfileEditorErrorActionType; | ToggleProfileEditorErrorActionType;
// Action Creators // Action Creators
export const actions = { export const actions = {
hideContactModal,
showContactModal,
toggleProfileEditor, toggleProfileEditor,
toggleProfileEditorHasError, toggleProfileEditorHasError,
}; };
function hideContactModal(): HideContactModalActionType {
return {
type: HIDE_CONTACT_MODAL,
};
}
function showContactModal(
contactId: string,
conversationId?: string
): ShowContactModalActionType {
return {
type: SHOW_CONTACT_MODAL,
payload: {
contactId,
conversationId,
},
};
}
function toggleProfileEditor(): ToggleProfileEditorActionType { function toggleProfileEditor(): ToggleProfileEditorActionType {
return { type: TOGGLE_PROFILE_EDITOR }; return { type: TOGGLE_PROFILE_EDITOR };
} }
@ -68,5 +108,19 @@ export function reducer(
}; };
} }
if (action.type === SHOW_CONTACT_MODAL) {
return {
...state,
contactModalState: action.payload,
};
}
if (action.type === HIDE_CONTACT_MODAL) {
return {
...state,
contactModalState: undefined,
};
}
return state; return state;
} }

View File

@ -1,21 +0,0 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import {
SmartContactModal,
SmartContactModalProps,
} from '../smart/ContactModal';
export const createContactModal = (
store: Store,
props: SmartContactModalProps
): React.ReactElement => (
<Provider store={store}>
<SmartContactModal {...props} />
</Provider>
);

View File

@ -5,33 +5,18 @@ import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions'; import { mapDispatchToProps } from '../actions';
import { import {
ContactModal, ContactModal,
PropsType, PropsDataType,
} from '../../components/conversation/ContactModal'; } from '../../components/conversation/ContactModal';
import { StateType } from '../reducer'; import { StateType } from '../reducer';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
import { getConversationSelector } from '../selectors/conversations'; import { getConversationSelector } from '../selectors/conversations';
export type SmartContactModalProps = { const mapStateToProps = (state: StateType): PropsDataType => {
contactId: string; const { contactId, conversationId } =
currentConversationId: string; state.globalModals.contactModalState || {};
readonly onClose: () => unknown;
readonly openConversation: (conversationId: string) => void;
readonly removeMember: (conversationId: string) => void;
readonly showSafetyNumber: (conversationId: string) => void;
readonly toggleAdmin: (conversationId: string) => void;
readonly updateSharedGroups: () => void;
};
const mapStateToProps = ( const currentConversation = getConversationSelector(state)(conversationId);
state: StateType,
props: SmartContactModalProps
): PropsType => {
const { contactId, currentConversationId } = props;
const currentConversation = getConversationSelector(state)(
currentConversationId
);
const contact = getConversationSelector(state)(contactId); const contact = getConversationSelector(state)(contactId);
const areWeAdmin = const areWeAdmin =
@ -51,9 +36,9 @@ const mapStateToProps = (
} }
return { return {
...props,
areWeAdmin, areWeAdmin,
contact, contact,
conversationId,
i18n: getIntl(state), i18n: getIntl(state),
isAdmin, isAdmin,
isMember, isMember,

View File

@ -25,7 +25,6 @@ export type SmartConversationDetailsProps = {
loadRecentMediaItems: (limit: number) => void; loadRecentMediaItems: (limit: number) => void;
setDisappearingMessages: (seconds: number) => void; setDisappearingMessages: (seconds: number) => void;
showAllMedia: () => void; showAllMedia: () => void;
showContactModal: (conversationId: string) => void;
showGroupChatColorEditor: () => void; showGroupChatColorEditor: () => void;
showGroupLinkManagement: () => void; showGroupLinkManagement: () => void;
showGroupV2Permissions: () => void; showGroupV2Permissions: () => void;

View File

@ -7,6 +7,7 @@ import { mapDispatchToProps } from '../actions';
import { GlobalModalContainer } from '../../components/GlobalModalContainer'; import { GlobalModalContainer } from '../../components/GlobalModalContainer';
import { StateType } from '../reducer'; import { StateType } from '../reducer';
import { SmartProfileEditorModal } from './ProfileEditorModal'; import { SmartProfileEditorModal } from './ProfileEditorModal';
import { SmartContactModal } from './ContactModal';
const FilteredSmartProfileEditorModal = SmartProfileEditorModal; const FilteredSmartProfileEditorModal = SmartProfileEditorModal;
@ -14,9 +15,14 @@ function renderProfileEditor(): JSX.Element {
return <FilteredSmartProfileEditorModal />; return <FilteredSmartProfileEditorModal />;
} }
function renderContactModal(): JSX.Element {
return <SmartContactModal />;
}
const mapStateToProps = (state: StateType) => { const mapStateToProps = (state: StateType) => {
return { return {
...state.globalModals, ...state.globalModals,
renderContactModal,
renderProfileEditor, renderProfileEditor,
}; };
}; };

View File

@ -12717,20 +12717,6 @@
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2021-07-30T16:57:33.618Z" "updated": "2021-07-30T16:57:33.618Z"
}, },
{
"rule": "React-useRef",
"path": "ts/components/conversation/ContactModal.tsx",
"line": " const overlayRef = useRef<HTMLElement | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2021-08-03T21:17:38.615Z"
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/ContactModal.tsx",
"line": " const closeButtonRef = useRef<HTMLElement | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2021-08-03T21:17:38.615Z"
},
{ {
"rule": "React-createRef", "rule": "React-createRef",
"path": "ts/components/conversation/ConversationHeader.js", "path": "ts/components/conversation/ConversationHeader.js",

View File

@ -646,22 +646,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
return this; return this;
} }
getMuteExpirationLabel(): string | undefined {
const muteExpiresAt = this.model.get('muteExpiresAt');
if (!this.model.isMuted()) {
return;
}
const today = window.moment(Date.now());
const expires = window.moment(muteExpiresAt);
if (today.isSame(expires, 'day')) {
return expires.format('hh:mm A');
}
return expires.format('M/D/YY, hh:mm A');
}
setMuteExpiration(ms = 0): void { setMuteExpiration(ms = 0): void {
this.model.setMuteExpiration( this.model.setMuteExpiration(
ms >= Number.MAX_SAFE_INTEGER ? ms : Date.now() + ms ms >= Number.MAX_SAFE_INTEGER ? ms : Date.now() + ms
@ -3298,81 +3282,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
} }
showContactModal(contactId: string): void { showContactModal(contactId: string): void {
if (this.contactModalView) { window.reduxActions.globalModals.showContactModal(contactId, this.model.id);
this.contactModalView.remove();
this.contactModalView = undefined;
}
this.previousFocus = document.activeElement as HTMLElement;
const hideContactModal = () => {
if (this.contactModalView) {
this.contactModalView.remove();
this.contactModalView = undefined;
if (this.previousFocus && this.previousFocus.focus) {
this.previousFocus.focus();
this.previousFocus = undefined;
}
}
};
this.contactModalView = new Whisper.ReactWrapperView({
JSX: window.Signal.State.Roots.createContactModal(window.reduxStore, {
contactId,
currentConversationId: this.model.id,
onClose: hideContactModal,
openConversation: (conversationId: string) => {
hideContactModal();
this.openConversation(conversationId);
},
removeMember: (conversationId: string) => {
hideContactModal();
this.model.removeFromGroupV2(conversationId);
},
showSafetyNumber: (conversationId: string) => {
hideContactModal();
this.showSafetyNumber(conversationId);
},
toggleAdmin: (conversationId: string) => {
hideContactModal();
const isAdmin = this.model.isAdmin(conversationId);
const conversationModel = window.ConversationController.get(
conversationId
);
if (!conversationModel) {
log.info(
'conversation_view/toggleAdmin: Could not find conversation to toggle admin privileges'
);
return;
}
window.showConfirmationDialog({
cancelText: window.i18n('cancel'),
message: isAdmin
? window.i18n('ContactModal--rm-admin-info', [
conversationModel.getTitle(),
])
: window.i18n('ContactModal--make-admin-info', [
conversationModel.getTitle(),
]),
okText: isAdmin
? window.i18n('ContactModal--rm-admin')
: window.i18n('ContactModal--make-admin'),
resolve: () => this.model.toggleAdmin(conversationId),
});
},
updateSharedGroups: () => {
const conversation = window.ConversationController.get(contactId);
if (conversation && conversation.throttledUpdateSharedGroups) {
conversation.throttledUpdateSharedGroups();
}
},
}),
});
this.contactModalView.render();
} }
showGroupLinkManagement(): void { showGroupLinkManagement(): void {

View File

@ -23,6 +23,9 @@ const ConversationStack = Whisper.View.extend({
model: conversation, model: conversation,
}); });
this.listenTo(conversation, 'unload', () => this.onUnload(conversation)); this.listenTo(conversation, 'unload', () => this.onUnload(conversation));
this.listenTo(conversation, 'showSafetyNumber', () =>
view.showSafetyNumber()
);
view.$el.appendTo(this.el); view.$el.appendTo(this.el);
if (this.lastConversation && this.lastConversation !== conversation) { if (this.lastConversation && this.lastConversation !== conversation) {
@ -119,6 +122,13 @@ Whisper.InboxView = Whisper.View.extend({
this.focusConversation(); this.focusConversation();
}); });
window.Whisper.events.on('showSafetyNumberInConversation', id => {
const conversation = window.ConversationController.get(id);
if (conversation) {
conversation.trigger('showSafetyNumber');
}
});
window.Whisper.events.on('loadingProgress', count => { window.Whisper.events.on('loadingProgress', count => {
const view = this.appLoadingScreen; const view = this.appLoadingScreen;
if (view) { if (view) {

2
ts/window.d.ts vendored
View File

@ -43,7 +43,6 @@ import { createStore } from './state/createStore';
import { createApp } from './state/roots/createApp'; import { createApp } from './state/roots/createApp';
import { createChatColorPicker } from './state/roots/createChatColorPicker'; import { createChatColorPicker } from './state/roots/createChatColorPicker';
import { createCompositionArea } from './state/roots/createCompositionArea'; import { createCompositionArea } from './state/roots/createCompositionArea';
import { createContactModal } from './state/roots/createContactModal';
import { createConversationDetails } from './state/roots/createConversationDetails'; import { createConversationDetails } from './state/roots/createConversationDetails';
import { createConversationHeader } from './state/roots/createConversationHeader'; import { createConversationHeader } from './state/roots/createConversationHeader';
import { createForwardMessageModal } from './state/roots/createForwardMessageModal'; import { createForwardMessageModal } from './state/roots/createForwardMessageModal';
@ -424,7 +423,6 @@ declare global {
createApp: typeof createApp; createApp: typeof createApp;
createChatColorPicker: typeof createChatColorPicker; createChatColorPicker: typeof createChatColorPicker;
createCompositionArea: typeof createCompositionArea; createCompositionArea: typeof createCompositionArea;
createContactModal: typeof createContactModal;
createConversationDetails: typeof createConversationDetails; createConversationDetails: typeof createConversationDetails;
createConversationHeader: typeof createConversationHeader; createConversationHeader: typeof createConversationHeader;
createForwardMessageModal: typeof createForwardMessageModal; createForwardMessageModal: typeof createForwardMessageModal;