From c0510b08a55880d629f69ff303e25d51f9478392 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Fri, 29 Jan 2021 16:19:24 -0500 Subject: [PATCH] Introduce conversation details screen for New Groups Co-authored-by: Chris Svenningsen Co-authored-by: Sidney Keese --- _locales/en/messages.json | 305 +++++++++- images/icons/v2/block-24.svg | 2 +- images/icons/v2/leave-24.svg | 3 + images/icons/v2/link-16.svg | 3 + images/icons/v2/lock-outline-24.svg | 3 + images/icons/v2/pending-invite-24.svg | 4 + images/icons/v2/share-ios-24.svg | 1 + images/icons/v2/undo-24.svg | 1 + js/modules/signal.js | 16 + protos/Groups.proto | 1 - stylesheets/_modules.scss | 507 +++++++++++++++++ ts/RemoteConfig.ts | 1 + ts/components/CompositionArea.stories.tsx | 2 +- ts/components/CompositionArea.tsx | 6 +- ts/components/CompositionInput.stories.tsx | 4 +- ts/components/CompositionInput.tsx | 14 +- .../conversation/ContactModal.stories.tsx | 2 + ts/components/conversation/ContactModal.tsx | 40 +- .../ConversationHeader.stories.tsx | 1 + .../conversation/ConversationHeader.tsx | 37 +- .../ConversationDetails.stories.tsx | 87 +++ .../ConversationDetails.tsx | 176 ++++++ .../ConversationDetailsActions.stories.tsx | 34 ++ .../ConversationDetailsActions.tsx | 95 ++++ .../ConversationDetailsHeader.stories.tsx | 40 ++ .../ConversationDetailsHeader.tsx | 42 ++ .../ConversationDetailsIcon.stories.tsx | 38 ++ .../ConversationDetailsIcon.tsx | 37 ++ .../ConversationDetailsMediaList.stories.tsx | 45 ++ .../ConversationDetailsMediaList.tsx | 73 +++ ...versationDetailsMembershipList.stories.tsx | 76 +++ .../ConversationDetailsMembershipList.tsx | 76 +++ .../GroupLinkManagement.stories.tsx | 86 +++ .../GroupLinkManagement.tsx | 130 +++++ .../GroupV2Permissions.stories.tsx | 58 ++ .../GroupV2Permissions.tsx | 85 +++ .../conversation-details/PanelRow.stories.tsx | 76 +++ .../conversation-details/PanelRow.tsx | 58 ++ .../PanelSection.stories.tsx | 71 +++ .../conversation-details/PanelSection.tsx | 34 ++ .../PendingInvites.stories.tsx | 87 +++ .../conversation-details/PendingInvites.tsx | 477 ++++++++++++++++ .../conversation/conversation-details/util.ts | 30 + ts/groups.ts | 213 ++++++- ts/model-types.d.ts | 2 + ts/models/conversations.ts | 522 +++++++++++++++++- ts/state/ducks/conversations.ts | 114 +++- ts/state/roots/createConversationDetails.tsx | 21 + ts/state/roots/createGroupLinkManagement.tsx | 21 + ts/state/roots/createGroupV2Permissions.tsx | 21 + ts/state/roots/createPendingInvites.tsx | 21 + ts/state/smart/ContactModal.tsx | 19 +- ts/state/smart/ConversationDetails.tsx | 56 ++ ts/state/smart/ConversationHeader.tsx | 3 + ts/state/smart/GroupLinkManagement.tsx | 39 ++ ts/state/smart/GroupV2Permissions.tsx | 37 ++ ts/state/smart/PendingInvites.tsx | 39 ++ .../helpers/getDefaultConversation.ts | 316 ++++++++++- ts/textsecure.d.ts | 6 + ts/types/Attachment.ts | 2 + ts/util/getAccessControlOptions.ts | 26 + ts/util/lint/exceptions.json | 2 +- ts/views/conversation_view.ts | 323 ++++++++++- ts/window.d.ts | 13 +- 64 files changed, 4699 insertions(+), 81 deletions(-) create mode 100644 images/icons/v2/leave-24.svg create mode 100644 images/icons/v2/link-16.svg create mode 100644 images/icons/v2/lock-outline-24.svg create mode 100644 images/icons/v2/pending-invite-24.svg create mode 100644 images/icons/v2/share-ios-24.svg create mode 100644 images/icons/v2/undo-24.svg create mode 100644 ts/components/conversation/conversation-details/ConversationDetails.stories.tsx create mode 100644 ts/components/conversation/conversation-details/ConversationDetails.tsx create mode 100644 ts/components/conversation/conversation-details/ConversationDetailsActions.stories.tsx create mode 100644 ts/components/conversation/conversation-details/ConversationDetailsActions.tsx create mode 100644 ts/components/conversation/conversation-details/ConversationDetailsHeader.stories.tsx create mode 100644 ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx create mode 100644 ts/components/conversation/conversation-details/ConversationDetailsIcon.stories.tsx create mode 100644 ts/components/conversation/conversation-details/ConversationDetailsIcon.tsx create mode 100644 ts/components/conversation/conversation-details/ConversationDetailsMediaList.stories.tsx create mode 100644 ts/components/conversation/conversation-details/ConversationDetailsMediaList.tsx create mode 100644 ts/components/conversation/conversation-details/ConversationDetailsMembershipList.stories.tsx create mode 100644 ts/components/conversation/conversation-details/ConversationDetailsMembershipList.tsx create mode 100644 ts/components/conversation/conversation-details/GroupLinkManagement.stories.tsx create mode 100644 ts/components/conversation/conversation-details/GroupLinkManagement.tsx create mode 100644 ts/components/conversation/conversation-details/GroupV2Permissions.stories.tsx create mode 100644 ts/components/conversation/conversation-details/GroupV2Permissions.tsx create mode 100644 ts/components/conversation/conversation-details/PanelRow.stories.tsx create mode 100644 ts/components/conversation/conversation-details/PanelRow.tsx create mode 100644 ts/components/conversation/conversation-details/PanelSection.stories.tsx create mode 100644 ts/components/conversation/conversation-details/PanelSection.tsx create mode 100644 ts/components/conversation/conversation-details/PendingInvites.stories.tsx create mode 100644 ts/components/conversation/conversation-details/PendingInvites.tsx create mode 100644 ts/components/conversation/conversation-details/util.ts create mode 100644 ts/state/roots/createConversationDetails.tsx create mode 100644 ts/state/roots/createGroupLinkManagement.tsx create mode 100644 ts/state/roots/createGroupV2Permissions.tsx create mode 100644 ts/state/roots/createPendingInvites.tsx create mode 100644 ts/state/smart/ConversationDetails.tsx create mode 100644 ts/state/smart/GroupLinkManagement.tsx create mode 100644 ts/state/smart/GroupV2Permissions.tsx create mode 100644 ts/state/smart/PendingInvites.tsx create mode 100644 ts/util/getAccessControlOptions.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 666ad55505e9..c8e943234a82 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -523,6 +523,10 @@ "message": "You don’t have any media in this conversation", "description": "Message shown to user in the media gallery when there are no messages with media attachments (images or video)" }, + "allMedia": { + "message": "All Media", + "description": "Header for the media gallery" + }, "documents": { "message": "Documents", "description": "Header of the secondary pane in the media gallery, showing every non-media attachment" @@ -966,6 +970,17 @@ "delete": { "message": "Delete" }, + "accept": { + "message": "Accept" + }, + "on": { + "message": "On", + "description": "Label for when something is turned on" + }, + "off": { + "message": "Off", + "description": "Label for when something is turned off" + }, "deleteWarning": { "message": "Clicking 'delete' will permanently remove this message from your devices only.", "description": "Text shown in the confirmation dialog for deleting a message locally" @@ -3233,7 +3248,11 @@ "GroupV2--admin": { "message": "Admin", - "description": "Shown next to the set of administrators in a group" + "description": "Label for a group administrator" + }, + "GroupV2--all-members": { + "message": "All members", + "description": "Label for describing the general non-privileged members of a group" }, "updating": { "message": "Updating...", @@ -4422,12 +4441,296 @@ "message": "Message", "description": "Button text for send message button in Group Contact Details modal" }, + "ContactModal--rm-admin": { + "message": "Remove as admin", + "description": "Button text for removing as admin 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--make-admin-info": { + "message": "$contact$ will be able to edit this group and its members.", + "description": "Shown in a confirmation dialog when you are about to grant admin privileges to someone", + "placeholders": { + "contact": { + "content": "$1", + "example": "Homer" + } + } + }, + "ContactModal--rm-admin-info": { + "message": "Remove $contact$ as group admin", + "description": "Shown in a confirmation dialog when you are about to remove admin privileges from someone", + "placeholders": { + "contact": { + "content": "$1", + "example": "Homer" + } + } + }, "ContactModal--remove-from-group": { "message": "Remove from group", "description": "Button text for remove from group button in Group Contact Details modal" + }, + "showConversationDetails": { + "message": "Group settings", + "description": "This is a button in the conversation context menu to show group settings" + }, + "ConversationDetails--group-link": { + "message": "Group link", + "description": "This is the label for the group link management panel" + }, + "ConversationDetails--disappearing-messages-label": { + "message": "Disappearing messages", + "description": "This is the label for the disappearing messages setting panel" + }, + "ConversationDetails--disappearing-messages-info": { + "message": "When enabled, messages sent and received in this group will disappear after they've been seen.", + "description": "This is the info about the disappearing messages setting" + }, + "ConversationDetails--group-info-label": { + "message": "Who can edit group info", + "description": "This is the label for the 'who can edit the group' panel" + }, + "ConversationDetails--group-info-info": { + "message": "Choose who can edit group name, avatar, and disappearing messages timer.", + "description": "This is the additional info for the 'who can edit the group' panel" + }, + "ConversationDetails--add-members-label": { + "message": "Who can add members", + "description": "This is the label for the 'who can add members' panel" + }, + "ConversationDetails--add-members-info": { + "message": "Choose who can add members to this group.", + "description": "This is the additional info for the 'who can add members' panel" + }, + "ConversationDetails--requests-and-invites": { + "message": "Requests & Invites", + "description": "This is a button to display which members have been invited but have not joined yet" + }, + "ConversationDetailsActions--leave-group": { + "message": "Leave group", + "description": "This is a button to leave a group" + }, + "ConversationDetailsActions--block-group": { + "message": "Block group", + "description": "This is a button to block a group" + }, + "ConversationDetailsActions--leave-group-modal-title": { + "message": "Do you really want to leave?", + "description": "This is the modal title for confirming leaving a group" + }, + "ConversationDetailsActions--leave-group-modal-content": { + "message": "You will no longer be able to send or receive messages in this group.", + "description": "This is the modal content for confirming leaving a group" + }, + "ConversationDetailsActions--leave-group-modal-confirm": { + "message": "Leave", + "description": "This is the modal button to confirm leaving a group" + }, + "ConversationDetailsActions--block-group-modal-title": { + "message": "Block and Leave the \"$groupName$\" Group?", + "description": "This is the modal title for confirming blocking a group", + "placeholders": { + "groupName": { + "content": "$1", + "example": "Our Conversation" + } + } + }, + "ConversationDetailsActions--block-group-modal-content": { + "message": "You will no longer receive messages or updates from this group.", + "description": "This is the modal content for confirming blocking a group" + }, + "ConversationDetailsActions--block-group-modal-confirm": { + "message": "Block", + "description": "This is the modal button to confirm blocking a group" + }, + "ConversationDetailsHeader--members": { + "message": "$number$ members", + "description": "This is the number of members in a group", + "placeholders": { + "number": { + "content": "$1", + "example": "10" + } + } + }, + "ConversationDetailsMediaList--shared-media": { + "message": "Shared media", + "description": "Title for the media thumbnails in the conversation details screen" + }, + "ConversationDetailsMediaList--show-all": { + "message": "See all", + "description": "This is a button on the conversation details to show all media" + }, + "ConversationDetailsMembershipList--title": { + "message": "$number$ members", + "description": "The title of the membership list panel", + "placeholders": { + "number": { + "content": "$1", + "example": "10" + } + } + }, + "ConversationDetailsMembershipList--show-all": { + "message": "See all", + "description": "This is a button on the conversation details to show all members" + }, + "GroupLinkManagement--clipboard": { + "message": "Group link copied.", + "description": "Shown in a toast when a user selects to copy group link" + }, + "GroupLinkManagement--share": { + "message": "Copy link", + "description": "This lets users share their group link" + }, + "GroupLinkManagement--confirm-reset": { + "message": "Are you sure you want to reset the group link? People will no longer be able to join the group using the current link.", + "description": "Shown in the confirmation dialog when an admin is about to reset the group link" + }, + "GroupLinkManagement--reset": { + "message": "Reset link", + "description": "This lets users generate a new group link" + }, + "GroupLinkManagement--approve-label": { + "message": "Approve new members", + "description": "Title for the approve new members select area" + }, + "GroupLinkManagement--approve-info": { + "message": "Require an admin to approve new members joining via the group link", + "description": "Description for the approve new members select area" + }, + "PendingInvites--tab-requests": { + "message": "Requests ($count$)", + "description": "Label for the tab to view pending requests", + "placeholders": { + "name": { + "content": "$1", + "example": "4" + } + } + }, + "PendingInvites--tab-invites": { + "message": "Invites ($count$)", + "description": "Label for the tab to view pending invites", + "placeholders": { + "name": { + "content": "$1", + "example": "2" + } + } + }, + "PendingRequests--approve-for": { + "message": "Approve request from \"$name$\"?", + "description": "This is the modal content when confirming approving a group request to join", + "placeholders": { + "name": { + "content": "$1", + "example": "Meowsy Purrington" + } + } + }, + "PendingRequests--deny-for": { + "message": "Deny request from \"$name$\"?", + "description": "This is the modal content when confirming denying a group request to join", + "placeholders": { + "name": { + "content": "$1", + "example": "Meowsy Purrington" + } + } + }, + "PendingInvites--invites": { + "message": "Invited by you", + "description": "This is the title list of all invites" + }, + "PendingInvites--invited-by-you": { + "message": "Invited by you", + "description": "This is the title for the list of members you have invited" + }, + "PendingInvites--invited-by-others": { + "message": "Invited by others", + "description": "This is the title for the list of members who have invited other people" + }, + "PendingInvites--invited-count": { + "message": "Invited $number$", + "description": "This is the label for the number of members someone has invited", + "placeholders": { + "number": { + "content": "$1", + "example": "3" + } + } + }, + "PendingInvites--revoke-for-label": { + "message": "Revoke group invite", + "description": "This is aria label for revoking a group invite icon" + }, + "PendingInvites--revoke-for": { + "message": "Revoke group invite for \"$name$\"?", + "description": "This is the modal content when confirming revoking a single invite", + "placeholders": { + "number": { + "content": "$1", + "example": "3" + }, + "name": { + "content": "$2", + "example": "Fred Riley III" + } + } + }, + "PendingInvites--revoke-from-singular": { + "message": "Revoke 1 invite sent by \"$name$\"?", + "description": "This is the modal content when confirming revoking a single invite", + "placeholders": { + "name": { + "content": "$2", + "example": "Fred Riley III" + } + } + }, + "PendingInvites--revoke-from-plural": { + "message": "Revoke $number$ invites sent by \"$name$\"", + "description": "This is the modal content when confirming revoking multiple invites", + "placeholders": { + "number": { + "content": "$1", + "example": "3" + }, + "name": { + "content": "$2", + "example": "Fred Riley III" + } + } + }, + "PendingInvites--revoke": { + "message": "Revoke", + "description": "This is the modal button to confirm revoking invites" + }, + "PendingRequests--approve": { + "message": "Approve Request", + "description": "This is the modal button to approve group request to join" + }, + "PendingRequests--deny": { + "message": "Deny Request", + "description": "This is the modal button to deny group request to join" + }, + "PendingRequests--info": { + "message": "People on this list are attempting to join \"$name$\" via the group link.", + "description": "Inforamtion shown below the pending admin approval list", + "placeholders": { + "name": { + "content": "$1", + "example": "Tahoe List" + } + } + }, + "PendingInvites--info": { + "message": "Details about people invited to this group aren’t shown until they join. Invitees will only see messages after they join the group.", + "description": "Information shown below the invite list" } } diff --git a/images/icons/v2/block-24.svg b/images/icons/v2/block-24.svg index ad2f5e5459b5..91f631f98db6 100644 --- a/images/icons/v2/block-24.svg +++ b/images/icons/v2/block-24.svg @@ -1 +1 @@ -block-24 \ No newline at end of file +block-24 diff --git a/images/icons/v2/leave-24.svg b/images/icons/v2/leave-24.svg new file mode 100644 index 000000000000..d968d61e4d3f --- /dev/null +++ b/images/icons/v2/leave-24.svg @@ -0,0 +1,3 @@ + + + diff --git a/images/icons/v2/link-16.svg b/images/icons/v2/link-16.svg new file mode 100644 index 000000000000..dc8f05cbda05 --- /dev/null +++ b/images/icons/v2/link-16.svg @@ -0,0 +1,3 @@ + + + diff --git a/images/icons/v2/lock-outline-24.svg b/images/icons/v2/lock-outline-24.svg new file mode 100644 index 000000000000..b0967ded1609 --- /dev/null +++ b/images/icons/v2/lock-outline-24.svg @@ -0,0 +1,3 @@ + + + diff --git a/images/icons/v2/pending-invite-24.svg b/images/icons/v2/pending-invite-24.svg new file mode 100644 index 000000000000..77068184bb10 --- /dev/null +++ b/images/icons/v2/pending-invite-24.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/icons/v2/share-ios-24.svg b/images/icons/v2/share-ios-24.svg new file mode 100644 index 000000000000..f7a7d33b25c7 --- /dev/null +++ b/images/icons/v2/share-ios-24.svg @@ -0,0 +1 @@ +share-ios-24 \ No newline at end of file diff --git a/images/icons/v2/undo-24.svg b/images/icons/v2/undo-24.svg new file mode 100644 index 000000000000..27dc9eb579bc --- /dev/null +++ b/images/icons/v2/undo-24.svg @@ -0,0 +1 @@ +undo-24 \ No newline at end of file diff --git a/js/modules/signal.js b/js/modules/signal.js index 276d26342e6a..7353e7f7a418 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -66,14 +66,26 @@ const { const { createContactModal, } = require('../../ts/state/roots/createContactModal'); +const { + createConversationDetails, +} = require('../../ts/state/roots/createConversationDetails'); const { createConversationHeader, } = require('../../ts/state/roots/createConversationHeader'); const { createCallManager } = require('../../ts/state/roots/createCallManager'); +const { + createGroupLinkManagement, +} = require('../../ts/state/roots/createGroupLinkManagement'); const { createGroupV1MigrationModal, } = require('../../ts/state/roots/createGroupV1MigrationModal'); const { createLeftPane } = require('../../ts/state/roots/createLeftPane'); +const { + createGroupV2Permissions, +} = require('../../ts/state/roots/createGroupV2Permissions'); +const { + createPendingInvites, +} = require('../../ts/state/roots/createPendingInvites'); const { createSafetyNumberViewer, } = require('../../ts/state/roots/createSafetyNumberViewer'); @@ -324,9 +336,13 @@ exports.setup = (options = {}) => { createCallManager, createCompositionArea, createContactModal, + createConversationDetails, createConversationHeader, + createGroupLinkManagement, createGroupV1MigrationModal, + createGroupV2Permissions, createLeftPane, + createPendingInvites, createSafetyNumberViewer, createShortcutGuideModal, createStickerManager, diff --git a/protos/Groups.proto b/protos/Groups.proto index c5272019fafa..3a42b6b3da23 100644 --- a/protos/Groups.proto +++ b/protos/Groups.proto @@ -116,7 +116,6 @@ message GroupChange { Member.Role role = 2; } - message ModifyTitleAction { bytes title = 1; } diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 8b5c30faaaa3..20f18711d730 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -3151,6 +3151,467 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05', } } +// Module: Conversation Details + +.conversation-details-panel { + max-width: 750px; + margin: 0 auto; + + @at-root .conversation #{&} { + overflow-y: auto; + } +} + +// Brought this up here to add specificity +button.module-conversation-details__action-button { + margin-left: 16px; +} + +.module-conversation-details { + &-header { + &__root { + align-items: center; + display: flex; + flex-direction: column; + padding-bottom: 24px; + text-align: center; + } + + &__title { + @include font-body-1-bold; + padding-top: 12px; + padding-bottom: 8px; + } + + &__subtitle { + @include font-body-1; + color: $color-gray-60; + padding-bottom: 6px; + + @include dark-theme { + color: $color-gray-25; + } + } + } + + &__tabs { + display: flex; + justify-content: space-around; + } + + &__tab { + @include font-body-1; + cursor: pointer; + padding: 15px; + + &:focus { + @include mouse-mode { + outline: none; + } + } + + &--selected { + @include font-body-1-bold; + border-bottom: 2px solid $color-black; + } + } + + &__pending--info { + @include font-subtitle; + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-25; + } + padding: 0 28px; + padding-top: 16px; + } + + &-icon { + &__button { + background: none; + border: none; + padding: none; + + &:focus { + @include mouse-mode { + outline: none; + } + } + } + + &__icon { + height: 32px; + width: 32px; + display: flex; + align-items: center; + justify-content: center; + + &::after { + display: block; + content: ''; + width: 24px; + height: 24px; + -webkit-mask-size: 100%; + } + + &--timer { + &::after { + -webkit-mask: url(../images/icons/v2/timer-disabled-24.svg) no-repeat + center; + + @include light-theme { + background-color: $color-gray-75; + } + + @include dark-theme { + background-color: $color-gray-15; + } + } + } + + &--lock { + &::after { + -webkit-mask: url(../images/icons/v2/lock-outline-24.svg) no-repeat + center; + + @include light-theme { + background-color: $color-gray-75; + } + + @include dark-theme { + background-color: $color-gray-15; + } + } + } + + &--approve { + &::after { + -webkit-mask: url(../images/icons/v2/check-24.svg) no-repeat center; + + @include light-theme { + background-color: $color-gray-75; + } + + @include dark-theme { + background-color: $color-gray-15; + } + } + } + + &--link { + &::after { + -webkit-mask: url(../images/icons/v2/link-16.svg) no-repeat center; + + @include light-theme { + background-color: $color-gray-75; + } + + @include dark-theme { + background-color: $color-gray-15; + } + } + } + + &--share { + &::after { + -webkit-mask: url(../images/icons/v2/share-ios-24.svg) no-repeat + center; + + @include light-theme { + background-color: $color-gray-75; + } + + @include dark-theme { + background-color: $color-gray-15; + } + } + } + + &--reset { + &::after { + transform: scaleX(-1); + -webkit-mask: url(../images/icons/v2/undo-24.svg) no-repeat center; + + @include light-theme { + background-color: $color-gray-75; + } + + @include dark-theme { + background-color: $color-gray-15; + } + } + } + + &--trash { + &::after { + -webkit-mask: url(../images/icons/v2/trash-outline-24.svg) no-repeat + center; + + @include light-theme { + background-color: $color-gray-75; + } + + @include dark-theme { + background-color: $color-gray-15; + } + } + } + + &--invites { + &::after { + -webkit-mask: url(../images/icons/v2/pending-invite-24.svg) no-repeat + center; + + @include light-theme { + background-color: $color-gray-75; + } + + @include dark-theme { + background-color: $color-gray-15; + } + } + } + + &--down { + border-radius: 18px; + @include light-theme { + background-color: $color-gray-02; + } + + @include dark-theme { + background-color: $color-gray-90; + } + + &::after { + -webkit-mask: url(../images/icons/v2/chevron-down-16.svg) no-repeat + center; + + @include light-theme { + background-color: $color-gray-60; + } + + @include dark-theme { + background-color: $color-gray-25; + } + } + } + + &--leave { + &::after { + -webkit-mask: url(../images/icons/v2/leave-24.svg) no-repeat center; + background-color: $color-accent-red; + } + } + + &--block { + &::after { + -webkit-mask: url(../images/icons/v2/block-24.svg) no-repeat center; + background-color: $color-accent-red; + } + } + } + } + + &-media-list { + &__root { + display: flex; + justify-content: center; + padding: 0 20px; + + .module-media-grid-item { + border-radius: 4px; + height: auto; + margin: 0 4px; + max-height: 94px; + overflow: hidden; + width: calc(100% / 6); + + .module-media-grid-item__icon { + &::before { + content: ''; + display: block; + padding-top: 100%; + } + } + + .module-media-grid-item__image-container, + img { + margin: 0; + } + } + } + + &__show-all { + background: none; + border: none; + padding: 0; + } + } + + &-panel-row { + &__root { + align-items: center; + display: flex; + padding: 16px 24px; + user-select: none; + width: 100%; + + &--button { + color: inherit; + background: none; + border: none; + } + + &:hover { + @include light-theme { + background-color: $color-gray-02; + } + + @include dark-theme { + background-color: $color-gray-90; + } + + & .module-conversation-details-panel-row__actions { + opacity: 1; + } + } + } + + &__icon { + margin-right: 12px; + flex-shrink: 0; + } + + &__label { + flex-grow: 1; + text-align: left; + margin-right: 12px; + } + + &__info { + @include font-body-2; + color: $color-gray-60; + margin-top: 4px; + } + + &__right { + color: $color-gray-45; + } + + &__actions { + margin-left: 12px; + overflow: hidden; + opacity: 0; + + &:focus-within { + opacity: 1; + } + } + } + + &-panel-section { + &__root { + position: relative; + + &:not(:first-child)::before { + border-top: 1px solid transparent; + + @include light-theme { + border-top-color: $color-gray-15; + } + + @include dark-theme { + border-top-color: $color-gray-65; + } + + content: ''; + display: block; + left: 0; + margin: 0; + position: absolute; + right: 0; + top: 0; + } + + &--borderless { + &:not(:first-child)::before { + border-top: none; + } + } + } + + &__header { + display: flex; + justify-content: space-between; + padding: 18px 24px 12px; + + &--center { + justify-content: center; + } + } + + &__title { + @include font-body-1-bold; + } + } + + &-select { + position: relative; + + select { + @include font-body-2; + -webkit-appearance: none; + border-radius: 4px; + border: 1px solid $color-gray-25; + cursor: pointer; + height: 40px; + min-width: 124px; + outline: 0; + padding: 10px; + padding-left: 12px; + padding-right: 32px; + text-overflow: ellipsis; + width: 100%; + + @include dark-theme { + background-color: $color-gray-90; + border-color: $color-gray-60; + color: $color-gray-05; + } + + &:focus { + border: 3px solid $ultramarine-ui-light; + line-height: 14px; + padding-left: 10px; + } + } + + &::after { + border: 2px solid $color-gray-60; + border-radius: 2px; + border-right: 0; + border-top: 0; + content: ' '; + display: block; + height: 10px; + pointer-events: none; + position: absolute; + right: 15px; + top: 14px; + transform-origin: center; + transform: rotate(-45deg); + width: 10px; + z-index: 2; + + @include dark-theme { + border-color: $color-gray-15; + } + } + } +} + // Module: Message Detail .module-message-detail { @@ -10329,6 +10790,33 @@ button.module-image__border-overlay:focus { } .module-button { + &__small { + @include font-body-2; + @include button-reset; + + @include keyboard-mode { + &:focus { + box-shadow: 0px 0px 0px 2px $ultramarine-ui-light; + } + } + + @include light-theme { + color: $color-gray-90; + border-color: $color-gray-15; + } + + @include dark-theme { + color: $color-gray-05; + border-color: $color-gray-65; + } + + border-radius: 4px; + border-style: solid; + border-width: 1px; + outline: none; + padding: 7px 12px; + } + &__gray { @include font-body-1-bold; background-color: $color-gray-45; @@ -10505,6 +10993,25 @@ $contact-modal-padding: 18px; } } +.module-contact-modal__make-admin__bubble-icon { + height: 16px; + width: 18px; + + @include light-theme { + @include color-svg( + '../images/icons/v2/group-outline-20.svg', + $color-gray-75 + ); + } + + @include dark-theme { + @include color-svg( + '../images/icons/v2/group-outline-20.svg', + $color-gray-15 + ); + } +} + .module-contact-modal__remove-from-group__bubble-icon { height: 16px; width: 16px; diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index e3e026bafa73..a3132c6d3c30 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -10,6 +10,7 @@ type ConfigKeyType = | 'desktop.disableGV1' | 'desktop.groupCalling' | 'desktop.gv2' + | 'desktop.gv2Admin' | 'desktop.mandatoryProfileSharing' | 'desktop.messageRequests' | 'desktop.storage' diff --git a/ts/components/CompositionArea.stories.tsx b/ts/components/CompositionArea.stories.tsx index 63817cebc226..4732f944f0fa 100644 --- a/ts/components/CompositionArea.stories.tsx +++ b/ts/components/CompositionArea.stories.tsx @@ -39,7 +39,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ draftText: overrideProps.draftText || undefined, clearQuotedMessage: action('clearQuotedMessage'), getQuotedMessage: action('getQuotedMessage'), - members: [], + sortedGroupMembers: [], // EmojiButton onPickEmoji: action('onPickEmoji'), onSetSkinTone: action('onSetSkinTone'), diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index 7ff18cd14687..ba89741feb7d 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -54,7 +54,7 @@ export type OwnProps = { export type Props = Pick< CompositionInputProps, - | 'members' + | 'sortedGroupMembers' | 'onSubmit' | 'onEditorStateChange' | 'onTextTooLong' @@ -106,7 +106,7 @@ export const CompositionArea = ({ draftBodyRanges, clearQuotedMessage, getQuotedMessage, - members, + sortedGroupMembers, // EmojiButton onPickEmoji, onSetSkinTone, @@ -450,7 +450,7 @@ export const CompositionArea = ({ draftBodyRanges={draftBodyRanges} clearQuotedMessage={clearQuotedMessage} getQuotedMessage={getQuotedMessage} - members={members} + sortedGroupMembers={sortedGroupMembers} /> {!large ? ( diff --git a/ts/components/CompositionInput.stories.tsx b/ts/components/CompositionInput.stories.tsx index 8c18677724fe..abeb4ad7e256 100644 --- a/ts/components/CompositionInput.stories.tsx +++ b/ts/components/CompositionInput.stories.tsx @@ -28,7 +28,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ getQuotedMessage: action('getQuotedMessage'), onPickEmoji: action('onPickEmoji'), large: boolean('large', overrideProps.large || false), - members: overrideProps.members || [], + sortedGroupMembers: overrideProps.sortedGroupMembers || [], skinTone: select( 'skinTone', { @@ -103,7 +103,7 @@ story.add('Emojis', () => { story.add('Mentions', () => { const props = createProps({ - members: [ + sortedGroupMembers: [ { id: '0', type: 'direct', diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx index 70b34bf11755..ed4f8dd7c6bb 100644 --- a/ts/components/CompositionInput.tsx +++ b/ts/components/CompositionInput.tsx @@ -63,7 +63,7 @@ export type Props = { readonly skinTone?: EmojiPickDataType['skinTone']; readonly draftText?: string; readonly draftBodyRanges?: Array; - members?: Array; + sortedGroupMembers?: Array; onDirtyChange?(dirty: boolean): unknown; onEditorStateChange?( messageText: string, @@ -92,7 +92,7 @@ export const CompositionInput: React.ComponentType = props => { draftBodyRanges, getQuotedMessage, clearQuotedMessage, - members, + sortedGroupMembers, } = props; const [emojiCompletionElement, setEmojiCompletionElement] = React.useState< @@ -459,11 +459,11 @@ export const CompositionInput: React.ComponentType = props => { quill.updateContents(newDelta as any); }; - const memberIds = members ? members.map(m => m.id) : []; + const memberIds = sortedGroupMembers ? sortedGroupMembers.map(m => m.id) : []; React.useEffect(() => { - memberRepositoryRef.current.updateMembers(members || []); - removeStaleMentions(members || []); + memberRepositoryRef.current.updateMembers(sortedGroupMembers || []); + removeStaleMentions(sortedGroupMembers || []); // We are still depending on members, but ESLint can't tell // Comparing the actual members list does not work for a couple reasons: // * Arrays with the same objects are not "equal" to React @@ -510,7 +510,9 @@ export const CompositionInput: React.ComponentType = props => { skinTone, }, mentionCompletion: { - me: members ? members.find(foo => foo.isMe) : undefined, + me: sortedGroupMembers + ? sortedGroupMembers.find(foo => foo.isMe) + : undefined, memberRepositoryRef, setMentionPickerElement: setMentionCompletionElement, i18n, diff --git a/ts/components/conversation/ContactModal.stories.tsx b/ts/components/conversation/ContactModal.stories.tsx index b04ce05da8c8..a4b020a782b1 100644 --- a/ts/components/conversation/ContactModal.stories.tsx +++ b/ts/components/conversation/ContactModal.stories.tsx @@ -31,11 +31,13 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ areWeAdmin: boolean('areWeAdmin', overrideProps.areWeAdmin || false), contact: overrideProps.contact || defaultContact, 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'), + toggleAdmin: action('toggleAdmin'), }); story.add('As non-admin', () => { diff --git a/ts/components/conversation/ContactModal.tsx b/ts/components/conversation/ContactModal.tsx index 5fdda46a6588..845b73c738aa 100644 --- a/ts/components/conversation/ContactModal.tsx +++ b/ts/components/conversation/ContactModal.tsx @@ -13,22 +13,26 @@ export type PropsType = { areWeAdmin: boolean; contact?: ConversationType; 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; }; export const ContactModal = ({ areWeAdmin, contact, i18n, + isAdmin, isMember, onClose, openConversation, removeMember, showSafetyNumber, + toggleAdmin, }: PropsType): ReactPortal | null => { if (!contact) { throw new Error('Contact modal opened without a matching contact'); @@ -143,16 +147,32 @@ export const ContactModal = ({ )} {!contact.isMe && areWeAdmin && isMember && ( - + <> + + + )} diff --git a/ts/components/conversation/ConversationHeader.stories.tsx b/ts/components/conversation/ConversationHeader.stories.tsx index 157cf713e66c..5f005ac1feee 100644 --- a/ts/components/conversation/ConversationHeader.stories.tsx +++ b/ts/components/conversation/ConversationHeader.stories.tsx @@ -33,6 +33,7 @@ const commonProps = { i18n, + onShowConversationDetails: action('onShowConversationDetails'), onSetDisappearingMessages: action('onSetDisappearingMessages'), onDeleteMessages: action('onDeleteMessages'), onResetSession: action('onResetSession'), diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index 070bedd03f60..ad5028fb2c34 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -33,6 +33,7 @@ export enum OutgoingCallButtonStyle { } export type PropsDataType = { + conversationTitle?: string; id: string; name?: string; @@ -51,6 +52,7 @@ export type PropsDataType = { isMissingMandatoryProfileSharing?: boolean; left?: boolean; markedUnread?: boolean; + groupVersion?: number; canChangeTimer?: boolean; expireTimer?: number; @@ -71,6 +73,7 @@ export type PropsActionsType = { onOutgoingVideoCallInConversation: () => void; onSetPin: (value: boolean) => void; + onShowConversationDetails: () => void; onShowSafetyNumber: () => void; onShowAllMedia: () => void; onShowGroupMembers: () => void; @@ -126,7 +129,7 @@ export class ConversationHeader extends React.Component { ); } - public renderTitle(): JSX.Element { + public renderTitle(): JSX.Element | null { const { name, phoneNumber, @@ -352,11 +355,13 @@ export class ConversationHeader extends React.Component { muteExpiresAt, isMissingMandatoryProfileSharing, left, + groupVersion, onDeleteMessages, onResetSession, onSetDisappearingMessages, onSetMuteNotifications, onShowAllMedia, + onShowConversationDetails, onShowGroupMembers, onShowSafetyNumber, onArchive, @@ -401,6 +406,11 @@ export class ConversationHeader extends React.Component { isMissingMandatoryProfileSharing ); + const hasGV2AdminEnabled = + isGroup && + groupVersion === 2 && + window.Signal.RemoteConfig.isEnabled('desktop.gv2Admin'); + return ( {disableTimerChanges ? null : ( @@ -430,7 +440,12 @@ export class ConversationHeader extends React.Component { ))} - {isGroup ? ( + {hasGV2AdminEnabled ? ( + + {i18n('showConversationDetails')} + + ) : null} + {isGroup && !hasGV2AdminEnabled ? ( {i18n('showMembers')} @@ -470,7 +485,23 @@ export class ConversationHeader extends React.Component { } private renderHeader(): JSX.Element { - const { id, isMe, onShowContactModal, type } = this.props; + const { + conversationTitle, + id, + isMe, + onShowContactModal, + type, + } = this.props; + + if (conversationTitle) { + return ( +
+
+ {conversationTitle} +
+
+ ); + } if (type === 'group' || isMe) { return ( diff --git a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx new file mode 100644 index 000000000000..3b6b8fe2611b --- /dev/null +++ b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx @@ -0,0 +1,87 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { setup as setupI18n } from '../../../../js/modules/i18n'; +import enMessages from '../../../../_locales/en/messages.json'; +import { ConversationDetails, Props } from './ConversationDetails'; +import { ConversationType } from '../../../state/ducks/conversations'; +import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf( + 'Components/Conversation/ConversationDetails/ConversationDetails', + module +); + +const conversation: ConversationType = { + id: '', + lastUpdated: 0, + markedUnread: false, + memberships: Array.from(Array(32)).map(() => ({ + isAdmin: false, + member: getDefaultConversation({}), + metadata: { + conversationId: '', + joinedAtVersion: 0, + role: 2, + }, + })), + pendingMemberships: Array.from(Array(16)).map(() => ({ + member: getDefaultConversation({}), + metadata: { + conversationId: '', + role: 2, + timestamp: Date.now(), + }, + })), + title: 'Some Conversation', + type: 'group', +}; + +const createProps = (hasGroupLink = false): Props => ({ + canEditGroupInfo: false, + conversation, + hasGroupLink, + i18n, + isAdmin: false, + loadRecentMediaItems: action('loadRecentMediaItems'), + setDisappearingMessages: action('setDisappearingMessages'), + showAllMedia: action('showAllMedia'), + showContactModal: action('showContactModal'), + showGroupLinkManagement: action('showGroupLinkManagement'), + showGroupV2Permissions: action('showGroupV2Permissions'), + showPendingInvites: action('showPendingInvites'), + showLightboxForMedia: action('showLightboxForMedia'), + onBlockAndDelete: action('onBlockAndDelete'), + onDelete: action('onDelete'), +}); + +story.add('Basic', () => { + const props = createProps(); + + return ; +}); + +story.add('as Admin', () => { + const props = createProps(); + + return ; +}); + +story.add('Group Editable', () => { + const props = createProps(); + + return ; +}); + +story.add('Group Links On', () => { + const props = createProps(true); + + return ; +}); diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx new file mode 100644 index 000000000000..b6338c2c59d8 --- /dev/null +++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx @@ -0,0 +1,176 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { ConversationType } from '../../../state/ducks/conversations'; +import { + ExpirationTimerOptions, + TimerOption, +} from '../../../util/ExpirationTimerOptions'; +import { LocalizerType } from '../../../types/Util'; +import { MediaItemType } from '../../LightboxGallery'; + +import { PanelRow } from './PanelRow'; +import { PanelSection } from './PanelSection'; +import { ConversationDetailsActions } from './ConversationDetailsActions'; +import { ConversationDetailsHeader } from './ConversationDetailsHeader'; +import { ConversationDetailsIcon } from './ConversationDetailsIcon'; +import { ConversationDetailsMediaList } from './ConversationDetailsMediaList'; +import { ConversationDetailsMembershipList } from './ConversationDetailsMembershipList'; + +export type StateProps = { + canEditGroupInfo: boolean; + conversation?: ConversationType; + hasGroupLink: boolean; + i18n: LocalizerType; + isAdmin: boolean; + loadRecentMediaItems: (limit: number) => void; + setDisappearingMessages: (seconds: number) => void; + showAllMedia: () => void; + showContactModal: (conversationId: string) => void; + showGroupLinkManagement: () => void; + showGroupV2Permissions: () => void; + showPendingInvites: () => void; + showLightboxForMedia: ( + selectedMediaItem: MediaItemType, + media: Array + ) => void; + onBlockAndDelete: () => void; + onDelete: () => void; +}; + +export type Props = StateProps; + +export const ConversationDetails: React.ComponentType = ({ + canEditGroupInfo, + conversation, + hasGroupLink, + i18n, + isAdmin, + loadRecentMediaItems, + setDisappearingMessages, + showAllMedia, + showContactModal, + showGroupLinkManagement, + showGroupV2Permissions, + showPendingInvites, + showLightboxForMedia, + onBlockAndDelete, + onDelete, +}) => { + const updateExpireTimer = (event: React.ChangeEvent) => { + setDisappearingMessages(parseInt(event.target.value, 10)); + }; + + if (conversation === undefined) { + throw new Error('ConversationDetails rendered without a conversation'); + } + + const pendingMemberships = conversation.pendingMemberships || []; + const pendingApprovalMemberships = + conversation.pendingApprovalMemberships || []; + const invitesCount = + pendingMemberships.length + pendingApprovalMemberships.length; + + return ( +
+ + + {canEditGroupInfo ? ( + + + } + info={i18n('ConversationDetails--disappearing-messages-info')} + label={i18n('ConversationDetails--disappearing-messages-label')} + right={ +
+ +
+ } + /> +
+ ) : null} + + + + + {isAdmin ? ( + + } + label={i18n('ConversationDetails--group-link')} + onClick={showGroupLinkManagement} + right={hasGroupLink ? i18n('on') : i18n('off')} + /> + ) : null} + + } + label={i18n('ConversationDetails--requests-and-invites')} + onClick={showPendingInvites} + right={invitesCount} + /> + {isAdmin ? ( + + } + label={i18n('permissions')} + onClick={showGroupV2Permissions} + /> + ) : null} + + + + + +
+ ); +}; diff --git a/ts/components/conversation/conversation-details/ConversationDetailsActions.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetailsActions.stories.tsx new file mode 100644 index 000000000000..81550629da4b --- /dev/null +++ b/ts/components/conversation/conversation-details/ConversationDetailsActions.stories.tsx @@ -0,0 +1,34 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { setup as setupI18n } from '../../../../js/modules/i18n'; +import enMessages from '../../../../_locales/en/messages.json'; +import { + ConversationDetailsActions, + Props, +} from './ConversationDetailsActions'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf( + 'Components/Conversation/ConversationDetails/ConversationDetailsActions', + module +); + +const createProps = (overrideProps: Partial = {}): Props => ({ + conversationTitle: overrideProps.conversationTitle || '', + onBlockAndDelete: action('onBlockAndDelete'), + onDelete: action('onDelete'), + i18n, +}); + +story.add('Basic', () => { + const props = createProps(); + + return ; +}); diff --git a/ts/components/conversation/conversation-details/ConversationDetailsActions.tsx b/ts/components/conversation/conversation-details/ConversationDetailsActions.tsx new file mode 100644 index 000000000000..5b09d07e02bf --- /dev/null +++ b/ts/components/conversation/conversation-details/ConversationDetailsActions.tsx @@ -0,0 +1,95 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { LocalizerType } from '../../../types/Util'; +import { ConfirmationModal } from '../../ConfirmationModal'; + +import { PanelRow } from './PanelRow'; +import { PanelSection } from './PanelSection'; +import { ConversationDetailsIcon } from './ConversationDetailsIcon'; + +export type Props = { + conversationTitle: string; + onBlockAndDelete: () => void; + onDelete: () => void; + i18n: LocalizerType; +}; + +export const ConversationDetailsActions: React.ComponentType = ({ + conversationTitle, + onBlockAndDelete, + onDelete, + i18n, +}) => { + const [confirmingLeave, setConfirmingLeave] = React.useState(false); + const [confirmingBlock, setConfirmingBlock] = React.useState(false); + + return ( + <> + + setConfirmingLeave(true)} + icon={ + + } + label={i18n('ConversationDetailsActions--leave-group')} + /> + setConfirmingBlock(true)} + icon={ + + } + label={i18n('ConversationDetailsActions--block-group')} + /> + + + {confirmingLeave && ( + setConfirmingLeave(false)} + title={i18n('ConversationDetailsActions--leave-group-modal-title')} + > + {i18n('ConversationDetailsActions--leave-group-modal-content')} + + )} + + {confirmingBlock && ( + setConfirmingBlock(false)} + title={i18n('ConversationDetailsActions--block-group-modal-title', [ + conversationTitle, + ])} + > + {i18n('ConversationDetailsActions--block-group-modal-content')} + + )} + + ); +}; diff --git a/ts/components/conversation/conversation-details/ConversationDetailsHeader.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetailsHeader.stories.tsx new file mode 100644 index 000000000000..9bbe6ea285dd --- /dev/null +++ b/ts/components/conversation/conversation-details/ConversationDetailsHeader.stories.tsx @@ -0,0 +1,40 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { number, text } from '@storybook/addon-knobs'; + +import { setup as setupI18n } from '../../../../js/modules/i18n'; +import enMessages from '../../../../_locales/en/messages.json'; +import { ConversationType } from '../../../state/ducks/conversations'; + +import { ConversationDetailsHeader, Props } from './ConversationDetailsHeader'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf( + 'Components/Conversation/ConversationDetails/ConversationDetailHeader', + module +); + +const createConversation = (): ConversationType => ({ + id: '', + markedUnread: false, + type: 'group', + lastUpdated: 0, + title: text('conversation title', 'Some Conversation'), + memberships: new Array(number('conversation members length', 0)), +}); + +const createProps = (): Props => ({ + conversation: createConversation(), + i18n, +}); + +story.add('Basic', () => { + const props = createProps(); + + return ; +}); diff --git a/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx b/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx new file mode 100644 index 000000000000..25111fcf2eed --- /dev/null +++ b/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx @@ -0,0 +1,42 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { Avatar } from '../../Avatar'; +import { LocalizerType } from '../../../types/Util'; +import { ConversationType } from '../../../state/ducks/conversations'; +import { bemGenerator } from './util'; + +export type Props = { + i18n: LocalizerType; + conversation: ConversationType; +}; + +const bem = bemGenerator('module-conversation-details-header'); + +export const ConversationDetailsHeader: React.ComponentType = ({ + i18n, + conversation, +}) => { + const memberships = conversation.memberships || []; + + return ( +
+ +
+
{conversation.title}
+
+ {i18n('ConversationDetailsHeader--members', [ + memberships.length.toString(), + ])} +
+
+
+ ); +}; diff --git a/ts/components/conversation/conversation-details/ConversationDetailsIcon.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetailsIcon.stories.tsx new file mode 100644 index 000000000000..ecbcbe8522c4 --- /dev/null +++ b/ts/components/conversation/conversation-details/ConversationDetailsIcon.stories.tsx @@ -0,0 +1,38 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { ConversationDetailsIcon, Props } from './ConversationDetailsIcon'; + +const story = storiesOf( + 'Components/Conversation/ConversationDetails/ConversationDetailIcon', + module +); + +const createProps = (overrideProps: Partial): Props => ({ + ariaLabel: overrideProps.ariaLabel || '', + icon: overrideProps.icon || '', + onClick: overrideProps.onClick, +}); + +story.add('All', () => { + const icons = ['timer', 'trash', 'invites', 'block', 'leave', 'down']; + + return icons.map(icon => ( + + )); +}); + +story.add('Clickable Icons', () => { + const icons = ['timer', 'trash', 'invites', 'block', 'leave', 'down']; + + const onClick = action('onClick'); + + return icons.map(icon => ( + + )); +}); diff --git a/ts/components/conversation/conversation-details/ConversationDetailsIcon.tsx b/ts/components/conversation/conversation-details/ConversationDetailsIcon.tsx new file mode 100644 index 000000000000..4f47818a1a72 --- /dev/null +++ b/ts/components/conversation/conversation-details/ConversationDetailsIcon.tsx @@ -0,0 +1,37 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { bemGenerator } from './util'; + +export type Props = { + ariaLabel: string; + icon: string; + onClick?: () => void; +}; + +const bem = bemGenerator('module-conversation-details-icon'); + +export const ConversationDetailsIcon: React.ComponentType = ({ + ariaLabel, + icon, + onClick, +}) => { + const content =
; + + if (onClick) { + return ( + + ); + } + + return content; +}; diff --git a/ts/components/conversation/conversation-details/ConversationDetailsMediaList.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetailsMediaList.stories.tsx new file mode 100644 index 000000000000..964cadeb95b9 --- /dev/null +++ b/ts/components/conversation/conversation-details/ConversationDetailsMediaList.stories.tsx @@ -0,0 +1,45 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { setup as setupI18n } from '../../../../js/modules/i18n'; +import enMessages from '../../../../_locales/en/messages.json'; + +import { + ConversationDetailsMediaList, + Props, +} from './ConversationDetailsMediaList'; +import { + createPreparedMediaItems, + createRandomMedia, +} from '../media-gallery/AttachmentSection.stories'; +import { MediaItemType } from '../../LightboxGallery'; +import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf( + 'Components/Conversation/ConversationDetails/ConversationMediaList', + module +); + +const createProps = (mediaItems?: Array): Props => ({ + conversation: getDefaultConversation({ + recentMediaItems: mediaItems || [], + }), + i18n, + loadRecentMediaItems: action('loadRecentMediaItems'), + showAllMedia: action('showAllMedia'), + showLightboxForMedia: action('showLightboxForMedia'), +}); + +story.add('Basic', () => { + const mediaItems = createPreparedMediaItems(createRandomMedia); + const props = createProps(mediaItems); + + return ; +}); diff --git a/ts/components/conversation/conversation-details/ConversationDetailsMediaList.tsx b/ts/components/conversation/conversation-details/ConversationDetailsMediaList.tsx new file mode 100644 index 000000000000..fb04346e53d0 --- /dev/null +++ b/ts/components/conversation/conversation-details/ConversationDetailsMediaList.tsx @@ -0,0 +1,73 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { LocalizerType } from '../../../types/Util'; + +import { MediaItemType } from '../../LightboxGallery'; +import { ConversationType } from '../../../state/ducks/conversations'; + +import { PanelSection } from './PanelSection'; +import { bemGenerator } from './util'; +import { MediaGridItem } from '../media-gallery/MediaGridItem'; + +export type Props = { + conversation: ConversationType; + i18n: LocalizerType; + loadRecentMediaItems: (limit: number) => void; + showAllMedia: () => void; + showLightboxForMedia: ( + selectedMediaItem: MediaItemType, + media: Array + ) => void; +}; + +const MEDIA_ITEM_LIMIT = 6; + +const bem = bemGenerator('module-conversation-details-media-list'); + +export const ConversationDetailsMediaList: React.ComponentType = ({ + conversation, + i18n, + loadRecentMediaItems, + showAllMedia, + showLightboxForMedia, +}) => { + const mediaItems = conversation.recentMediaItems || []; + + React.useEffect(() => { + loadRecentMediaItems(MEDIA_ITEM_LIMIT); + }, [loadRecentMediaItems]); + + if (mediaItems.length === 0) { + return null; + } + + return ( + + {i18n('ConversationDetailsMediaList--show-all')} + + } + borderless + title={i18n('ConversationDetailsMediaList--shared-media')} + > +
+ {mediaItems.slice(0, MEDIA_ITEM_LIMIT).map(mediaItem => ( + showLightboxForMedia(mediaItem, mediaItems)} + /> + ))} +
+
+ ); +}; diff --git a/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.stories.tsx new file mode 100644 index 000000000000..fda68f5adae9 --- /dev/null +++ b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.stories.tsx @@ -0,0 +1,76 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { number } from '@storybook/addon-knobs'; + +import { setup as setupI18n } from '../../../../js/modules/i18n'; +import enMessages from '../../../../_locales/en/messages.json'; +import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; + +import { + ConversationDetailsMembershipList, + Props, + GroupV2Membership, +} from './ConversationDetailsMembershipList'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf( + 'Components/Conversation/ConversationDetails/ConversationDetailsMembershipList', + module +); + +const createMemberships = ( + numberOfMemberships = 10 +): Array => { + return Array.from( + new Array(number('number of memberships', numberOfMemberships)) + ).map( + (_, i): GroupV2Membership => ({ + isAdmin: i % 3 === 0, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + metadata: {} as any, + member: getDefaultConversation({}), + }) + ); +}; + +const createProps = (overrideProps: Partial): Props => ({ + i18n, + showContactModal: action('showContactModal'), + memberships: overrideProps.memberships || [], +}); + +story.add('Basic', () => { + const memberships = createMemberships(10); + + const props = createProps({ memberships }); + + return ; +}); + +story.add('Few', () => { + const memberships = createMemberships(3); + + const props = createProps({ memberships }); + + return ; +}); + +story.add('Many', () => { + const memberships = createMemberships(100); + + const props = createProps({ memberships }); + + return ; +}); + +story.add('None', () => { + const props = createProps({ memberships: [] }); + + return ; +}); diff --git a/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.tsx b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.tsx new file mode 100644 index 000000000000..0115823b21b0 --- /dev/null +++ b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.tsx @@ -0,0 +1,76 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { LocalizerType } from '../../../types/Util'; +import { Avatar } from '../../Avatar'; + +import { ConversationDetailsIcon } from './ConversationDetailsIcon'; +import { ConversationType } from '../../../state/ducks/conversations'; +import { GroupV2MemberType } from '../../../model-types.d'; +import { PanelRow } from './PanelRow'; +import { PanelSection } from './PanelSection'; + +export type GroupV2Membership = { + isAdmin: boolean; + metadata: GroupV2MemberType; + member: ConversationType; +}; + +export type Props = { + memberships: Array; + showContactModal: (conversationId: string) => void; + i18n: LocalizerType; +}; + +const INITIAL_MEMBER_COUNT = 5; + +export const ConversationDetailsMembershipList: React.ComponentType = ({ + memberships, + showContactModal, + i18n, +}) => { + const [showAllMembers, setShowAllMembers] = React.useState(false); + + return ( + + {memberships + .slice(0, showAllMembers ? undefined : INITIAL_MEMBER_COUNT) + .map(({ isAdmin, member }) => ( + showContactModal(member.id)} + icon={ + + } + label={member.title} + right={isAdmin ? i18n('GroupV2--admin') : ''} + /> + ))} + {showAllMembers === false && + memberships.length > INITIAL_MEMBER_COUNT && ( + + } + onClick={() => setShowAllMembers(true)} + label={i18n('ConversationDetailsMembershipList--show-all')} + /> + )} + + ); +}; diff --git a/ts/components/conversation/conversation-details/GroupLinkManagement.stories.tsx b/ts/components/conversation/conversation-details/GroupLinkManagement.stories.tsx new file mode 100644 index 000000000000..5a75f63dad27 --- /dev/null +++ b/ts/components/conversation/conversation-details/GroupLinkManagement.stories.tsx @@ -0,0 +1,86 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { setup as setupI18n } from '../../../../js/modules/i18n'; +import enMessages from '../../../../_locales/en/messages.json'; +import { GroupLinkManagement, PropsType } from './GroupLinkManagement'; +import { ConversationType } from '../../../state/ducks/conversations'; +import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf( + 'Components/Conversation/ConversationDetails/GroupLinkManagement', + module +); + +class AccessEnum { + static ANY = 0; + + static UNKNOWN = 1; + + static MEMBER = 2; + + static ADMINISTRATOR = 3; + + static UNSATISFIABLE = 4; +} + +function getConversation( + groupLink?: string, + accessControlAddFromInviteLink?: number +): ConversationType { + return { + id: '', + lastUpdated: 0, + markedUnread: false, + memberships: Array(32).fill({ member: getDefaultConversation({}) }), + pendingMemberships: Array(16).fill({ member: getDefaultConversation({}) }), + title: 'Some Conversation', + type: 'group', + groupLink, + accessControlAddFromInviteLink: + accessControlAddFromInviteLink !== undefined + ? accessControlAddFromInviteLink + : AccessEnum.UNSATISFIABLE, + }; +} + +const createProps = (conversation?: ConversationType): PropsType => ({ + accessEnum: AccessEnum, + changeHasGroupLink: action('changeHasGroupLink'), + conversation: conversation || getConversation(), + copyGroupLink: action('copyGroupLink'), + generateNewGroupLink: action('generateNewGroupLink'), + i18n, + setAccessControlAddFromInviteLinkSetting: action( + 'setAccessControlAddFromInviteLinkSetting' + ), +}); + +story.add('Off', () => { + const props = createProps(); + + return ; +}); + +story.add('On', () => { + const props = createProps( + getConversation('https://signal.group/1', AccessEnum.ANY) + ); + + return ; +}); + +story.add('On (Admin Approval Needed)', () => { + const props = createProps( + getConversation('https://signal.group/1', AccessEnum.ADMINISTRATOR) + ); + + return ; +}); diff --git a/ts/components/conversation/conversation-details/GroupLinkManagement.tsx b/ts/components/conversation/conversation-details/GroupLinkManagement.tsx new file mode 100644 index 000000000000..a3927689d87f --- /dev/null +++ b/ts/components/conversation/conversation-details/GroupLinkManagement.tsx @@ -0,0 +1,130 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { ConversationDetailsIcon } from './ConversationDetailsIcon'; +import { ConversationType } from '../../../state/ducks/conversations'; +import { LocalizerType } from '../../../types/Util'; +import { PanelRow } from './PanelRow'; +import { PanelSection } from './PanelSection'; +import { AccessControlClass } from '../../../textsecure.d'; + +export type PropsType = { + accessEnum: typeof AccessControlClass.AccessRequired; + changeHasGroupLink: (value: boolean) => void; + conversation?: ConversationType; + copyGroupLink: (groupLink: string) => void; + generateNewGroupLink: () => void; + i18n: LocalizerType; + setAccessControlAddFromInviteLinkSetting: (value: boolean) => void; +}; + +export const GroupLinkManagement: React.ComponentType = ({ + accessEnum, + changeHasGroupLink, + conversation, + copyGroupLink, + generateNewGroupLink, + i18n, + setAccessControlAddFromInviteLinkSetting, +}) => { + if (conversation === undefined) { + throw new Error('GroupLinkManagement rendered without a conversation'); + } + + const createEventHandler = (handleEvent: (x: boolean) => void) => { + return (event: React.ChangeEvent) => { + handleEvent(event.target.value === 'true'); + }; + }; + + const membersNeedAdminApproval = + conversation.accessControlAddFromInviteLink === accessEnum.ADMINISTRATOR; + + const hasGroupLink = + conversation.groupLink && + conversation.accessControlAddFromInviteLink !== accessEnum.UNSATISFIABLE; + const groupLinkInfo = hasGroupLink ? conversation.groupLink : ''; + + return ( + <> + + + +
+ } + /> + + + {hasGroupLink ? ( + <> + + + } + label={i18n('GroupLinkManagement--share')} + onClick={() => { + if (conversation.groupLink) { + copyGroupLink(conversation.groupLink); + } + }} + /> + + } + label={i18n('GroupLinkManagement--reset')} + onClick={generateNewGroupLink} + /> + + + + + + + } + /> + + + ) : null} + + ); +}; diff --git a/ts/components/conversation/conversation-details/GroupV2Permissions.stories.tsx b/ts/components/conversation/conversation-details/GroupV2Permissions.stories.tsx new file mode 100644 index 000000000000..aa6ec687bcba --- /dev/null +++ b/ts/components/conversation/conversation-details/GroupV2Permissions.stories.tsx @@ -0,0 +1,58 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { setup as setupI18n } from '../../../../js/modules/i18n'; +import enMessages from '../../../../_locales/en/messages.json'; +import { GroupV2Permissions, PropsType } from './GroupV2Permissions'; +import { ConversationType } from '../../../state/ducks/conversations'; +import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf( + 'Components/Conversation/ConversationDetails/GroupV2Permissions', + module +); + +const conversation: ConversationType = { + id: '', + lastUpdated: 0, + markedUnread: false, + memberships: Array(32).fill({ member: getDefaultConversation({}) }), + pendingMemberships: Array(16).fill({ member: getDefaultConversation({}) }), + title: 'Some Conversation', + type: 'group', +}; + +class AccessEnum { + static ANY = 0; + + static UNKNOWN = 1; + + static MEMBER = 2; + + static ADMINISTRATOR = 3; + + static UNSATISFIABLE = 4; +} + +const createProps = (): PropsType => ({ + accessEnum: AccessEnum, + conversation, + i18n, + setAccessControlAttributesSetting: action( + 'setAccessControlAttributesSetting' + ), + setAccessControlMembersSetting: action('setAccessControlMembersSetting'), +}); + +story.add('Basic', () => { + const props = createProps(); + + return ; +}); diff --git a/ts/components/conversation/conversation-details/GroupV2Permissions.tsx b/ts/components/conversation/conversation-details/GroupV2Permissions.tsx new file mode 100644 index 000000000000..9bd82dc0c5d2 --- /dev/null +++ b/ts/components/conversation/conversation-details/GroupV2Permissions.tsx @@ -0,0 +1,85 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { ConversationType } from '../../../state/ducks/conversations'; +import { LocalizerType } from '../../../types/Util'; +import { getAccessControlOptions } from '../../../util/getAccessControlOptions'; +import { AccessControlClass } from '../../../textsecure.d'; + +import { PanelRow } from './PanelRow'; +import { PanelSection } from './PanelSection'; + +export type PropsType = { + accessEnum: typeof AccessControlClass.AccessRequired; + conversation?: ConversationType; + i18n: LocalizerType; + setAccessControlAttributesSetting: (value: number) => void; + setAccessControlMembersSetting: (value: number) => void; +}; + +export const GroupV2Permissions: React.ComponentType = ({ + accessEnum, + conversation, + i18n, + setAccessControlAttributesSetting, + setAccessControlMembersSetting, +}) => { + if (conversation === undefined) { + throw new Error('GroupV2Permissions rendered without a conversation'); + } + + const updateAccessControlAttributes = ( + event: React.ChangeEvent + ) => { + setAccessControlAttributesSetting(Number(event.target.value)); + }; + const updateAccessControlMembers = ( + event: React.ChangeEvent + ) => { + setAccessControlMembersSetting(Number(event.target.value)); + }; + const accessControlOptions = getAccessControlOptions(accessEnum, i18n); + + return ( + + + + + } + /> + + + + } + /> + + ); +}; diff --git a/ts/components/conversation/conversation-details/PanelRow.stories.tsx b/ts/components/conversation/conversation-details/PanelRow.stories.tsx new file mode 100644 index 000000000000..d6be023525ed --- /dev/null +++ b/ts/components/conversation/conversation-details/PanelRow.stories.tsx @@ -0,0 +1,76 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { boolean, text } from '@storybook/addon-knobs'; + +import { ConversationDetailsIcon } from './ConversationDetailsIcon'; +import { PanelRow, Props } from './PanelRow'; + +const story = storiesOf( + 'Components/Conversation/ConversationDetails/PanelRow', + module +); + +const createProps = (overrideProps: Partial = {}): Props => ({ + icon: boolean('with icon', overrideProps.icon !== undefined) ? ( + + ) : null, + label: text('label', overrideProps.label || ''), + info: text('info', overrideProps.info || ''), + right: text('right', (overrideProps.right as string) || ''), + actions: boolean('with action', overrideProps.actions !== undefined) ? ( + + ) : null, + onClick: boolean('clickable', overrideProps.onClick !== undefined) + ? overrideProps.onClick || action('onClick') + : undefined, +}); + +story.add('Basic', () => { + const props = createProps({ + label: 'this is a panel row', + }); + + return ; +}); + +story.add('Simple', () => { + const props = createProps({ + label: 'this is a panel row', + icon: 'with icon', + right: 'side text', + }); + + return ; +}); + +story.add('Full', () => { + const props = createProps({ + label: 'this is a panel row', + icon: 'with icon', + info: 'this is some info that exists below the main label', + right: 'side text', + actions: 'with action', + }); + + return ; +}); + +story.add('Button', () => { + const props = createProps({ + label: 'this is a panel row', + icon: 'with icon', + right: 'side text', + onClick: action('onClick'), + }); + + return ; +}); diff --git a/ts/components/conversation/conversation-details/PanelRow.tsx b/ts/components/conversation/conversation-details/PanelRow.tsx new file mode 100644 index 000000000000..81347c531c61 --- /dev/null +++ b/ts/components/conversation/conversation-details/PanelRow.tsx @@ -0,0 +1,58 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import classNames from 'classnames'; +import { bemGenerator } from './util'; + +export type Props = { + alwaysShowActions?: boolean; + className?: string; + icon?: React.ReactNode; + label: string; + info?: string; + right?: string | React.ReactNode; + actions?: React.ReactNode; + onClick?: () => void; +}; + +const bem = bemGenerator('module-conversation-details-panel-row'); + +export const PanelRow: React.ComponentType = ({ + alwaysShowActions, + className, + icon, + label, + info, + right, + actions, + onClick, +}) => { + const content = ( + <> + {icon &&
{icon}
} +
+
{label}
+ {info &&
{info}
} +
+ {right &&
{right}
} + {actions && ( +
{actions}
+ )} + + ); + + if (onClick) { + return ( + + ); + } + + return
{content}
; +}; diff --git a/ts/components/conversation/conversation-details/PanelSection.stories.tsx b/ts/components/conversation/conversation-details/PanelSection.stories.tsx new file mode 100644 index 000000000000..be4481412628 --- /dev/null +++ b/ts/components/conversation/conversation-details/PanelSection.stories.tsx @@ -0,0 +1,71 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { boolean, text } from '@storybook/addon-knobs'; + +import { PanelSection, Props } from './PanelSection'; +import { PanelRow } from './PanelRow'; + +const story = storiesOf( + 'Components/Conversation/ConversationDetails/PanelSection', + module +); + +const createProps = (overrideProps: Partial = {}): Props => ({ + title: text('label', overrideProps.title || ''), + centerTitle: boolean('centerTitle', overrideProps.centerTitle || false), + actions: boolean('with action', overrideProps.actions !== undefined) ? ( + + ) : null, +}); + +story.add('Basic', () => { + const props = createProps({ + title: 'panel section header', + }); + + return ; +}); + +story.add('Centered', () => { + const props = createProps({ + title: 'this is a panel row', + centerTitle: true, + }); + + return ; +}); + +story.add('With Actions', () => { + const props = createProps({ + title: 'this is a panel row', + actions: ( + + ), + }); + + return ; +}); + +story.add('With Content', () => { + const props = createProps({ + title: 'this is a panel row', + }); + + return ( + + + + + + + ); +}); diff --git a/ts/components/conversation/conversation-details/PanelSection.tsx b/ts/components/conversation/conversation-details/PanelSection.tsx new file mode 100644 index 000000000000..58ee3168b0bf --- /dev/null +++ b/ts/components/conversation/conversation-details/PanelSection.tsx @@ -0,0 +1,34 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import classNames from 'classnames'; +import { bemGenerator } from './util'; + +export type Props = { + actions?: React.ReactNode; + borderless?: boolean; + centerTitle?: boolean; + title?: string; +}; + +const bem = bemGenerator('module-conversation-details-panel-section'); +const borderlessClass = bem('root', 'borderless'); + +export const PanelSection: React.ComponentType = ({ + actions, + borderless, + centerTitle, + children, + title, +}) => ( +
+ {(title || actions) && ( +
+ {title &&
{title}
} + {actions &&
{actions}
} +
+ )} +
{children}
+
+); diff --git a/ts/components/conversation/conversation-details/PendingInvites.stories.tsx b/ts/components/conversation/conversation-details/PendingInvites.stories.tsx new file mode 100644 index 000000000000..266cec04275b --- /dev/null +++ b/ts/components/conversation/conversation-details/PendingInvites.stories.tsx @@ -0,0 +1,87 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { setup as setupI18n } from '../../../../js/modules/i18n'; +import enMessages from '../../../../_locales/en/messages.json'; +import { PendingInvites, PropsType } from './PendingInvites'; +import { ConversationType } from '../../../state/ducks/conversations'; +import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf( + 'Components/Conversation/ConversationDetails/PendingInvites', + module +); + +const sortedGroupMembers = Array.from(Array(32)).map((_, i) => + i === 0 + ? getDefaultConversation({ id: 'def456' }) + : getDefaultConversation({}) +); + +const conversation: ConversationType = { + areWeAdmin: true, + id: '', + lastUpdated: 0, + markedUnread: false, + memberships: sortedGroupMembers.map(member => ({ + isAdmin: false, + member, + metadata: { + conversationId: 'abc123', + joinedAtVersion: 1, + role: 1, + }, + })), + pendingMemberships: Array.from(Array(4)) + .map(() => ({ + member: getDefaultConversation({}), + metadata: { + addedByUserId: 'abc123', + conversationId: 'xyz789', + role: 1, + timestamp: Date.now(), + }, + })) + .concat( + Array.from(Array(8)).map(() => ({ + member: getDefaultConversation({}), + metadata: { + addedByUserId: 'def456', + conversationId: 'xyz789', + role: 1, + timestamp: Date.now(), + }, + })) + ), + pendingApprovalMemberships: Array.from(Array(5)).map(() => ({ + member: getDefaultConversation({}), + metadata: { + conversationId: 'xyz789', + timestamp: Date.now(), + }, + })), + sortedGroupMembers, + title: 'Some Conversation', + type: 'group', +}; + +const createProps = (): PropsType => ({ + approvePendingMembership: action('approvePendingMembership'), + conversation, + i18n, + ourConversationId: 'abc123', + revokePendingMemberships: action('revokePendingMemberships'), +}); + +story.add('Basic', () => { + const props = createProps(); + + return ; +}); diff --git a/ts/components/conversation/conversation-details/PendingInvites.tsx b/ts/components/conversation/conversation-details/PendingInvites.tsx new file mode 100644 index 000000000000..9cf73f1a9c91 --- /dev/null +++ b/ts/components/conversation/conversation-details/PendingInvites.tsx @@ -0,0 +1,477 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import classNames from 'classnames'; +import _ from 'lodash'; + +import { ConversationType } from '../../../state/ducks/conversations'; +import { LocalizerType } from '../../../types/Util'; +import { Avatar } from '../../Avatar'; +import { ConfirmationModal } from '../../ConfirmationModal'; +import { PanelSection } from './PanelSection'; +import { PanelRow } from './PanelRow'; +import { ConversationDetailsIcon } from './ConversationDetailsIcon'; +import { + GroupV2PendingAdminApprovalType, + GroupV2PendingMemberType, +} from '../../../model-types.d'; + +export type PropsType = { + conversation?: ConversationType; + readonly i18n: LocalizerType; + ourConversationId?: string; + readonly approvePendingMembership: (conversationId: string) => void; + readonly revokePendingMemberships: (conversationIds: Array) => void; +}; + +export type GroupV2PendingMembership = { + metadata: GroupV2PendingMemberType; + member: ConversationType; +}; + +export type GroupV2RequestingMembership = { + metadata: GroupV2PendingAdminApprovalType; + member: ConversationType; +}; + +enum Tab { + Requests = 'Requests', + Pending = 'Pending', +} + +enum StageType { + APPROVE_REQUEST = 'APPROVE_REQUEST', + DENY_REQUEST = 'DENY_REQUEST', + REVOKE_INVITE = 'REVOKE_INVITE', +} + +type StagedMembershipType = { + type: StageType; + membership: GroupV2PendingMembership | GroupV2RequestingMembership; +}; + +export const PendingInvites: React.ComponentType = ({ + approvePendingMembership, + conversation, + i18n, + ourConversationId, + revokePendingMemberships, +}) => { + if (!conversation || !ourConversationId) { + throw new Error( + 'PendingInvites rendered without a conversation or ourConversationId' + ); + } + + const [selectedTab, setSelectedTab] = React.useState(Tab.Requests); + const [stagedMemberships, setStagedMemberships] = React.useState | null>(null); + + const allPendingMemberships = conversation.pendingMemberships || []; + const allRequestingMemberships = + conversation.pendingApprovalMemberships || []; + + return ( +
+
+
{ + setSelectedTab(Tab.Requests); + }} + onKeyUp={(e: React.KeyboardEvent) => { + if (e.target === e.currentTarget && e.keyCode === 13) { + setSelectedTab(Tab.Requests); + } + }} + role="tab" + tabIndex={0} + > + {i18n('PendingInvites--tab-requests', { + count: String(allRequestingMemberships.length), + })} +
+ +
{ + setSelectedTab(Tab.Pending); + }} + onKeyUp={(e: React.KeyboardEvent) => { + if (e.target === e.currentTarget && e.keyCode === 13) { + setSelectedTab(Tab.Pending); + } + }} + role="tab" + tabIndex={0} + > + {i18n('PendingInvites--tab-invites', { + count: String(allPendingMemberships.length), + })} +
+
+ + {selectedTab === Tab.Requests ? ( + + ) : null} + {selectedTab === Tab.Pending ? ( + + ) : null} + + {stagedMemberships && stagedMemberships.length && ( + setStagedMemberships(null)} + ourConversationId={ourConversationId} + revokePendingMemberships={revokePendingMemberships} + stagedMemberships={stagedMemberships} + /> + )} +
+ ); +}; + +function MembershipActionConfirmation({ + approvePendingMembership, + i18n, + members, + onClose, + ourConversationId, + revokePendingMemberships, + stagedMemberships, +}: { + approvePendingMembership: (conversationId: string) => void; + i18n: LocalizerType; + members: Array; + onClose: () => void; + ourConversationId: string; + revokePendingMemberships: (conversationIds: Array) => void; + stagedMemberships: Array; +}) { + const revokeStagedMemberships = () => { + if (!stagedMemberships) { + return; + } + revokePendingMemberships( + stagedMemberships.map(({ membership }) => membership.member.id) + ); + }; + + const approveStagedMembership = () => { + if (!stagedMemberships) { + return; + } + approvePendingMembership(stagedMemberships[0].membership.member.id); + }; + + const membershipType = stagedMemberships[0].type; + + const modalAction = + membershipType === StageType.APPROVE_REQUEST + ? approveStagedMembership + : revokeStagedMemberships; + + let modalActionText = i18n('PendingInvites--revoke'); + + if (membershipType === StageType.APPROVE_REQUEST) { + modalActionText = i18n('PendingRequests--approve'); + } else if (membershipType === StageType.DENY_REQUEST) { + modalActionText = i18n('PendingRequests--deny'); + } else if (membershipType === StageType.REVOKE_INVITE) { + modalActionText = i18n('PendingInvites--revoke'); + } + + return ( + + {getConfirmationMessage({ + i18n, + members, + ourConversationId, + stagedMemberships, + })} + + ); +} + +function getConfirmationMessage({ + i18n, + members, + ourConversationId, + stagedMemberships, +}: { + i18n: LocalizerType; + members: Array; + ourConversationId: string; + stagedMemberships: Array; +}): string { + if (!stagedMemberships || !stagedMemberships.length) { + return ''; + } + + const membershipType = stagedMemberships[0].type; + const firstMembership = stagedMemberships[0].membership; + + // Requesting a membership since they weren't added by anyone + if (membershipType === StageType.DENY_REQUEST) { + return i18n('PendingRequests--deny-for', { + name: firstMembership.member.title, + }); + } + + if (membershipType === StageType.APPROVE_REQUEST) { + return i18n('PendingRequests--approve-for', { + name: firstMembership.member.title, + }); + } + + if (membershipType !== StageType.REVOKE_INVITE) { + throw new Error('getConfirmationMessage: Invalid staging type'); + } + + const firstPendingMembership = firstMembership as GroupV2PendingMembership; + + // Pending invite + const invitedByUs = + firstPendingMembership.metadata.addedByUserId === ourConversationId; + + if (invitedByUs) { + return i18n('PendingInvites--revoke-for', { + name: firstPendingMembership.member.title, + }); + } + + const inviter = members.find( + ({ id }) => id === firstPendingMembership.metadata.addedByUserId + ); + + if (inviter === undefined) { + return ''; + } + + const name = inviter.title; + + if (stagedMemberships.length === 1) { + return i18n('PendingInvites--revoke-from-singular', { name }); + } + + return i18n('PendingInvites--revoke-from-plural', { + number: stagedMemberships.length.toString(), + name, + }); +} + +function MembersPendingAdminApproval({ + conversation, + i18n, + memberships, + setStagedMemberships, +}: { + conversation: ConversationType; + i18n: LocalizerType; + memberships: Array; + setStagedMemberships: (stagedMembership: Array) => void; +}) { + return ( + + {memberships.map(membership => ( + + } + label={membership.member.title} + actions={ + conversation.areWeAdmin ? ( + <> + + + + ) : null + } + /> + ))} +
+ {i18n('PendingRequests--info', [conversation.title])} +
+
+ ); +} + +function MembersPendingProfileKey({ + conversation, + i18n, + members, + memberships, + ourConversationId, + setStagedMemberships, +}: { + conversation: ConversationType; + i18n: LocalizerType; + members: Array; + memberships: Array; + ourConversationId: string; + setStagedMemberships: (stagedMembership: Array) => void; +}) { + const groupedPendingMemberships = _.groupBy( + memberships, + membership => membership.metadata.addedByUserId + ); + + const { + [ourConversationId]: ourPendingMemberships, + ...otherPendingMembershipGroups + } = groupedPendingMemberships; + + const otherPendingMemberships = Object.keys(otherPendingMembershipGroups) + .map(id => members.find(member => member.id === id)) + .filter((member): member is ConversationType => member !== undefined) + .map(member => ({ + member, + pendingMemberships: otherPendingMembershipGroups[member.id], + })); + + return ( + + {ourPendingMemberships && ( + + {ourPendingMemberships.map(membership => ( + + } + label={membership.member.title} + actions={ + conversation.areWeAdmin ? ( + { + setStagedMemberships([ + { + type: StageType.REVOKE_INVITE, + membership, + }, + ]); + }} + /> + ) : null + } + /> + ))} + + )} + {otherPendingMemberships.length > 0 && ( + + {otherPendingMemberships.map(({ member, pendingMemberships }) => ( + + } + label={member.title} + right={i18n('PendingInvites--invited-count', [ + pendingMemberships.length.toString(), + ])} + actions={ + conversation.areWeAdmin ? ( + { + setStagedMemberships( + pendingMemberships.map(membership => ({ + type: StageType.REVOKE_INVITE, + membership, + })) + ); + }} + /> + ) : null + } + /> + ))} + + )} +
+ {i18n('PendingInvites--info')} +
+
+ ); +} diff --git a/ts/components/conversation/conversation-details/util.ts b/ts/components/conversation/conversation-details/util.ts new file mode 100644 index 000000000000..3672694be3db --- /dev/null +++ b/ts/components/conversation/conversation-details/util.ts @@ -0,0 +1,30 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import classNames from 'classnames'; + +export const bemGenerator = (block: string) => ( + element: string, + modifier?: string | Record +): string => { + const base = `${block}__${element}`; + const classes = [base]; + + let conditionals: Record = {}; + + if (modifier) { + if (typeof modifier === 'string') { + classes.push(`${base}--${modifier}`); + } else { + conditionals = Object.keys(modifier).reduce( + (acc, key) => ({ + ...acc, + [`${base}--${key}`]: modifier[key], + }), + {} as Record + ); + } + } + + return classNames(classes, conditionals); +}; diff --git a/ts/groups.ts b/ts/groups.ts index bd4c03a56834..658d80a36043 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -49,8 +49,10 @@ import { computeHash, deriveMasterKeyFromGroupV1, fromEncodedBinaryToArrayBuffer, + getRandomBytes, } from './Crypto'; import { + AccessRequiredEnum, GroupAttributeBlobClass, GroupChangeClass, GroupChangesClass, @@ -225,6 +227,35 @@ const TEMPORAL_AUTH_REJECTED_CODE = 401; const GROUP_ACCESS_DENIED_CODE = 403; const GROUP_NONEXISTENT_CODE = 404; const SUPPORTED_CHANGE_EPOCH = 1; +const GROUP_INVITE_LINK_PASSWORD_LENGTH = 16; + +// Group Links + +export function generateGroupInviteLinkPassword(): ArrayBuffer { + return getRandomBytes(GROUP_INVITE_LINK_PASSWORD_LENGTH); +} + +export function toWebSafeBase64(base64: string): string { + return base64.replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, ''); +} + +export function buildGroupLink(conversation: ConversationModel): string { + const { masterKey, groupInviteLinkPassword } = conversation.attributes; + + const subProto = new window.textsecure.protobuf.GroupInviteLink.GroupInviteLinkContentsV1(); + subProto.groupMasterKey = window.Signal.Crypto.base64ToArrayBuffer(masterKey); + subProto.inviteLinkPassword = window.Signal.Crypto.base64ToArrayBuffer( + groupInviteLinkPassword + ); + + const proto = new window.textsecure.protobuf.GroupInviteLink(); + proto.v1Contents = subProto; + + const bytes = proto.toArrayBuffer(); + const hash = toWebSafeBase64(window.Signal.Crypto.arrayBufferToBase64(bytes)); + + return `sgnl://signal.group/#${hash}`; +} // Group Modifications @@ -457,11 +488,119 @@ export function buildDisappearingMessagesTimerChange({ return actions; } -export function buildDeletePendingMemberChange({ +export function buildInviteLinkPasswordChange( + group: ConversationAttributesType, + inviteLinkPassword: string +): GroupChangeClass.Actions { + const inviteLinkPasswordAction = new window.textsecure.protobuf.GroupChange.Actions.ModifyInviteLinkPasswordAction(); + inviteLinkPasswordAction.inviteLinkPassword = base64ToArrayBuffer( + inviteLinkPassword + ); + + const actions = new window.textsecure.protobuf.GroupChange.Actions(); + actions.version = (group.revision || 0) + 1; + actions.modifyInviteLinkPassword = inviteLinkPasswordAction; + + return actions; +} + +export function buildNewGroupLinkChange( + group: ConversationAttributesType, + inviteLinkPassword: string, + addFromInviteLinkAccess: AccessRequiredEnum +): GroupChangeClass.Actions { + const accessControlAction = new window.textsecure.protobuf.GroupChange.Actions.ModifyAddFromInviteLinkAccessControlAction(); + accessControlAction.addFromInviteLinkAccess = addFromInviteLinkAccess; + + const inviteLinkPasswordAction = new window.textsecure.protobuf.GroupChange.Actions.ModifyInviteLinkPasswordAction(); + inviteLinkPasswordAction.inviteLinkPassword = base64ToArrayBuffer( + inviteLinkPassword + ); + + const actions = new window.textsecure.protobuf.GroupChange.Actions(); + actions.version = (group.revision || 0) + 1; + actions.modifyAddFromInviteLinkAccess = accessControlAction; + actions.modifyInviteLinkPassword = inviteLinkPasswordAction; + + return actions; +} + +export function buildAccessControlAddFromInviteLinkChange( + group: ConversationAttributesType, + value: AccessRequiredEnum +): GroupChangeClass.Actions { + const accessControlAction = new window.textsecure.protobuf.GroupChange.Actions.ModifyAddFromInviteLinkAccessControlAction(); + accessControlAction.addFromInviteLinkAccess = value; + + const actions = new window.textsecure.protobuf.GroupChange.Actions(); + actions.version = (group.revision || 0) + 1; + actions.modifyAddFromInviteLinkAccess = accessControlAction; + + return actions; +} + +export function buildAccessControlAttributesChange( + group: ConversationAttributesType, + value: AccessRequiredEnum +): GroupChangeClass.Actions { + const accessControlAction = new window.textsecure.protobuf.GroupChange.Actions.ModifyAttributesAccessControlAction(); + accessControlAction.attributesAccess = value; + + const actions = new window.textsecure.protobuf.GroupChange.Actions(); + actions.version = (group.revision || 0) + 1; + actions.modifyAttributesAccess = accessControlAction; + + return actions; +} + +export function buildAccessControlMembersChange( + group: ConversationAttributesType, + value: AccessRequiredEnum +): GroupChangeClass.Actions { + const accessControlAction = new window.textsecure.protobuf.GroupChange.Actions.ModifyMembersAccessControlAction(); + accessControlAction.membersAccess = value; + + const actions = new window.textsecure.protobuf.GroupChange.Actions(); + actions.version = (group.revision || 0) + 1; + actions.modifyMemberAccess = accessControlAction; + + return actions; +} + +// TODO AND-1101 +export function buildDeletePendingAdminApprovalMemberChange({ + group, uuid, +}: { + group: ConversationAttributesType; + uuid: string; +}): GroupChangeClass.Actions { + const actions = new window.textsecure.protobuf.GroupChange.Actions(); + + if (!group.secretParams) { + throw new Error( + 'buildDeletePendingAdminApprovalMemberChange: group was missing secretParams!' + ); + } + const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams); + const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid); + + const deleteMemberPendingAdminApproval = new window.textsecure.protobuf.GroupChange.Actions.DeleteMemberPendingAdminApprovalAction(); + deleteMemberPendingAdminApproval.deletedUserId = uuidCipherTextBuffer; + + actions.version = (group.revision || 0) + 1; + actions.deleteMemberPendingAdminApprovals = [ + deleteMemberPendingAdminApproval, + ]; + + return actions; +} + +export function buildDeletePendingMemberChange({ + uuids, group, }: { - uuid: string; + uuids: Array; group: ConversationAttributesType; }): GroupChangeClass.Actions { const actions = new window.textsecure.protobuf.GroupChange.Actions(); @@ -472,13 +611,16 @@ export function buildDeletePendingMemberChange({ ); } const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams); - const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid); - const deletePendingMember = new window.textsecure.protobuf.GroupChange.Actions.DeleteMemberPendingProfileKeyAction(); - deletePendingMember.deletedUserId = uuidCipherTextBuffer; + const deletePendingMembers = uuids.map(uuid => { + const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid); + const deletePendingMember = new window.textsecure.protobuf.GroupChange.Actions.DeleteMemberPendingProfileKeyAction(); + deletePendingMember.deletedUserId = uuidCipherTextBuffer; + return deletePendingMember; + }); actions.version = (group.revision || 0) + 1; - actions.deletePendingMembers = [deletePendingMember]; + actions.deletePendingMembers = deletePendingMembers; return actions; } @@ -507,6 +649,63 @@ export function buildDeleteMemberChange({ return actions; } +export function buildModifyMemberRoleChange({ + uuid, + group, + role, +}: { + uuid: string; + group: ConversationAttributesType; + role: number; +}): GroupChangeClass.Actions { + const actions = new window.textsecure.protobuf.GroupChange.Actions(); + + if (!group.secretParams) { + throw new Error('buildMakeAdminChange: group was missing secretParams!'); + } + + const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams); + const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid); + + const toggleAdmin = new window.textsecure.protobuf.GroupChange.Actions.ModifyMemberRoleAction(); + toggleAdmin.userId = uuidCipherTextBuffer; + toggleAdmin.role = role; + + actions.version = (group.revision || 0) + 1; + actions.modifyMemberRoles = [toggleAdmin]; + + return actions; +} + +export function buildPromotePendingAdminApprovalMemberChange({ + group, + uuid, +}: { + group: ConversationAttributesType; + uuid: string; +}): GroupChangeClass.Actions { + const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role; + const actions = new window.textsecure.protobuf.GroupChange.Actions(); + + if (!group.secretParams) { + throw new Error( + 'buildAddPendingAdminApprovalMemberChange: group was missing secretParams!' + ); + } + + const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams); + const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid); + + const promotePendingMember = new window.textsecure.protobuf.GroupChange.Actions.PromoteMemberPendingAdminApprovalAction(); + promotePendingMember.userId = uuidCipherTextBuffer; + promotePendingMember.role = MEMBER_ROLE_ENUM.DEFAULT; + + actions.version = (group.revision || 0) + 1; + actions.promoteMemberPendingAdminApprovals = [promotePendingMember]; + + return actions; +} + export function buildPromoteMemberChange({ group, profileKeyCredentialBase64, @@ -4300,8 +4499,6 @@ function decryptMemberPendingAdminApproval( return member; } -/* eslint-enable no-param-reassign */ - export function getMembershipList( conversationId: string ): Array<{ uuid: string; uuidCiphertext: ArrayBuffer }> { diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 64ada8a4779b..5e845c53c049 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -255,12 +255,14 @@ export type GroupV2MemberType = { joinedFromLink?: boolean; approvedByAdmin?: boolean; }; + export type GroupV2PendingMemberType = { addedByUserId?: string; conversationId: string; timestamp: number; role: MemberRoleEnum; }; + export type GroupV2PendingAdminApprovalType = { conversationId: string; timestamp: number; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index d18911037c95..f7686d4cadb1 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -10,6 +10,11 @@ import { ConversationAttributesType, VerificationOptions, } from '../model-types.d'; +import { + GroupV2PendingMembership, + GroupV2RequestingMembership, +} from '../components/conversation/conversation-details/PendingInvites'; +import { GroupV2Membership } from '../components/conversation/conversation-details/ConversationDetailsMembershipList'; import { CallMode, CallHistoryDetailsType } from '../types/Calling'; import { CallbackResultType, GroupV2InfoType } from '../textsecure/SendMessage'; import { @@ -295,6 +300,21 @@ export class ConversationModel extends window.Backbone.Model< } } + isMemberRequestingToJoin(conversationId: string): boolean { + if (!this.isGroupV2()) { + return false; + } + const pendingAdminApprovalV2 = this.get('pendingAdminApprovalV2'); + + if (!pendingAdminApprovalV2 || !pendingAdminApprovalV2.length) { + return false; + } + + return pendingAdminApprovalV2.some( + item => item.conversationId === conversationId + ); + } + isMemberPending(conversationId: string): boolean { if (!this.isGroupV2()) { return false; @@ -393,7 +413,7 @@ export class ConversationModel extends window.Backbone.Model< }); } - async removePendingMember( + async approvePendingApprovalRequest( conversationId: string ): Promise { const idLog = this.idForLogging(); @@ -401,9 +421,9 @@ export class ConversationModel extends window.Backbone.Model< // This user's pending state may have changed in the time between the user's // button press and when we get here. It's especially important to check here // in conflict/retry cases. - if (!this.isMemberPending(conversationId)) { + if (!this.isMemberRequestingToJoin(conversationId)) { window.log.warn( - `removePendingMember/${idLog}: ${conversationId} is not a pending member of group. Returning early.` + `approvePendingApprovalRequest/${idLog}: ${conversationId} is not requesting to join the group. Returning early.` ); return undefined; } @@ -411,20 +431,101 @@ export class ConversationModel extends window.Backbone.Model< const pendingMember = window.ConversationController.get(conversationId); if (!pendingMember) { throw new Error( - `removePendingMember/${idLog}: No conversation found for conversation ${conversationId}` + `approvePendingApprovalRequest/${idLog}: No conversation found for conversation ${conversationId}` ); } const uuid = pendingMember.get('uuid'); if (!uuid) { throw new Error( - `removePendingMember/${idLog}: Missing uuid for conversation ${pendingMember.idForLogging()}` + `approvePendingApprovalRequest/${idLog}: Missing uuid for conversation ${conversationId}` ); } + return window.Signal.Groups.buildPromotePendingAdminApprovalMemberChange({ + group: this.attributes, + uuid, + }); + } + + async denyPendingApprovalRequest( + conversationId: string + ): Promise { + const idLog = this.idForLogging(); + + // This user's pending state may have changed in the time between the user's + // button press and when we get here. It's especially important to check here + // in conflict/retry cases. + if (!this.isMemberRequestingToJoin(conversationId)) { + window.log.warn( + `denyPendingApprovalRequest/${idLog}: ${conversationId} is not requesting to join the group. Returning early.` + ); + return undefined; + } + + const pendingMember = window.ConversationController.get(conversationId); + if (!pendingMember) { + throw new Error( + `denyPendingApprovalRequest/${idLog}: No conversation found for conversation ${conversationId}` + ); + } + + const uuid = pendingMember.get('uuid'); + if (!uuid) { + throw new Error( + `denyPendingApprovalRequest/${idLog}: Missing uuid for conversation ${pendingMember.idForLogging()}` + ); + } + + return window.Signal.Groups.buildDeletePendingAdminApprovalMemberChange({ + group: this.attributes, + uuid, + }); + } + + async removePendingMember( + conversationIds: Array + ): Promise { + const idLog = this.idForLogging(); + + const uuids = conversationIds + .map(conversationId => { + // This user's pending state may have changed in the time between the user's + // button press and when we get here. It's especially important to check here + // in conflict/retry cases. + if (!this.isMemberPending(conversationId)) { + window.log.warn( + `removePendingMember/${idLog}: ${conversationId} is not a pending member of group. Returning early.` + ); + return undefined; + } + + const pendingMember = window.ConversationController.get(conversationId); + if (!pendingMember) { + window.log.warn( + `removePendingMember/${idLog}: No conversation found for conversation ${conversationId}` + ); + return undefined; + } + + const uuid = pendingMember.get('uuid'); + if (!uuid) { + window.log.warn( + `removePendingMember/${idLog}: Missing uuid for conversation ${pendingMember.idForLogging()}` + ); + return undefined; + } + return uuid; + }) + .filter((uuid): uuid is string => Boolean(uuid)); + + if (!uuids.length) { + return undefined; + } + return window.Signal.Groups.buildDeletePendingMemberChange({ group: this.attributes, - uuid, + uuids, }); } @@ -463,6 +564,49 @@ export class ConversationModel extends window.Backbone.Model< }); } + async toggleAdminChange( + conversationId: string + ): Promise { + if (!this.isGroupV2()) { + return undefined; + } + + const idLog = this.idForLogging(); + + if (!this.isMember(conversationId)) { + window.log.warn( + `toggleAdminChange/${idLog}: ${conversationId} is not a pending member of group. Returning early.` + ); + return undefined; + } + + const conversation = window.ConversationController.get(conversationId); + if (!conversation) { + throw new Error( + `toggleAdminChange/${idLog}: No conversation found for conversation ${conversationId}` + ); + } + + const uuid = conversation.get('uuid'); + if (!uuid) { + throw new Error( + `toggleAdminChange/${idLog}: Missing uuid for conversation ${conversationId}` + ); + } + + const MEMBER_ROLES = window.textsecure.protobuf.Member.Role; + + const role = this.isAdmin(conversationId) + ? MEMBER_ROLES.DEFAULT + : MEMBER_ROLES.ADMINISTRATOR; + + return window.Signal.Groups.buildModifyMemberRoleChange({ + group: this.attributes, + uuid, + role, + }); + } + async modifyGroupV2({ name, createGroupChange, @@ -1158,7 +1302,7 @@ export class ConversationModel extends window.Backbone.Model< groupVersion = 2; } - const members = this.isGroupV2() + const sortedGroupMembers = this.isGroupV2() ? this.getMembers() .sort((left, right) => sortConversationTitles(left, right, this.intlCollator) @@ -1182,6 +1326,7 @@ export class ConversationModel extends window.Backbone.Model< ), areWeAdmin: this.areWeAdmin(), canChangeTimer: this.canChangeTimer(), + canEditGroupInfo: this.canEditGroupInfo(), avatarPath: this.getAvatarPath()!, color, draftBodyRanges, @@ -1190,6 +1335,7 @@ export class ConversationModel extends window.Backbone.Model< firstName: this.get('profileName')!, groupVersion, groupId: this.get('groupId'), + groupLink: this.getGroupLink(), inboxPosition, isArchived: this.get('isArchived')!, isBlocked: this.isBlocked(), @@ -1207,11 +1353,17 @@ export class ConversationModel extends window.Backbone.Model< lastUpdated: this.get('timestamp')!, left: Boolean(this.get('left')), markedUnread: this.get('markedUnread')!, - members, membersCount: this.isPrivate() ? undefined : (this.get('membersV2')! || this.get('members')! || []).length, + memberships: this.getMemberships(), + pendingMemberships: this.getPendingMemberships(), + pendingApprovalMemberships: this.getPendingApprovalMemberships(), messageRequestsEnabled, + accessControlAddFromInviteLink: this.get('accessControl') + ?.addFromInviteLink, + accessControlAttributes: this.get('accessControl')?.attributes, + accessControlMembers: this.get('accessControl')?.members, expireTimer: this.get('expireTimer'), muteExpiresAt: this.get('muteExpiresAt')!, name: this.get('name')!, @@ -1221,6 +1373,7 @@ export class ConversationModel extends window.Backbone.Model< secretParams: this.get('secretParams'), sharedGroupNames: this.get('sharedGroupNames')!, shouldShowDraft, + sortedGroupMembers, timestamp, title: this.getTitle()!, type: (this.isPrivate() ? 'direct' : 'group') as ConversationTypeType, @@ -1480,7 +1633,7 @@ export class ConversationModel extends window.Backbone.Model< ) { await this.modifyGroupV2({ name: 'delete', - createGroupChange: () => this.removePendingMember(ourConversationId), + createGroupChange: () => this.removePendingMember([ourConversationId]), }); } else if ( ourConversationId && @@ -1498,11 +1651,76 @@ export class ConversationModel extends window.Backbone.Model< } } - async removeFromGroupV2(conversationId: string): Promise { - if (this.isGroupV2() && this.isMemberPending(conversationId)) { + async toggleAdmin(conversationId: string): Promise { + if (!this.isGroupV2()) { + return; + } + + if (!this.isMember(conversationId)) { + window.log.error( + `toggleAdmin: Member ${conversationId} is not a member of the group` + ); + return; + } + + await this.modifyGroupV2({ + name: 'toggleAdmin', + createGroupChange: () => this.toggleAdminChange(conversationId), + }); + } + + async approvePendingMembershipFromGroupV2( + conversationId: string + ): Promise { + if (this.isGroupV2() && this.isMemberRequestingToJoin(conversationId)) { + await this.modifyGroupV2({ + name: 'approvePendingApprovalRequest', + createGroupChange: () => + this.approvePendingApprovalRequest(conversationId), + }); + } + } + + async revokePendingMembershipsFromGroupV2( + conversationIds: Array + ): Promise { + if (!this.isGroupV2()) { + return; + } + + const [conversationId] = conversationIds; + + // Only pending memberships can be revoked for multiple members at once + if (conversationIds.length > 1) { await this.modifyGroupV2({ name: 'removePendingMember', - createGroupChange: () => this.removePendingMember(conversationId), + createGroupChange: () => this.removePendingMember(conversationIds), + }); + } else if (this.isMemberRequestingToJoin(conversationId)) { + await this.modifyGroupV2({ + name: 'denyPendingApprovalRequest', + createGroupChange: () => + this.denyPendingApprovalRequest(conversationId), + }); + } else if (this.isMemberPending(conversationId)) { + await this.modifyGroupV2({ + name: 'removePendingMember', + createGroupChange: () => this.removePendingMember([conversationId]), + }); + } + } + + async removeFromGroupV2(conversationId: string): Promise { + if (this.isGroupV2() && this.isMemberRequestingToJoin(conversationId)) { + await this.modifyGroupV2({ + name: 'denyPendingApprovalRequest', + createGroupChange: () => + this.denyPendingApprovalRequest(conversationId), + }); + } else 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({ @@ -2274,6 +2492,114 @@ export class ConversationModel extends window.Backbone.Model< return this.jobQueue.add(taskWithTimeout); } + isAdmin(conversationId: string): boolean { + if (!this.isGroupV2()) { + return false; + } + + const members = this.get('membersV2') || []; + const member = members.find(x => x.conversationId === conversationId); + if (!member) { + return false; + } + + const MEMBER_ROLES = window.textsecure.protobuf.Member.Role; + + return member.role === MEMBER_ROLES.ADMINISTRATOR; + } + + getMemberships(): Array { + if (!this.isGroupV2()) { + return []; + } + + const members = this.get('membersV2') || []; + return members + .map(member => { + const conversationModel = window.ConversationController.get( + member.conversationId + ); + if (!conversationModel || conversationModel.isUnregistered()) { + return null; + } + + return { + isAdmin: + member.role === + window.textsecure.protobuf.Member.Role.ADMINISTRATOR, + metadata: member, + member: conversationModel.format(), + }; + }) + .filter( + (membership): membership is GroupV2Membership => membership !== null + ); + } + + getGroupLink(): string | undefined { + if (!this.isGroupV2()) { + return undefined; + } + + if (!this.get('groupInviteLinkPassword')) { + return undefined; + } + + return window.Signal.Groups.buildGroupLink(this); + } + + getPendingMemberships(): Array { + if (!this.isGroupV2()) { + return []; + } + + const members = this.get('pendingMembersV2') || []; + return members + .map(member => { + const conversationModel = window.ConversationController.get( + member.conversationId + ); + if (!conversationModel || conversationModel.isUnregistered()) { + return null; + } + + return { + metadata: member, + member: conversationModel.format(), + }; + }) + .filter( + (membership): membership is GroupV2PendingMembership => + membership !== null + ); + } + + getPendingApprovalMemberships(): Array { + if (!this.isGroupV2()) { + return []; + } + + const members = this.get('pendingAdminApprovalV2') || []; + return members + .map(member => { + const conversationModel = window.ConversationController.get( + member.conversationId + ); + if (!conversationModel || conversationModel.isUnregistered()) { + return null; + } + + return { + metadata: member, + member: conversationModel.format(), + }; + }) + .filter( + (membership): membership is GroupV2RequestingMembership => + membership !== null + ); + } + getMembers( options: { includePendingMembers?: boolean } = {} ): Array { @@ -3199,6 +3525,166 @@ export class ConversationModel extends window.Backbone.Model< window.Whisper.events.trigger('updateUnreadCount'); } + async refreshGroupLink(): Promise { + if (!this.isGroupV2()) { + return; + } + + const groupInviteLinkPassword = arrayBufferToBase64( + window.Signal.Groups.generateGroupInviteLinkPassword() + ); + + window.log.info('refreshGroupLink for conversation', this.idForLogging()); + + await this.modifyGroupV2({ + name: 'updateInviteLinkPassword', + createGroupChange: async () => + window.Signal.Groups.buildInviteLinkPasswordChange( + this.attributes, + groupInviteLinkPassword + ), + }); + + this.set({ groupInviteLinkPassword }); + } + + async toggleGroupLink(value: boolean): Promise { + if (!this.isGroupV2()) { + return; + } + + const shouldCreateNewGroupLink = + value && !this.get('groupInviteLinkPassword'); + const groupInviteLinkPassword = + this.get('groupInviteLinkPassword') || + arrayBufferToBase64( + window.Signal.Groups.generateGroupInviteLinkPassword() + ); + + window.log.info( + 'toggleGroupLink for conversation', + this.idForLogging(), + value + ); + + const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired; + const addFromInviteLink = value + ? ACCESS_ENUM.ANY + : ACCESS_ENUM.UNSATISFIABLE; + + if (shouldCreateNewGroupLink) { + await this.modifyGroupV2({ + name: 'updateNewGroupLink', + createGroupChange: async () => + window.Signal.Groups.buildNewGroupLinkChange( + this.attributes, + groupInviteLinkPassword, + addFromInviteLink + ), + }); + } else { + await this.modifyGroupV2({ + name: 'updateAccessControlAddFromInviteLink', + createGroupChange: async () => + window.Signal.Groups.buildAccessControlAddFromInviteLinkChange( + this.attributes, + addFromInviteLink + ), + }); + } + + this.set({ + accessControl: { + addFromInviteLink, + attributes: this.get('accessControl')?.attributes || ACCESS_ENUM.MEMBER, + members: this.get('accessControl')?.members || ACCESS_ENUM.MEMBER, + }, + }); + + if (shouldCreateNewGroupLink) { + this.set({ groupInviteLinkPassword }); + } + } + + async updateAccessControlAddFromInviteLink(value: boolean): Promise { + if (!this.isGroupV2()) { + return; + } + + const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired; + + const addFromInviteLink = value + ? ACCESS_ENUM.ADMINISTRATOR + : ACCESS_ENUM.ANY; + + await this.modifyGroupV2({ + name: 'updateAccessControlAddFromInviteLink', + createGroupChange: async () => + window.Signal.Groups.buildAccessControlAddFromInviteLinkChange( + this.attributes, + addFromInviteLink + ), + }); + + this.set({ + accessControl: { + addFromInviteLink, + attributes: this.get('accessControl')?.attributes || ACCESS_ENUM.MEMBER, + members: this.get('accessControl')?.members || ACCESS_ENUM.MEMBER, + }, + }); + } + + async updateAccessControlAttributes(value: number): Promise { + if (!this.isGroupV2()) { + return; + } + + await this.modifyGroupV2({ + name: 'updateAccessControlAttributes', + createGroupChange: async () => + window.Signal.Groups.buildAccessControlAttributesChange( + this.attributes, + value + ), + }); + + const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired; + this.set({ + accessControl: { + addFromInviteLink: + this.get('accessControl')?.addFromInviteLink || ACCESS_ENUM.MEMBER, + attributes: value, + members: this.get('accessControl')?.members || ACCESS_ENUM.MEMBER, + }, + }); + } + + async updateAccessControlMembers(value: number): Promise { + if (!this.isGroupV2()) { + return; + } + + await this.modifyGroupV2({ + name: 'updateAccessControlMembers', + createGroupChange: async () => + window.Signal.Groups.buildAccessControlMembersChange( + this.attributes, + value + ), + }); + + const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired; + this.set({ + accessControl: { + addFromInviteLink: + this.get('accessControl')?.addFromInviteLink || ACCESS_ENUM.MEMBER, + attributes: this.get('accessControl')?.attributes || ACCESS_ENUM.MEMBER, + members: value, + }, + }); + } + async updateExpirationTimer( providedExpireTimer: number | undefined, providedSource: unknown, @@ -4187,6 +4673,18 @@ export class ConversationModel extends window.Backbone.Model< return this.areWeAdmin(); } + canEditGroupInfo(): boolean { + if (!this.isGroupV2()) { + return false; + } + + return ( + this.areWeAdmin() || + this.get('accessControl')?.attributes === + window.textsecure.protobuf.AccessControl.AccessRequired.MEMBER + ); + } + areWeAdmin(): boolean { if (!this.isGroupV2()) { return false; diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 7d404f284c90..613be5985511 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -24,6 +24,12 @@ import { AttachmentType } from '../../types/Attachment'; import { ColorType } from '../../types/Colors'; import { BodyRangeType } from '../../types/Util'; import { CallMode, CallHistoryDetailsFromDiskType } from '../../types/Calling'; +import { + GroupV2PendingMembership, + GroupV2RequestingMembership, +} from '../../components/conversation/conversation-details/PendingInvites'; +import { GroupV2Membership } from '../../components/conversation/conversation-details/ConversationDetailsMembershipList'; +import { MediaItemType } from '../../components/LightboxGallery'; // State @@ -56,6 +62,7 @@ export type ConversationType = { areWeAdmin?: boolean; areWePending?: boolean; canChangeTimer?: boolean; + canEditGroupInfo?: boolean; color?: ColorType; isAccepted?: boolean; isArchived?: boolean; @@ -76,12 +83,21 @@ export type ConversationType = { markedUnread?: boolean; phoneNumber?: string; membersCount?: number; + accessControlAddFromInviteLink?: number; + accessControlAttributes?: number; + accessControlMembers?: number; expireTimer?: number; - members?: Array; + // This is used by the ConversationDetails set of components, it includes the + // membersV2 data and also has some extra metadata attached to the object + memberships?: Array; + pendingMemberships?: Array; + pendingApprovalMemberships?: Array; muteExpiresAt?: number; type: ConversationTypeType; isMe?: boolean; lastUpdated?: number; + // This is used by the CompositionInput for @mentions + sortedGroupMembers?: Array; title: string; unreadCount?: number; isSelected?: boolean; @@ -92,6 +108,7 @@ export type ConversationType = { phoneNumber?: string; profileName?: string; } | null; + recentMediaItems?: Array; shouldShowDraft?: boolean; draftText?: string | null; @@ -101,6 +118,7 @@ export type ConversationType = { sharedGroupNames?: Array; groupVersion?: 1 | 2; groupId?: string; + groupLink?: string; isMissingMandatoryProfileSharing?: boolean; messageRequestsEnabled?: boolean; acceptedMessageRequest?: boolean; @@ -198,6 +216,7 @@ export type ConversationsStateType = { selectedConversation?: string; selectedMessage?: string; selectedMessageCounter: number; + selectedConversationTitle?: string; selectedConversationPanelDepth: number; showArchived: boolean; @@ -347,6 +366,10 @@ export type SetIsNearBottomActionType = { isNearBottom: boolean; }; }; +export type SetConversationHeaderTitleActionType = { + type: 'SET_CONVERSATION_HEADER_TITLE'; + payload: { title?: string }; +}; export type SetSelectedConversationPanelDepthActionType = { type: 'SET_SELECTED_CONVERSATION_PANEL_DEPTH'; payload: { panelDepth: number }; @@ -389,6 +412,13 @@ export type ShowArchivedConversationsActionType = { type: 'SHOW_ARCHIVED_CONVERSATIONS'; payload: null; }; +type SetRecentMediaItemsActionType = { + type: 'SET_RECENT_MEDIA_ITEMS'; + payload: { + id: string; + recentMediaItems: Array; + }; +}; export type ConversationActionType = | ConversationAddedActionType @@ -411,41 +441,45 @@ export type ConversationActionType = | ClearSelectedMessageActionType | ClearUnreadMetricsActionType | ScrollToMessageActionType + | SetConversationHeaderTitleActionType | SetSelectedConversationPanelDepthActionType | SelectedConversationChangedActionType | MessageDeletedActionType | SelectedConversationChangedActionType + | SetRecentMediaItemsActionType | ShowInboxActionType | ShowArchivedConversationsActionType; // Action Creators export const actions = { + clearChangedMessages, + clearSelectedMessage, + clearUnreadMetrics, conversationAdded, conversationChanged, conversationRemoved, conversationUnloaded, - removeAllConversations, - selectMessage, - messageDeleted, messageChanged, + messageDeleted, messageSizeChanged, messagesAdded, messagesReset, - setMessagesLoading, - setLoadCountdownStart, - setIsNearBottom, - setSelectedConversationPanelDepth, - clearChangedMessages, - clearSelectedMessage, - clearUnreadMetrics, - scrollToMessage, - openConversationInternal, openConversationExternal, - showInbox, - showArchivedConversations, + openConversationInternal, + removeAllConversations, repairNewestMessage, repairOldestMessage, + scrollToMessage, + selectMessage, + setIsNearBottom, + setLoadCountdownStart, + setMessagesLoading, + setRecentMediaItems, + setSelectedConversationHeaderTitle, + setSelectedConversationPanelDepth, + showArchivedConversations, + showInbox, }; function conversationAdded( @@ -642,6 +676,14 @@ function setIsNearBottom( }, }; } +function setSelectedConversationHeaderTitle( + title?: string +): SetConversationHeaderTitleActionType { + return { + type: 'SET_CONVERSATION_HEADER_TITLE', + payload: { title }, + }; +} function setSelectedConversationPanelDepth( panelDepth: number ): SetSelectedConversationPanelDepthActionType { @@ -650,6 +692,15 @@ function setSelectedConversationPanelDepth( payload: { panelDepth }, }; } +function setRecentMediaItems( + id: string, + recentMediaItems: Array +): SetRecentMediaItemsActionType { + return { + type: 'SET_RECENT_MEDIA_ITEMS', + payload: { id, recentMediaItems }, + }; +} function clearChangedMessages( conversationId: string ): ClearChangedMessagesActionType { @@ -743,6 +794,7 @@ export function getEmptyState(): ConversationsStateType { messagesLookup: {}, selectedMessageCounter: 0, showArchived: false, + selectedConversationTitle: '', selectedConversationPanelDepth: 0, }; } @@ -1547,5 +1599,37 @@ export function reducer( }; } + if (action.type === 'SET_CONVERSATION_HEADER_TITLE') { + return { + ...state, + selectedConversationTitle: action.payload.title, + }; + } + + if (action.type === 'SET_RECENT_MEDIA_ITEMS') { + const { id, recentMediaItems } = action.payload; + const { conversationLookup } = state; + + const conversationData = conversationLookup[id]; + + if (!conversationData) { + return state; + } + + const data = { + ...conversationData, + recentMediaItems, + }; + + return { + ...state, + conversationLookup: { + ...conversationLookup, + [id]: data, + }, + ...updateConversationLookups(data, undefined, state), + }; + } + return state; } diff --git a/ts/state/roots/createConversationDetails.tsx b/ts/state/roots/createConversationDetails.tsx new file mode 100644 index 000000000000..ddb5467a095b --- /dev/null +++ b/ts/state/roots/createConversationDetails.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 { + SmartConversationDetails, + SmartConversationDetailsProps, +} from '../smart/ConversationDetails'; + +export const createConversationDetails = ( + store: Store, + props: SmartConversationDetailsProps +): React.ReactElement => ( + + + +); diff --git a/ts/state/roots/createGroupLinkManagement.tsx b/ts/state/roots/createGroupLinkManagement.tsx new file mode 100644 index 000000000000..7c70750d5d73 --- /dev/null +++ b/ts/state/roots/createGroupLinkManagement.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 { + SmartGroupLinkManagement, + SmartGroupLinkManagementProps, +} from '../smart/GroupLinkManagement'; + +export const createGroupLinkManagement = ( + store: Store, + props: SmartGroupLinkManagementProps +): React.ReactElement => ( + + + +); diff --git a/ts/state/roots/createGroupV2Permissions.tsx b/ts/state/roots/createGroupV2Permissions.tsx new file mode 100644 index 000000000000..d04bb9af55b3 --- /dev/null +++ b/ts/state/roots/createGroupV2Permissions.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 { + SmartGroupV2Permissions, + SmartGroupV2PermissionsProps, +} from '../smart/GroupV2Permissions'; + +export const createGroupV2Permissions = ( + store: Store, + props: SmartGroupV2PermissionsProps +): React.ReactElement => ( + + + +); diff --git a/ts/state/roots/createPendingInvites.tsx b/ts/state/roots/createPendingInvites.tsx new file mode 100644 index 000000000000..4bb603a69a22 --- /dev/null +++ b/ts/state/roots/createPendingInvites.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 { + SmartPendingInvites, + SmartPendingInvitesProps, +} from '../smart/PendingInvites'; + +export const createPendingInvites = ( + store: Store, + props: SmartPendingInvitesProps +): React.ReactElement => ( + + + +); diff --git a/ts/state/smart/ContactModal.tsx b/ts/state/smart/ContactModal.tsx index 9a7287dd98e3..3af9d44c2090 100644 --- a/ts/state/smart/ContactModal.tsx +++ b/ts/state/smart/ContactModal.tsx @@ -17,8 +17,9 @@ export type SmartContactModalProps = { currentConversationId: string; readonly onClose: () => unknown; readonly openConversation: (conversationId: string) => void; - readonly showSafetyNumber: (conversationId: string) => void; readonly removeMember: (conversationId: string) => void; + readonly showSafetyNumber: (conversationId: string) => void; + readonly toggleAdmin: (conversationId: string) => void; }; const mapStateToProps = ( @@ -31,21 +32,29 @@ const mapStateToProps = ( 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; + let isMember = false; + let isAdmin = false; + if (contact && currentConversation && currentConversation.memberships) { + currentConversation.memberships.forEach(membership => { + if (membership.member.id === contact.id) { + isMember = true; + isAdmin = membership.isAdmin; + } + }); + } + return { ...props, areWeAdmin, contact, i18n: getIntl(state), + isAdmin, isMember, }; }; diff --git a/ts/state/smart/ConversationDetails.tsx b/ts/state/smart/ConversationDetails.tsx new file mode 100644 index 000000000000..cfd632ad5831 --- /dev/null +++ b/ts/state/smart/ConversationDetails.tsx @@ -0,0 +1,56 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { connect } from 'react-redux'; + +import { StateType } from '../reducer'; +import { + ConversationDetails, + StateProps, +} from '../../components/conversation/conversation-details/ConversationDetails'; +import { getConversationSelector } from '../selectors/conversations'; +import { getIntl } from '../selectors/user'; +import { MediaItemType } from '../../components/LightboxGallery'; + +export type SmartConversationDetailsProps = { + conversationId: string; + hasGroupLink: boolean; + loadRecentMediaItems: (limit: number) => void; + setDisappearingMessages: (seconds: number) => void; + showAllMedia: () => void; + showContactModal: (conversationId: string) => void; + showGroupLinkManagement: () => void; + showGroupV2Permissions: () => void; + showPendingInvites: () => void; + showLightboxForMedia: ( + selectedMediaItem: MediaItemType, + media: Array + ) => void; + onBlockAndDelete: () => void; + onDelete: () => void; +}; + +const mapStateToProps = ( + state: StateType, + props: SmartConversationDetailsProps +): StateProps => { + const conversation = getConversationSelector(state)(props.conversationId); + const canEditGroupInfo = + conversation && conversation.canEditGroupInfo + ? conversation.canEditGroupInfo + : false; + const isAdmin = + conversation && conversation.areWeAdmin ? conversation.areWeAdmin : false; + + return { + ...props, + canEditGroupInfo, + conversation, + i18n: getIntl(state), + isAdmin, + }; +}; + +const smart = connect(mapStateToProps); + +export const SmartConversationDetails = smart(ConversationDetails); diff --git a/ts/state/smart/ConversationHeader.tsx b/ts/state/smart/ConversationHeader.tsx index 2eaf3067e34c..1b19a3895747 100644 --- a/ts/state/smart/ConversationHeader.tsx +++ b/ts/state/smart/ConversationHeader.tsx @@ -40,6 +40,7 @@ export type OwnProps = { onMarkUnread: () => void; onMoveToInbox: () => void; onShowSafetyNumber: () => void; + onShowConversationDetails: () => void; }; const getOutgoingCallButtonStyle = ( @@ -102,7 +103,9 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => { 'profileName', 'title', 'type', + 'groupVersion', ]), + conversationTitle: state.conversations.selectedConversationTitle, i18n: getIntl(state), showBackButton: state.conversations.selectedConversationPanelDepth > 0, outgoingCallButtonStyle: getOutgoingCallButtonStyle(conversation, state), diff --git a/ts/state/smart/GroupLinkManagement.tsx b/ts/state/smart/GroupLinkManagement.tsx new file mode 100644 index 000000000000..d8d6e7ad0c26 --- /dev/null +++ b/ts/state/smart/GroupLinkManagement.tsx @@ -0,0 +1,39 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { connect } from 'react-redux'; + +import { StateType } from '../reducer'; +import { + GroupLinkManagement, + PropsType, +} from '../../components/conversation/conversation-details/GroupLinkManagement'; +import { getConversationSelector } from '../selectors/conversations'; +import { getIntl } from '../selectors/user'; +import { AccessControlClass } from '../../textsecure.d'; + +export type SmartGroupLinkManagementProps = { + accessEnum: typeof AccessControlClass.AccessRequired; + changeHasGroupLink: (value: boolean) => void; + conversationId: string; + copyGroupLink: (groupLink: string) => void; + generateNewGroupLink: () => void; + setAccessControlAddFromInviteLinkSetting: (value: boolean) => void; +}; + +const mapStateToProps = ( + state: StateType, + props: SmartGroupLinkManagementProps +): PropsType => { + const conversation = getConversationSelector(state)(props.conversationId); + + return { + ...props, + conversation, + i18n: getIntl(state), + }; +}; + +const smart = connect(mapStateToProps); + +export const SmartGroupLinkManagement = smart(GroupLinkManagement); diff --git a/ts/state/smart/GroupV2Permissions.tsx b/ts/state/smart/GroupV2Permissions.tsx new file mode 100644 index 000000000000..de180fc45d0e --- /dev/null +++ b/ts/state/smart/GroupV2Permissions.tsx @@ -0,0 +1,37 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { connect } from 'react-redux'; + +import { StateType } from '../reducer'; +import { + GroupV2Permissions, + PropsType, +} from '../../components/conversation/conversation-details/GroupV2Permissions'; +import { getConversationSelector } from '../selectors/conversations'; +import { getIntl } from '../selectors/user'; +import { AccessControlClass } from '../../textsecure.d'; + +export type SmartGroupV2PermissionsProps = { + accessEnum: typeof AccessControlClass.AccessRequired; + conversationId: string; + setAccessControlAttributesSetting: (value: number) => void; + setAccessControlMembersSetting: (value: number) => void; +}; + +const mapStateToProps = ( + state: StateType, + props: SmartGroupV2PermissionsProps +): PropsType => { + const conversation = getConversationSelector(state)(props.conversationId); + + return { + ...props, + conversation, + i18n: getIntl(state), + }; +}; + +const smart = connect(mapStateToProps); + +export const SmartGroupV2Permissions = smart(GroupV2Permissions); diff --git a/ts/state/smart/PendingInvites.tsx b/ts/state/smart/PendingInvites.tsx new file mode 100644 index 000000000000..d02413fcedb9 --- /dev/null +++ b/ts/state/smart/PendingInvites.tsx @@ -0,0 +1,39 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { connect } from 'react-redux'; +import { mapDispatchToProps } from '../actions'; +import { + PendingInvites, + PropsType, +} from '../../components/conversation/conversation-details/PendingInvites'; +import { StateType } from '../reducer'; + +import { getIntl } from '../selectors/user'; +import { getConversationSelector } from '../selectors/conversations'; + +export type SmartPendingInvitesProps = { + conversationId: string; + ourConversationId?: string; + readonly approvePendingMembership: (conversationid: string) => void; + readonly revokePendingMemberships: (membershipIds: Array) => void; +}; + +const mapStateToProps = ( + state: StateType, + props: SmartPendingInvitesProps +): PropsType => { + const { conversationId } = props; + + const conversation = getConversationSelector(state)(conversationId); + + return { + ...props, + conversation, + i18n: getIntl(state), + }; +}; + +const smart = connect(mapStateToProps, mapDispatchToProps); + +export const SmartPendingInvites = smart(PendingInvites); diff --git a/ts/test-both/helpers/getDefaultConversation.ts b/ts/test-both/helpers/getDefaultConversation.ts index e7512f9e6331..47e88b7d5193 100644 --- a/ts/test-both/helpers/getDefaultConversation.ts +++ b/ts/test-both/helpers/getDefaultConversation.ts @@ -4,15 +4,327 @@ import { v4 as generateUuid } from 'uuid'; import { ConversationType } from '../../state/ducks/conversations'; +const FIRST_NAMES = [ + 'James', + 'John', + 'Robert', + 'Michael', + 'William', + 'David', + 'Richard', + 'Joseph', + 'Thomas', + 'Charles', + 'Christopher', + 'Daniel', + 'Matthew', + 'Anthony', + 'Donald', + 'Mark', + 'Paul', + 'Steven', + 'Andrew', + 'Kenneth', + 'Joshua', + 'Kevin', + 'Brian', + 'George', + 'Edward', + 'Ronald', + 'Timothy', + 'Jason', + 'Jeffrey', + 'Ryan', + 'Jacob', + 'Gary', + 'Nicholas', + 'Eric', + 'Jonathan', + 'Stephen', + 'Larry', + 'Justin', + 'Scott', + 'Brandon', + 'Benjamin', + 'Samuel', + 'Frank', + 'Gregory', + 'Raymond', + 'Alexander', + 'Patrick', + 'Jack', + 'Dennis', + 'Jerry', + 'Tyler', + 'Aaron', + 'Jose', + 'Henry', + 'Adam', + 'Douglas', + 'Nathan', + 'Peter', + 'Zachary', + 'Kyle', + 'Walter', + 'Harold', + 'Jeremy', + 'Ethan', + 'Carl', + 'Keith', + 'Roger', + 'Gerald', + 'Christian', + 'Terry', + 'Sean', + 'Arthur', + 'Austin', + 'Noah', + 'Lawrence', + 'Jesse', + 'Joe', + 'Bryan', + 'Billy', + 'Jordan', + 'Albert', + 'Dylan', + 'Bruce', + 'Willie', + 'Gabriel', + 'Alan', + 'Juan', + 'Logan', + 'Wayne', + 'Ralph', + 'Roy', + 'Eugene', + 'Randy', + 'Vincent', + 'Russell', + 'Louis', + 'Philip', + 'Bobby', + 'Johnny', + 'Bradley', + 'Mary', + 'Patricia', + 'Jennifer', + 'Linda', + 'Elizabeth', + 'Barbara', + 'Susan', + 'Jessica', + 'Sarah', + 'Karen', + 'Nancy', + 'Lisa', + 'Margaret', + 'Betty', + 'Sandra', + 'Ashley', + 'Dorothy', + 'Kimberly', + 'Emily', + 'Donna', + 'Michelle', + 'Carol', + 'Amanda', + 'Melissa', + 'Deborah', + 'Stephanie', + 'Rebecca', + 'Laura', + 'Sharon', + 'Cynthia', + 'Kathleen', + 'Amy', + 'Shirley', + 'Angela', + 'Helen', + 'Anna', + 'Brenda', + 'Pamela', + 'Nicole', + 'Samantha', + 'Katherine', + 'Emma', + 'Ruth', + 'Christine', + 'Catherine', + 'Debra', + 'Rachel', + 'Carolyn', + 'Janet', + 'Virginia', + 'Maria', + 'Heather', + 'Diane', + 'Julie', + 'Joyce', + 'Victoria', + 'Kelly', + 'Christina', + 'Lauren', + 'Joan', + 'Evelyn', + 'Olivia', + 'Judith', + 'Megan', + 'Cheryl', + 'Martha', + 'Andrea', + 'Frances', + 'Hannah', + 'Jacqueline', + 'Ann', + 'Gloria', + 'Jean', + 'Kathryn', + 'Alice', + 'Teresa', + 'Sara', + 'Janice', + 'Doris', + 'Madison', + 'Julia', + 'Grace', + 'Judy', + 'Abigail', + 'Marie', + 'Denise', + 'Beverly', + 'Amber', + 'Theresa', + 'Marilyn', + 'Danielle', + 'Diana', + 'Brittany', + 'Natalie', + 'Sophia', + 'Rose', + 'Isabella', + 'Alexis', + 'Kayla', + 'Charlotte', +]; + +const LAST_NAMES = [ + 'Smith', + 'Johnson', + 'Williams', + 'Brown', + 'Jones', + 'Garcia', + 'Miller', + 'Davis', + 'Rodriguez', + 'Martinez', + 'Hernandez', + 'Lopez', + 'Gonzales', + 'Wilson', + 'Anderson', + 'Thomas', + 'Taylor', + 'Moore', + 'Jackson', + 'Martin', + 'Lee', + 'Perez', + 'Thompson', + 'White', + 'Harris', + 'Sanchez', + 'Clark', + 'Ramirez', + 'Lewis', + 'Robinson', + 'Walker', + 'Young', + 'Allen', + 'King', + 'Wright', + 'Scott', + 'Torres', + 'Nguyen', + 'Hill', + 'Flores', + 'Green', + 'Adams', + 'Nelson', + 'Baker', + 'Hall', + 'Rivera', + 'Campbell', + 'Mitchell', + 'Carter', + 'Roberts', + 'Gomez', + 'Phillips', + 'Evans', + 'Turner', + 'Diaz', + 'Parker', + 'Cruz', + 'Edwards', + 'Collins', + 'Reyes', + 'Stewart', + 'Morris', + 'Morales', + 'Murphy', + 'Cook', + 'Rogers', + 'Gutierrez', + 'Ortiz', + 'Morgan', + 'Cooper', + 'Peterson', + 'Bailey', + 'Reed', + 'Kelly', + 'Howard', + 'Ramos', + 'Kim', + 'Cox', + 'Ward', + 'Richardson', + 'Watson', + 'Brooks', + 'Chavez', + 'Wood', + 'James', + 'Bennet', + 'Gray', + 'Mendoza', + 'Ruiz', + 'Hughes', + 'Price', + 'Alvarez', + 'Castillo', + 'Sanders', + 'Patel', + 'Myers', + 'Long', + 'Ross', + 'Foster', + 'Jimenez', +]; + +export function getRandomTitle(): string { + const firstName = FIRST_NAMES[Math.floor(Math.random() * FIRST_NAMES.length)]; + const lastName = LAST_NAMES[Math.floor(Math.random() * LAST_NAMES.length)]; + return `${firstName} ${lastName}`; +} + export function getDefaultConversation( overrideProps: Partial ): ConversationType { return { - id: 'guid-1', + id: generateUuid(), lastUpdated: Date.now(), markedUnread: Boolean(overrideProps.markedUnread), e164: '+1300555000', - title: 'Alice', + title: getRandomTitle(), type: 'direct' as const, uuid: generateUuid(), ...overrideProps, diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index 30da7d2c2d33..3e9a6de7a680 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -504,6 +504,12 @@ export declare class GroupExternalCredentialClass { } export declare class GroupInviteLinkClass { + static decode: ( + data: ArrayBuffer | ByteBufferClass, + encoding?: string + ) => GroupInviteLinkClass; + toArrayBuffer: () => ArrayBuffer; + v1Contents?: GroupInviteLinkClass.GroupInviteLinkContentsV1; // Note: this isn't part of the proto, but our protobuf library tells us which diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index 6784ff6f9e17..b9a275db61af 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -34,6 +34,7 @@ export type AttachmentType = { pending?: boolean; width?: number; height?: number; + path?: string; screenshot?: { height: number; width: number; @@ -46,6 +47,7 @@ export type AttachmentType = { width: number; url: string; contentType: MIME.MIMEType; + path: string; }; }; diff --git a/ts/util/getAccessControlOptions.ts b/ts/util/getAccessControlOptions.ts new file mode 100644 index 000000000000..97fe8cb31ec0 --- /dev/null +++ b/ts/util/getAccessControlOptions.ts @@ -0,0 +1,26 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { LocalizerType } from '../types/Util'; +import { AccessControlClass } from '../textsecure.d'; + +type AccessControlOption = { + name: string; + value: number; +}; + +export function getAccessControlOptions( + accessEnum: typeof AccessControlClass.AccessRequired, + i18n: LocalizerType +): Array { + return [ + { + name: i18n('GroupV2--all-members'), + value: accessEnum.MEMBER, + }, + { + name: i18n('GroupV2--admin'), + value: accessEnum.ADMINISTRATOR, + }, + ]; +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index e4fb2588a22a..a8cf5154e2af 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -14770,7 +14770,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/ConversationHeader.tsx", "line": " this.menuTriggerRef = React.createRef();", - "lineNumber": 102, + "lineNumber": 105, "reasonCategory": "usageTrusted", "updated": "2020-05-20T20:10:43.540Z", "reasonDetail": "Used to reference popup menu" diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index cfe642a6d3e7..545d67f1772c 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -7,7 +7,10 @@ // use normal import syntax, nor can we use 'import type' syntax, or this will be turned // into a module, and we'll get the dreaded 'exports is not defined' error. // see https://github.com/microsoft/TypeScript/issues/41562 +type AttachmentType = import('../types/Attachment').AttachmentType; type GroupV2PendingMemberType = import('../model-types.d').GroupV2PendingMemberType; +type MediaItemType = import('../components/LightboxGallery').MediaItemType; +type MessageType = import('../state/ducks/conversations').MessageType; type GetLinkPreviewResult = { title: string; @@ -29,7 +32,7 @@ const LINK_PREVIEW_TIMEOUT = 60 * 1000; window.Whisper = window.Whisper || {}; const { Whisper } = window; -const { Message, MIME, VisualAttachment } = window.Signal.Types; +const { Message, MIME, VisualAttachment, Attachment } = window.Signal.Types; const { copyIntoTempDirectory, @@ -224,6 +227,12 @@ Whisper.ReactionFailedToast = Whisper.ToastView.extend({ }, }); +Whisper.GroupLinkCopiedToast = Whisper.ToastView.extend({ + render_attributes() { + return { toastMessage: window.i18n('GroupLinkManagement--clipboard') }; + }, +}); + Whisper.PinnedConversationsFullToast = Whisper.ToastView.extend({ render_attributes() { return { toastMessage: window.i18n('pinnedConversationsFull') }; @@ -523,6 +532,9 @@ Whisper.ConversationView = Whisper.View.extend({ } }, + onShowConversationDetails: () => { + this.showConversationDetails(); + }, onShowSafetyNumber: () => { this.showSafetyNumber(); }, @@ -565,6 +577,7 @@ Whisper.ConversationView = Whisper.View.extend({ ), }); this.$('.conversation-header').append(this.titleView.el); + window.reduxActions.conversations.setSelectedConversationHeaderTitle(); }, setupCompositionArea({ attachmentListEl }: any) { @@ -2124,10 +2137,6 @@ Whisper.ConversationView = Whisper.View.extend({ }, async showAllMedia() { - if (this.panels && this.panels.length > 0) { - return; - } - // We fetch more documents than media as they don’t require to be loaded // into memory right away. Revisit this once we have infinite scrolling: const DEFAULT_MEDIA_FETCH_COUNT = 50; @@ -2267,6 +2276,7 @@ Whisper.ConversationView = Whisper.View.extend({ this.stopListening(this.model.messageCollection, 'remove', update); }, }); + view.headerTitle = window.i18n('allMedia'); const update = async () => { view.update(await getProps()); @@ -2570,7 +2580,49 @@ Whisper.ConversationView = Whisper.View.extend({ }); }, - showLightbox({ attachment, messageId }: any) { + // TODO: DESKTOP-1133 (DRY up these lightboxes) + showLightboxForMedia(selectedMediaItem: any, media: Array = []) { + const onSave = async (options: any = {}) => { + const fullPath = await window.Signal.Types.Attachment.save({ + attachment: options.attachment, + index: options.index + 1, + readAttachmentData, + saveAttachmentToDisk, + timestamp: options.message.get('sent_at'), + }); + + if (fullPath) { + this.showToast(Whisper.FileSavedToast, { fullPath }); + } + }; + + const selectedIndex = media.findIndex( + mediaItem => + mediaItem.attachment.path === selectedMediaItem.attachment.path + ); + + this.lightboxGalleryView = new Whisper.ReactWrapperView({ + className: 'lightbox-wrapper', + Component: window.Signal.Components.LightboxGallery, + props: { + media, + onSave, + selectedIndex, + }, + onClose: () => window.Signal.Backbone.Views.Lightbox.hide(), + }); + + window.Signal.Backbone.Views.Lightbox.show(this.lightboxGalleryView.el); + }, + + showLightbox({ + attachment, + messageId, + }: { + attachment: typeof Attachment; + messageId: string; + showSingle?: boolean; + }) { const message = this.model.messageCollection.get(messageId); if (!message) { throw new Error(`showLightbox: did not find message for id ${messageId}`); @@ -2686,7 +2738,6 @@ Whisper.ConversationView = Whisper.View.extend({ }; this.contactModalView = new Whisper.ReactWrapperView({ - className: 'progress-modal-wrapper', JSX: window.Signal.State.Roots.createContactModal(window.reduxStore, { contactId, currentConversationId: this.model.id, @@ -2695,13 +2746,43 @@ Whisper.ConversationView = Whisper.View.extend({ hideContactModal(); this.openConversation(conversationId); }, + removeMember: (conversationId: string) => { + hideContactModal(); + this.model.removeFromGroupV2(conversationId); + }, showSafetyNumber: (conversationId: string) => { hideContactModal(); this.showSafetyNumber(conversationId); }, - removeMember: (conversationId: string) => { + toggleAdmin: (conversationId: string) => { hideContactModal(); - this.model.removeFromGroupV2(conversationId); + + const isAdmin = this.model.isAdmin(conversationId); + const conversationModel = window.ConversationController.get( + conversationId + ); + + if (!conversationModel) { + window.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), + }); }, }), }); @@ -2709,6 +2790,136 @@ Whisper.ConversationView = Whisper.View.extend({ this.contactModalView.render(); }, + showGroupLinkManagement() { + const view = new Whisper.ReactWrapperView({ + className: 'panel', + JSX: window.Signal.State.Roots.createGroupLinkManagement( + window.reduxStore, + { + accessEnum: window.textsecure.protobuf.AccessControl.AccessRequired, + changeHasGroupLink: this.changeHasGroupLink.bind(this), + conversationId: this.model.id, + copyGroupLink: this.copyGroupLink.bind(this), + generateNewGroupLink: this.generateNewGroupLink.bind(this), + setAccessControlAddFromInviteLinkSetting: this.setAccessControlAddFromInviteLinkSetting.bind( + this + ), + } + ), + }); + view.headerTitle = window.i18n('ConversationDetails--group-link'); + + this.listenBack(view); + view.render(); + }, + + showGroupV2Permissions() { + const view = new Whisper.ReactWrapperView({ + className: 'panel', + JSX: window.Signal.State.Roots.createGroupV2Permissions( + window.reduxStore, + { + accessEnum: window.textsecure.protobuf.AccessControl.AccessRequired, + conversationId: this.model.id, + setAccessControlAttributesSetting: this.setAccessControlAttributesSetting.bind( + this + ), + setAccessControlMembersSetting: this.setAccessControlMembersSetting.bind( + this + ), + } + ), + }); + view.headerTitle = window.i18n('permissions'); + + this.listenBack(view); + view.render(); + }, + + showPendingInvites() { + const view = new Whisper.ReactWrapperView({ + className: 'panel', + JSX: window.Signal.State.Roots.createPendingInvites(window.reduxStore, { + conversationId: this.model.id, + ourConversationId: window.ConversationController.getOurConversationId(), + approvePendingMembership: (conversationId: string) => { + this.model.approvePendingMembershipFromGroupV2(conversationId); + }, + revokePendingMemberships: conversationIds => { + this.model.revokePendingMembershipsFromGroupV2(conversationIds); + }, + }), + }); + view.headerTitle = window.i18n('ConversationDetails--requests-and-invites'); + + this.listenBack(view); + view.render(); + }, + + showConversationDetails() { + const conversation = this.model; + + const messageRequestEnum = + window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type; + + // these methods are used in more than one place and should probably be + // dried up and hoisted to methods on ConversationView + + const onDelete = () => { + this.longRunningTaskWrapper({ + name: 'onDelete', + task: this.model.syncMessageRequestResponse.bind( + this.model, + messageRequestEnum.DELETE + ), + }); + }; + + const onBlockAndDelete = () => { + this.longRunningTaskWrapper({ + name: 'onBlockAndDelete', + task: this.model.syncMessageRequestResponse.bind( + this.model, + messageRequestEnum.BLOCK_AND_DELETE + ), + }); + }; + + const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired; + + const hasGroupLink = + conversation.get('groupInviteLinkPassword') && + conversation.get('accessControl')?.addFromInviteLink !== + ACCESS_ENUM.UNSATISFIABLE; + + const props = { + conversationId: conversation.get('id'), + hasGroupLink, + loadRecentMediaItems: this.loadRecentMediaItems.bind(this), + setDisappearingMessages: this.setDisappearingMessages.bind(this), + showAllMedia: this.showAllMedia.bind(this), + showContactModal: this.showContactModal.bind(this), + showGroupLinkManagement: this.showGroupLinkManagement.bind(this), + showGroupV2Permissions: this.showGroupV2Permissions.bind(this), + showPendingInvites: this.showPendingInvites.bind(this), + showLightboxForMedia: this.showLightboxForMedia.bind(this), + onDelete, + onBlockAndDelete, + }; + + const view = new Whisper.ReactWrapperView({ + className: 'conversation-details-pane panel', + JSX: window.Signal.State.Roots.createConversationDetails( + window.reduxStore, + props + ), + }); + view.headerTitle = ''; + + this.listenBack(view); + view.render(); + }, + showMessageDetail(messageId: any) { const message = this.model.messageCollection.get(messageId); if (!message) { @@ -2797,6 +3008,9 @@ Whisper.ConversationView = Whisper.View.extend({ window.reduxActions.conversations.setSelectedConversationPanelDepth( this.panels.length ); + window.reduxActions.conversations.setSelectedConversationHeaderTitle( + view.headerTitle + ); }, resetPanel() { if (!this.panels || !this.panels.length) { @@ -2830,12 +3044,56 @@ Whisper.ConversationView = Whisper.View.extend({ window.reduxActions.conversations.setSelectedConversationPanelDepth( this.panels.length ); + window.reduxActions.conversations.setSelectedConversationHeaderTitle( + this.panels[0]?.headerTitle + ); }, endSession() { this.model.endSession(); }, + async loadRecentMediaItems(limit: number): Promise { + const messages: Array = await window.Signal.Data.getMessagesWithVisualMediaAttachments( + this.model.id, + { + limit, + } + ); + + const loadedRecentMediaItems = messages + .filter(message => message.attachments !== undefined) + .reduce( + (acc, message) => [ + ...acc, + ...message.attachments.map( + (attachment: AttachmentType, index: number): MediaItemType => { + const { thumbnail } = attachment; + + return { + objectURL: getAbsoluteAttachmentPath(attachment.path || ''), + thumbnailObjectUrl: thumbnail + ? getAbsoluteAttachmentPath(thumbnail.path) + : '', + contentType: attachment.contentType, + index, + attachment, + // this message is a valid structure, but doesn't work with ts + // eslint-disable-next-line @typescript-eslint/no-explicit-any + message: message as any, + }; + } + ), + ], + [] as Array + ); + + window.reduxActions.conversations.setRecentMediaItems( + this.model.id, + loadedRecentMediaItems + ); + }, + async setDisappearingMessages(seconds: any) { const valueToSet = seconds > 0 ? seconds : null; @@ -2845,6 +3103,53 @@ Whisper.ConversationView = Whisper.View.extend({ }); }, + async changeHasGroupLink(value: boolean) { + await this.longRunningTaskWrapper({ + name: 'toggleGroupLink', + task: async () => this.model.toggleGroupLink(value), + }); + }, + + async copyGroupLink(groupLink: string) { + await navigator.clipboard.writeText(groupLink); + this.showToast(Whisper.GroupLinkCopiedToast); + }, + + async generateNewGroupLink() { + window.showConfirmationDialog({ + confirmStyle: 'negative', + message: window.i18n('GroupLinkManagement--confirm-reset'), + okText: window.i18n('GroupLinkManagement--reset'), + resolve: async () => { + await this.longRunningTaskWrapper({ + name: 'refreshGroupLink', + task: async () => this.model.refreshGroupLink(), + }); + }, + }); + }, + + async setAccessControlAddFromInviteLinkSetting(value: boolean) { + await this.longRunningTaskWrapper({ + name: 'updateAccessControlAddFromInviteLink', + task: async () => this.model.updateAccessControlAddFromInviteLink(value), + }); + }, + + async setAccessControlAttributesSetting(value: number) { + await this.longRunningTaskWrapper({ + name: 'updateAccessControlAttributes', + task: async () => this.model.updateAccessControlAttributes(value), + }); + }, + + async setAccessControlMembersSetting(value: number) { + await this.longRunningTaskWrapper({ + name: 'updateAccessControlMembers', + task: async () => this.model.updateAccessControlMembers(value), + }); + }, + setMuteNotifications(ms: number) { const muteExpiresAt = ms > 0 ? Date.now() + ms : undefined; diff --git a/ts/window.d.ts b/ts/window.d.ts index eac042100ff9..8b76a0e211b8 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -43,9 +43,13 @@ import { createStore } from './state/createStore'; import { createCallManager } from './state/roots/createCallManager'; import { createCompositionArea } from './state/roots/createCompositionArea'; import { createContactModal } from './state/roots/createContactModal'; +import { createConversationDetails } from './state/roots/createConversationDetails'; import { createConversationHeader } from './state/roots/createConversationHeader'; +import { createGroupLinkManagement } from './state/roots/createGroupLinkManagement'; import { createGroupV1MigrationModal } from './state/roots/createGroupV1MigrationModal'; +import { createGroupV2Permissions } from './state/roots/createGroupV2Permissions'; import { createLeftPane } from './state/roots/createLeftPane'; +import { createPendingInvites } from './state/roots/createPendingInvites'; import { createSafetyNumberViewer } from './state/roots/createSafetyNumberViewer'; import { createShortcutGuideModal } from './state/roots/createShortcutGuideModal'; import { createStickerManager } from './state/roots/createStickerManager'; @@ -85,6 +89,7 @@ import { MessageDetail } from './components/conversation/MessageDetail'; import { ProgressModal } from './components/ProgressModal'; import { Quote } from './components/conversation/Quote'; import { StagedLinkPreview } from './components/conversation/StagedLinkPreview'; +import { MIMEType } from './types/MIME'; export { Long } from 'long'; @@ -335,8 +340,9 @@ declare global { path: string; objectUrl: string; }; - contentType: string; + contentType: MIMEType; error: unknown; + caption: string; migrateDataToFileSystem: ( attachment: WhatIsThis, @@ -448,9 +454,13 @@ declare global { createCallManager: typeof createCallManager; createCompositionArea: typeof createCompositionArea; createContactModal: typeof createContactModal; + createConversationDetails: typeof createConversationDetails; createConversationHeader: typeof createConversationHeader; + createGroupLinkManagement: typeof createGroupLinkManagement; createGroupV1MigrationModal: typeof createGroupV1MigrationModal; + createGroupV2Permissions: typeof createGroupV2Permissions; createLeftPane: typeof createLeftPane; + createPendingInvites: typeof createPendingInvites; createSafetyNumberViewer: typeof createSafetyNumberViewer; createShortcutGuideModal: typeof createShortcutGuideModal; createStickerManager: typeof createStickerManager; @@ -641,6 +651,7 @@ export type WhisperType = { BannerView: any; RecorderView: any; GroupMemberList: any; + GroupLinkCopiedToast: typeof Backbone.View; KeyVerificationPanelView: any; SafetyNumberChangeDialogView: any; BodyRangesType: BodyRangesType;