Introduce conversation details screen for New Groups

Co-authored-by: Chris Svenningsen <chris@carbonfive.com>
Co-authored-by: Sidney Keese <me@sidke.com>
This commit is contained in:
Josh Perez 2021-01-29 16:19:24 -05:00 committed by GitHub
parent 1268945840
commit c0510b08a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
64 changed files with 4699 additions and 81 deletions

View File

@ -523,6 +523,10 @@
"message": "You dont 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 arent shown until they join. Invitees will only see messages after they join the group.",
"description": "Information shown below the invite list"
}
}

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>block-24</title><path d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,1.5a9.448,9.448,0,0,1,6.159,2.281L4.781,18.159A9.488,9.488,0,0,1,12,2.5Zm0,19a9.448,9.448,0,0,1-6.159-2.281L19.219,5.841A9.488,9.488,0,0,1,12,21.5Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>block-24</title><path d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,1.5a9.448,9.448,0,0,1,6.159,2.281L4.781,18.159A9.488,9.488,0,0,1,12,2.5Zm0,19a9.448,9.448,0,0,1-6.159-2.281L19.219,5.841A9.488,9.488,0,0,1,12,21.5Z"/></svg>

Before

Width:  |  Height:  |  Size: 315 B

After

Width:  |  Height:  |  Size: 316 B

View File

@ -0,0 +1,3 @@
<svg width="19" height="19" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.5 12.25V19H5V12.25H6.5ZM16 0H8C7.20435 0 6.44129 0.316071 5.87868 0.87868C5.31607 1.44129 5 2.20435 5 3V7.75H6.5V3C6.5 2.60218 6.65804 2.22064 6.93934 1.93934C7.22064 1.65804 7.60218 1.5 8 1.5H16C16.3978 1.5 16.7794 1.65804 17.0607 1.93934C17.342 2.22064 17.5 2.60218 17.5 3V19H19V3C19 2.20435 18.6839 1.44129 18.1213 0.87868C17.5587 0.316071 16.7956 0 16 0ZM9.906 4.485L8.846 5.545L11.822 8.522L12.841 9.25H0V10.75H12.841L11.698 11.567L8.843 14.457L9.909 15.512L15.388 9.967L9.906 4.485Z" fill="#F44336"/>
</svg>

After

Width:  |  Height:  |  Size: 622 B

View File

@ -0,0 +1,3 @@
<svg width="22" height="8" viewBox="0 0 22 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.5 4.25C21.5 5.24456 21.1049 6.19839 20.4016 6.90165C19.6984 7.60491 18.7446 8 17.75 8H11.75C10.7554 8 9.80161 7.60491 9.09835 6.90165C8.39509 6.19839 8 5.24456 8 4.25C8.00051 3.99815 8.02563 3.74696 8.075 3.5H9.638C9.54917 3.74019 9.50249 3.99392 9.5 4.25C9.5 4.84674 9.73705 5.41903 10.159 5.84099C10.581 6.26295 11.1533 6.5 11.75 6.5H17.75C18.3467 6.5 18.919 6.26295 19.341 5.84099C19.7629 5.41903 20 4.84674 20 4.25C20 3.65326 19.7629 3.08097 19.341 2.65901C18.919 2.23705 18.3467 2 17.75 2H15.5L14 0.5H17.75C18.7446 0.5 19.6984 0.895088 20.4016 1.59835C21.1049 2.30161 21.5 3.25544 21.5 4.25ZM4.25 8H8L6.5 6.5H4.25C3.65326 6.5 3.08097 6.26295 2.65901 5.84099C2.23705 5.41903 2 4.84674 2 4.25C2 3.65326 2.23705 3.08097 2.65901 2.65901C3.08097 2.23705 3.65326 2 4.25 2H10.25C10.8467 2 11.419 2.23705 11.841 2.65901C12.2629 3.08097 12.5 3.65326 12.5 4.25C12.4975 4.50608 12.4508 4.75981 12.362 5H13.925C13.9744 4.75304 13.9995 4.50185 14 4.25C14 3.25544 13.6049 2.30161 12.9017 1.59835C12.1984 0.895088 11.2446 0.5 10.25 0.5H4.25C3.25544 0.5 2.30161 0.895088 1.59835 1.59835C0.895088 2.30161 0.5 3.25544 0.5 4.25C0.5 5.24456 0.895088 6.19839 1.59835 6.90165C2.30161 7.60491 3.25544 8 4.25 8Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,3 @@
<svg width="16" height="21" viewBox="0 0 16 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 8V5C13 3.67392 12.4732 2.40215 11.5355 1.46447C10.5979 0.526784 9.32608 0 8 0C6.67392 0 5.40215 0.526784 4.46447 1.46447C3.52678 2.40215 3 3.67392 3 5V8C2.20435 8 1.44129 8.31607 0.87868 8.87868C0.316071 9.44129 0 10.2044 0 11V18C0 18.7956 0.316071 19.5587 0.87868 20.1213C1.44129 20.6839 2.20435 21 3 21H13C13.7956 21 14.5587 20.6839 15.1213 20.1213C15.6839 19.5587 16 18.7956 16 18V11C16 10.2044 15.6839 9.44129 15.1213 8.87868C14.5587 8.31607 13.7956 8 13 8ZM4.5 5C4.5 4.07174 4.86875 3.1815 5.52513 2.52513C6.1815 1.86875 7.07174 1.5 8 1.5C8.92826 1.5 9.8185 1.86875 10.4749 2.52513C11.1313 3.1815 11.5 4.07174 11.5 5V8H4.5V5ZM14.5 18C14.5 18.3978 14.342 18.7794 14.0607 19.0607C13.7794 19.342 13.3978 19.5 13 19.5H3C2.60218 19.5 2.22064 19.342 1.93934 19.0607C1.65804 18.7794 1.5 18.3978 1.5 18V11C1.5 10.6022 1.65804 10.2206 1.93934 9.93934C2.22064 9.65804 2.60218 9.5 3 9.5H13C13.3978 9.5 13.7794 9.65804 14.0607 9.93934C14.342 10.2206 14.5 10.6022 14.5 11V18ZM8.75 14.792V17H7.25V14.792C6.96404 14.6269 6.74054 14.3721 6.61418 14.067C6.48782 13.7619 6.46565 13.4237 6.55111 13.1047C6.63657 12.7858 6.82489 12.5039 7.08686 12.3029C7.34882 12.1019 7.6698 11.993 8 11.993C8.3302 11.993 8.65118 12.1019 8.91314 12.3029C9.17511 12.5039 9.36343 12.7858 9.44889 13.1047C9.53435 13.4237 9.51218 13.7619 9.38582 14.067C9.25946 14.3721 9.03596 14.6269 8.75 14.792Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.513 10.9418C20.0301 10.3219 20.3599 9.56762 20.464 8.76711C20.568 7.9666 20.4419 7.15301 20.1004 6.42156C19.7589 5.69011 19.2162 5.07103 18.5357 4.63678C17.8552 4.20253 17.0651 3.97105 16.2579 3.96944C15.4506 3.96784 14.6596 4.19617 13.9774 4.62771C13.2952 5.05926 12.75 5.67616 12.4056 6.40626C12.0612 7.13635 11.9319 7.94942 12.0327 8.75034C12.1335 9.55126 12.4604 10.3069 12.975 10.9288C12.5003 11.0641 12.0495 11.2726 11.639 11.5468C11.3987 10.9872 11.0407 10.4859 10.5894 10.077C10.1381 9.66812 9.60398 9.36119 9.02345 9.1771C8.44292 8.99301 7.82957 8.93608 7.22508 9.01018C6.62058 9.08427 6.03914 9.28767 5.52026 9.60652C5.00139 9.92538 4.55725 10.3522 4.21806 10.858C3.87886 11.3638 3.65255 11.9367 3.55452 12.5378C3.45649 13.1389 3.48903 13.754 3.64993 14.3414C3.81084 14.9288 4.09633 15.4746 4.487 15.9418C3.4877 16.2164 2.60581 16.8106 1.97612 17.6337C1.34644 18.4568 1.00359 19.4635 1 20.4998V21.9998H2.5V20.4998C2.50106 19.6382 2.84381 18.8121 3.45307 18.2029C4.06234 17.5936 4.88837 17.2509 5.75 17.2498H9.75C10.6116 17.2509 11.4377 17.5936 12.0469 18.2029C12.6562 18.8121 12.9989 19.6382 13 20.4998V21.9998H14.5V20.4998C14.4964 19.4635 14.1536 18.4568 13.5239 17.6337C12.8942 16.8106 12.0123 16.2164 11.013 15.9418C11.6468 15.1876 11.9961 14.235 12 13.2498C12 13.2208 11.992 13.1948 11.992 13.1668C12.5978 12.5806 13.4071 12.2519 14.25 12.2498H18.25C19.1116 12.2509 19.9377 12.5936 20.5469 13.2029C21.1562 13.8121 21.4989 14.6382 21.5 15.4998V16.9998H23V15.4998C22.9964 14.4635 22.6536 13.4568 22.0239 12.6337C21.3942 11.8106 20.5123 11.2164 19.513 10.9418ZM7.74999 15.9999C7.20609 15.9999 6.67441 15.8386 6.22217 15.5364C5.76994 15.2343 5.41746 14.8048 5.20932 14.3023C5.00118 13.7998 4.94672 13.2468 5.05283 12.7134C5.15894 12.1799 5.42085 11.6899 5.80545 11.3054C6.19004 10.9208 6.68004 10.6589 7.21349 10.5528C7.74694 10.4467 8.29987 10.5011 8.80237 10.7093C9.30487 10.9174 9.73436 11.2698 10.0365 11.7221C10.3387 12.1743 10.5 12.706 10.5 13.2499C10.5 13.9792 10.2103 14.6787 9.69453 15.1944C9.17881 15.7102 8.47934 15.9999 7.74999 15.9999ZM16.25 11.0001C15.7061 11.0001 15.1744 10.8389 14.7222 10.5367C14.2699 10.2345 13.9175 9.80501 13.7093 9.30252C13.5012 8.80002 13.4467 8.24709 13.5528 7.71364C13.6589 7.18019 13.9209 6.69019 14.3055 6.30559C14.6901 5.921 15.1801 5.65909 15.7135 5.55298C16.247 5.44687 16.7999 5.50133 17.3024 5.70947C17.8049 5.91761 18.2344 6.27008 18.5365 6.72232C18.8387 7.17455 19 7.70624 19 8.25014C19 8.61127 18.9289 8.96887 18.7907 9.30252C18.6525 9.63616 18.4499 9.93932 18.1945 10.1947C17.9392 10.45 17.636 10.6526 17.3024 10.7908C16.9687 10.929 16.6111 11.0001 16.25 11.0001Z" fill="black"/>
<path d="M9.45703 5.76128H11.5291V4.3497H9.45703V2.18173H8.06676V4.3497H6V5.76128H8.06676V7.92925H9.45703V5.76128Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>share-ios-24</title><path d="M22,11v8a3,3,0,0,1-3,3H5a3,3,0,0,1-3-3V11A3,3,0,0,1,5,8H9.75V9.5H5A1.5,1.5,0,0,0,3.5,11v8A1.5,1.5,0,0,0,5,20.5H19A1.5,1.5,0,0,0,20.5,19V11A1.5,1.5,0,0,0,19,9.5H14.25V8H19A3,3,0,0,1,22,11ZM11.99,1,7.523,5.47,8.584,6.53,10.417,4.7,11.25,3.53V15h1.5V3.57l.731,1.022,1.94,1.924L16.478,5.45Z"/></svg>

After

Width:  |  Height:  |  Size: 414 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>undo-24</title><path d="M12.073,2.5A9.438,9.438,0,0,0,5.356,5.282c-.39.39-.7.707-.955.963L4.75,4.938V2.557H3.25V8.5H9.193V7H6.812L5.39,7.379c.26-.265.589-.6,1.027-1.036A8,8,0,1,1,12.073,20H11.5v1.5h.573a9.5,9.5,0,0,0,0-19Z"/></svg>

After

Width:  |  Height:  |  Size: 321 B

View File

@ -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,

View File

@ -116,7 +116,6 @@ message GroupChange {
Member.Role role = 2;
}
message ModifyTitleAction {
bytes title = 1;
}

View File

@ -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;

View File

@ -10,6 +10,7 @@ type ConfigKeyType =
| 'desktop.disableGV1'
| 'desktop.groupCalling'
| 'desktop.gv2'
| 'desktop.gv2Admin'
| 'desktop.mandatoryProfileSharing'
| 'desktop.messageRequests'
| 'desktop.storage'

View File

@ -39,7 +39,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
draftText: overrideProps.draftText || undefined,
clearQuotedMessage: action('clearQuotedMessage'),
getQuotedMessage: action('getQuotedMessage'),
members: [],
sortedGroupMembers: [],
// EmojiButton
onPickEmoji: action('onPickEmoji'),
onSetSkinTone: action('onSetSkinTone'),

View File

@ -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}
/>
</div>
{!large ? (

View File

@ -28,7 +28,7 @@ const createProps = (overrideProps: Partial<Props> = {}): 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',

View File

@ -63,7 +63,7 @@ export type Props = {
readonly skinTone?: EmojiPickDataType['skinTone'];
readonly draftText?: string;
readonly draftBodyRanges?: Array<BodyRangeType>;
members?: Array<ConversationType>;
sortedGroupMembers?: Array<ConversationType>;
onDirtyChange?(dirty: boolean): unknown;
onEditorStateChange?(
messageText: string,
@ -92,7 +92,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
draftBodyRanges,
getQuotedMessage,
clearQuotedMessage,
members,
sortedGroupMembers,
} = props;
const [emojiCompletionElement, setEmojiCompletionElement] = React.useState<
@ -459,11 +459,11 @@ export const CompositionInput: React.ComponentType<Props> = 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> = props => {
skinTone,
},
mentionCompletion: {
me: members ? members.find(foo => foo.isMe) : undefined,
me: sortedGroupMembers
? sortedGroupMembers.find(foo => foo.isMe)
: undefined,
memberRepositoryRef,
setMentionPickerElement: setMentionCompletionElement,
i18n,

View File

@ -31,11 +31,13 @@ const createProps = (overrideProps: Partial<PropsType> = {}): 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', () => {

View File

@ -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 = ({
</button>
)}
{!contact.isMe && areWeAdmin && isMember && (
<button
type="button"
className="module-contact-modal__button module-contact-modal__remove-from-group"
onClick={() => removeMember(contact.id)}
>
<div className="module-contact-modal__bubble-icon">
<div className="module-contact-modal__remove-from-group__bubble-icon" />
</div>
<span>{i18n('ContactModal--remove-from-group')}</span>
</button>
<>
<button
type="button"
className="module-contact-modal__button module-contact-modal__make-admin"
onClick={() => toggleAdmin(contact.id)}
>
<div className="module-contact-modal__bubble-icon">
<div className="module-contact-modal__make-admin__bubble-icon" />
</div>
{isAdmin ? (
<span>{i18n('ContactModal--rm-admin')}</span>
) : (
<span>{i18n('ContactModal--make-admin')}</span>
)}
</button>
<button
type="button"
className="module-contact-modal__button module-contact-modal__remove-from-group"
onClick={() => removeMember(contact.id)}
>
<div className="module-contact-modal__bubble-icon">
<div className="module-contact-modal__remove-from-group__bubble-icon" />
</div>
<span>{i18n('ContactModal--remove-from-group')}</span>
</button>
</>
)}
</div>
</div>

View File

@ -33,6 +33,7 @@ const commonProps = {
i18n,
onShowConversationDetails: action('onShowConversationDetails'),
onSetDisappearingMessages: action('onSetDisappearingMessages'),
onDeleteMessages: action('onDeleteMessages'),
onResetSession: action('onResetSession'),

View File

@ -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<PropsType> {
);
}
public renderTitle(): JSX.Element {
public renderTitle(): JSX.Element | null {
const {
name,
phoneNumber,
@ -352,11 +355,13 @@ export class ConversationHeader extends React.Component<PropsType> {
muteExpiresAt,
isMissingMandatoryProfileSharing,
left,
groupVersion,
onDeleteMessages,
onResetSession,
onSetDisappearingMessages,
onSetMuteNotifications,
onShowAllMedia,
onShowConversationDetails,
onShowGroupMembers,
onShowSafetyNumber,
onArchive,
@ -401,6 +406,11 @@ export class ConversationHeader extends React.Component<PropsType> {
isMissingMandatoryProfileSharing
);
const hasGV2AdminEnabled =
isGroup &&
groupVersion === 2 &&
window.Signal.RemoteConfig.isEnabled('desktop.gv2Admin');
return (
<ContextMenu id={triggerId}>
{disableTimerChanges ? null : (
@ -430,7 +440,12 @@ export class ConversationHeader extends React.Component<PropsType> {
</MenuItem>
))}
</SubMenu>
{isGroup ? (
{hasGV2AdminEnabled ? (
<MenuItem onClick={onShowConversationDetails}>
{i18n('showConversationDetails')}
</MenuItem>
) : null}
{isGroup && !hasGV2AdminEnabled ? (
<MenuItem onClick={onShowGroupMembers}>
{i18n('showMembers')}
</MenuItem>
@ -470,7 +485,23 @@ export class ConversationHeader extends React.Component<PropsType> {
}
private renderHeader(): JSX.Element {
const { id, isMe, onShowContactModal, type } = this.props;
const {
conversationTitle,
id,
isMe,
onShowContactModal,
type,
} = this.props;
if (conversationTitle) {
return (
<div className="module-conversation-header__title-flex">
<div className="module-conversation-header__title">
{conversationTitle}
</div>
</div>
);
}
if (type === 'group' || isMe) {
return (

View File

@ -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 <ConversationDetails {...props} />;
});
story.add('as Admin', () => {
const props = createProps();
return <ConversationDetails {...props} isAdmin />;
});
story.add('Group Editable', () => {
const props = createProps();
return <ConversationDetails {...props} canEditGroupInfo />;
});
story.add('Group Links On', () => {
const props = createProps(true);
return <ConversationDetails {...props} isAdmin />;
});

View File

@ -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<MediaItemType>
) => void;
onBlockAndDelete: () => void;
onDelete: () => void;
};
export type Props = StateProps;
export const ConversationDetails: React.ComponentType<Props> = ({
canEditGroupInfo,
conversation,
hasGroupLink,
i18n,
isAdmin,
loadRecentMediaItems,
setDisappearingMessages,
showAllMedia,
showContactModal,
showGroupLinkManagement,
showGroupV2Permissions,
showPendingInvites,
showLightboxForMedia,
onBlockAndDelete,
onDelete,
}) => {
const updateExpireTimer = (event: React.ChangeEvent<HTMLSelectElement>) => {
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 (
<div className="conversation-details-panel">
<ConversationDetailsHeader i18n={i18n} conversation={conversation} />
{canEditGroupInfo ? (
<PanelSection>
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n(
'ConversationDetails--disappearing-messages-label'
)}
icon="timer"
/>
}
info={i18n('ConversationDetails--disappearing-messages-info')}
label={i18n('ConversationDetails--disappearing-messages-label')}
right={
<div className="module-conversation-details-select">
<select
onChange={updateExpireTimer}
value={conversation.expireTimer || 0}
>
{ExpirationTimerOptions.map((item: typeof TimerOption) => (
<option
value={item.get('seconds')}
key={item.get('seconds')}
aria-label={item.getName(i18n)}
>
{item.getName(i18n)}
</option>
))}
</select>
</div>
}
/>
</PanelSection>
) : null}
<ConversationDetailsMembershipList
i18n={i18n}
showContactModal={showContactModal}
memberships={conversation.memberships || []}
/>
<PanelSection>
{isAdmin ? (
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('ConversationDetails--group-link')}
icon="link"
/>
}
label={i18n('ConversationDetails--group-link')}
onClick={showGroupLinkManagement}
right={hasGroupLink ? i18n('on') : i18n('off')}
/>
) : null}
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('ConversationDetails--requests-and-invites')}
icon="invites"
/>
}
label={i18n('ConversationDetails--requests-and-invites')}
onClick={showPendingInvites}
right={invitesCount}
/>
{isAdmin ? (
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('permissions')}
icon="lock"
/>
}
label={i18n('permissions')}
onClick={showGroupV2Permissions}
/>
) : null}
</PanelSection>
<ConversationDetailsMediaList
conversation={conversation}
i18n={i18n}
loadRecentMediaItems={loadRecentMediaItems}
showAllMedia={showAllMedia}
showLightboxForMedia={showLightboxForMedia}
/>
<ConversationDetailsActions
i18n={i18n}
conversationTitle={conversation.title}
onDelete={onDelete}
onBlockAndDelete={onBlockAndDelete}
/>
</div>
);
};

View File

@ -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> = {}): Props => ({
conversationTitle: overrideProps.conversationTitle || '',
onBlockAndDelete: action('onBlockAndDelete'),
onDelete: action('onDelete'),
i18n,
});
story.add('Basic', () => {
const props = createProps();
return <ConversationDetailsActions {...props} />;
});

View File

@ -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<Props> = ({
conversationTitle,
onBlockAndDelete,
onDelete,
i18n,
}) => {
const [confirmingLeave, setConfirmingLeave] = React.useState<boolean>(false);
const [confirmingBlock, setConfirmingBlock] = React.useState<boolean>(false);
return (
<>
<PanelSection>
<PanelRow
onClick={() => setConfirmingLeave(true)}
icon={
<ConversationDetailsIcon
ariaLabel={i18n('ConversationDetailsActions--leave-group')}
icon="leave"
/>
}
label={i18n('ConversationDetailsActions--leave-group')}
/>
<PanelRow
onClick={() => setConfirmingBlock(true)}
icon={
<ConversationDetailsIcon
ariaLabel={i18n('ConversationDetailsActions--block-group')}
icon="block"
/>
}
label={i18n('ConversationDetailsActions--block-group')}
/>
</PanelSection>
{confirmingLeave && (
<ConfirmationModal
actions={[
{
text: i18n(
'ConversationDetailsActions--leave-group-modal-confirm'
),
action: onDelete,
style: 'affirmative',
},
]}
i18n={i18n}
onClose={() => setConfirmingLeave(false)}
title={i18n('ConversationDetailsActions--leave-group-modal-title')}
>
{i18n('ConversationDetailsActions--leave-group-modal-content')}
</ConfirmationModal>
)}
{confirmingBlock && (
<ConfirmationModal
actions={[
{
text: i18n(
'ConversationDetailsActions--block-group-modal-confirm'
),
action: onBlockAndDelete,
style: 'affirmative',
},
]}
i18n={i18n}
onClose={() => setConfirmingBlock(false)}
title={i18n('ConversationDetailsActions--block-group-modal-title', [
conversationTitle,
])}
>
{i18n('ConversationDetailsActions--block-group-modal-content')}
</ConfirmationModal>
)}
</>
);
};

View File

@ -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 <ConversationDetailsHeader {...props} />;
});

View File

@ -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<Props> = ({
i18n,
conversation,
}) => {
const memberships = conversation.memberships || [];
return (
<div className={bem('root')}>
<Avatar
conversationType="group"
i18n={i18n}
size={80}
{...conversation}
/>
<div>
<div className={bem('title')}>{conversation.title}</div>
<div className={bem('subtitle')}>
{i18n('ConversationDetailsHeader--members', [
memberships.length.toString(),
])}
</div>
</div>
</div>
);
};

View File

@ -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>): 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 => (
<ConversationDetailsIcon {...createProps({ icon })} />
));
});
story.add('Clickable Icons', () => {
const icons = ['timer', 'trash', 'invites', 'block', 'leave', 'down'];
const onClick = action('onClick');
return icons.map(icon => (
<ConversationDetailsIcon {...createProps({ icon, onClick })} />
));
});

View File

@ -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<Props> = ({
ariaLabel,
icon,
onClick,
}) => {
const content = <div className={bem('icon', icon)} />;
if (onClick) {
return (
<button
aria-label={ariaLabel}
className={bem('button')}
type="button"
onClick={onClick}
>
{content}
</button>
);
}
return content;
};

View File

@ -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<MediaItemType>): 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 <ConversationDetailsMediaList {...props} />;
});

View File

@ -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<MediaItemType>
) => void;
};
const MEDIA_ITEM_LIMIT = 6;
const bem = bemGenerator('module-conversation-details-media-list');
export const ConversationDetailsMediaList: React.ComponentType<Props> = ({
conversation,
i18n,
loadRecentMediaItems,
showAllMedia,
showLightboxForMedia,
}) => {
const mediaItems = conversation.recentMediaItems || [];
React.useEffect(() => {
loadRecentMediaItems(MEDIA_ITEM_LIMIT);
}, [loadRecentMediaItems]);
if (mediaItems.length === 0) {
return null;
}
return (
<PanelSection
actions={
<button
className={bem('show-all')}
onClick={showAllMedia}
type="button"
>
{i18n('ConversationDetailsMediaList--show-all')}
</button>
}
borderless
title={i18n('ConversationDetailsMediaList--shared-media')}
>
<div className={bem('root')}>
{mediaItems.slice(0, MEDIA_ITEM_LIMIT).map(mediaItem => (
<MediaGridItem
key={`${mediaItem.message.id}-${mediaItem.index}`}
mediaItem={mediaItem}
i18n={i18n}
onClick={() => showLightboxForMedia(mediaItem, mediaItems)}
/>
))}
</div>
</PanelSection>
);
};

View File

@ -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<GroupV2Membership> => {
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>): Props => ({
i18n,
showContactModal: action('showContactModal'),
memberships: overrideProps.memberships || [],
});
story.add('Basic', () => {
const memberships = createMemberships(10);
const props = createProps({ memberships });
return <ConversationDetailsMembershipList {...props} />;
});
story.add('Few', () => {
const memberships = createMemberships(3);
const props = createProps({ memberships });
return <ConversationDetailsMembershipList {...props} />;
});
story.add('Many', () => {
const memberships = createMemberships(100);
const props = createProps({ memberships });
return <ConversationDetailsMembershipList {...props} />;
});
story.add('None', () => {
const props = createProps({ memberships: [] });
return <ConversationDetailsMembershipList {...props} />;
});

View File

@ -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<GroupV2Membership>;
showContactModal: (conversationId: string) => void;
i18n: LocalizerType;
};
const INITIAL_MEMBER_COUNT = 5;
export const ConversationDetailsMembershipList: React.ComponentType<Props> = ({
memberships,
showContactModal,
i18n,
}) => {
const [showAllMembers, setShowAllMembers] = React.useState<boolean>(false);
return (
<PanelSection
title={i18n('ConversationDetailsMembershipList--title', [
memberships.length.toString(),
])}
>
{memberships
.slice(0, showAllMembers ? undefined : INITIAL_MEMBER_COUNT)
.map(({ isAdmin, member }) => (
<PanelRow
key={member.id}
onClick={() => showContactModal(member.id)}
icon={
<Avatar
conversationType="direct"
i18n={i18n}
size={32}
{...member}
/>
}
label={member.title}
right={isAdmin ? i18n('GroupV2--admin') : ''}
/>
))}
{showAllMembers === false &&
memberships.length > INITIAL_MEMBER_COUNT && (
<PanelRow
className="module-conversation-details-membership-list--show-all"
icon={
<ConversationDetailsIcon
ariaLabel={i18n('ConversationDetailsMembershipList--show-all')}
icon="down"
/>
}
onClick={() => setShowAllMembers(true)}
label={i18n('ConversationDetailsMembershipList--show-all')}
/>
)}
</PanelSection>
);
};

View File

@ -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 <GroupLinkManagement {...props} />;
});
story.add('On', () => {
const props = createProps(
getConversation('https://signal.group/1', AccessEnum.ANY)
);
return <GroupLinkManagement {...props} />;
});
story.add('On (Admin Approval Needed)', () => {
const props = createProps(
getConversation('https://signal.group/1', AccessEnum.ADMINISTRATOR)
);
return <GroupLinkManagement {...props} />;
});

View File

@ -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<PropsType> = ({
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<HTMLSelectElement>) => {
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 (
<>
<PanelSection>
<PanelRow
info={groupLinkInfo}
label={i18n('ConversationDetails--group-link')}
right={
<div className="module-conversation-details-select">
<select
onChange={createEventHandler(changeHasGroupLink)}
value={String(Boolean(hasGroupLink))}
>
<option value="true" aria-label={i18n('on')}>
{i18n('on')}
</option>
<option value="false" aria-label={i18n('off')}>
{i18n('off')}
</option>
</select>
</div>
}
/>
</PanelSection>
{hasGroupLink ? (
<>
<PanelSection>
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('GroupLinkManagement--share')}
icon="share"
/>
}
label={i18n('GroupLinkManagement--share')}
onClick={() => {
if (conversation.groupLink) {
copyGroupLink(conversation.groupLink);
}
}}
/>
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('GroupLinkManagement--reset')}
icon="reset"
/>
}
label={i18n('GroupLinkManagement--reset')}
onClick={generateNewGroupLink}
/>
</PanelSection>
<PanelSection>
<PanelRow
info={i18n('GroupLinkManagement--approve-info')}
label={i18n('GroupLinkManagement--approve-label')}
right={
<div className="module-conversation-details-select">
<select
onChange={createEventHandler(
setAccessControlAddFromInviteLinkSetting
)}
value={String(membersNeedAdminApproval)}
>
<option value="true" aria-label={i18n('on')}>
{i18n('on')}
</option>
<option value="false" aria-label={i18n('off')}>
{i18n('off')}
</option>
</select>
</div>
}
/>
</PanelSection>
</>
) : null}
</>
);
};

View File

@ -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 <GroupV2Permissions {...props} />;
});

View File

@ -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<PropsType> = ({
accessEnum,
conversation,
i18n,
setAccessControlAttributesSetting,
setAccessControlMembersSetting,
}) => {
if (conversation === undefined) {
throw new Error('GroupV2Permissions rendered without a conversation');
}
const updateAccessControlAttributes = (
event: React.ChangeEvent<HTMLSelectElement>
) => {
setAccessControlAttributesSetting(Number(event.target.value));
};
const updateAccessControlMembers = (
event: React.ChangeEvent<HTMLSelectElement>
) => {
setAccessControlMembersSetting(Number(event.target.value));
};
const accessControlOptions = getAccessControlOptions(accessEnum, i18n);
return (
<PanelSection>
<PanelRow
label={i18n('ConversationDetails--group-info-label')}
info={i18n('ConversationDetails--group-info-info')}
right={
<div className="module-conversation-details-select">
<select
onChange={updateAccessControlAttributes}
value={conversation.accessControlAttributes}
>
{accessControlOptions.map(({ name, value }) => (
<option aria-label={name} key={name} value={value}>
{name}
</option>
))}
</select>
</div>
}
/>
<PanelRow
label={i18n('ConversationDetails--add-members-label')}
info={i18n('ConversationDetails--add-members-info')}
right={
<div className="module-conversation-details-select">
<select
onChange={updateAccessControlMembers}
value={conversation.accessControlMembers}
>
{accessControlOptions.map(({ name, value }) => (
<option aria-label={name} key={name} value={value}>
{name}
</option>
))}
</select>
</div>
}
/>
</PanelSection>
);
};

View File

@ -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> = {}): Props => ({
icon: boolean('with icon', overrideProps.icon !== undefined) ? (
<ConversationDetailsIcon ariaLabel="timer" icon="timer" />
) : 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) ? (
<ConversationDetailsIcon
ariaLabel="trash"
icon="trash"
onClick={action('action onClick')}
/>
) : 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 <PanelRow {...props} />;
});
story.add('Simple', () => {
const props = createProps({
label: 'this is a panel row',
icon: 'with icon',
right: 'side text',
});
return <PanelRow {...props} />;
});
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 <PanelRow {...props} />;
});
story.add('Button', () => {
const props = createProps({
label: 'this is a panel row',
icon: 'with icon',
right: 'side text',
onClick: action('onClick'),
});
return <PanelRow {...props} />;
});

View File

@ -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<Props> = ({
alwaysShowActions,
className,
icon,
label,
info,
right,
actions,
onClick,
}) => {
const content = (
<>
{icon && <div className={bem('icon')}>{icon}</div>}
<div className={bem('label')}>
<div>{label}</div>
{info && <div className={bem('info')}>{info}</div>}
</div>
{right && <div className={bem('right')}>{right}</div>}
{actions && (
<div className={alwaysShowActions ? '' : bem('actions')}>{actions}</div>
)}
</>
);
if (onClick) {
return (
<button
type="button"
className={classNames(bem('root', 'button'), className)}
onClick={onClick}
>
{content}
</button>
);
}
return <div className={classNames(bem('root'), className)}>{content}</div>;
};

View File

@ -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> = {}): Props => ({
title: text('label', overrideProps.title || ''),
centerTitle: boolean('centerTitle', overrideProps.centerTitle || false),
actions: boolean('with action', overrideProps.actions !== undefined) ? (
<button onClick={action('actions onClick')} type="button">
action
</button>
) : null,
});
story.add('Basic', () => {
const props = createProps({
title: 'panel section header',
});
return <PanelSection {...props} />;
});
story.add('Centered', () => {
const props = createProps({
title: 'this is a panel row',
centerTitle: true,
});
return <PanelSection {...props} />;
});
story.add('With Actions', () => {
const props = createProps({
title: 'this is a panel row',
actions: (
<button onClick={action('actions onClick')} type="button">
action
</button>
),
});
return <PanelSection {...props} />;
});
story.add('With Content', () => {
const props = createProps({
title: 'this is a panel row',
});
return (
<PanelSection {...props}>
<PanelRow label="this is panel row one" />
<PanelRow label="this is panel row two" />
<PanelRow label="this is panel row three" />
<PanelRow label="this is panel row four" />
</PanelSection>
);
});

View File

@ -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<Props> = ({
actions,
borderless,
centerTitle,
children,
title,
}) => (
<div className={classNames(bem('root'), borderless ? borderlessClass : null)}>
{(title || actions) && (
<div className={bem('header', { center: centerTitle || false })}>
{title && <div className={bem('title')}>{title}</div>}
{actions && <div>{actions}</div>}
</div>
)}
<div>{children}</div>
</div>
);

View File

@ -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 <PendingInvites {...props} />;
});

View File

@ -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<string>) => 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<PropsType> = ({
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<Array<
StagedMembershipType
> | null>(null);
const allPendingMemberships = conversation.pendingMemberships || [];
const allRequestingMemberships =
conversation.pendingApprovalMemberships || [];
return (
<div className="conversation-details-panel">
<div className="module-conversation-details__tabs">
<div
className={classNames({
'module-conversation-details__tab': true,
'module-conversation-details__tab--selected':
selectedTab === Tab.Requests,
})}
onClick={() => {
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),
})}
</div>
<div
className={classNames({
'module-conversation-details__tab': true,
'module-conversation-details__tab--selected':
selectedTab === Tab.Pending,
})}
onClick={() => {
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),
})}
</div>
</div>
{selectedTab === Tab.Requests ? (
<MembersPendingAdminApproval
conversation={conversation}
i18n={i18n}
memberships={allRequestingMemberships}
setStagedMemberships={setStagedMemberships}
/>
) : null}
{selectedTab === Tab.Pending ? (
<MembersPendingProfileKey
conversation={conversation}
i18n={i18n}
members={conversation.sortedGroupMembers || []}
memberships={allPendingMemberships}
ourConversationId={ourConversationId}
setStagedMemberships={setStagedMemberships}
/>
) : null}
{stagedMemberships && stagedMemberships.length && (
<MembershipActionConfirmation
approvePendingMembership={approvePendingMembership}
i18n={i18n}
members={conversation.sortedGroupMembers || []}
onClose={() => setStagedMemberships(null)}
ourConversationId={ourConversationId}
revokePendingMemberships={revokePendingMemberships}
stagedMemberships={stagedMemberships}
/>
)}
</div>
);
};
function MembershipActionConfirmation({
approvePendingMembership,
i18n,
members,
onClose,
ourConversationId,
revokePendingMemberships,
stagedMemberships,
}: {
approvePendingMembership: (conversationId: string) => void;
i18n: LocalizerType;
members: Array<ConversationType>;
onClose: () => void;
ourConversationId: string;
revokePendingMemberships: (conversationIds: Array<string>) => void;
stagedMemberships: Array<StagedMembershipType>;
}) {
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 (
<ConfirmationModal
actions={[
{
action: modalAction,
style: 'affirmative',
text: modalActionText,
},
]}
i18n={i18n}
onClose={onClose}
>
{getConfirmationMessage({
i18n,
members,
ourConversationId,
stagedMemberships,
})}
</ConfirmationModal>
);
}
function getConfirmationMessage({
i18n,
members,
ourConversationId,
stagedMemberships,
}: {
i18n: LocalizerType;
members: Array<ConversationType>;
ourConversationId: string;
stagedMemberships: Array<StagedMembershipType>;
}): 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<GroupV2RequestingMembership>;
setStagedMemberships: (stagedMembership: Array<StagedMembershipType>) => void;
}) {
return (
<PanelSection>
{memberships.map(membership => (
<PanelRow
alwaysShowActions
key={membership.member.id}
icon={
<Avatar
conversationType="direct"
size={32}
i18n={i18n}
{...membership.member}
/>
}
label={membership.member.title}
actions={
conversation.areWeAdmin ? (
<>
<button
type="button"
className="module-button__small module-conversation-details__action-button"
onClick={() => {
setStagedMemberships([
{
type: StageType.DENY_REQUEST,
membership,
},
]);
}}
>
{i18n('delete')}
</button>
<button
type="button"
className="module-button__small module-conversation-details__action-button"
onClick={() => {
setStagedMemberships([
{
type: StageType.APPROVE_REQUEST,
membership,
},
]);
}}
>
{i18n('accept')}
</button>
</>
) : null
}
/>
))}
<div className="module-conversation-details__pending--info">
{i18n('PendingRequests--info', [conversation.title])}
</div>
</PanelSection>
);
}
function MembersPendingProfileKey({
conversation,
i18n,
members,
memberships,
ourConversationId,
setStagedMemberships,
}: {
conversation: ConversationType;
i18n: LocalizerType;
members: Array<ConversationType>;
memberships: Array<GroupV2PendingMembership>;
ourConversationId: string;
setStagedMemberships: (stagedMembership: Array<StagedMembershipType>) => 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 (
<PanelSection>
{ourPendingMemberships && (
<PanelSection title={i18n('PendingInvites--invited-by-you')}>
{ourPendingMemberships.map(membership => (
<PanelRow
key={membership.member.id}
icon={
<Avatar
conversationType="direct"
size={32}
i18n={i18n}
{...membership.member}
/>
}
label={membership.member.title}
actions={
conversation.areWeAdmin ? (
<ConversationDetailsIcon
ariaLabel={i18n('PendingInvites--revoke-for-label')}
icon="trash"
onClick={() => {
setStagedMemberships([
{
type: StageType.REVOKE_INVITE,
membership,
},
]);
}}
/>
) : null
}
/>
))}
</PanelSection>
)}
{otherPendingMemberships.length > 0 && (
<PanelSection title={i18n('PendingInvites--invited-by-others')}>
{otherPendingMemberships.map(({ member, pendingMemberships }) => (
<PanelRow
key={member.id}
icon={
<Avatar
conversationType="direct"
size={32}
i18n={i18n}
{...member}
/>
}
label={member.title}
right={i18n('PendingInvites--invited-count', [
pendingMemberships.length.toString(),
])}
actions={
conversation.areWeAdmin ? (
<ConversationDetailsIcon
ariaLabel={i18n('PendingInvites--revoke-for-label')}
icon="trash"
onClick={() => {
setStagedMemberships(
pendingMemberships.map(membership => ({
type: StageType.REVOKE_INVITE,
membership,
}))
);
}}
/>
) : null
}
/>
))}
</PanelSection>
)}
<div className="module-conversation-details__pending--info">
{i18n('PendingInvites--info')}
</div>
</PanelSection>
);
}

View File

@ -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, boolean>
): string => {
const base = `${block}__${element}`;
const classes = [base];
let conditionals: Record<string, boolean> = {};
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<string, boolean>
);
}
}
return classNames(classes, conditionals);
};

View File

@ -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<string>;
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 }> {

2
ts/model-types.d.ts vendored
View File

@ -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;

View File

@ -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<GroupChangeClass.Actions | undefined> {
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<GroupChangeClass.Actions | undefined> {
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<string>
): Promise<GroupChangeClass.Actions | undefined> {
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<GroupChangeClass.Actions | undefined> {
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<void> {
if (this.isGroupV2() && this.isMemberPending(conversationId)) {
async toggleAdmin(conversationId: string): Promise<void> {
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<void> {
if (this.isGroupV2() && this.isMemberRequestingToJoin(conversationId)) {
await this.modifyGroupV2({
name: 'approvePendingApprovalRequest',
createGroupChange: () =>
this.approvePendingApprovalRequest(conversationId),
});
}
}
async revokePendingMembershipsFromGroupV2(
conversationIds: Array<string>
): Promise<void> {
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<void> {
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<GroupV2Membership> {
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<GroupV2PendingMembership> {
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<GroupV2RequestingMembership> {
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<ConversationModel> {
@ -3199,6 +3525,166 @@ export class ConversationModel extends window.Backbone.Model<
window.Whisper.events.trigger('updateUnreadCount');
}
async refreshGroupLink(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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;

View File

@ -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<ConversationType>;
// 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<GroupV2Membership>;
pendingMemberships?: Array<GroupV2PendingMembership>;
pendingApprovalMemberships?: Array<GroupV2RequestingMembership>;
muteExpiresAt?: number;
type: ConversationTypeType;
isMe?: boolean;
lastUpdated?: number;
// This is used by the CompositionInput for @mentions
sortedGroupMembers?: Array<ConversationType>;
title: string;
unreadCount?: number;
isSelected?: boolean;
@ -92,6 +108,7 @@ export type ConversationType = {
phoneNumber?: string;
profileName?: string;
} | null;
recentMediaItems?: Array<MediaItemType>;
shouldShowDraft?: boolean;
draftText?: string | null;
@ -101,6 +118,7 @@ export type ConversationType = {
sharedGroupNames?: Array<string>;
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<MediaItemType>;
};
};
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<MediaItemType>
): 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;
}

View File

@ -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 => (
<Provider store={store}>
<SmartConversationDetails {...props} />
</Provider>
);

View File

@ -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 => (
<Provider store={store}>
<SmartGroupLinkManagement {...props} />
</Provider>
);

View File

@ -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 => (
<Provider store={store}>
<SmartGroupV2Permissions {...props} />
</Provider>
);

View File

@ -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 => (
<Provider store={store}>
<SmartPendingInvites {...props} />
</Provider>
);

View File

@ -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,
};
};

View File

@ -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<MediaItemType>
) => 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);

View File

@ -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),

View File

@ -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);

View File

@ -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);

View File

@ -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<string>) => 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);

View File

@ -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>
): 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,

6
ts/textsecure.d.ts vendored
View File

@ -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

View File

@ -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;
};
};

View File

@ -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<AccessControlOption> {
return [
{
name: i18n('GroupV2--all-members'),
value: accessEnum.MEMBER,
},
{
name: i18n('GroupV2--admin'),
value: accessEnum.ADMINISTRATOR,
},
];
}

View File

@ -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"

View File

@ -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 dont 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<any> = []) {
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<void> {
const messages: Array<MessageType> = 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<MediaItemType>
);
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;

13
ts/window.d.ts vendored
View File

@ -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;