diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 6345e680d..c337220a5 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -3901,5 +3901,17 @@ "countMutedConversationsDescription": { "message": "Count muted conversations in badge count", "description": "Description for counting muted conversations in badge setting" + }, + "ContactModal--message": { + "message": "Message", + "description": "Button text for send message button in Group Contact Details modal" + }, + "ContactModal--make-admin": { + "message": "Make admin", + "description": "Button text for make admin button in Group Contact Details modal" + }, + "ContactModal--remove-from-group": { + "message": "Remove from group", + "description": "Button text for remove from group button in Group Contact Details modal" } } diff --git a/images/icons/v2/leave-group-outline-16.svg b/images/icons/v2/leave-group-outline-16.svg new file mode 100644 index 000000000..02dd578b2 --- /dev/null +++ b/images/icons/v2/leave-group-outline-16.svg @@ -0,0 +1,3 @@ + + + diff --git a/js/modules/signal.js b/js/modules/signal.js index afd233e44..031ea520f 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -36,6 +36,9 @@ const { ContactDetail, } = require('../../ts/components/conversation/ContactDetail'); const { ContactListItem } = require('../../ts/components/ContactListItem'); +const { + ContactModal, +} = require('../../ts/components/conversation/ContactModal'); const { Emojify } = require('../../ts/components/conversation/Emojify'); const { ErrorModal } = require('../../ts/components/ErrorModal'); const { Lightbox } = require('../../ts/components/Lightbox'); @@ -63,6 +66,9 @@ const { createTimeline } = require('../../ts/state/roots/createTimeline'); const { createCompositionArea, } = require('../../ts/state/roots/createCompositionArea'); +const { + createContactModal, +} = require('../../ts/state/roots/createContactModal'); const { createConversationHeader, } = require('../../ts/state/roots/createConversationHeader'); @@ -298,6 +304,7 @@ exports.setup = (options = {}) => { ConfirmationModal, ContactDetail, ContactListItem, + ContactModal, Emojify, ErrorModal, getCallingNotificationText, @@ -317,6 +324,7 @@ exports.setup = (options = {}) => { const Roots = { createCallManager, createCompositionArea, + createContactModal, createConversationHeader, createLeftPane, createSafetyNumberViewer, diff --git a/js/views/contact_list_view.js b/js/views/contact_list_view.js index 01dac756f..9af1cc658 100644 --- a/js/views/contact_list_view.js +++ b/js/views/contact_list_view.js @@ -18,6 +18,7 @@ this.ourNumber = textsecure.storage.user.getNumber(); this.listenBack = options.listenBack; this.loading = false; + this.conversation = options.conversation; this.listenTo(this.model, 'change', this.render); }, @@ -27,34 +28,23 @@ this.contactView = null; } + const formattedContact = this.model.format(); + this.contactView = new Whisper.ReactWrapperView({ className: 'contact-wrapper', Component: window.Signal.Components.ContactListItem, props: { - ...this.model.format(), - onClick: this.showIdentity.bind(this), + ...formattedContact, + onClick: () => + this.conversation.trigger( + 'show-contact-modal', + formattedContact.id + ), }, }); this.$el.append(this.contactView.el); return this; }, - showIdentity() { - if (this.model.isMe() || this.loading) { - return; - } - - this.loading = true; - this.render(); - - this.panelView = new Whisper.KeyVerificationPanelView({ - model: this.model, - onLoad: view => { - this.loading = false; - this.listenBack(view); - this.render(); - }, - }); - }, }), }); })(); diff --git a/js/views/group_member_list_view.js b/js/views/group_member_list_view.js index d9adc735b..fd080f625 100644 --- a/js/views/group_member_list_view.js +++ b/js/views/group_member_list_view.js @@ -21,6 +21,7 @@ className: 'members', toInclude: { listenBack: options.listenBack, + conversation: options.conversation, }, }); this.member_list_view.render(); diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index eaa142a2e..9676397d7 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -1404,9 +1404,22 @@ } .module-message__author-avatar { + @include button-reset; + + cursor: pointer; position: absolute; bottom: 0px; right: calc(100% + 8px); + + &:focus { + outline: none; + + .module-avatar { + @include keyboard-mode { + box-shadow: 0 0 0 3px $ultramarine-ui-light; + } + } + } } .module-message__typing-container { @@ -4156,11 +4169,33 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05', height: 58px; width: 58px; } + .module-avatar__icon--80.module-avatar__icon--direct { height: 62px; width: 62px; } +.module-avatar--96 { + height: 96px; + width: 96px; + + img { + height: 96px; + width: 96px; + } +} + +.module-avatar__label--96 { + width: 96px; + font-size: 48px; + line-height: 96px; +} + +.module-avatar__icon--96 { + height: 70px; + width: 70px; +} + .module-avatar--112 { height: 112px; width: 112px; @@ -9657,6 +9692,188 @@ button.module-image__border-overlay:focus { } } +// Module: Group Contact Details +$contact-modal-padding: 18px; +.module-contact-modal { + @include font-body-2; + + min-width: 250px; + 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-body-1-bold; + + margin-top: 6px; +} + +.module-contact-modal__profile-and-number { + 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-theme { + 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__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: 16px; + height: 16px; + + @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: $ultramarine-ui-light; + } + } +} + .module-background-color { &__default { background-color: $color-black-alpha-40; diff --git a/ts/background.ts b/ts/background.ts index 17cac5ee8..997a8cae1 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -983,6 +983,10 @@ type WhatIsThis = typeof window.WhatIsThis; if (className.includes('module-main-header__search__input')) { return; } + + if (className.includes('module-contact-modal')) { + return; + } } // These add listeners to document, but we'll run first diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index d0441e5cb..daa61d22f 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -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 { 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!`); } diff --git a/ts/components/conversation/ContactModal.stories.tsx b/ts/components/conversation/ContactModal.stories.tsx new file mode 100644 index 000000000..3847dbc70 --- /dev/null +++ b/ts/components/conversation/ContactModal.stories.tsx @@ -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 => ({ + 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 ; +}); + +story.add('As admin', () => { + const props = createProps({ + areWeAdmin: true, + }); + return ; +}); + +story.add('As admin, viewing non-member of group', () => { + const props = createProps({ + isMember: false, + }); + + return ; +}); + +story.add('Without phone number', () => { + const props = createProps({ + contact: { + ...defaultContact, + phoneNumber: undefined, + }, + }); + + return ; +}); + +story.add('Viewing self', () => { + const props = createProps({ + contact: { + ...defaultContact, + isMe: true, + }, + }); + + return ; +}); diff --git a/ts/components/conversation/ContactModal.tsx b/ts/components/conversation/ContactModal.tsx new file mode 100644 index 000000000..35baf1608 --- /dev/null +++ b/ts/components/conversation/ContactModal.tsx @@ -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(null); + const overlayRef = React.useRef(null); + const closeButtonRef = React.useRef(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) => { + if (e.target === overlayRef.current) { + e.preventDefault(); + e.stopPropagation(); + + onClose(); + } + }; + + return root + ? createPortal( +
{ + overlayRef.current = ref; + }} + role="presentation" + className="module-contact-modal__overlay" + onClick={onClickOverlay} + > +
+ + {!contact.isMe && ( + + )} + {!contact.isMe && areWeAdmin && isMember && ( + + )} +
+
+ , + root + ) + : null; +}; diff --git a/ts/components/conversation/Message.stories.tsx b/ts/components/conversation/Message.stories.tsx index ec2b41824..8fb303fc7 100644 --- a/ts/components/conversation/Message.stories.tsx +++ b/ts/components/conversation/Message.stories.tsx @@ -43,6 +43,7 @@ const renderEmojiPicker: Props['renderEmojiPicker'] = ({ const createProps = (overrideProps: Partial = {}): 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 => ({ scrollToQuotedMessage: action('scrollToQuotedMessage'), selectMessage: action('selectMessage'), showContactDetail: action('showContactDetail'), + showContactModal: action('showContactModal'), showExpiredIncomingTapToViewToast: action( 'showExpiredIncomingTapToViewToast' ), diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 35ee9945b..151e1971f 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -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 { public renderAvatar(): JSX.Element | undefined { const { authorAvatarPath, + authorId, authorName, authorPhoneNumber, authorProfileName, @@ -1064,6 +1067,7 @@ export class Message extends React.PureComponent { conversationType, direction, i18n, + showContactModal, } = this.props; if ( @@ -1071,12 +1075,16 @@ export class Message extends React.PureComponent { conversationType !== 'group' || direction === 'outgoing' ) { - return; + return undefined; } - // eslint-disable-next-line consistent-return return ( -
+
+ ); } diff --git a/ts/components/conversation/MessageDetail.stories.tsx b/ts/components/conversation/MessageDetail.stories.tsx index 55fe76339..8f94553c0 100644 --- a/ts/components/conversation/MessageDetail.stories.tsx +++ b/ts/components/conversation/MessageDetail.stories.tsx @@ -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, diff --git a/ts/components/conversation/Quote.stories.tsx b/ts/components/conversation/Quote.stories.tsx index d97f0ee37..78f428257 100644 --- a/ts/components/conversation/Quote.stories.tsx +++ b/ts/components/conversation/Quote.stories.tsx @@ -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, diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 76c2aa114..ca866d02d 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -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'), diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index ef7919fd3..e74b32bd4 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -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'), diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 288941ca3..3d60279aa 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -1139,6 +1139,7 @@ export class ConversationModel extends window.Backbone.Model< areWePending: Boolean( ourConversationId && this.isMemberPending(ourConversationId) ), + areWeAdmin: this.areWeAdmin(), canChangeTimer: this.canChangeTimer(), avatarPath: this.getAvatarPath()!, color, @@ -1437,6 +1438,24 @@ export class ConversationModel extends window.Backbone.Model< } } + async removeFromGroupV2(conversationId: string): Promise { + if (this.isGroupV2() && this.isMemberPending(conversationId)) { + await this.modifyGroupV2({ + name: 'removePendingMember', + createGroupChange: () => this.removePendingMember(conversationId), + }); + } else if (this.isGroupV2() && this.isMember(conversationId)) { + await this.modifyGroupV2({ + name: 'removeFromGroup', + createGroupChange: () => this.removeMember(conversationId), + }); + } else { + window.log.error( + `removeFromGroupV2: Member ${conversationId} is neither a member nor a pending member of the group` + ); + } + } + async syncMessageRequestResponse(response: number): Promise { // In GroupsV2, this may modify the server. We only want to continue if those // server updates were successful. @@ -4013,6 +4032,14 @@ export class ConversationModel extends window.Backbone.Model< return true; } + return this.areWeAdmin(); + } + + areWeAdmin(): boolean { + if (!this.isGroupV2()) { + return false; + } + const memberEnum = window.textsecure.protobuf.Member.Role; const members = this.get('membersV2') || []; const myId = window.ConversationController.getOurConversationId(); @@ -4021,12 +4048,7 @@ export class ConversationModel extends window.Backbone.Model< return false; } - const isAdministrator = me.role === memberEnum.ADMINISTRATOR; - if (isAdministrator) { - return true; - } - - return false; + return me.role === memberEnum.ADMINISTRATOR; } // Set of items to captureChanges on: diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 0d5e99593..6a60fe339 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -753,6 +753,7 @@ export class MessageModel extends window.Backbone.Model { canReply: this.canReply(), canDeleteForEveryone: this.canDeleteForEveryone(), canDownload: this.canDownload(), + authorId: contact.id, authorTitle: contact.title, authorColor, authorName: contact.name, diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index f97642411..4e6984c44 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -46,6 +46,7 @@ export type ConversationType = { firstName?: string; profileName?: string; avatarPath?: string; + areWeAdmin?: boolean; areWePending?: boolean; canChangeTimer?: boolean; color?: ColorType; diff --git a/ts/state/roots/createContactModal.tsx b/ts/state/roots/createContactModal.tsx new file mode 100644 index 000000000..91cb9aa6d --- /dev/null +++ b/ts/state/roots/createContactModal.tsx @@ -0,0 +1,21 @@ +// 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 => ( + + + +); diff --git a/ts/state/smart/ContactModal.tsx b/ts/state/smart/ContactModal.tsx new file mode 100644 index 000000000..9a7287dd9 --- /dev/null +++ b/ts/state/smart/ContactModal.tsx @@ -0,0 +1,55 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { connect } from 'react-redux'; +import { mapDispatchToProps } from '../actions'; +import { + ContactModal, + PropsType, +} from '../../components/conversation/ContactModal'; +import { StateType } from '../reducer'; + +import { getIntl } from '../selectors/user'; +import { getConversationSelector } from '../selectors/conversations'; + +export type SmartContactModalProps = { + contactId: string; + currentConversationId: string; + readonly onClose: () => unknown; + readonly openConversation: (conversationId: string) => void; + readonly showSafetyNumber: (conversationId: string) => void; + readonly removeMember: (conversationId: string) => void; +}; + +const mapStateToProps = ( + state: StateType, + props: SmartContactModalProps +): PropsType => { + const { contactId, currentConversationId } = props; + + const currentConversation = getConversationSelector(state)( + currentConversationId + ); + const contact = getConversationSelector(state)(contactId); + const isMember = + contact && currentConversation && currentConversation.members + ? currentConversation.members.includes(contact) + : false; + + const areWeAdmin = + currentConversation && currentConversation.areWeAdmin + ? currentConversation.areWeAdmin + : false; + + return { + ...props, + areWeAdmin, + contact, + i18n: getIntl(state), + isMember, + }; +}; + +const smart = connect(mapStateToProps, mapDispatchToProps); + +export const SmartContactModal = smart(ContactModal); diff --git a/ts/test/quill/memberRepository_test.ts b/ts/test/quill/memberRepository_test.ts index 729c19fdc..b50b6975a 100644 --- a/ts/test/quill/memberRepository_test.ts +++ b/ts/test/quill/memberRepository_test.ts @@ -16,6 +16,7 @@ const memberMahershala: ConversationType = { type: 'direct', lastUpdated: Date.now(), markedUnread: false, + areWeAdmin: false, }; const memberShia: ConversationType = { @@ -28,6 +29,7 @@ const memberShia: ConversationType = { type: 'direct', lastUpdated: Date.now(), markedUnread: false, + areWeAdmin: false, }; const members: Array = [memberMahershala, memberShia]; @@ -42,6 +44,7 @@ const singleMember: ConversationType = { type: 'direct', lastUpdated: Date.now(), markedUnread: false, + areWeAdmin: false, }; describe('MemberRepository', () => { diff --git a/ts/test/quill/mentions/completion_test.tsx b/ts/test/quill/mentions/completion_test.tsx index 8c47116e9..46652e2c4 100644 --- a/ts/test/quill/mentions/completion_test.tsx +++ b/ts/test/quill/mentions/completion_test.tsx @@ -23,6 +23,7 @@ const me: ConversationType = { type: 'direct', lastUpdated: Date.now(), markedUnread: false, + areWeAdmin: false, }; const members: Array = [ @@ -35,6 +36,7 @@ const members: Array = [ type: 'direct', lastUpdated: Date.now(), markedUnread: false, + areWeAdmin: false, }, { id: '333222', @@ -45,6 +47,7 @@ const members: Array = [ type: 'direct', lastUpdated: Date.now(), markedUnread: false, + areWeAdmin: false, }, me, ]; diff --git a/ts/test/quill/mentions/matchers_test.ts b/ts/test/quill/mentions/matchers_test.ts index 6a0d44d97..ab1d7b04b 100644 --- a/ts/test/quill/mentions/matchers_test.ts +++ b/ts/test/quill/mentions/matchers_test.ts @@ -46,6 +46,7 @@ const memberMahershala: ConversationType = { type: 'direct', lastUpdated: Date.now(), markedUnread: false, + areWeAdmin: false, }; const memberShia: ConversationType = { @@ -57,6 +58,7 @@ const memberShia: ConversationType = { type: 'direct', lastUpdated: Date.now(), markedUnread: false, + areWeAdmin: false, }; const members: Array = [memberMahershala, memberShia]; diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 7dba66fd1..207e4710e 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -370,7 +370,7 @@ "rule": "jQuery-append(", "path": "js/views/contact_list_view.js", "line": " this.$el.append(this.contactView.el);", - "lineNumber": 38, + "lineNumber": 45, "reasonCategory": "usageTrusted", "updated": "2019-07-31T00:19:18.696Z", "reasonDetail": "Known DOM elements" @@ -469,7 +469,7 @@ "rule": "jQuery-$(", "path": "js/views/group_member_list_view.js", "line": " this.$('.container').append(this.member_list_view.el);", - "lineNumber": 28, + "lineNumber": 29, "reasonCategory": "usageTrusted", "updated": "2018-09-19T21:59:32.770Z", "reasonDetail": "Protected from arbitrary input" @@ -478,7 +478,7 @@ "rule": "jQuery-append(", "path": "js/views/group_member_list_view.js", "line": " this.$('.container').append(this.member_list_view.el);", - "lineNumber": 28, + "lineNumber": 29, "reasonCategory": "usageTrusted", "updated": "2018-09-19T18:13:29.628Z", "reasonDetail": "Interacting with already-existing DOM nodes" @@ -14708,6 +14708,22 @@ "updated": "2020-10-26T19:12:24.410Z", "reasonDetail": "Only used to focus the element." }, + { + "rule": "React-useRef", + "path": "ts/components/conversation/ContactModal.js", + "line": " const overlayRef = react_1.default.useRef(null);", + "lineNumber": 16, + "reasonCategory": "usageTrusted", + "updated": "2020-11-09T17:48:12.173Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/conversation/ContactModal.js", + "line": " const closeButtonRef = react_1.default.useRef(null);", + "lineNumber": 17, + "reasonCategory": "usageTrusted", + "updated": "2020-11-10T21:27:04.909Z" + }, { "rule": "React-createRef", "path": "ts/components/conversation/ConversationHeader.js", @@ -14792,23 +14808,23 @@ "rule": "React-createRef", "path": "ts/components/conversation/Message.tsx", "line": " public audioRef: React.RefObject = React.createRef();", - "lineNumber": 221, - "reasonCategory": "usageTrusted", - "updated": "2020-09-08T20:19:01.913Z" - }, - { - "rule": "React-createRef", - "path": "ts/components/conversation/Message.tsx", - "line": " public focusRef: React.RefObject = React.createRef();", "lineNumber": 223, "reasonCategory": "usageTrusted", "updated": "2020-09-08T20:19:01.913Z" }, + { + "rule": "React-createRef", + "path": "ts/components/conversation/Message.tsx", + "line": " public focusRef: React.RefObject = React.createRef();", + "lineNumber": 225, + "reasonCategory": "usageTrusted", + "updated": "2020-09-08T20:19:01.913Z" + }, { "rule": "React-createRef", "path": "ts/components/conversation/Message.tsx", "line": " > = React.createRef();", - "lineNumber": 227, + "lineNumber": 229, "reasonCategory": "usageTrusted", "updated": "2020-08-28T19:36:40.817Z" }, @@ -15160,4 +15176,4 @@ "reasonCategory": "falseMatch", "updated": "2020-09-08T23:07:22.682Z" } -] +] \ No newline at end of file diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 6b274546b..2f2c77be3 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -298,6 +298,7 @@ Whisper.ConversationView = Whisper.View.extend({ this.listenTo(this.model, 'attach-file', this.onChooseAttachment); this.listenTo(this.model, 'escape-pressed', this.resetPanel); this.listenTo(this.model, 'show-message-details', this.showMessageDetail); + this.listenTo(this.model, 'show-contact-modal', this.showContactModal); this.listenTo(this.model, 'toggle-reply', (messageId: any) => { const target = this.quote || !messageId ? null : messageId; this.setQuoteMessage(target); @@ -750,6 +751,9 @@ Whisper.ConversationView = Whisper.View.extend({ const showMessageDetail = (messageId: any) => { this.showMessageDetail(messageId); }; + const showContactModal = (contactId: string) => { + this.showContactModal(contactId); + }; const openConversation = (conversationId: any, messageId: any) => { this.openConversation(conversationId, messageId); }; @@ -941,6 +945,7 @@ Whisper.ConversationView = Whisper.View.extend({ retrySend, scrollToQuotedMessage, showContactDetail, + showContactModal, showIdentity, showMessageDetail, showVisualAttachment, @@ -1222,6 +1227,9 @@ Whisper.ConversationView = Whisper.View.extend({ if (this.captionEditorView) { this.captionEditorView.remove(); } + if (this.contactModalView) { + this.contactModalView.remove(); + } if (this.stickerButtonView) { this.stickerButtonView.remove(); } @@ -2252,6 +2260,7 @@ Whisper.ConversationView = Whisper.View.extend({ // we pass this in to allow nested panels listenBack: this.listenBack.bind(this), needVerify: options.needVerify, + conversation: this.model, }); this.listenBack(view); @@ -2592,6 +2601,49 @@ Whisper.ConversationView = Whisper.View.extend({ window.Signal.Backbone.Views.Lightbox.show(this.lightboxGalleryView.el); }, + showContactModal(contactId: string) { + if (this.contactModalView) { + this.contactModalView.remove(); + this.contactModalView = null; + } + + this.previousFocus = document.activeElement; + + const hideContactModal = () => { + if (this.contactModalView) { + this.contactModalView.remove(); + this.contactModalView = null; + if (this.previousFocus && this.previousFocus.focus) { + this.previousFocus.focus(); + this.previousFocus = null; + } + } + }; + + this.contactModalView = new Whisper.ReactWrapperView({ + className: 'progress-modal-wrapper', + JSX: window.Signal.State.Roots.createContactModal(window.reduxStore, { + contactId, + currentConversationId: this.model.id, + onClose: hideContactModal, + openConversation: (conversationId: string) => { + hideContactModal(); + this.openConversation(conversationId); + }, + showSafetyNumber: (conversationId: string) => { + hideContactModal(); + this.showSafetyNumber(conversationId); + }, + removeMember: (conversationId: string) => { + hideContactModal(); + this.model.removeFromGroupV2(conversationId); + }, + }), + }); + + this.contactModalView.render(); + }, + showMessageDetail(messageId: any) { const message = this.model.messageCollection.get(messageId); if (!message) { diff --git a/ts/window.d.ts b/ts/window.d.ts index 2d05e5e02..da3fabd51 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -34,6 +34,7 @@ import { ReduxActions } from './state/types'; import { createStore } from './state/createStore'; import { createCallManager } from './state/roots/createCallManager'; import { createCompositionArea } from './state/roots/createCompositionArea'; +import { createContactModal } from './state/roots/createContactModal'; import { createConversationHeader } from './state/roots/createConversationHeader'; import { createLeftPane } from './state/roots/createLeftPane'; import { createSafetyNumberViewer } from './state/roots/createSafetyNumberViewer'; @@ -65,6 +66,7 @@ import { combineNames } from './util'; import { BatcherType } from './util/batcher'; import { ErrorModal } from './components/ErrorModal'; import { ProgressModal } from './components/ProgressModal'; +import { ContactModal } from './components/conversation/ContactModal'; export { Long } from 'long'; @@ -394,6 +396,7 @@ declare global { CaptionEditor: any; ContactDetail: any; ErrorModal: typeof ErrorModal; + ContactModal: typeof ContactModal; Lightbox: any; LightboxGallery: any; MediaGallery: any; @@ -425,6 +428,7 @@ declare global { Roots: { createCallManager: typeof createCallManager; createCompositionArea: typeof createCompositionArea; + createContactModal: typeof createContactModal; createConversationHeader: typeof createConversationHeader; createLeftPane: typeof createLeftPane; createSafetyNumberViewer: typeof createSafetyNumberViewer;