diff --git a/.babelrc.js b/.babelrc.js index a302d6d5e6..dbb5246d94 100644 --- a/.babelrc.js +++ b/.babelrc.js @@ -1,4 +1,4 @@ -// Copyright 2019-2020 Signal Messenger, LLC +// Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only module.exports = { @@ -9,6 +9,7 @@ module.exports = { 'react-hot-loader/babel', 'lodash', '@babel/plugin-proposal-class-properties', + '@babel/plugin-proposal-optional-chaining', // This plugin converts commonjs to esmodules which is required for // importing commonjs modules from esmodules in storybook. As a part of // converting to TypeScript we should use esmodules and can eventually diff --git a/.eslintrc.js b/.eslintrc.js index d2691c3fbe..e5bdae8c36 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -125,6 +125,7 @@ const rules = { '`with` is disallowed in strict mode because it makes code impossible to predict and optimize.', }, ], + curly: 'error', }; module.exports = { diff --git a/.gitignore b/.gitignore index ae9ef166bf..96e153e8eb 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ release/ *.sublime* /sql/ /start.sh +.eslintcache +tsconfig.tsbuildinfo # generated files js/components.js @@ -36,3 +38,4 @@ sticker-creator/**/*.js sticker-creator/dist/* /.idea +/storybook-static/ diff --git a/.storybook/config.js b/.storybook/config.js index d2bf3b8822..83ad39d5be 100644 --- a/.storybook/config.js +++ b/.storybook/config.js @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; -import { addDecorator, configure } from '@storybook/react'; +import { addDecorator, addParameters, configure } from '@storybook/react'; import { withKnobs, boolean, optionsKnob } from '@storybook/addon-knobs'; import classnames from 'classnames'; import * as styles from './styles.scss'; @@ -126,6 +126,12 @@ addDecorator(Story => ); addDecorator(story => {story()}); +addParameters({ + axe: { + disabledRules: ['html-has-lang'], + }, +}); + configure(() => { // Load main app stories const tsComponentsContext = require.context( diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index cb1b68758f..703a50c2fc 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -45,6 +45,54 @@ Signal Desktop makes use of the following open source projects. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## @types/pino + + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE + +## @types/pino-multi-stream + + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE + ## abort-controller MIT License @@ -361,32 +409,6 @@ Signal Desktop makes use of the following open source projects. License: MIT -## bunyan - - # This is the MIT license - - Copyright 2016 Trent Mick - Copyright 2016 Joyent Inc. - - Permission is hereby granted, free of charge, to any person obtaining a - copy of this software and associated documentation files (the - "Software"), to deal in the Software without restriction, including - without limitation the rights to use, copy, modify, merge, publish, - distribute, sublicense, and/or sell copies of the Software, and to - permit persons to whom the Software is furnished to do so, subject to - the following conditions: - - The above copyright notice and this permission notice shall be included - in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS - OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY - CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, - TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ## classnames The MIT License (MIT) @@ -1301,6 +1323,24 @@ Signal Desktop makes use of the following open source projects. licenses; we recommend you read them, as their terms may differ from the terms above. +## lru-cache + + The ISC License + + Copyright (c) Isaac Z. Schlueter and Contributors + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR + IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + ## memoizee ISC License @@ -1719,33 +1759,6 @@ Signal Desktop makes use of the following open source projects. PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. -## node-gyp - - (The MIT License) - - Copyright (c) 2012 Nathan Rajlich - - Permission is hereby granted, free of charge, to any person - obtaining a copy of this software and associated documentation - files (the "Software"), to deal in the Software without - restriction, including without limitation the rights to use, - copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the - Software is furnished to do so, subject to the following - conditions: - - The above copyright notice and this permission notice shall be - included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES - OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR - OTHER DEALINGS IN THE SOFTWARE. - ## normalize-path The MIT License (MIT) @@ -1862,6 +1875,37 @@ Signal Desktop makes use of the following open source projects. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## pino + + The MIT License (MIT) + + Copyright (c) 2016-2019 Matteo Collina, David Mark Clements and the Pino contributors + + Pino contributors listed at https://github.com/pinojs/pino#the-team and in + the README file. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + +## pino-multi-stream + + License: MIT + ## popper.js License: MIT @@ -2365,6 +2409,30 @@ Signal Desktop makes use of the following open source projects. ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +## rotating-file-stream + + The MIT License (MIT) + + Copyright (c) 2015-2020 Daniele Ricci + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + ## sanitize-filename License: WTFPL OR ISC @@ -2990,3 +3058,27 @@ Signal Desktop makes use of the following open source projects. of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS + +## zod + + MIT License + + Copyright (c) 2020 Colin McDonnell + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 687f894d8c..4ef4f8a089 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,10 +43,11 @@ Install the [Xcode Command-Line Tools](http://osxdaily.com/2014/02/12/install-co ### Linux 1. Pick your favorite package manager. -1. Install `python` +1. Install `python` (Python 2.7+) 1. Install `gcc` 1. Install `g++` 1. Install `make` +1. Install `git-lfs` ### All platforms @@ -55,6 +56,7 @@ Now, run these commands in your preferred terminal in a good directory for devel ``` git clone https://github.com/signalapp/Signal-Desktop.git cd Signal-Desktop +git-lfs install # Setup Git LFS. npm install --global yarn # (only if you don’t already have `yarn`) yarn install --frozen-lockfile # Install and build dependencies (this will take a while) yarn grunt # Generate final JS and CSS assets @@ -79,6 +81,8 @@ while you make changes: yarn grunt dev # runs until you stop it, re-generating built assets on file changes ``` +If you miss the `git-lfs` step, run `yarn cache clean` and remove `node_modules` before trying again. + ### webpack Some parts of the app (such as the Sticker Creator) have moved to webpack. diff --git a/Gruntfile.js b/Gruntfile.js index c42bf529df..3e7abf84c3 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,4 +1,4 @@ -// Copyright 2014-2020 Signal Messenger, LLC +// Copyright 2014-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only const { join } = require('path'); @@ -113,13 +113,9 @@ module.exports = grunt => { tasks: ['exec:build-protobuf'], }, sass: { - files: ['./stylesheets/*.scss'], + files: ['./stylesheets/*.scss', './stylesheets/**/*.scss'], tasks: ['sass'], }, - transpile: { - files: ['./ts/**/*.ts', './ts/**/*.tsx'], - tasks: ['exec:transpile'], - }, }, exec: { 'tx-pull-new': { @@ -386,12 +382,18 @@ module.exports = grunt => { console.log('window opened'); }) .then(() => - // Get the window's title - app.client.getTitle() - ) - .then(title => { // Verify the window's title - assert.equal(title, packageJson.productName); + app.client.waitUntil( + async () => + (await app.client.getTitle()) === packageJson.productName, + { + timeoutMsg: `Expected window title to be ${JSON.stringify( + packageJson.productName + )}`, + } + ) + ) + .then(() => { console.log('title ok'); }) .then(() => { diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 3fe43aaffc..7d61d0aa8b 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -982,7 +982,7 @@ "description": "Label for when something is turned off" }, "deleteWarning": { - "message": "Clicking 'delete' will permanently remove this message from your devices only.", + "message": "This message will be deleted from this device.", "description": "Text shown in the confirmation dialog for deleting a message locally" }, "deleteForEveryoneWarning": { @@ -1077,6 +1077,22 @@ "message": "Secure session reset", "description": "This is a past tense, informational message. In other words, your secure session has been reset." }, + "ChatRefresh--notification": { + "message": "Chat session refreshed", + "description": "Shown in timeline when a error happened, and the session was automatically reset." + }, + "ChatRefresh--learnMore": { + "message": "Learn More", + "description": "Shown in timeline when session is automatically reset, to provide access to a popup info dialog" + }, + "ChatRefresh--summary": { + "message": "Signal uses end-to-end encryption and it may need to refresh your chat session sometimes. This doesn’t affect your chat’s security but you may have missed a message from this contact and you can ask them to resend it.", + "description": "Shown on explainer dialog available from chat session refreshed timeline events" + }, + "ChatRefresh--contactSupport": { + "message": "Contact Support", + "description": "Shown on explainer dialog available from chat session refreshed timeline events" + }, "quoteThumbnailAlt": { "message": "Thumbnail of image from quoted message", "description": "Used in alt tag of thumbnail images inside of an embedded message quote" @@ -1877,6 +1893,108 @@ "message": "Start new conversation…", "description": "Label underneath number a user enters that is not an existing contact" }, + "newConversation": { + "message": "New conversation", + "description": "Label for header when starting a new conversation" + }, + "contactSearchPlaceholder": { + "message": "Search by name or phone number", + "description": "Placeholder to use when searching for contacts in the composer" + }, + "noContactsFound": { + "message": "No contacts found", + "description": "Label shown when there are no contacts to compose to" + }, + "chooseGroupMembers__title": { + "message": "Choose members", + "description": "The title for the 'choose group members' left pane screen" + }, + "chooseGroupMembers__back-button": { + "message": "Back", + "description": "Used as alt-text of the back button on the 'choose group members' left pane screen" + }, + "chooseGroupMembers__skip": { + "message": "Skip", + "description": "The 'skip' button text in the 'choose group members' left pane screen" + }, + "chooseGroupMembers__next": { + "message": "Next", + "description": "The 'next' button text in the 'choose group members' left pane screen" + }, + "chooseGroupMembers__maximum-group-size__title": { + "message": "Maximum group size reached", + "description": "Shown in the alert when you add the maximum number of group members" + }, + "chooseGroupMembers__maximum-group-size__body": { + "message": "Signal groups can have a maximum of $max$ members.", + "description": "Shown in the alert when you add the maximum number of group members", + "placeholders": { + "max": { + "content": "$1", + "example": "1000" + } + } + }, + "chooseGroupMembers__maximum-recommended-group-size__title": { + "message": "Recommended member limit reached", + "description": "Shown in the alert when you add the maximum recommended number of group members" + }, + "chooseGroupMembers__maximum-recommended-group-size__body": { + "message": "Signal groups perform best with $max$ members or less. Adding more members will cause delays sending and receiving messages.", + "description": "Shown in the alert when you add the maximum recommended number of group members", + "placeholders": { + "max": { + "content": "$1", + "example": "150" + } + } + }, + "chooseGroupMembers__cant-add-member__title": { + "message": "Can’t add member", + "description": "Shown in the alert when you try to add someone who can't be added to a group" + }, + "chooseGroupMembers__cant-add-member__body": { + "message": "\"$name$\" can’t be added to the group because they’re using an old version of Signal. You can add them to the group after they’ve updated Signal.", + "description": "Shown in the alert when you try to add someone who can't be added to a group", + "placeholders": { + "max": { + "content": "$1", + "example": "Jane Doe" + } + } + }, + "setGroupMetadata__title": { + "message": "Name this group", + "description": "The title for the 'set group metadata' left pane screen" + }, + "setGroupMetadata__back-button": { + "message": "Back to member selection", + "description": "Used as alt-text of the back button on the 'set group metadata' left pane screen" + }, + "setGroupMetadata__group-name-placeholder": { + "message": "Group name (required)", + "description": "The placeholder for the group name placeholder" + }, + "setGroupMetadata__create-group": { + "message": "Create", + "description": "The 'create group' button text in the 'set group metadata' left pane screen" + }, + "setGroupMetadata__members-header": { + "message": "Members", + "description": "The header for the members list in the 'set group metadata' left pane screen" + }, + "setGroupMetadata__error-message": { + "message": "This group couldn’t be created. Check your connection and try again.", + "description": "Shown in the modal when we can't create a group" + }, + "updateGroupAttributes__title": { + "message": "Edit group name and photo", + "description": "Shown in the modal when we want to update a group" + }, + "updateGroupAttributes__error-message": { + "message": "Failed to update the group. Check your connection and try again.", + "description": "Shown in the modal when we can't update a group" + }, "notSupportedSMS": { "message": "SMS/MMS messages are not supported.", "description": "Label underneath number informing user that SMS is not supported on desktop" @@ -2310,6 +2428,10 @@ "message": "Open conversation menu", "description": "Shown in the shortcuts guide" }, + "Keyboard--new-conversation": { + "message": "Start new conversation", + "description": "Shown in the shortcuts guide" + }, "Keyboard--archive-conversation": { "message": "Archive conversation", "description": "Shown in the shortcuts guide" @@ -3000,6 +3122,10 @@ } } }, + "no-groups-in-common": { + "message": "No groups in common.", + "description": "Shown to indicate this user is not a member of any groups" + }, "acceptCall": { "message": "Answer", "description": "Shown in tooltip for the button to accept a call (audio or video)" @@ -3358,6 +3484,10 @@ "message": "Admin", "description": "Label for a group administrator" }, + "GroupV2--only-admins": { + "message": "Only Admins", + "description": "Label for group administrators -- used in drop-downs to select permissions that apply to admins" + }, "GroupV2--all-members": { "message": "All members", "description": "Label for describing the general non-privileged members of a group" @@ -4625,6 +4755,10 @@ "message": "Block group", "description": "This is a button to block a group" }, + "ConversationDetailsActions--leave-group-must-choose-new-admin": { + "message": "Before you leave, you must choose at least one new admin for this group.", + "description": "Shown if, before leaving a group, you need to choose an admin" + }, "ConversationDetailsActions--leave-group-modal-title": { "message": "Do you really want to leave?", "description": "This is the modal title for confirming leaving a group" @@ -4683,6 +4817,10 @@ } } }, + "ConversationDetailsMembershipList--add-members": { + "message": "Add members", + "description": "The button that you can click to add new members" + }, "ConversationDetailsMembershipList--show-all": { "message": "See all", "description": "This is a button on the conversation details to show all members" @@ -4840,5 +4978,149 @@ "PendingInvites--info": { "message": "Details about people invited to this group aren’t shown until they join. Invitees will only see messages after they join the group.", "description": "Information shown below the invite list" + }, + "AvatarInput--no-photo-label--group": { + "message": "Add a group photo", + "description": "The label for the avatar uploader when no group photo is selected" + }, + "AvatarInput--change-photo-label": { + "message": "Change photo", + "description": "The label for the avatar uploader when a photo is selected" + }, + "AvatarInput--upload-photo-choice": { + "message": "Upload photo", + "description": "The button text when you click on an uploaded avatar and want to upload a new one" + }, + "AvatarInput--remove-photo-choice": { + "message": "Remove photo", + "description": "The button text when you click on an uploaded avatar and want to remove it" + }, + "ContactPill--remove": { + "message": "Remove contact", + "description": "The label for the 'remove' button on the contact pill" + }, + "ComposeErrorDialog--close": { + "message": "Okay", + "description": "The text on the button when there's an error in the composer" + }, + "NewlyCreatedGroupInvitedContactsDialog--title--one": { + "message": "Invitation sent", + "description": "When creating a new group and inviting users, this is shown in the dialog" + }, + "NewlyCreatedGroupInvitedContactsDialog--title--many": { + "message": "$count$ invitations sent", + "description": "When creating a new group and inviting users, this is shown in the dialog", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "NewlyCreatedGroupInvitedContactsDialog--body--user-paragraph--one": { + "message": "$name$ can’t be automatically added to this group by you.", + "description": "When creating a new group and inviting users, this is shown in the dialog", + "placeholders": { + "name": { + "content": "$1", + "example": "Jane Doe" + } + } + }, + "NewlyCreatedGroupInvitedContactsDialog--body--user-paragraph--many": { + "message": "These users can’t be automatically added to this group by you.", + "description": "When creating a new group and inviting users, this is shown in the dialog" + }, + "NewlyCreatedGroupInvitedContactsDialog--body--info-paragraph": { + "message": "They’ve been invited to join, and won’t see any group messages until they accept.", + "description": "When creating a new group and inviting users, this is shown in the dialog" + }, + "NewlyCreatedGroupInvitedContactsDialog--body--learn-more": { + "message": "Learn more", + "description": "When creating a new group and inviting users, this is shown in the dialog" + }, + "AddGroupMembersModal--title": { + "message": "Add members", + "description": "When adding new members to an existing group, this is shown in the dialog" + }, + "AddGroupMembersModal--continue-to-confirm": { + "message": "Update", + "description": "When adding new members to an existing group, this is shown in the dialog" + }, + "AddGroupMembersModal--confirm-title--one": { + "message": "Add $person$ to \"$group$\"?", + "description": "When adding new members to an existing group, this is shown in the confirmation dialog", + "placeholders": { + "person": { + "content": "$1", + "example": "Jane Doe" + }, + "group": { + "content": "$2", + "example": "Tahoe Trip" + } + } + }, + "AddGroupMembersModal--confirm-title--many": { + "message": "Add $count$ members to \"$group$\"?", + "description": "When adding new members to an existing group, this is shown in the confirmation dialog", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + }, + "group": { + "content": "$2", + "example": "Tahoe Trip" + } + } + }, + "AddGroupMembersModal--confirm-button--one": { + "message": "Add member", + "description": "When adding new members to an existing group, this is shown on the confirmation dialog button" + }, + "AddGroupMembersModal--confirm-button--many": { + "message": "Add members", + "description": "When adding new members to an existing group, this is shown on the confirmation dialog button" + }, + "createNewGroupButton": { + "message": "New group", + "description": "The text of the button to create new groups" + }, + "selectContact": { + "message": "Select contact", + "description": "The label for contact checkboxes that are non-selected (clicking them should select the contact)" + }, + "deselectContact": { + "message": "De-select contact", + "description": "The label for contact checkboxes that are selected (clicking them should de-select the contact)" + }, + "cannotSelectContact": { + "message": "Cannot select contact", + "description": "The label for contact checkboxes that are disabled" + }, + "alreadyAMember": { + "message": "Already a member", + "description": "The label for contact checkboxes that are disabled because they're already a member" + }, + "MessageAudio--play": { + "message": "Play audio attachment", + "description": "Aria label for audio attachment's Play button" + }, + "MessageAudio--pause": { + "message": "Pause audio attachment", + "description": "Aria label for audio attachment's Pause button" + }, + "MessageAudio--download": { + "message": "Download audio attachment", + "description": "Aria label for audio attachment's Download button" + }, + "MessageAudio--pending": { + "message": "Downloading audio attachment...", + "description": "Aria label for pending audio attachment spinner" + }, + "MessageAudio--slider": { + "message": "Playback time of audio attachment", + "description": "Aria label for audio attachment's playback time slider" } } diff --git a/_locales/es_419/messages.json b/_locales/es_419/messages.json deleted file mode 100644 index 7c37a855cd..0000000000 --- a/_locales/es_419/messages.json +++ /dev/null @@ -1,1400 +0,0 @@ -{ - "mainMenuFile": { - "message": "&File", - "description": "The label that is used for the File menu in the program main menu. The '&' indicates that the following letter will be used as the keyboard 'shortcut letter' for accessing the menu with the Alt- combination." - }, - "mainMenuEdit": { - "message": "&Edit", - "description": "The label that is used for the Edit menu in the program main menu. The '&' indicates that the following letter will be used as the keyboard 'shortcut letter' for accessing the menu with the Alt- combination." - }, - "mainMenuView": { - "message": "&View", - "description": "The label that is used for the View menu in the program main menu. The '&' indicates that the following letter will be used as the keyboard 'shortcut letter' for accessing the menu with the Alt- combination." - }, - "mainMenuWindow": { - "message": "&Window", - "description": "The label that is used for the Window menu in the program main menu. The '&' indicates that the following letter will be used as the keyboard 'shortcut letter' for accessing the menu with the Alt- combination." - }, - "mainMenuHelp": { - "message": "&Help", - "description": "The label that is used for the Help menu in the program main menu. The '&' indicates that the following letter will be used as the keyboard 'shortcut letter' for accessing the menu with the Alt- combination." - }, - "mainMenuSettings": { - "message": "Preferences…", - "description": "The label that is used for the Preferences menu in the program main menu. This should be consistent with the standard naming for ‘Preferences’ on the operating system." - }, - "appMenuHide": { - "message": "Hide", - "description": "Application menu command to hide the window" - }, - "appMenuHideOthers": { - "message": "Hide Others", - "description": "Application menu command to hide all other windows" - }, - "appMenuUnhide": { - "message": "Show All", - "description": "Application menu command to show all application windows" - }, - "appMenuQuit": { - "message": "Quit Signal", - "description": "Application menu command to close the application" - }, - "editMenuUndo": { - "message": "Undo", - "description": "Edit menu command to remove recently-typed text" - }, - "editMenuRedo": { - "message": "Redo", - "description": "Edit menu command to restore previously undone typed text" - }, - "editMenuCut": { - "message": "Cut", - "description": "Edit menu command to remove selected text and add it to clipboard" - }, - "editMenuCopy": { - "message": "Copy", - "description": "Edit menu command to add selected text to clipboard" - }, - "editMenuPaste": { - "message": "Paste", - "description": "Edit menu command to insert text from clipboard at cursor location" - }, - "editMenuPasteAndMatchStyle": { - "message": "Paste and Match Style", - "description": "Edit menu command to insert text from clipboard at cursor location, taking only text and not style information" - }, - "editMenuDelete": { - "message": "Borrar", - "description": "Edit menu command to remove the selected text" - }, - "editMenuSelectAll": { - "message": "Select All", - "description": "Edit menu comand to select all of the text in selected text box" - }, - "editMenuStartSpeaking": { - "message": "Start speaking", - "description": "Edit menu item under 'speech' to start dictation" - }, - "editMenuStopSpeaking": { - "message": "Stop speaking", - "description": "Edit menu item under 'speech' to stop dictation" - }, - "windowMenuClose": { - "message": "Close Window", - "description": "Window menu command to close the current window" - }, - "windowMenuMinimize": { - "message": "Minimize", - "description": "Window menu command to minimize the current window" - }, - "windowMenuZoom": { - "message": "Zoom", - "description": "Window menu command to make the current window the size of the whole screen" - }, - "windowMenuBringAllToFront": { - "message": "Bring All to Front", - "description": "Window menu command to bring all windows of current applicatinon to front" - }, - "viewMenuResetZoom": { - "message": "Actual Size", - "description": "View menu command to go back to the default zoom" - }, - "viewMenuZoomIn": { - "message": "Zoom In", - "description": "View menu command to make everything bigger" - }, - "viewMenuZoomOut": { - "message": "Zoom Out", - "description": "View menu command to make everything smaller" - }, - "viewMenuToggleFullScreen": { - "message": "Toggle Full Screen", - "description": "View menu command to enter or leave Full Screen mode" - }, - "viewMenuToggleDevTools": { - "message": "Toggle Developer Tools", - "description": "View menu command to show or hide the developer tools" - }, - "menuSetupWithImport": { - "message": "Set Up with Import", - "description": "When the application is not yet set up, menu option to start up the import sequence" - }, - "menuSetupAsNewDevice": { - "message": "Set Up as New Device", - "description": "When the application is not yet set up, menu option to start up the set up as fresh device" - }, - "menuSetupAsStandalone": { - "message": "Set Up as Standalone Device", - "description": "Only available on development modes, menu option to open up the standalone device setup sequence" - }, - "loading": { - "message": "Cargando...", - "description": "Message shown on the loading screen before we've loaded any messages" - }, - "optimizingApplication": { - "message": "Optimizing application...", - "description": "Message shown on the loading screen while we are doing application optimizations" - }, - "migratingToSQLCipher": { - "message": "Optimizing messages... $status$ complete.", - "description": "Message shown on the loading screen while we are doing application optimizations", - "placeholders": { - "status": { - "content": "$1", - "example": "45/200" - } - } - }, - "chooseDirectory": { - "message": "Selecciona un directorio", - "description": "Button to allow the user to find a folder on disk" - }, - "loadDataHeader": { - "message": "Load your data", - "description": "Header shown on the first screen in the data import process" - }, - "loadDataDescription": { - "message": "You've just gone through the export process, and your contacts and messages are waiting patiently on your computer. Select the folder that contains your saved Signal data.", - "description": "Introduction to the process of importing messages and contacts from disk" - }, - "importChooserTitle": { - "message": "Escoje el directorio con la información exportada", - "description": "Title of the popup window used to select data previously exported" - }, - "importErrorHeader": { - "message": "Something went wrong!", - "description": "Header of the error screen after a failed import" - }, - "importingHeader": { - "message": "Loading contacts and messages", - "description": "Header of screen shown as data is import" - }, - "importErrorFirst": { - "message": "Make sure you have chosen the correct directory that contains your saved Signal data. Its name should begin with 'Signal Export.' You can also save a new copy of your data from the Chrome App.", - "description": "Message shown if the import went wrong; first paragraph" - }, - "importErrorSecond": { - "message": "If these steps don't work for you, please submit a debug log (View -> Debug Log) so that we can help you get migrated!", - "description": "Message shown if the import went wrong; second paragraph" - }, - "importAgain": { - "message": "Choose folder and try again", - "description": "Button shown if the user runs into an error during import, allowing them to start over" - }, - "importCompleteHeader": { - "message": "Success!", - "description": "Header shown on the screen at the end of a successful import process" - }, - "importCompleteStartButton": { - "message": "Start using Signal Desktop", - "description": "Button shown at end of successful import process, nothing left but a restart" - }, - "importCompleteLinkButton": { - "message": "Link this device to your phone", - "description": "Button shown at end of successful 'light' import process, so the standard linking process still needs to happen" - }, - "selectedLocation": { - "message": "tu ubicación seleccionada", - "description": "Message shown as the export location if we didn't capture the target directory" - }, - "upgradingDatabase": { - "message": "Actualizando base de datos. Esto pudiera tardar un poco...", - "description": "Message shown on the loading screen when we're changing database structure on first run of a new version" - }, - "loadingMessages": { - "message": "Cargando mensajes. Hasta ahora $count$...", - "description": "Message shown on the loading screen when we're catching up on the backlog of messages", - "placeholders": { - "count": { - "content": "$1", - "example": "5" - } - } - }, - "me": { - "message": "Yo", - "description": "The label for yourself when shown in a group member list" - }, - "view": { - "message": "Ver", - "description": "Used as a label on a button allowing user to see more information" - }, - "youLeftTheGroup": { - "message": "Abandonaste el grupo", - "description": "Displayed when a user can't send a message because they have left the group" - }, - "scrollDown": { - "message": "Desplazar al final de la conversación", - "description": "Alt text for button to take user down to bottom of conversation, shown when user scrolls up" - }, - "messageBelow": { - "message": "Nuevo mensaje abajo", - "description": "Alt text for button to take user down to bottom of conversation with a new message out of screen" - }, - "messagesBelow": { - "message": "Nuevos mensajes abajo", - "description": "Alt text for button to take user down to bottom of conversation with more than one message out of screen" - }, - "unreadMessage": { - "message": "1 Mensaje sin Leer", - "description": "Text for unread message separator, just one message" - }, - "unreadMessages": { - "message": "$count$ Mensajes No Leídos", - "description": "Text for unread message separator, with count", - "placeholders": { - "count": { - "content": "$1", - "example": "5" - } - } - }, - "youMarkedAsVerified": { - "message": "You marked your Safety Number with $name$ as verified", - "description": "Shown in the conversation history when the user marks a contact as verified.", - "placeholders": { - "name": { - "content": "$1", - "example": "Bob" - } - } - }, - "youMarkedAsNotVerified": { - "message": "You marked your Safety Number with $name$ as not verified", - "description": "Shown in the conversation history when the user marks a contact as not verified, whether on the Safety Number screen or by dismissing a banner or dialog.", - "placeholders": { - "name": { - "content": "$1", - "example": "Bob" - } - } - }, - "youMarkedAsVerifiedOtherDevice": { - "message": "You marked your Safety Number with $name$ as verified from another device", - "description": "Shown in the conversation history when we discover that the user marked a contact as verified on another device.", - "placeholders": { - "name": { - "content": "$1", - "example": "Bob" - } - } - }, - "youMarkedAsNotVerifiedOtherDevice": { - "message": "You marked your Safety Number with $name$ as not verified from another device", - "description": "Shown in the conversation history when we discover that the user marked a contact as not verified on another device.", - "placeholders": { - "name": { - "content": "$1", - "example": "Bob" - } - } - }, - "membersNeedingVerification": { - "message": "Tus números de seguridad con estos miembros del grupo han cambiado desde la última vez que verificaste. Presiona en un miembro de grupo para ver tu número de seguridad con él.", - "description": "When there are multiple previously-verified group members with safety number changes, a banner will be shown. The list of contacts with safety number changes is shown, and this text introduces that list." - }, - "changedSinceVerifiedMultiple": { - "message": "Tus números de seguridad con múltiples miembros del grupo han cambiado desde que verificaste por última vez. Esto podría significar que alguien esta tratando de interceptar tus comunicaciones o simplemente que ellos han reinstalado Signal.", - "description": "Shown on confirmation dialog when user attempts to send a message" - }, - "changedSinceVerified": { - "message": "Tu número de seguridad con $name$ ha cambiado desde la última vez que verificaste. Esto puede deberse a que alguien está intentando interceptar tu comunicación o simplemente a que $name$ a reinstalado Signal.", - "description": "Shown on confirmation dialog when user attempts to send a message", - "placeholders": { - "name": { - "content": "$1", - "example": "Bob" - } - } - }, - "changedRightAfterVerify": { - "message": "El número de seguridad que estas tratando de verificar ha cambiado. Por favor revisa tu nuevo número de seguridad con $name$. Recuerda, este cambio pudiera significar que alguien esta intentando interceptar tu comunicación o simplemente que $name$ ha reinstalado Signal.", - "description": "Shown on the safety number screen when the user has selected to verify/unverify a contact's safety number, and we immediately discover a safety number change", - "placeholders": { - "name": { - "content": "$1", - "example": "Bob" - } - } - }, - "changedRecentlyMultiple": { - "message": "Tus números de seguridad con multiples miembros de este grupo han cambiando recientemente. Esto pudiera significar que alguien esta intentando interceptar tu comunicación o simplemente que ellos han reinstalado Signal.", - "description": "Shown on confirmation dialog when user attempts to send a message" - }, - "changedRecently": { - "message": "Tu número de seguridad con $name$ ha cambiado recientemente. Esto puede deberse a que alguien está intentando interceptar tu comunicación o simplemente a que $name$ ha reinstalado Signal.", - "description": "Shown on confirmation dialog when user attempts to send a message", - "placeholders": { - "name": { - "content": "$1", - "example": "Bob" - } - } - }, - "identityKeyErrorOnSend": { - "message": "Tu número de seguridad con $name$ ha cambiado. Esto podría deberse a una de dos razones, alguien está intentando interceptar tu comunicación o simplemente que $name$ ha reinstalado Signal. Quizá desees verificar tu número de seguridad con este contacto.", - "description": "Shown when user clicks on a failed recipient in the message detail view after an identity key change", - "placeholders": { - "name": { - "content": "$1", - "example": "Bob" - } - } - }, - "sendAnyway": { - "message": "Enviar de Cualquier Forma", - "description": "Used on a warning dialog to make it clear that it might be risky to send the message." - }, - "noLongerVerified": { - "message": "Tu número de seguridad con $name$ ha cambiado y ya no está verificado. Presiona para mostrar.", - "description": "Shown in converation banner when user's safety number has changed, but they were previously verified.", - "placeholders": { - "name": { - "content": "$1", - "example": "Bob" - } - } - }, - "multipleNoLongerVerified": { - "message": "Tus números de seguridad con múltiples miembros de este grupo han cambiado y ya no están verificados. Presiona para mostrar.", - "description": "Shown in conversation banner when more than one group member's safety number has changed, but they were previously verified." - }, - "debugLogExplanation": { - "message": "Esta bitácora será publicada en línea para ser observada por los contribuyentes. Debes examinarla y editarla antes de enviarla.", - "description": "" - }, - "debugLogError": { - "message": "Something went wrong with the upload! Please consider manually adding your log to the bug you file.", - "description": "" - }, - "reportIssue": { - "message": "Reportar un problema", - "description": "Link to open the issue tracker" - }, - "gotIt": { - "message": "¡Entendido!", - "description": "Label for a button that dismisses a dialog. The user clicks it to confirm that they understand the message in the dialog." - }, - "submit": { - "message": "Enviar", - "description": "" - }, - "acceptNewKey": { - "message": "Aceptar", - "description": "Label for a button to accept a new safety number" - }, - "verify": { - "message": "Marcar como verificado", - "description": "" - }, - "unverify": { - "message": "Marcar como no verificado", - "description": "" - }, - "isVerified": { - "message": "Has verificado tu número de seguridad con $name$.", - "description": "Summary state shown at top of the safety number screen if user has verified contact.", - "placeholders": { - "name": { - "content": "$1", - "example": "Bob" - } - } - }, - "isNotVerified": { - "message": "No has verificado tu número de seguridad con $name$.", - "description": "Summary state shown at top of the safety number screen if user has not verified contact.", - "placeholders": { - "name": { - "content": "$1", - "example": "Bob" - } - } - }, - "verified": { - "message": "Verificado", - "description": "" - }, - "newIdentity": { - "message": "Nuevo número de seguridad", - "description": "Header for a key change dialog" - }, - "identityChanged": { - "message": "Tu número de seguridad con este contacto cambió. Esto puede significar que alguien está tratando de interceptar tus mensajes y/o llamadas o tu contacto simplemente reinstaló Signal. Deberías verificar tu número de seguridad a continuación.", - "description": "" - }, - "incomingError": { - "message": "Error handling incoming message", - "description": "" - }, - "media": { - "message": "Media", - "description": "Header of the default pane in the media gallery, showing images and videos" - }, - "mediaEmptyState": { - "message": "You don’t have any media in this conversation", - "description": "Message shown to user in the media gallery when there are no messages with media attachments (images or video)" - }, - "documents": { - "message": "Documents", - "description": "Header of the secondary pane in the media gallery, showing every non-media attachment" - }, - "documentsEmptyState": { - "message": "You don’t have any documents in this conversation", - "description": "Message shown to user in the media gallery when there are no messages with document attachments (anything other than images or video)" - }, - "today": { - "message": "Today", - "description": "Section header in the media gallery" - }, - "yesterday": { - "message": "Yesterday", - "description": "Section header in the media gallery" - }, - "thisWeek": { - "message": "This Week", - "description": "Section header in the media gallery" - }, - "thisMonth": { - "message": "This Month", - "description": "Section header in the media gallery" - }, - "unsupportedAttachment": { - "message": "Tipo de archivo adjunto no soportado. Haz click para guardar.", - "description": "Displayed for incoming unsupported attachment" - }, - "clickToSave": { - "message": "Presiona para guardar", - "description": "Hover text for attachment filenames" - }, - "unnamedFile": { - "message": "Archivo Sin Nombre", - "description": "Hover text for attachment filenames" - }, - "voiceMessage": { - "message": "Mensaje de Voz", - "description": "Name for a voice message attachment" - }, - "unsupportedFileType": { - "message": "Tipo de archivo no soportado", - "description": "Displayed for outgoing unsupported attachment" - }, - "fileSizeWarning": { - "message": "Disculpe, el archivo seleccionado excede las restricciones de tamaño del mensaje.", - "description": "" - }, - "disconnected": { - "message": "Desconectado", - "description": "Displayed when the desktop client cannot connect to the server." - }, - "connecting": { - "message": "Conectando", - "description": "Displayed when the desktop client is currently connecting to the server." - }, - "offline": { - "message": "Desconectado", - "description": "Displayed when the desktop client has no network connection." - }, - "checkNetworkConnection": { - "message": "Revisa tu conexión de red", - "description": "Obvious instructions for when a user's computer loses its network connection" - }, - "attemptingReconnection": { - "message": "Intentando reconectar en $reconnect_duration_in_seconds$ segundos", - "description": "", - "placeholders": { - "reconnect_duration_in_seconds": { - "content": "$1", - "example": "10" - } - } - }, - "submitDebugLog": { - "message": "Debug log", - "description": "Menu item and header text for debug log modal (sentence case)" - }, - "debugLog": { - "message": "Registro de depuración", - "description": "View menu item to open the debug log (title case)" - }, - "goToReleaseNotes": { - "message": "Go to Release Notes", - "description": "" - }, - "goToForums": { - "message": "Go to Forums", - "description": "Item under the Help menu, takes you to the forums" - }, - "goToSupportPage": { - "message": "Go to Support Page", - "description": "Item under the Help menu, takes you to the support page" - }, - "menuReportIssue": { - "message": "Report an Issue", - "description": "Item under the Help menu, takes you to GitHub new issue form (title case)" - }, - "signalDesktopPreferences": { - "message": "Signal Desktop Preferences", - "description": "Title of the window that pops up with Signal Desktop preferences in it" - }, - "aboutSignalDesktop": { - "message": "Acerca de Signal Desktop", - "description": "Item under the Help menu, which opens a small about window" - }, - "speech": { - "message": "Voz", - "description": "Item under the Edit menu, with 'start/stop speaking' items below it" - }, - "show": { - "message": "Mostrar", - "description": "Command under Window menu, to show the window" - }, - "hide": { - "message": "Hide", - "description": "Command in the tray icon menu, to hide the window" - }, - "quit": { - "message": "Quit", - "description": "Command in the tray icon menu, to quit the application" - }, - "trayTooltip": { - "message": "Signal Desktop", - "description": "Tooltip for the tray icon" - }, - "searchForPeopleOrGroups": { - "message": "Enter name or number", - "description": "Placeholder text in the search input" - }, - "welcomeToSignal": { - "message": "Bienvenido a Signal", - "description": "" - }, - "selectAContact": { - "message": "Escoge un contacto o grupo para comenzar una conversación", - "description": "" - }, - "contactAvatarAlt": { - "message": "Avatar for contact $name$", - "description": "Used in the alt tag for the image avatar of a contact", - "placeholders": { - "name": { - "content": "$1", - "example": "John" - } - } - }, - "sendMessageToContact": { - "message": "Send Message", - "description": "Shown when you are sent a contact and that contact has a signal account" - }, - "home": { - "message": "home", - "description": "Shown on contact detail screen as a label for an address/phone/email" - }, - "work": { - "message": "work", - "description": "Shown on contact detail screen as a label for an address/phone/email" - }, - "mobile": { - "message": "mobile", - "description": "Shown on contact detail screen as a label for aa phone or email" - }, - "email": { - "message": "email", - "description": "Generic label shown if contact email has custom type but no label" - }, - "phone": { - "message": "phone", - "description": "Generic label shown if contact phone has custom type but no label" - }, - "address": { - "message": "address", - "description": "Generic label shown if contact address has custom type but no label" - }, - "poBox": { - "message": "PO Box", - "description": "When rendering an address, used to provide context to a post office box" - }, - "downloadAttachment": { - "message": "Download Attachment", - "description": "Shown in a message's triple-dot menu if there isn't room for a dedicated download button" - }, - "replyToMessage": { - "message": "Reply to Message", - "description": "Shown in triple-dot menu next to message to allow user to start crafting a message with a quotation" - }, - "originalMessageNotFound": { - "message": "Original message not found", - "description": "Shown in quote if reference message was not found as message was initially downloaded and processed" - }, - "originalMessageNotAvailable": { - "message": "Original message no longer available", - "description": "Shown in toast if user clicks on quote that references message no longer in database" - }, - "messageFoundButNotLoaded": { - "message": "Original message found, but not loaded. Scroll up to load it.", - "description": "Shown in toast if user clicks on quote references messages not loaded in view, but in database" - }, - "you": { - "message": "You", - "description": "In Android theme, shown in quote if you or someone else replies to you" - }, - "replyingTo": { - "message": "Replying to $name$", - "description": "Shown in iOS theme when you or someone quotes to a message which is not from you", - "placeholders": { - "name": { - "content": "$1", - "example": "John" - } - } - }, - "audioPermissionNeeded": { - "message": "To send audio messages, allow Signal Desktop to access your microphone.", - "description": "Shown if the user attempts to send an audio message without audio permssions turned on" - }, - "allowAccess": { - "message": "Allow Access", - "description": "Button shown in popup asking to enable microphon/video permissions to send audio messages" - }, - "showSettings": { - "message": "Show Settings", - "description": "A button shown in dialog requesting the user to turn on audio permissions" - }, - "audio": { - "message": "Audio", - "description": "Shown in a quotation of a message containing an audio attachment if no text was originally provided with that attachment" - }, - "video": { - "message": "Video", - "description": "Shown in a quotation of a message containing a video if no text was originally provided with that video" - }, - "photo": { - "message": "Photo", - "description": "Shown in a quotation of a message containing a photo if no text was originally provided with that image" - }, - "ok": { - "message": "OK", - "description": "" - }, - "cancel": { - "message": "Cancelar", - "description": "" - }, - "failedToSend": { - "message": "Fallo en el envío a algunos destinatarios. Revisa tu conexión a la red.", - "description": "" - }, - "error": { - "message": "Error", - "description": "" - }, - "messageDetail": { - "message": "Detalles del mensaje", - "description": "" - }, - "delete": { - "message": "Borrar", - "description": "" - }, - "deleteWarning": { - "message": "Are you sure? Clicking 'delete' will permanently remove this message from this device only.", - "description": "" - }, - "deleteThisMessage": { - "message": "Borrar este mensaje", - "description": "" - }, - "from": { - "message": "De", - "description": "Label for the sender of a message" - }, - "to": { - "message": "Para", - "description": "Label for the receiver of a message" - }, - "sent": { - "message": "Enviados", - "description": "Label for the time a message was sent" - }, - "received": { - "message": "Recibidos", - "description": "Label for the time a message was received" - }, - "sendMessage": { - "message": "Enviar un mensaje", - "description": "Placeholder text in the message entry field" - }, - "groupMembers": { - "message": "Miembros del grupo", - "description": "" - }, - "showMembers": { - "message": "Mostrar miembros", - "description": "" - }, - "resetSession": { - "message": "Reiniciar sesión", - "description": "This is a menu item for resetting the session, using the imperative case, as in a command." - }, - "showSafetyNumber": { - "message": "View safety number", - "description": "" - }, - "viewAllMedia": { - "message": "View all media", - "description": "This is a menu item for viewing all media (images + video) in a conversation, using the imperative case, as in a command." - }, - "verifyHelp": { - "message": "Si deseas verificar la seguridad de la criptografía de extremo-a-extremo con $name$, compara los números de arriba con los números en su dispositivo.", - "description": "", - "placeholders": { - "name": { - "content": "$1", - "example": "John" - } - } - }, - "theirIdentityUnknown": { - "message": "Tú no has intercambiado ningún mensaje con este contacto todavía. Tu número de seguridad estará disponible después del primer mensaje.", - "description": "" - }, - "moreInfo": { - "message": "More Info...", - "description": "Shown on the drop-down menu for an individual message, takes you to message detail screen" - }, - "retrySend": { - "message": "Retry Send", - "description": "Shown on the drop-down menu for an indinvidaul message, but only if it is an outgoing message that failed to send" - }, - "deleteMessage": { - "message": "Delete Message", - "description": "Shown on the drop-down menu for an individual message, deletes single message" - }, - "deleteMessages": { - "message": "Borrar mensajes", - "description": "Menu item for deleting messages, title case." - }, - "deleteConversationConfirmation": { - "message": "Borrar esta conversación permanentemente?", - "description": "Confirmation dialog text that asks the user if they really wish to delete the conversation. Answer buttons use the strings 'ok' and 'cancel'. The deletion is permanent, i.e. it cannot be undone." - }, - "sessionEnded": { - "message": "Reiniciar sesión segura", - "description": "This is a past tense, informational message. In other words, your secure session has been reset." - }, - "quoteThumbnailAlt": { - "message": "Thumbnail of image from quoted message", - "description": "Used in alt tag of thumbnail images inside of an embedded message quote" - }, - "imageFailedToLoad": { - "message": "Image failed to load", - "description": "When an image attachment is missing, this message is shown" - }, - "videoScreenshotFailedToLoad": { - "message": "Video screenshot failed to load", - "description": "When a attachment video screenshot is missing, this message is shown" - }, - "imageAttachmentAlt": { - "message": "Image attached to message", - "description": "Used in alt tag of image attachment" - }, - "videoAttachmentAlt": { - "message": "Screenshot of video attached to message", - "description": "Used in alt tag of video attachment preview" - }, - "lightboxImageAlt": { - "message": "Image sent in conversation", - "description": "Used in the alt tag for the image shown in a full-screen lightbox view" - }, - "fileIconAlt": { - "message": "File icon", - "description": "Used in the media gallery documents tab to visually represent a file" - }, - "installWelcome": { - "message": "Bienvenido a Signal Desktop", - "description": "Welcome title on the install page" - }, - "installTagline": { - "message": "La privacidad es posible. Signal lo hace fácil.", - "description": "Tagline displayed under 'installWelcome' string on the install page" - }, - "linkYourPhone": { - "message": "Link your phone to Signal Desktop", - "description": "Shown on the front page when the application first starst, above the QR code" - }, - "signalSettings": { - "message": "Signal Settings", - "description": "Used in the guidance to help people find the 'link new device' area of their Signal mobile app" - }, - "linkedDevices": { - "message": "Linked Devices", - "description": "Used in the guidance to help people find the 'link new device' area of their Signal mobile app" - }, - "plusButton": { - "message": "'+' Button", - "description": "The button used in Signal Android to add a new linked device" - }, - "linkNewDevice": { - "message": "Link New Device", - "description": "The menu option shown in Signal iOS to add a new linked device" - }, - "deviceName": { - "message": "Device name", - "description": "The label in settings panel shown for the user-provided name for this desktop instance" - }, - "chooseDeviceName": { - "message": "Choose this device's name", - "description": "The header shown on the 'choose device name' screen in the device linking process" - }, - "finishLinkingPhone": { - "message": "Finish linking phone", - "description": "The text on the button to finish the linking process, after choosing the device name" - }, - "initialSync": { - "message": "Syncing contacts and groups", - "description": "Shown during initial link while contacts and groups are being pulled from mobile device" - }, - "installConnectionFailed": { - "message": "Fallo al conectarse al servidor", - "description": "Displayed when we can't connect to the server." - }, - "installTooManyDevices": { - "message": "Disculpe, tienes muchos dispositivos conectados. Intenta remover algunos.", - "description": "" - }, - "settings": { - "message": "Configuraciones", - "description": "Menu item and header for global settings" - }, - "theme": { - "message": "Estilo", - "description": "Header for theme settings" - }, - "permissions": { - "message": "Permissions", - "description": "Header for permissions section of settings" - }, - "mediaPermissionsDescription": { - "message": "Allow access to camera and microphone", - "description": "Description of the media permission description" - }, - "spellCheck": { - "message": "Spell Check", - "description": "Description of the media permission description" - }, - "spellCheckDescription": { - "message": "Enable spell check of text entered in message composition box", - "description": "Description of the media permission description" - }, - "clearDataHeader": { - "message": "Clear Data", - "description": "Header in the settings dialog for the section dealing with data deletion" - }, - "clearDataExplanation": { - "message": "This will clear all data in the application, removing all messages and saved account information.", - "description": "Text describing what the clear data button will do." - }, - "clearDataButton": { - "message": "Clear data", - "description": "Button in the settings dialog starting process to delete all data" - }, - "deleteAllDataHeader": { - "message": "Delete all data?", - "description": "Header of the full-screen delete data confirmation screen" - }, - "deleteAllDataBody": { - "message": "You are about to delete all of this application's saved account information, including all contacts and all messages. You can always link with your mobile device again, but that will not restore deleted messages.", - "description": "Text describing what exactly will happen if the user clicks the button to delete all data" - }, - "deleteAllDataButton": { - "message": "Delete all data", - "description": "Text of the button that deletes all data" - }, - "deleteAllDataProgress": { - "message": "Disconnecting and deleting all data", - "description": "Message shown to user when app is disconnected and data deleted" - }, - "notifications": { - "message": "Notificaciones", - "description": "Header for notification settings" - }, - "notificationSettingsDialog": { - "message": "Cuando los mensajes llegan, muestran notificaciones que revelan:", - "description": "Explain the purpose of the notification settings" - }, - "disableNotifications": { - "message": "Deshabilitar notificaciones", - "description": "Label for disabling notifications" - }, - "nameAndMessage": { - "message": "Nombre y mensaje", - "description": "Label for setting notifications to display name and message text" - }, - "noNameOrMessage": { - "message": "Sin nombre ni mensaje", - "description": "Label for setting notifications to display no name and no message text" - }, - "nameOnly": { - "message": "Solo el nombre", - "description": "Label for setting notifications to display sender name only" - }, - "newMessage": { - "message": "Nuevo mensaje", - "description": "Displayed in notifications for only 1 message" - }, - "newMessages": { - "message": "Nuevos mensajes", - "description": "Displayed in notifications for multiple messages" - }, - "notificationMostRecentFrom": { - "message": "Most recent from:", - "description": "Displayed in notifications when setting is 'name only' and more than one message is waiting" - }, - "notificationFrom": { - "message": "From:", - "description": "Displayed in notifications when setting is 'name only' and one message is waiting" - }, - "notificationMostRecent": { - "message": "Most recent:", - "description": "Displayed in notifications when setting is 'name and message' and more than one message is waiting" - }, - "sendFailed": { - "message": "Send failed", - "description": "Shown on outgoing message if it fails to send" - }, - "showMore": { - "message": "Detalles", - "description": "Displays the details of a key change" - }, - "showLess": { - "message": "Esconder detalles", - "description": "Hides the details of a key change" - }, - "learnMore": { - "message": "Aprende más acerca de la verificación de números de seguridad", - "description": "Text that links to a support article on verifying safety numbers" - }, - "expiredWarning": { - "message": "Esta versión de Signal Desktop está obsoleta. Por favor, actualiza a la versión más reciente para continuar mensajeando.", - "description": "Warning notification that this version of the app has expired" - }, - "androidMessageLengthWarning": { - "message": "Clientes de Android solo recibirán los primeros 2000 caracteres de este mensaje", - "description": "Warning that long messages could not get received completely by Android clients." - }, - "upgrade": { - "message": "Actualizar", - "description": "Label text for button to upgrade the app to the latest version" - }, - "mediaMessage": { - "message": "Mensaje multimedia", - "description": "Description of a message that has an attachment and no text, displayed in the conversation list as a preview." - }, - "unregisteredUser": { - "message": "Número no está registrado", - "description": "Error message displayed when sending to an unregistered user." - }, - "sync": { - "message": "Contactos", - "description": "Label for contact and group sync settings" - }, - "syncExplanation": { - "message": "Importar todos los contactos y grupos de Signal desde tu dispositivo móvil", - "description": "Explanatory text for sync settings" - }, - "lastSynced": { - "message": "Última importación a las", - "description": "Label for date and time of last sync operation" - }, - "syncNow": { - "message": "Importar ya", - "description": "Label for a button that syncs contacts and groups from your phone" - }, - "syncing": { - "message": "Importando...", - "description": "Label for a disabled sync button while sync is in progress." - }, - "syncFailed": { - "message": "La importación ha fallado. Asegúrate de que tu computadora y tu dispositivo móvil están conectados al internet.", - "description": "Informational text displayed if a sync operation times out." - }, - "timestamp_s": { - "message": "ahora", - "description": "Brief timestamp for messages sent less than a minute ago. Displayed in the conversation list and message bubble." - }, - "timestamp_m": { - "message": "1 minuto", - "description": "Brief timestamp for messages sent about one minute ago. Displayed in the conversation list and message bubble." - }, - "timestamp_h": { - "message": "1 hora", - "description": "Brief timestamp for messages sent about one hour ago. Displayed in the conversation list and message bubble." - }, - "hoursAgoShort": { - "message": "$hours$ hr", - "description": "Even further contracted form of 'X hours ago' which works both for singular and plural, used in the left pane", - "placeholders": { - "hours": { - "content": "$1", - "example": "2" - } - } - }, - "hoursAgo": { - "message": "$hours$ hr ago", - "description": "Contracted form of 'X hours ago' which works both for singular and plural", - "placeholders": { - "hours": { - "content": "$1", - "example": "2" - } - } - }, - "minutesAgoShort": { - "message": "$minutes$ min", - "description": "Even further contracted form of 'X minutes ago' which works both for singular and plural, used in the left pane", - "placeholders": { - "minutes": { - "content": "$1", - "example": "10" - } - } - }, - "minutesAgo": { - "message": "$minutes$ min ago", - "description": "Contracted form of 'X minutes ago' which works both for singular and plural", - "placeholders": { - "minutes": { - "content": "$1", - "example": "10" - } - } - }, - "justNow": { - "message": "ahora", - "description": "Shown if a message is very recent, less than 60 seconds old" - }, - "timestampFormat_M": { - "message": "D MMM", - "description": "Timestamp format string for displaying month and day (but not the year) of a date within the current year, ex: use 'MMM D' for 'Aug 8', or 'D MMM' for '8 Aug'." - }, - "unblockToSend": { - "message": "Desbloquea este contacto para enviarle un mensaje.", - "description": "Brief message shown when trying to message a blocked number" - }, - "youChangedTheTimer": { - "message": "You set the disappearing message timer to $time$", - "description": "Message displayed when you change the message expiration timer in a conversation.", - "placeholders": { - "time": { - "content": "$1", - "example": "10m" - } - } - }, - "timerSetOnSync": { - "message": "Updated disappearing message timer to $time$", - "description": "Message displayed when timer is set on initial link of desktop device.", - "placeholders": { - "time": { - "content": "$1", - "example": "10m" - } - } - }, - "theyChangedTheTimer": { - "message": "$name$ set the disappearing message timer to $time$", - "description": "Message displayed when someone else changes the message expiration timer in a conversation.", - "placeholders": { - "name": { - "content": "$1", - "example": "Bob" - }, - "time": { - "content": "$2", - "example": "10m" - } - } - }, - "timerOption_0_seconds": { - "message": "apagado", - "description": "Label for option to turn off message expiration in the timer menu" - }, - "timerOption_5_seconds": { - "message": "5 segundos", - "description": "Label for a selectable option in the message expiration timer menu" - }, - "timerOption_10_seconds": { - "message": "10 segundos", - "description": "Label for a selectable option in the message expiration timer menu" - }, - "timerOption_30_seconds": { - "message": "30 segundos", - "description": "Label for a selectable option in the message expiration timer menu" - }, - "timerOption_1_minute": { - "message": "1 minuto", - "description": "Label for a selectable option in the message expiration timer menu" - }, - "timerOption_5_minutes": { - "message": "5 minutes", - "description": "Label for a selectable option in the message expiration timer menu" - }, - "timerOption_30_minutes": { - "message": "30 minutes", - "description": "Label for a selectable option in the message expiration timer menu" - }, - "timerOption_1_hour": { - "message": "1 hora", - "description": "Label for a selectable option in the message expiration timer menu" - }, - "timerOption_6_hours": { - "message": "6 hours", - "description": "Label for a selectable option in the message expiration timer menu" - }, - "timerOption_12_hours": { - "message": "12 hours", - "description": "Label for a selectable option in the message expiration timer menu" - }, - "timerOption_1_day": { - "message": "1 día", - "description": "Label for a selectable option in the message expiration timer menu" - }, - "timerOption_1_week": { - "message": "1 semana", - "description": "Label for a selectable option in the message expiration timer menu" - }, - "disappearingMessages": { - "message": "Mensajes auto destructivos", - "description": "Conversation menu option to enable disappearing messages" - }, - "timerOption_0_seconds_abbreviated": { - "message": "apagado", - "description": "Short format indicating current timer setting in the conversation list snippet" - }, - "timerOption_5_seconds_abbreviated": { - "message": "5s", - "description": "Very short format indicating current timer setting in the conversation header" - }, - "timerOption_10_seconds_abbreviated": { - "message": "10s", - "description": "Very short format indicating current timer setting in the conversation header" - }, - "timerOption_30_seconds_abbreviated": { - "message": "30s", - "description": "Very short format indicating current timer setting in the conversation header" - }, - "timerOption_1_minute_abbreviated": { - "message": "1m", - "description": "Very short format indicating current timer setting in the conversation header" - }, - "timerOption_5_minutes_abbreviated": { - "message": "5m", - "description": "Very short format indicating current timer setting in the conversation header" - }, - "timerOption_30_minutes_abbreviated": { - "message": "30m", - "description": "Very short format indicating current timer setting in the conversation header" - }, - "timerOption_1_hour_abbreviated": { - "message": "1h", - "description": "Very short format indicating current timer setting in the conversation header" - }, - "timerOption_6_hours_abbreviated": { - "message": "6h", - "description": "Very short format indicating current timer setting in the conversation header" - }, - "timerOption_12_hours_abbreviated": { - "message": "12h", - "description": "Very short format indicating current timer setting in the conversation header" - }, - "timerOption_1_day_abbreviated": { - "message": "1d", - "description": "Very short format indicating current timer setting in the conversation header" - }, - "timerOption_1_week_abbreviated": { - "message": "1 sem", - "description": "Very short format indicating current timer setting in the conversation header" - }, - "disappearingMessagesDisabled": { - "message": "Disappearing messages disabled", - "description": "Displayed in the left pane when the timer is turned off" - }, - "disabledDisappearingMessages": { - "message": "$name$ disabled disappearing messages", - "description": "Displayed in the conversation list when the timer is turned off", - "placeholders": { - "name": { - "content": "$1", - "example": "John" - } - } - }, - "youDisabledDisappearingMessages": { - "message": "You disabled disappearing messages", - "description": "Displayed in the conversation list when the timer is turned off" - }, - "timerSetTo": { - "message": "Contador puesto en $time$", - "description": "Displayed in the conversation list when the timer is updated by some automatic action, or in the left pane", - "placeholders": { - "time": { - "content": "$1", - "example": "1w" - } - } - }, - "audioNotificationDescription": { - "message": "Reproducir notificación de audio", - "description": "Description for audio notification setting" - }, - "safetyNumberChanged": { - "message": "Safety Number has changed", - "description": "A notification shown in the conversation when a contact reinstalls" - }, - "safetyNumberChangedGroup": { - "message": "Safety Number with $name$ has changed", - "description": "A notification shown in a group conversation when a contact reinstalls, showing the contact name", - "placeholders": { - "name": { - "content": "$1", - "example": "John" - } - } - }, - "verifyNewNumber": { - "message": "Verify Safety Number", - "description": "Label on button included with safety number change notification in the conversation" - }, - "yourSafetyNumberWith": { - "message": "Tu número de seguridad con $name$:", - "description": "Heading for safety number view", - "placeholders": { - "name": { - "content": "$1", - "example": "John" - } - } - }, - "themeLight": { - "message": "Light", - "description": "Label text for light theme (normal)" - }, - "themeDark": { - "message": "Dark", - "description": "Label text for dark theme" - }, - "hideMenuBar": { - "message": "Esconder barra de menú", - "description": "Label text for menu bar visibility setting" - }, - "startConversation": { - "message": "Start conversation…", - "description": "Label underneath number a user enters that is not an existing contact" - }, - "newPhoneNumber": { - "message": "Introduce un número telefónico para agregar contacto", - "description": "Placeholder for adding a new number to a contact" - }, - "invalidNumberError": { - "message": "Número inválido", - "description": "When a person inputs a number that is invalid" - }, - "unlinkedWarning": { - "message": "Re-enlaza Signal Desktop a tu dispositivo móvil para continuar mensajeando.", - "description": "" - }, - "unlinked": { - "message": "No enlazado", - "description": "" - }, - "relink": { - "message": "Re-enlazar", - "description": "" - }, - "autoUpdateNewVersionTitle": { - "message": "Actualización de Signal disponible", - "description": "" - }, - "autoUpdateNewVersionMessage": { - "message": "Hay una nueva versión de Signal disponible.", - "description": "" - }, - "autoUpdateNewVersionInstructions": { - "message": "Press Restart Signal to apply the updates.", - "description": "" - }, - "autoUpdateRestartButtonLabel": { - "message": "Restart Signal", - "description": "" - }, - "autoUpdateLaterButtonLabel": { - "message": "Más tarde", - "description": "" - }, - "leftTheGroup": { - "message": "$name$ left the group", - "description": "Shown in the conversation history when a single person leaves the group", - "placeholders": { - "name": { - "content": "$1", - "example": "Bob" - } - } - }, - "multipleLeftTheGroup": { - "message": "$name$ left the group", - "description": "Shown in the conversation history when multiple people leave the group", - "placeholders": { - "name": { - "content": "$1", - "example": "Alice, Bob" - } - } - }, - "updatedTheGroup": { - "message": "Group updated", - "description": "Shown in the conversation history when someone updates the group", - "placeholders": { - "name": { - "content": "$1", - "example": "Alice" - } - } - }, - "titleIsNow": { - "message": "Title is now '$name$'", - "description": "Shown in the conversation history when someone changes the title of the group", - "placeholders": { - "name": { - "content": "$1", - "example": "Book Club" - } - } - }, - "joinedTheGroup": { - "message": "$name$ joined the group", - "description": "Shown in the conversation history when a single person joins the group", - "placeholders": { - "name": { - "content": "$1", - "example": "Alice" - } - } - }, - "multipleJoinedTheGroup": { - "message": "$names$ joined the group", - "description": "Shown in the conversation history when more than one person joins the group", - "placeholders": { - "names": { - "content": "$1", - "example": "Alice, Bob" - } - } - }, - "privacyPolicy": { - "message": "Terms & Privacy Policy", - "description": "Shown in the about box for the link to https://signal.org/legal" - } -} diff --git a/about_preload.js b/about_preload.js index 1d7d969b3a..2f9fa80f7d 100644 --- a/about_preload.js +++ b/about_preload.js @@ -25,4 +25,4 @@ window.closeAbout = () => ipcRenderer.send('close-about'); window.i18n = i18n.setup(locale, localeMessages); -require('./ts/logging/set_up_renderer_logging'); +require('./ts/logging/set_up_renderer_logging').initialize(); diff --git a/app/menu.js b/app/menu.js index 23979d5e39..10e2a71372 100644 --- a/app/menu.js +++ b/app/menu.js @@ -10,6 +10,7 @@ exports.createTemplate = (options, messages) => { const { isBeta, + devTools, includeSetup, openContactUs, openForums, @@ -118,13 +119,17 @@ exports.createTemplate = (options, messages) => { label: messages.debugLog.message, click: showDebugLog, }, - { - type: 'separator', - }, - { - role: 'toggledevtools', - label: messages.viewMenuToggleDevTools.message, - }, + ...(devTools + ? [ + { + type: 'separator', + }, + { + role: 'toggledevtools', + label: messages.viewMenuToggleDevTools.message, + }, + ] + : []), ], }, { diff --git a/app/tray_icon.js b/app/tray_icon.js index 8a88b4319b..fde1eab4c3 100644 --- a/app/tray_icon.js +++ b/app/tray_icon.js @@ -11,8 +11,19 @@ let trayContextMenu = null; let tray = null; function createTrayIcon(getMainWindow, messages) { - // A smaller icon is needed on macOS - const iconSize = process.platform === 'darwin' ? '16' : '256'; + let iconSize; + switch (process.platform) { + case 'darwin': + iconSize = '16'; + break; + case 'win32': + iconSize = '32'; + break; + default: + iconSize = '256'; + break; + } + const iconNoNewMessages = path.join( __dirname, '..', diff --git a/background.html b/background.html index f29d0177e1..8e602dee0a 100644 --- a/background.html +++ b/background.html @@ -19,7 +19,7 @@ img-src 'self' blob: data:; media-src 'self' blob:; object-src 'none'; - script-src 'self'; + script-src 'self' 'sha256-5J9nLKMi84ERvoy7r/3XVwiW1iZ5YaPic9BNaF/0rtI='; style-src 'self' 'unsafe-inline';" > Signal @@ -329,7 +329,6 @@ - @@ -348,14 +347,11 @@ - - - @@ -386,6 +382,12 @@ - + + diff --git a/components/GroupTitleInput.scss b/components/GroupTitleInput.scss new file mode 100644 index 0000000000..8b30d0e4cd --- /dev/null +++ b/components/GroupTitleInput.scss @@ -0,0 +1,46 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-GroupTitleInput { + margin: 16px; + @include font-body-1; + padding: 8px 12px; + border-radius: 6px; + border-width: 2px; + border-style: solid; + + @include light-theme { + background: $color-white; + color: $color-black; + border-color: $color-gray-15; + + &:disabled { + background: $color-gray-02; + border-color: $color-gray-05; + color: $color-gray-90; + } + } + + @include dark-theme { + background: $color-gray-80; + color: $color-gray-05; + border-color: $color-gray-45; + + &:disabled { + background: $color-gray-95; + border-color: $color-gray-60; + color: $color-gray-20; + } + } + + &:focus { + outline: none; + + @include light-theme { + border-color: $ultramarine-ui-light; + } + @include dark-theme { + border-color: $ultramarine-ui-dark; + } + } +} diff --git a/config/default.json b/config/default.json index 8a8f67d7f1..d87e2e7bab 100644 --- a/config/default.json +++ b/config/default.json @@ -13,6 +13,7 @@ "updatesPublicKey": "05fd7dd3de7149dc0a127909fee7de0f7620ddd0de061b37a2c303e37de802a401", "sfuUrl": "https://sfu.voip.signal.org/", "updatesEnabled": false, + "enableCI": false, "openDevTools": false, "buildExpiration": 0, "certificateAuthority": "-----BEGIN CERTIFICATE-----\nMIID7zCCAtegAwIBAgIJAIm6LatK5PNiMA0GCSqGSIb3DQEBBQUAMIGNMQswCQYD\nVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5j\naXNjbzEdMBsGA1UECgwUT3BlbiBXaGlzcGVyIFN5c3RlbXMxHTAbBgNVBAsMFE9w\nZW4gV2hpc3BlciBTeXN0ZW1zMRMwEQYDVQQDDApUZXh0U2VjdXJlMB4XDTEzMDMy\nNTIyMTgzNVoXDTIzMDMyMzIyMTgzNVowgY0xCzAJBgNVBAYTAlVTMRMwEQYDVQQI\nDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMR0wGwYDVQQKDBRP\ncGVuIFdoaXNwZXIgU3lzdGVtczEdMBsGA1UECwwUT3BlbiBXaGlzcGVyIFN5c3Rl\nbXMxEzARBgNVBAMMClRleHRTZWN1cmUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw\nggEKAoIBAQDBSWBpOCBDF0i4q2d4jAXkSXUGpbeWugVPQCjaL6qD9QDOxeW1afvf\nPo863i6Crq1KDxHpB36EwzVcjwLkFTIMeo7t9s1FQolAt3mErV2U0vie6Ves+yj6\ngrSfxwIDAcdsKmI0a1SQCZlr3Q1tcHAkAKFRxYNawADyps5B+Zmqcgf653TXS5/0\nIPPQLocLn8GWLwOYNnYfBvILKDMItmZTtEbucdigxEA9mfIvvHADEbteLtVgwBm9\nR5vVvtwrD6CCxI3pgH7EH7kMP0Od93wLisvn1yhHY7FuYlrkYqdkMvWUrKoASVw4\njb69vaeJCUdU+HCoXOSP1PQcL6WenNCHAgMBAAGjUDBOMB0GA1UdDgQWBBQBixjx\nP/s5GURuhYa+lGUypzI8kDAfBgNVHSMEGDAWgBQBixjxP/s5GURuhYa+lGUypzI8\nkDAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQB+Hr4hC56m0LvJAu1R\nK6NuPDbTMEN7/jMojFHxH4P3XPFfupjR+bkDq0pPOU6JjIxnrD1XD/EVmTTaTVY5\niOheyv7UzJOefb2pLOc9qsuvI4fnaESh9bhzln+LXxtCrRPGhkxA1IMIo3J/s2WF\n/KVYZyciu6b4ubJ91XPAuBNZwImug7/srWvbpk0hq6A6z140WTVSKtJG7EP41kJe\n/oF4usY5J7LPkxK3LWzMJnb5EIJDmRvyH8pyRwWg6Qm6qiGFaI4nL8QU4La1x2en\n4DGXRaLMPRwjELNgQPodR38zoCMuA8gHZfZYYoZ7D7Q1wNUiVHcxuFrEeBaYJbLE\nrwLV\n-----END CERTIFICATE-----\n", diff --git a/debug_log.html b/debug_log.html index fb5d01cfb6..1ae262a282 100644 --- a/debug_log.html +++ b/debug_log.html @@ -55,8 +55,6 @@ - - diff --git a/debug_log_preload.js b/debug_log_preload.js index 1e97475bca..c63f7a552d 100644 --- a/debug_log_preload.js +++ b/debug_log_preload.js @@ -29,7 +29,10 @@ window.nodeSetImmediate = setImmediate; window.getNodeVersion = () => config.node_version; window.getEnvironment = getEnvironment; -require('./ts/logging/set_up_renderer_logging'); +window.Backbone = require('backbone'); +require('./ts/backbone/views/whisper_view'); +require('./ts/backbone/views/toast_view'); +require('./ts/logging/set_up_renderer_logging').initialize(); window.closeDebugLog = () => ipcRenderer.send('close-debug-log'); window.Backbone = require('backbone'); diff --git a/images/alert/16/1.png b/images/alert/16/1.png index a9cec2b98e..58e24e8b0d 100644 Binary files a/images/alert/16/1.png and b/images/alert/16/1.png differ diff --git a/images/alert/16/10.png b/images/alert/16/10.png index 60a7ee4721..a6c674ad86 100644 Binary files a/images/alert/16/10.png and b/images/alert/16/10.png differ diff --git a/images/alert/16/2.png b/images/alert/16/2.png index 40ec679896..f855d0f337 100644 Binary files a/images/alert/16/2.png and b/images/alert/16/2.png differ diff --git a/images/alert/16/3.png b/images/alert/16/3.png index 9cfaf91198..33a1938b29 100644 Binary files a/images/alert/16/3.png and b/images/alert/16/3.png differ diff --git a/images/alert/16/4.png b/images/alert/16/4.png index 3ca26f2866..a12976c03d 100644 Binary files a/images/alert/16/4.png and b/images/alert/16/4.png differ diff --git a/images/alert/16/5.png b/images/alert/16/5.png index b8a01e7103..39943e4e93 100644 Binary files a/images/alert/16/5.png and b/images/alert/16/5.png differ diff --git a/images/alert/16/6.png b/images/alert/16/6.png index 3abbd83fb8..5abc11b9d3 100644 Binary files a/images/alert/16/6.png and b/images/alert/16/6.png differ diff --git a/images/alert/16/7.png b/images/alert/16/7.png index 35097c3ae8..5da1e85ed2 100644 Binary files a/images/alert/16/7.png and b/images/alert/16/7.png differ diff --git a/images/alert/16/8.png b/images/alert/16/8.png index b86ba17eda..9006a4ca2d 100644 Binary files a/images/alert/16/8.png and b/images/alert/16/8.png differ diff --git a/images/alert/16/9.png b/images/alert/16/9.png index 416ecf8763..9afc4a8ccd 100644 Binary files a/images/alert/16/9.png and b/images/alert/16/9.png differ diff --git a/images/alert/256/1.png b/images/alert/256/1.png index 72071cc382..369bd96c2d 100644 Binary files a/images/alert/256/1.png and b/images/alert/256/1.png differ diff --git a/images/alert/256/10.png b/images/alert/256/10.png index 72af053888..1f5f97a968 100644 Binary files a/images/alert/256/10.png and b/images/alert/256/10.png differ diff --git a/images/alert/256/2.png b/images/alert/256/2.png index f1c5aa8a06..ae2fd7f86b 100644 Binary files a/images/alert/256/2.png and b/images/alert/256/2.png differ diff --git a/images/alert/256/3.png b/images/alert/256/3.png index 1f6a6007cb..c4b7c41539 100644 Binary files a/images/alert/256/3.png and b/images/alert/256/3.png differ diff --git a/images/alert/256/4.png b/images/alert/256/4.png index 13d2acc2e6..c69eedf8d9 100644 Binary files a/images/alert/256/4.png and b/images/alert/256/4.png differ diff --git a/images/alert/256/5.png b/images/alert/256/5.png index c865fc5715..8f9771d3c8 100644 Binary files a/images/alert/256/5.png and b/images/alert/256/5.png differ diff --git a/images/alert/256/6.png b/images/alert/256/6.png index 80dfd2fe42..1b1125538f 100644 Binary files a/images/alert/256/6.png and b/images/alert/256/6.png differ diff --git a/images/alert/256/7.png b/images/alert/256/7.png index 0e5650bb98..990613bca7 100644 Binary files a/images/alert/256/7.png and b/images/alert/256/7.png differ diff --git a/images/alert/256/8.png b/images/alert/256/8.png index bc56352eb1..06db61f6ca 100644 Binary files a/images/alert/256/8.png and b/images/alert/256/8.png differ diff --git a/images/alert/256/9.png b/images/alert/256/9.png index fd3ce9a685..12b01d4f64 100644 Binary files a/images/alert/256/9.png and b/images/alert/256/9.png differ diff --git a/images/alert/32/1.png b/images/alert/32/1.png new file mode 100644 index 0000000000..325ade90aa Binary files /dev/null and b/images/alert/32/1.png differ diff --git a/images/alert/32/10.png b/images/alert/32/10.png new file mode 100644 index 0000000000..245cf4ec68 Binary files /dev/null and b/images/alert/32/10.png differ diff --git a/images/alert/32/2.png b/images/alert/32/2.png new file mode 100644 index 0000000000..579f399d7b Binary files /dev/null and b/images/alert/32/2.png differ diff --git a/images/alert/32/3.png b/images/alert/32/3.png new file mode 100644 index 0000000000..73e8b7fbeb Binary files /dev/null and b/images/alert/32/3.png differ diff --git a/images/alert/32/4.png b/images/alert/32/4.png new file mode 100644 index 0000000000..a96a0620fc Binary files /dev/null and b/images/alert/32/4.png differ diff --git a/images/alert/32/5.png b/images/alert/32/5.png new file mode 100644 index 0000000000..abf0c1ca71 Binary files /dev/null and b/images/alert/32/5.png differ diff --git a/images/alert/32/6.png b/images/alert/32/6.png new file mode 100644 index 0000000000..f952161d1e Binary files /dev/null and b/images/alert/32/6.png differ diff --git a/images/alert/32/7.png b/images/alert/32/7.png new file mode 100644 index 0000000000..5427452d87 Binary files /dev/null and b/images/alert/32/7.png differ diff --git a/images/alert/32/8.png b/images/alert/32/8.png new file mode 100644 index 0000000000..f7bb6dfc4d Binary files /dev/null and b/images/alert/32/8.png differ diff --git a/images/alert/32/9.png b/images/alert/32/9.png new file mode 100644 index 0000000000..84521bc4fe Binary files /dev/null and b/images/alert/32/9.png differ diff --git a/images/any-emoji-32-dark-hover.svg b/images/any-emoji-32-dark-hover.svg index 5017da6bf9..4c3bd95732 100644 --- a/images/any-emoji-32-dark-hover.svg +++ b/images/any-emoji-32-dark-hover.svg @@ -1,6 +1 @@ - - - - - - + \ No newline at end of file diff --git a/images/any-emoji-32-dark.svg b/images/any-emoji-32-dark.svg index 8124370054..0412142328 100644 --- a/images/any-emoji-32-dark.svg +++ b/images/any-emoji-32-dark.svg @@ -1,6 +1 @@ - - - - - - + \ No newline at end of file diff --git a/images/any-emoji-32-light-hover.svg b/images/any-emoji-32-light-hover.svg index 96a9fab0d7..d87d4f006a 100644 --- a/images/any-emoji-32-light-hover.svg +++ b/images/any-emoji-32-light-hover.svg @@ -1,6 +1 @@ - - - - - - + \ No newline at end of file diff --git a/images/any-emoji-32-light.svg b/images/any-emoji-32-light.svg index a91e2651f4..c92c061735 100644 --- a/images/any-emoji-32-light.svg +++ b/images/any-emoji-32-light.svg @@ -1,6 +1 @@ - - - - - - + \ No newline at end of file diff --git a/images/caption-shadow.svg b/images/caption-shadow.svg index 96a795fac3..42ab5a93a2 100644 --- a/images/caption-shadow.svg +++ b/images/caption-shadow.svg @@ -1,63 +1 @@ - - - - caption-shadow-24 - Created with Sketch. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/images/chat-session-refresh.svg b/images/chat-session-refresh.svg new file mode 100644 index 0000000000..812fce788f --- /dev/null +++ b/images/chat-session-refresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/check-circle-outline.svg b/images/check-circle-outline.svg index f00955ec91..2c03039440 100644 --- a/images/check-circle-outline.svg +++ b/images/check-circle-outline.svg @@ -1,12 +1 @@ - - - - check - Created with Sketch. - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/images/double-check.svg b/images/double-check.svg index 8a6958f981..79b9fcdbd1 100644 --- a/images/double-check.svg +++ b/images/double-check.svg @@ -1,17 +1 @@ - - - - double check - Created with Sketch. - - - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/images/file-gradient.svg b/images/file-gradient.svg index 740f6dd6bb..d431b721c1 100644 --- a/images/file-gradient.svg +++ b/images/file-gradient.svg @@ -1,51 +1 @@ - - - - File - Created with Sketch. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/images/full-screen-flow/alert-outline.svg b/images/full-screen-flow/alert-outline.svg index cb7627f88a..5ee4c68215 100644 --- a/images/full-screen-flow/alert-outline.svg +++ b/images/full-screen-flow/alert-outline.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/images/full-screen-flow/android.svg b/images/full-screen-flow/android.svg index 3edcb81a87..303145abab 100644 --- a/images/full-screen-flow/android.svg +++ b/images/full-screen-flow/android.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/images/full-screen-flow/apple.svg b/images/full-screen-flow/apple.svg index c67e912050..e920c829f8 100644 --- a/images/full-screen-flow/apple.svg +++ b/images/full-screen-flow/apple.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/images/full-screen-flow/check-circle-outline.svg b/images/full-screen-flow/check-circle-outline.svg index f00955ec91..2c03039440 100644 --- a/images/full-screen-flow/check-circle-outline.svg +++ b/images/full-screen-flow/check-circle-outline.svg @@ -1,12 +1 @@ - - - - check - Created with Sketch. - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/images/full-screen-flow/delete.svg b/images/full-screen-flow/delete.svg index f9daefe90e..94d4c7b423 100644 --- a/images/full-screen-flow/delete.svg +++ b/images/full-screen-flow/delete.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/images/full-screen-flow/folder-outline.svg b/images/full-screen-flow/folder-outline.svg index 2a7ad7b8ea..ea44b141ab 100644 --- a/images/full-screen-flow/folder-outline.svg +++ b/images/full-screen-flow/folder-outline.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/images/full-screen-flow/import.svg b/images/full-screen-flow/import.svg index 2cc83b8399..94a2da3758 100644 --- a/images/full-screen-flow/import.svg +++ b/images/full-screen-flow/import.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/images/full-screen-flow/lead-pencil.svg b/images/full-screen-flow/lead-pencil.svg index 0d661d6eb2..34f8e82277 100644 --- a/images/full-screen-flow/lead-pencil.svg +++ b/images/full-screen-flow/lead-pencil.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/images/full-screen-flow/sync.svg b/images/full-screen-flow/sync.svg index e0ed6c2248..05614396b6 100644 --- a/images/full-screen-flow/sync.svg +++ b/images/full-screen-flow/sync.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/images/group_default.png b/images/group_default.png index 6b503e9ff8..a60dcf4990 100644 Binary files a/images/group_default.png and b/images/group_default.png differ diff --git a/images/icon_1024.png b/images/icon_1024.png index f890f594f9..9db5bdbb5b 100644 Binary files a/images/icon_1024.png and b/images/icon_1024.png differ diff --git a/images/icon_128.png b/images/icon_128.png index 09a4e3dc90..5eb8558d49 100644 Binary files a/images/icon_128.png and b/images/icon_128.png differ diff --git a/images/icon_250.png b/images/icon_250.png index 0bd494caef..b0b3937b08 100644 Binary files a/images/icon_250.png and b/images/icon_250.png differ diff --git a/images/icon_256.png b/images/icon_256.png index 433945c928..82e6ff270f 100644 Binary files a/images/icon_256.png and b/images/icon_256.png differ diff --git a/images/icon_48.png b/images/icon_48.png index 27d7f9142a..8923fe1de4 100644 Binary files a/images/icon_48.png and b/images/icon_48.png differ diff --git a/images/icons/v2/archive-outline-16.svg b/images/icons/v2/archive-outline-16.svg index 0b2339bfc6..ae305d6b2f 100644 --- a/images/icons/v2/archive-outline-16.svg +++ b/images/icons/v2/archive-outline-16.svg @@ -1 +1 @@ -archive-outline-16 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/archive-solid-16.svg b/images/icons/v2/archive-solid-16.svg index b65bc54d75..085f1d877c 100644 --- a/images/icons/v2/archive-solid-16.svg +++ b/images/icons/v2/archive-solid-16.svg @@ -1 +1 @@ -archive-solid-16 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/arrow-down-20.svg b/images/icons/v2/arrow-down-20.svg new file mode 100644 index 0000000000..f2e1fe4ca4 --- /dev/null +++ b/images/icons/v2/arrow-down-20.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/arrow-down-24.svg b/images/icons/v2/arrow-down-24.svg index f165dbee92..7b7635cb01 100644 --- a/images/icons/v2/arrow-down-24.svg +++ b/images/icons/v2/arrow-down-24.svg @@ -1 +1 @@ -arrow-down-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/audio-spinner-arc-22.svg b/images/icons/v2/audio-spinner-arc-22.svg new file mode 100644 index 0000000000..73d0d7d216 --- /dev/null +++ b/images/icons/v2/audio-spinner-arc-22.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/block-24.svg b/images/icons/v2/block-24.svg index 91f631f98d..3a4cf24adf 100644 --- a/images/icons/v2/block-24.svg +++ b/images/icons/v2/block-24.svg @@ -1 +1 @@ -block-24 + \ No newline at end of file diff --git a/images/icons/v2/camera-outline-24.svg b/images/icons/v2/camera-outline-24.svg new file mode 100644 index 0000000000..329aaa8e09 --- /dev/null +++ b/images/icons/v2/camera-outline-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/check-24.svg b/images/icons/v2/check-24.svg index 4e35aa8cf2..8caf050fc9 100644 --- a/images/icons/v2/check-24.svg +++ b/images/icons/v2/check-24.svg @@ -1 +1 @@ -check-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/check-circle-outline-24.svg b/images/icons/v2/check-circle-outline-24.svg index 4d373b22c1..3718cc3539 100644 --- a/images/icons/v2/check-circle-outline-24.svg +++ b/images/icons/v2/check-circle-outline-24.svg @@ -1 +1 @@ -check-circle-outline-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/check-circle-solid-24.svg b/images/icons/v2/check-circle-solid-24.svg index dae8e49ef9..030a2152fe 100644 --- a/images/icons/v2/check-circle-solid-24.svg +++ b/images/icons/v2/check-circle-solid-24.svg @@ -1 +1 @@ -check-circle-solid-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/chevron-down-16.svg b/images/icons/v2/chevron-down-16.svg index 420cb23774..e1f3f6bd2e 100644 --- a/images/icons/v2/chevron-down-16.svg +++ b/images/icons/v2/chevron-down-16.svg @@ -1 +1 @@ -chevron-down-16 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/chevron-left-16.svg b/images/icons/v2/chevron-left-16.svg index 2524c99a03..98e19d2f55 100644 --- a/images/icons/v2/chevron-left-16.svg +++ b/images/icons/v2/chevron-left-16.svg @@ -1 +1 @@ -chevron-left-16 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/chevron-left-24.svg b/images/icons/v2/chevron-left-24.svg index aa5148770f..2ad8b503d8 100644 --- a/images/icons/v2/chevron-left-24.svg +++ b/images/icons/v2/chevron-left-24.svg @@ -1 +1 @@ -chevron-left-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/chevron-right-16.svg b/images/icons/v2/chevron-right-16.svg index 7d51130f69..05237d4236 100644 --- a/images/icons/v2/chevron-right-16.svg +++ b/images/icons/v2/chevron-right-16.svg @@ -1 +1 @@ -chevron-right-16 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/chevron-right-24.svg b/images/icons/v2/chevron-right-24.svg index f917e91730..639435173b 100644 --- a/images/icons/v2/chevron-right-24.svg +++ b/images/icons/v2/chevron-right-24.svg @@ -1 +1 @@ -chevron-right-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/collapse-down-20.svg b/images/icons/v2/collapse-down-20.svg index 985c00a55c..b8d116802e 100644 --- a/images/icons/v2/collapse-down-20.svg +++ b/images/icons/v2/collapse-down-20.svg @@ -1 +1 @@ -collapse-down-20 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/compose-outline-24.svg b/images/icons/v2/compose-outline-24.svg new file mode 100644 index 0000000000..9cf1803e44 --- /dev/null +++ b/images/icons/v2/compose-outline-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/compose-solid-24.svg b/images/icons/v2/compose-solid-24.svg new file mode 100644 index 0000000000..7efa2cbc74 --- /dev/null +++ b/images/icons/v2/compose-solid-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/emoji-activity-outline-20.svg b/images/icons/v2/emoji-activity-outline-20.svg index 3ea6011d08..71ccf91126 100644 --- a/images/icons/v2/emoji-activity-outline-20.svg +++ b/images/icons/v2/emoji-activity-outline-20.svg @@ -1 +1 @@ -emoji-activity-outline-20 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/emoji-activity-solid-20.svg b/images/icons/v2/emoji-activity-solid-20.svg index ccc5e7e2e1..8be8031f2a 100644 --- a/images/icons/v2/emoji-activity-solid-20.svg +++ b/images/icons/v2/emoji-activity-solid-20.svg @@ -1 +1 @@ -emoji-activity-solid-20 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/emoji-animal-outline-20.svg b/images/icons/v2/emoji-animal-outline-20.svg index 8ebfe9587b..36eba927ed 100644 --- a/images/icons/v2/emoji-animal-outline-20.svg +++ b/images/icons/v2/emoji-animal-outline-20.svg @@ -1 +1 @@ -emoji-animal-outline-20 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/emoji-animal-solid-20.svg b/images/icons/v2/emoji-animal-solid-20.svg index d31a8878e2..c7ca698db9 100644 --- a/images/icons/v2/emoji-animal-solid-20.svg +++ b/images/icons/v2/emoji-animal-solid-20.svg @@ -1 +1 @@ -emoji-animal-solid-20 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/emoji-flag-outline-20.svg b/images/icons/v2/emoji-flag-outline-20.svg index ac0a994b8d..e27dbc007d 100644 --- a/images/icons/v2/emoji-flag-outline-20.svg +++ b/images/icons/v2/emoji-flag-outline-20.svg @@ -1 +1 @@ -emoji-flag-outline-20 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/emoji-flag-solid-20.svg b/images/icons/v2/emoji-flag-solid-20.svg index 62564dcac3..1e20208804 100644 --- a/images/icons/v2/emoji-flag-solid-20.svg +++ b/images/icons/v2/emoji-flag-solid-20.svg @@ -1 +1 @@ -emoji-flag-solid-20 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/emoji-food-outline-20.svg b/images/icons/v2/emoji-food-outline-20.svg index 411a63ea05..8d6ad35804 100644 --- a/images/icons/v2/emoji-food-outline-20.svg +++ b/images/icons/v2/emoji-food-outline-20.svg @@ -1 +1 @@ -emoji-food-outline-20 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/emoji-food-solid-20.svg b/images/icons/v2/emoji-food-solid-20.svg index a1515961d6..3ac7c52606 100644 --- a/images/icons/v2/emoji-food-solid-20.svg +++ b/images/icons/v2/emoji-food-solid-20.svg @@ -1 +1 @@ -emoji-food-solid-20 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/emoji-object-outline-20.svg b/images/icons/v2/emoji-object-outline-20.svg index 22cd0b2f95..48ad0f2495 100644 --- a/images/icons/v2/emoji-object-outline-20.svg +++ b/images/icons/v2/emoji-object-outline-20.svg @@ -1 +1 @@ -emoji-object-outline-20 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/emoji-object-solid-20.svg b/images/icons/v2/emoji-object-solid-20.svg index 6efac076a3..22ebc4482b 100644 --- a/images/icons/v2/emoji-object-solid-20.svg +++ b/images/icons/v2/emoji-object-solid-20.svg @@ -1 +1 @@ -emoji-object-solid-20 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/emoji-smiley-outline-20.svg b/images/icons/v2/emoji-smiley-outline-20.svg index 58120b2f85..d2ac581cb3 100644 --- a/images/icons/v2/emoji-smiley-outline-20.svg +++ b/images/icons/v2/emoji-smiley-outline-20.svg @@ -1 +1 @@ -emoji-smiley-outline-20 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/emoji-smiley-outline-24.svg b/images/icons/v2/emoji-smiley-outline-24.svg index f4d5cd15db..d1c05a5acf 100644 --- a/images/icons/v2/emoji-smiley-outline-24.svg +++ b/images/icons/v2/emoji-smiley-outline-24.svg @@ -1 +1 @@ -emoji-smiley-outline-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/emoji-smiley-solid-20.svg b/images/icons/v2/emoji-smiley-solid-20.svg index d23bb61bce..90d0c8c19f 100644 --- a/images/icons/v2/emoji-smiley-solid-20.svg +++ b/images/icons/v2/emoji-smiley-solid-20.svg @@ -1 +1 @@ -emoji-smiley-solid-20 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/emoji-smiley-solid-24.svg b/images/icons/v2/emoji-smiley-solid-24.svg index 945549c7bf..241103453c 100644 --- a/images/icons/v2/emoji-smiley-solid-24.svg +++ b/images/icons/v2/emoji-smiley-solid-24.svg @@ -1 +1 @@ -emoji-smiley-solid-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/emoji-symbol-solid-20.svg b/images/icons/v2/emoji-symbol-solid-20.svg index 23c5f04342..c1062155c2 100644 --- a/images/icons/v2/emoji-symbol-solid-20.svg +++ b/images/icons/v2/emoji-symbol-solid-20.svg @@ -1 +1 @@ -emoji-symbol-solid-20 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/emoji-travel-outline-20.svg b/images/icons/v2/emoji-travel-outline-20.svg index 3a9ab17bd4..3591242ce1 100644 --- a/images/icons/v2/emoji-travel-outline-20.svg +++ b/images/icons/v2/emoji-travel-outline-20.svg @@ -1 +1 @@ -emoji-travel-outline-20 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/emoji-travel-solid-20.svg b/images/icons/v2/emoji-travel-solid-20.svg index 51e2bfdbe6..aef31c61cb 100644 --- a/images/icons/v2/emoji-travel-solid-20.svg +++ b/images/icons/v2/emoji-travel-solid-20.svg @@ -1 +1 @@ -emoji-travel-solid-20 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/error-outline-12.svg b/images/icons/v2/error-outline-12.svg index 33736830fa..e8c6bd4d90 100644 --- a/images/icons/v2/error-outline-12.svg +++ b/images/icons/v2/error-outline-12.svg @@ -1 +1 @@ -error-outline-12 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/error-outline-24.svg b/images/icons/v2/error-outline-24.svg index 4eaa871dfa..28ec32dba1 100644 --- a/images/icons/v2/error-outline-24.svg +++ b/images/icons/v2/error-outline-24.svg @@ -1 +1 @@ -error-outline-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/error-solid-12.svg b/images/icons/v2/error-solid-12.svg index c48275c2b9..0eb861c0e3 100644 --- a/images/icons/v2/error-solid-12.svg +++ b/images/icons/v2/error-solid-12.svg @@ -1 +1 @@ -error-solid-12 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/error-solid-24.svg b/images/icons/v2/error-solid-24.svg index 5cafa8fd7e..f4a24b2133 100644 --- a/images/icons/v2/error-solid-24.svg +++ b/images/icons/v2/error-solid-24.svg @@ -1 +1 @@ -error-solid-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/expand-up-20.svg b/images/icons/v2/expand-up-20.svg index 858b817817..c458db3344 100644 --- a/images/icons/v2/expand-up-20.svg +++ b/images/icons/v2/expand-up-20.svg @@ -1 +1 @@ -expand-up-20 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/group-outline-20.svg b/images/icons/v2/group-outline-20.svg index f4d336c8db..bea136d49a 100644 --- a/images/icons/v2/group-outline-20.svg +++ b/images/icons/v2/group-outline-20.svg @@ -1 +1 @@ -group-outline-20 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/group-outline-40.svg b/images/icons/v2/group-outline-40.svg index 751285beb4..d6ab151f33 100644 --- a/images/icons/v2/group-outline-40.svg +++ b/images/icons/v2/group-outline-40.svg @@ -1 +1 @@ -group-outline-40 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/group-solid-24.svg b/images/icons/v2/group-solid-24.svg index 6e9d3d0593..ca8f5fe600 100644 --- a/images/icons/v2/group-solid-24.svg +++ b/images/icons/v2/group-solid-24.svg @@ -1 +1 @@ -group-solid-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/info-outline-24.svg b/images/icons/v2/info-outline-24.svg index a241fc35e5..70307d6947 100644 --- a/images/icons/v2/info-outline-24.svg +++ b/images/icons/v2/info-outline-24.svg @@ -1 +1 @@ -info-outline-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/info-solid-24.svg b/images/icons/v2/info-solid-24.svg index 88c8f9dd8d..d7be23befd 100644 --- a/images/icons/v2/info-solid-24.svg +++ b/images/icons/v2/info-solid-24.svg @@ -1 +1 @@ -info-solid-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/leave-24.svg b/images/icons/v2/leave-24.svg index d968d61e4d..d99a7f321e 100644 --- a/images/icons/v2/leave-24.svg +++ b/images/icons/v2/leave-24.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/images/icons/v2/leave-group-outline-16.svg b/images/icons/v2/leave-group-outline-16.svg index 02dd578b2a..fad1764047 100644 --- a/images/icons/v2/leave-group-outline-16.svg +++ b/images/icons/v2/leave-group-outline-16.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/images/icons/v2/link-16.svg b/images/icons/v2/link-16.svg index dc8f05cbda..e7a6733e45 100644 --- a/images/icons/v2/link-16.svg +++ b/images/icons/v2/link-16.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/images/icons/v2/link-broken-16.svg b/images/icons/v2/link-broken-16.svg index 2e4bd3160a..73a9bb64f1 100644 --- a/images/icons/v2/link-broken-16.svg +++ b/images/icons/v2/link-broken-16.svg @@ -1 +1 @@ -link-broken-16 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/lock-outline-24.svg b/images/icons/v2/lock-outline-24.svg index b0967ded16..663c043208 100644 --- a/images/icons/v2/lock-outline-24.svg +++ b/images/icons/v2/lock-outline-24.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/images/icons/v2/message-outline-24.svg b/images/icons/v2/message-outline-24.svg index fb84c1ff2e..de6f400b4d 100644 --- a/images/icons/v2/message-outline-24.svg +++ b/images/icons/v2/message-outline-24.svg @@ -1 +1 @@ -message-outline-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/message-solid-24.svg b/images/icons/v2/message-solid-24.svg index 1b090753d7..7363fec2dc 100644 --- a/images/icons/v2/message-solid-24.svg +++ b/images/icons/v2/message-solid-24.svg @@ -1 +1 @@ -message-solid-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/mic-off-solid-28.svg b/images/icons/v2/mic-off-solid-28.svg index cf99da52ea..56621e603d 100644 --- a/images/icons/v2/mic-off-solid-28.svg +++ b/images/icons/v2/mic-off-solid-28.svg @@ -1 +1 @@ -mic-off-solid-28 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/mic-outline-24.svg b/images/icons/v2/mic-outline-24.svg index b6c702fc12..30d0012c39 100644 --- a/images/icons/v2/mic-outline-24.svg +++ b/images/icons/v2/mic-outline-24.svg @@ -1 +1 @@ -mic-outline-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/mic-solid-24.svg b/images/icons/v2/mic-solid-24.svg index 9d6f76307f..37ad08c985 100644 --- a/images/icons/v2/mic-solid-24.svg +++ b/images/icons/v2/mic-solid-24.svg @@ -1 +1 @@ -mic-solid-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/mic-solid-28.svg b/images/icons/v2/mic-solid-28.svg index f87a38b320..020e4d0772 100644 --- a/images/icons/v2/mic-solid-28.svg +++ b/images/icons/v2/mic-solid-28.svg @@ -1 +1 @@ -mic-solid-28 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/more-horiz-24.svg b/images/icons/v2/more-horiz-24.svg index e0680f6b90..f7b4c3acb5 100644 --- a/images/icons/v2/more-horiz-24.svg +++ b/images/icons/v2/more-horiz-24.svg @@ -1 +1 @@ -more-horiz-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/pause-solid-20.svg b/images/icons/v2/pause-solid-20.svg new file mode 100644 index 0000000000..6394f81007 --- /dev/null +++ b/images/icons/v2/pause-solid-20.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/pending-invite-24.svg b/images/icons/v2/pending-invite-24.svg index 77068184bb..2454a71fd2 100644 --- a/images/icons/v2/pending-invite-24.svg +++ b/images/icons/v2/pending-invite-24.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/images/icons/v2/phone-down-24.svg b/images/icons/v2/phone-down-24.svg index 4a5b860d5b..86309b56c3 100644 --- a/images/icons/v2/phone-down-24.svg +++ b/images/icons/v2/phone-down-24.svg @@ -1 +1 @@ -phone-down-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/phone-down-28.svg b/images/icons/v2/phone-down-28.svg index ef3d032304..b3b21dadac 100644 --- a/images/icons/v2/phone-down-28.svg +++ b/images/icons/v2/phone-down-28.svg @@ -1 +1 @@ -phone-down-28 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/phone-right-outline-24.svg b/images/icons/v2/phone-right-outline-24.svg index 88aa5fd3ce..06e210ea94 100644 --- a/images/icons/v2/phone-right-outline-24.svg +++ b/images/icons/v2/phone-right-outline-24.svg @@ -1 +1 @@ -phone-right-outline-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/phone-right-solid-24.svg b/images/icons/v2/phone-right-solid-24.svg index 9386ed4a34..bbda5b2777 100644 --- a/images/icons/v2/phone-right-solid-24.svg +++ b/images/icons/v2/phone-right-solid-24.svg @@ -1 +1 @@ -phone-right-solid-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/play-solid-20.svg b/images/icons/v2/play-solid-20.svg new file mode 100644 index 0000000000..02c2305868 --- /dev/null +++ b/images/icons/v2/play-solid-20.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/play-solid-24.svg b/images/icons/v2/play-solid-24.svg index 0a5d319bf6..ef894c1ee5 100644 --- a/images/icons/v2/play-solid-24.svg +++ b/images/icons/v2/play-solid-24.svg @@ -1 +1 @@ -play-solid-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/plus-20.svg b/images/icons/v2/plus-20.svg index 9c634a9b5b..f81645293b 100644 --- a/images/icons/v2/plus-20.svg +++ b/images/icons/v2/plus-20.svg @@ -1 +1 @@ -plus-20 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/plus-24.svg b/images/icons/v2/plus-24.svg index 4749a35a47..5122ea36f5 100644 --- a/images/icons/v2/plus-24.svg +++ b/images/icons/v2/plus-24.svg @@ -1 +1 @@ -plus-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/profile-circle-outline-24.svg b/images/icons/v2/profile-circle-outline-24.svg index 51a5aa4101..12678eeb5e 100644 --- a/images/icons/v2/profile-circle-outline-24.svg +++ b/images/icons/v2/profile-circle-outline-24.svg @@ -1 +1 @@ -profile-circle-outline-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/profile-circle-solid-24.svg b/images/icons/v2/profile-circle-solid-24.svg index 354d367c87..21e219d249 100644 --- a/images/icons/v2/profile-circle-solid-24.svg +++ b/images/icons/v2/profile-circle-solid-24.svg @@ -1 +1 @@ -profile-circle-solid-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/profile-outline-20.svg b/images/icons/v2/profile-outline-20.svg index 0309dcbc4b..9206eab933 100644 --- a/images/icons/v2/profile-outline-20.svg +++ b/images/icons/v2/profile-outline-20.svg @@ -1 +1 @@ -profile-outline-20 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/profile-outline-40.svg b/images/icons/v2/profile-outline-40.svg index b4c7b89359..322ebe573a 100644 --- a/images/icons/v2/profile-outline-40.svg +++ b/images/icons/v2/profile-outline-40.svg @@ -1 +1 @@ -profile-outline-40 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/recent-outline-20.svg b/images/icons/v2/recent-outline-20.svg index f94905cf40..a189b9762c 100644 --- a/images/icons/v2/recent-outline-20.svg +++ b/images/icons/v2/recent-outline-20.svg @@ -1 +1 @@ -recent-outline-20 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/recent-solid-20.svg b/images/icons/v2/recent-solid-20.svg index 822ccf5041..ec78d339c7 100644 --- a/images/icons/v2/recent-solid-20.svg +++ b/images/icons/v2/recent-solid-20.svg @@ -1 +1 @@ -recent-solid-20 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/refresh-16.svg b/images/icons/v2/refresh-16.svg new file mode 100644 index 0000000000..b0dc97e0a4 --- /dev/null +++ b/images/icons/v2/refresh-16.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/refresh-24.svg b/images/icons/v2/refresh-24.svg index 65a19cf6c4..98b6389e7a 100644 --- a/images/icons/v2/refresh-24.svg +++ b/images/icons/v2/refresh-24.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/images/icons/v2/reply-outline-24.svg b/images/icons/v2/reply-outline-24.svg index 072e684520..7fa0c1e944 100644 --- a/images/icons/v2/reply-outline-24.svg +++ b/images/icons/v2/reply-outline-24.svg @@ -1 +1 @@ -reply-outline-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/reply-solid-24.svg b/images/icons/v2/reply-solid-24.svg index d3e48e5df8..5fe8c3c73b 100644 --- a/images/icons/v2/reply-solid-24.svg +++ b/images/icons/v2/reply-solid-24.svg @@ -1 +1 @@ -reply-solid-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/safety-number-outline-24.svg b/images/icons/v2/safety-number-outline-24.svg index 8eba9d06e4..efcc8bfb89 100644 --- a/images/icons/v2/safety-number-outline-24.svg +++ b/images/icons/v2/safety-number-outline-24.svg @@ -1 +1 @@ -safety-number-outline-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/safety-number-solid-24.svg b/images/icons/v2/safety-number-solid-24.svg index 593c156cd3..56633a2a84 100644 --- a/images/icons/v2/safety-number-solid-24.svg +++ b/images/icons/v2/safety-number-solid-24.svg @@ -1 +1 @@ -safety-number-solid-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/save-outline-24.svg b/images/icons/v2/save-outline-24.svg index f149d36097..59bcad25c9 100644 --- a/images/icons/v2/save-outline-24.svg +++ b/images/icons/v2/save-outline-24.svg @@ -1 +1 @@ -save-outline-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/save-solid-24.svg b/images/icons/v2/save-solid-24.svg index 9ac14c0c9b..ab39de7774 100644 --- a/images/icons/v2/save-solid-24.svg +++ b/images/icons/v2/save-solid-24.svg @@ -1 +1 @@ -save-solid-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/search-16.svg b/images/icons/v2/search-16.svg index e8ae164048..c80d5cd184 100644 --- a/images/icons/v2/search-16.svg +++ b/images/icons/v2/search-16.svg @@ -1 +1 @@ -search-16 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/search-24.svg b/images/icons/v2/search-24.svg index 84c9592866..8ac4431be3 100644 --- a/images/icons/v2/search-24.svg +++ b/images/icons/v2/search-24.svg @@ -1 +1 @@ -search-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/send-24.svg b/images/icons/v2/send-24.svg index 0749625681..ab1ad17bd1 100644 --- a/images/icons/v2/send-24.svg +++ b/images/icons/v2/send-24.svg @@ -1 +1 @@ -send-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/settings-solid-16.svg b/images/icons/v2/settings-solid-16.svg index a19ebdcb5f..5ad6c36531 100644 --- a/images/icons/v2/settings-solid-16.svg +++ b/images/icons/v2/settings-solid-16.svg @@ -1 +1 @@ -settings-solid-16 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/share-ios-24.svg b/images/icons/v2/share-ios-24.svg index f7a7d33b25..21d705adcc 100644 --- a/images/icons/v2/share-ios-24.svg +++ b/images/icons/v2/share-ios-24.svg @@ -1 +1 @@ -share-ios-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/sound-off-outline-24.svg b/images/icons/v2/sound-off-outline-24.svg index 67160f53b9..43aefe49c2 100644 --- a/images/icons/v2/sound-off-outline-24.svg +++ b/images/icons/v2/sound-off-outline-24.svg @@ -1 +1 @@ -sound-off-outline-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/sticker-outline-24.svg b/images/icons/v2/sticker-outline-24.svg index e5a7bd483b..ae2acea8e9 100644 --- a/images/icons/v2/sticker-outline-24.svg +++ b/images/icons/v2/sticker-outline-24.svg @@ -1 +1 @@ -sticker-outline-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/sticker-solid-24.svg b/images/icons/v2/sticker-solid-24.svg index 1d27828f50..869bf977dc 100644 --- a/images/icons/v2/sticker-solid-24.svg +++ b/images/icons/v2/sticker-solid-24.svg @@ -1 +1 @@ -sticker-solid-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/timer-00-12.svg b/images/icons/v2/timer-00-12.svg index 5bdc9f1e6b..898ed53fae 100644 --- a/images/icons/v2/timer-00-12.svg +++ b/images/icons/v2/timer-00-12.svg @@ -1 +1 @@ -timer-00-12 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/timer-05-12.svg b/images/icons/v2/timer-05-12.svg index 69bb1d0c05..d15d1c69e7 100644 --- a/images/icons/v2/timer-05-12.svg +++ b/images/icons/v2/timer-05-12.svg @@ -1 +1 @@ -timer-05-12 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/timer-10-12.svg b/images/icons/v2/timer-10-12.svg index e77037c03e..37f81fd784 100644 --- a/images/icons/v2/timer-10-12.svg +++ b/images/icons/v2/timer-10-12.svg @@ -1 +1 @@ -timer-10-12 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/timer-15-12.svg b/images/icons/v2/timer-15-12.svg index 321b411d0a..887b402088 100644 --- a/images/icons/v2/timer-15-12.svg +++ b/images/icons/v2/timer-15-12.svg @@ -1 +1 @@ -timer-15-12 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/timer-20-12.svg b/images/icons/v2/timer-20-12.svg index 4ab3a1b3ea..0ec46657cb 100644 --- a/images/icons/v2/timer-20-12.svg +++ b/images/icons/v2/timer-20-12.svg @@ -1 +1 @@ -timer-20-12 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/timer-25-12.svg b/images/icons/v2/timer-25-12.svg index 27942a18d5..291e62aabc 100644 --- a/images/icons/v2/timer-25-12.svg +++ b/images/icons/v2/timer-25-12.svg @@ -1 +1 @@ -timer-25-12 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/timer-30-12.svg b/images/icons/v2/timer-30-12.svg index 55c7bb02f3..234e5b6271 100644 --- a/images/icons/v2/timer-30-12.svg +++ b/images/icons/v2/timer-30-12.svg @@ -1 +1 @@ -timer-30-12 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/timer-35-12.svg b/images/icons/v2/timer-35-12.svg index 6dcb9bda2d..0fca42bb38 100644 --- a/images/icons/v2/timer-35-12.svg +++ b/images/icons/v2/timer-35-12.svg @@ -1 +1 @@ -timer-35-12 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/timer-40-12.svg b/images/icons/v2/timer-40-12.svg index 0b27d77330..ba1c96bb9e 100644 --- a/images/icons/v2/timer-40-12.svg +++ b/images/icons/v2/timer-40-12.svg @@ -1 +1 @@ -timer-40-12 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/timer-45-12.svg b/images/icons/v2/timer-45-12.svg index c91d4b82fd..388404b782 100644 --- a/images/icons/v2/timer-45-12.svg +++ b/images/icons/v2/timer-45-12.svg @@ -1 +1 @@ -timer-45-12 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/timer-50-12.svg b/images/icons/v2/timer-50-12.svg index ac911ea99a..9cc6828cc5 100644 --- a/images/icons/v2/timer-50-12.svg +++ b/images/icons/v2/timer-50-12.svg @@ -1 +1 @@ -timer-50-12 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/timer-55-12.svg b/images/icons/v2/timer-55-12.svg index 1166a6ce1a..eda8f3e7cb 100644 --- a/images/icons/v2/timer-55-12.svg +++ b/images/icons/v2/timer-55-12.svg @@ -1 +1 @@ -timer-55-12 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/timer-60-12.svg b/images/icons/v2/timer-60-12.svg index 60d77a921e..ebee1b597f 100644 --- a/images/icons/v2/timer-60-12.svg +++ b/images/icons/v2/timer-60-12.svg @@ -1 +1 @@ -timer-60-12 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/timer-disabled-24.svg b/images/icons/v2/timer-disabled-24.svg index 32d575531e..6cee5c0f71 100644 --- a/images/icons/v2/timer-disabled-24.svg +++ b/images/icons/v2/timer-disabled-24.svg @@ -1 +1 @@ -timer-disabled-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/trash-outline-24.svg b/images/icons/v2/trash-outline-24.svg index cf8f68d817..b1e3b51970 100644 --- a/images/icons/v2/trash-outline-24.svg +++ b/images/icons/v2/trash-outline-24.svg @@ -1 +1 @@ -trash-outline-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/trash-solid-24.svg b/images/icons/v2/trash-solid-24.svg index 3d74ebb95e..b61d48eb82 100644 --- a/images/icons/v2/trash-solid-24.svg +++ b/images/icons/v2/trash-solid-24.svg @@ -1 +1 @@ -trash-solid-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/undo-24.svg b/images/icons/v2/undo-24.svg index 27dc9eb579..2369b9d41a 100644 --- a/images/icons/v2/undo-24.svg +++ b/images/icons/v2/undo-24.svg @@ -1 +1 @@ -undo-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/video-off-solid-24.svg b/images/icons/v2/video-off-solid-24.svg index 814c56d8f1..693ef5553c 100644 --- a/images/icons/v2/video-off-solid-24.svg +++ b/images/icons/v2/video-off-solid-24.svg @@ -1 +1 @@ -video-off-solid-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/video-off-solid-28.svg b/images/icons/v2/video-off-solid-28.svg index e3981d47a3..424dc30be1 100644 --- a/images/icons/v2/video-off-solid-28.svg +++ b/images/icons/v2/video-off-solid-28.svg @@ -1 +1 @@ -video-off-solid-28 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/video-outline-24.svg b/images/icons/v2/video-outline-24.svg index 6bceeab488..e7057247e2 100644 --- a/images/icons/v2/video-outline-24.svg +++ b/images/icons/v2/video-outline-24.svg @@ -1 +1 @@ -video-outline-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/video-solid-24.svg b/images/icons/v2/video-solid-24.svg index fc249348d3..26241301bd 100644 --- a/images/icons/v2/video-solid-24.svg +++ b/images/icons/v2/video-solid-24.svg @@ -1 +1 @@ -video-solid-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/video-solid-28.svg b/images/icons/v2/video-solid-28.svg index 41d31582f3..56989f2584 100644 --- a/images/icons/v2/video-solid-28.svg +++ b/images/icons/v2/video-solid-28.svg @@ -1 +1 @@ -video-solid-28 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/view-once-24.svg b/images/icons/v2/view-once-24.svg index 2bd91d62e6..13ab0454e1 100644 --- a/images/icons/v2/view-once-24.svg +++ b/images/icons/v2/view-once-24.svg @@ -1 +1 @@ -view-once-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v2/x-24.svg b/images/icons/v2/x-24.svg index c018fae6b8..875e953108 100644 --- a/images/icons/v2/x-24.svg +++ b/images/icons/v2/x-24.svg @@ -1 +1 @@ -x-24 \ No newline at end of file + \ No newline at end of file diff --git a/images/image.svg b/images/image.svg index 5c61724ff1..21ba4507b9 100644 --- a/images/image.svg +++ b/images/image.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/images/movie.svg b/images/movie.svg index 11700796f3..0cec0e7bb5 100644 --- a/images/movie.svg +++ b/images/movie.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/images/note-28.svg b/images/note-28.svg index a37aefc54d..be1ba3fc35 100644 --- a/images/note-28.svg +++ b/images/note-28.svg @@ -1 +1 @@ -note-28 \ No newline at end of file + \ No newline at end of file diff --git a/images/read.svg b/images/read.svg index cedd36d0cb..1083ee9cd8 100644 --- a/images/read.svg +++ b/images/read.svg @@ -1,22 +1 @@ - - - - Icons/Read/read-18x12 - Created with Sketch. - - - - - - - - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/images/sending.svg b/images/sending.svg index 68de68f85a..5f4c58e59c 100644 --- a/images/sending.svg +++ b/images/sending.svg @@ -1,12 +1 @@ - - - - sending - Created with Sketch. - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/images/signal-logo-desktop-linux.png b/images/signal-logo-desktop-linux.png index a469779989..866ad19bcd 100644 Binary files a/images/signal-logo-desktop-linux.png and b/images/signal-logo-desktop-linux.png differ diff --git a/images/spinner-24.svg b/images/spinner-24.svg index 392aa714f3..3544360267 100644 --- a/images/spinner-24.svg +++ b/images/spinner-24.svg @@ -1,9 +1 @@ - - - - Interderminate Spinner - 24 - Created with Sketch. - - - - \ No newline at end of file + \ No newline at end of file diff --git a/images/spinner-56.svg b/images/spinner-56.svg index 0badeb1095..d5a0e60ca4 100644 --- a/images/spinner-56.svg +++ b/images/spinner-56.svg @@ -1,9 +1 @@ - - - - Interderminate Spinner - 56 - Created with Sketch. - - - - \ No newline at end of file + \ No newline at end of file diff --git a/images/spinner-track-24.svg b/images/spinner-track-24.svg index 0ab7cac2ab..a209386503 100644 --- a/images/spinner-track-24.svg +++ b/images/spinner-track-24.svg @@ -1,9 +1 @@ - - - - Interderminate Track - 24 - Created with Sketch. - - - - \ No newline at end of file + \ No newline at end of file diff --git a/images/spinner-track-56.svg b/images/spinner-track-56.svg index d0aad7dac4..631863d731 100644 --- a/images/spinner-track-56.svg +++ b/images/spinner-track-56.svg @@ -1,9 +1 @@ - - - - Interderminate Track - 56 - Created with Sketch. - - - - \ No newline at end of file + \ No newline at end of file diff --git a/images/sticker_splash@1x.png b/images/sticker_splash@1x.png index da0942dd22..b56ddda661 100644 Binary files a/images/sticker_splash@1x.png and b/images/sticker_splash@1x.png differ diff --git a/images/sticker_splash@2x.png b/images/sticker_splash@2x.png index b11465350f..2ac67b8bc2 100644 Binary files a/images/sticker_splash@2x.png and b/images/sticker_splash@2x.png differ diff --git a/images/unidentified-delivery.svg b/images/unidentified-delivery.svg index c9eb41351d..c5312ddace 100644 --- a/images/unidentified-delivery.svg +++ b/images/unidentified-delivery.svg @@ -1,9 +1 @@ - - - - Secret Sender/secret-sender-20 - Created with Sketch. - - - - \ No newline at end of file + \ No newline at end of file diff --git a/images/x-shadow-16.svg b/images/x-shadow-16.svg index a68cb437bc..88a5250760 100644 --- a/images/x-shadow-16.svg +++ b/images/x-shadow-16.svg @@ -1 +1 @@ -x-shadow-12 + \ No newline at end of file diff --git a/js/delivery_receipts.js b/js/delivery_receipts.js index 44be4b5e56..09fd1826fb 100644 --- a/js/delivery_receipts.js +++ b/js/delivery_receipts.js @@ -100,9 +100,7 @@ await message.setToExpire(false, { skipSave: true }); } - await window.Signal.Data.saveMessage(message.attributes, { - Message: Whisper.Message, - }); + window.Signal.Util.queueUpdateMessage(message.attributes); // notify frontend listeners const conversation = ConversationController.get( diff --git a/js/expiring_messages.js b/js/expiring_messages.js index 5618ca5f20..fb181937ab 100644 --- a/js/expiring_messages.js +++ b/js/expiring_messages.js @@ -18,32 +18,38 @@ MessageCollection: Whisper.MessageCollection, }); - await Promise.all( - messages.map(async fromDB => { - const message = MessageController.register(fromDB.id, fromDB); + const messageIds = []; + const inMemoryMessages = []; + const messageCleanup = []; - window.log.info('Message expired', { - sentAt: message.get('sent_at'), - }); + messages.forEach(dbMessage => { + const message = MessageController.register(dbMessage.id, dbMessage); + messageIds.push(message.id); + inMemoryMessages.push(message); + messageCleanup.push(message.cleanup()); + }); - // We delete after the trigger to allow the conversation time to process - // the expiration before the message is removed from the database. - await window.Signal.Data.removeMessage(message.id, { - Message: Whisper.Message, - }); + // We delete after the trigger to allow the conversation time to process + // the expiration before the message is removed from the database. + await window.Signal.Data.removeMessages(messageIds); + await Promise.all(messageCleanup); - Whisper.events.trigger( - 'messageExpired', - message.id, - message.conversationId - ); + inMemoryMessages.forEach(message => { + window.log.info('Message expired', { + sentAt: message.get('sent_at'), + }); - const conversation = message.getConversation(); - if (conversation) { - conversation.trigger('expired', message); - } - }) - ); + Whisper.events.trigger( + 'messageExpired', + message.id, + message.conversationId + ); + + const conversation = message.getConversation(); + if (conversation) { + conversation.trigger('expired', message); + } + }); } catch (error) { window.log.error( 'destroyExpiredMessages: Error deleting expired messages', diff --git a/js/expiring_tap_to_view_messages.js b/js/expiring_tap_to_view_messages.js index 52ff70ea84..32cc027db5 100644 --- a/js/expiring_tap_to_view_messages.js +++ b/js/expiring_tap_to_view_messages.js @@ -58,7 +58,9 @@ return; } - const nextCheck = toAgeOut.get('received_at') + THIRTY_DAYS; + const receivedAt = + toAgeOut.get('received_at_ms') || toAgeOut.get('received_at'); + const nextCheck = receivedAt + THIRTY_DAYS; Whisper.TapToViewMessagesListener.nextCheck = nextCheck; window.log.info( diff --git a/js/message_controller.js b/js/message_controller.js index c42933b504..43f2bd85f3 100644 --- a/js/message_controller.js +++ b/js/message_controller.js @@ -6,6 +6,8 @@ window.Whisper = window.Whisper || {}; const messageLookup = Object.create(null); + const msgIDsBySender = new Map(); + const msgIDsBySentAt = new Map(); const SECOND = 1000; const MINUTE = SECOND * 60; @@ -31,10 +33,18 @@ timestamp: Date.now(), }; + msgIDsBySentAt.set(message.get('sent_at'), id); + msgIDsBySender.set(message.getSenderIdentifier(), id); + return message; } function unregister(id) { + const { message } = messageLookup[id] || {}; + if (message) { + msgIDsBySender.delete(message.getSenderIdentifier()); + msgIDsBySentAt.delete(message.get('sent_at')); + } delete messageLookup[id]; } @@ -50,7 +60,7 @@ now - timestamp > FIVE_MINUTES && (!conversation || !conversation.messageCollection.length) ) { - delete messageLookup[message.id]; + unregister(message.id); } } } @@ -60,6 +70,22 @@ return existing && existing.message ? existing.message : null; } + function findBySentAt(sentAt) { + const id = msgIDsBySentAt.get(sentAt); + if (!id) { + return null; + } + return getById(id); + } + + function findBySender(sender) { + const id = msgIDsBySender.get(sender); + if (!id) { + return null; + } + return getById(id); + } + function _get() { return messageLookup; } @@ -70,6 +96,8 @@ register, unregister, cleanup, + findBySender, + findBySentAt, getById, _get, }; diff --git a/js/modules/metadata/CiphertextMessage.js b/js/modules/metadata/CiphertextMessage.js deleted file mode 100644 index d38bdd0771..0000000000 --- a/js/modules/metadata/CiphertextMessage.js +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2018-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -module.exports = { - CURRENT_VERSION: 3, - - // This matches Envelope.Type.CIPHERTEXT - WHISPER_TYPE: 1, - // This matches Envelope.Type.PREKEY_BUNDLE - PREKEY_TYPE: 3, - - SENDERKEY_TYPE: 4, - SENDERKEY_DISTRIBUTION_TYPE: 5, - - ENCRYPTED_MESSAGE_OVERHEAD: 53, -}; diff --git a/js/modules/signal.js b/js/modules/signal.js index 1daad5214e..3331da9c3a 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -1,4 +1,4 @@ -// Copyright 2018-2020 Signal Messenger, LLC +// Copyright 2018-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // The idea with this file is to make it webpackable for the style guide @@ -21,8 +21,6 @@ const Stickers = require('./stickers'); const Settings = require('./settings'); const RemoteConfig = require('../../ts/RemoteConfig'); const Util = require('../../ts/util'); -const Metadata = require('./metadata/SecretSessionCipher'); -const RefreshSenderCertificate = require('./refresh_sender_certificate'); const LinkPreviews = require('./link_previews'); const AttachmentDownloads = require('./attachment_downloads'); @@ -83,6 +81,9 @@ const { createGroupV2JoinModal, } = require('../../ts/state/roots/createGroupV2JoinModal'); const { createLeftPane } = require('../../ts/state/roots/createLeftPane'); +const { + createMessageDetail, +} = require('../../ts/state/roots/createMessageDetail'); const { createGroupV2Permissions, } = require('../../ts/state/roots/createGroupV2Permissions'); @@ -346,6 +347,7 @@ exports.setup = (options = {}) => { createGroupV2JoinModal, createGroupV2Permissions, createLeftPane, + createMessageDetail, createPendingInvites, createSafetyNumberViewer, createShortcutGuideModal, @@ -428,11 +430,9 @@ exports.setup = (options = {}) => { GroupChange, IndexedDB, LinkPreviews, - Metadata, Migrations, Notifications, OS, - RefreshSenderCertificate, RemoteConfig, Settings, Services, diff --git a/js/notifications.js b/js/notifications.js index 0b2796925b..13685bb594 100644 --- a/js/notifications.js +++ b/js/notifications.js @@ -47,6 +47,7 @@ // isExpiringMessage: boolean; // reaction: { // emoji: string; + // fromId: string; // }; // } notificationData: null, @@ -56,16 +57,40 @@ this.update(); }, - removeBy({ conversationId, messageId }) { - const shouldClear = - Boolean(this.notificationData) && - ((conversationId && - this.notificationData.conversationId === conversationId) || - (messageId && this.notificationData.messageId === messageId)); - if (shouldClear) { - this.clear(); - this.update(); + // Remove the last notification if both conditions hold: + // + // 1. Either `conversationId` or `messageId` matches (if present) + // 2. `reactionFromId` matches (if present) + removeBy({ conversationId, messageId, reactionFromId }) { + if (!this.notificationData) { + return; } + + let shouldClear = false; + if ( + conversationId && + this.notificationData.conversationId === conversationId + ) { + shouldClear = true; + } + if (messageId && this.notificationData.messageId === messageId) { + shouldClear = true; + } + + if (!shouldClear) { + return; + } + + if ( + reactionFromId && + this.notificationData.reaction && + this.notificationData.reaction.fromId !== reactionFromId + ) { + return; + } + + this.clear(); + this.update(); }, fastUpdate() { diff --git a/js/reactions.js b/js/reactions.js index fd1d04f52c..8f8d01c738 100644 --- a/js/reactions.js +++ b/js/reactions.js @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only /* global @@ -31,7 +31,6 @@ const sentAt = message.get('sent_at'); const reactionsBySource = this.filter(re => { const targetSenderId = ConversationController.ensureContactIds({ - e164: re.get('targetAuthorE164'), uuid: re.get('targetAuthorUuid'), }); const targetTimestamp = re.get('targetTimestamp'); @@ -52,7 +51,6 @@ // to to figure that out. const targetConversation = await ConversationController.getConversationForTargetMessage( ConversationController.ensureContactIds({ - e164: reaction.get('targetAuthorE164'), uuid: reaction.get('targetAuthorUuid'), }), reaction.get('targetTimestamp') @@ -60,7 +58,6 @@ if (!targetConversation) { window.log.info( 'No target conversation for reaction', - reaction.get('targetAuthorE164'), reaction.get('targetAuthorUuid'), reaction.get('targetTimestamp') ); @@ -91,7 +88,6 @@ const mcid = contact.get('id'); const recid = ConversationController.ensureContactIds({ - e164: reaction.get('targetAuthorE164'), uuid: reaction.get('targetAuthorUuid'), }); return mcid === recid; @@ -100,7 +96,6 @@ if (!targetMessage) { window.log.info( 'No message for reaction', - reaction.get('targetAuthorE164'), reaction.get('targetAuthorUuid'), reaction.get('targetTimestamp') ); @@ -110,7 +105,6 @@ if (reaction.get('remove')) { this.remove(reaction); const oldReaction = this.where({ - targetAuthorE164: reaction.get('targetAuthorE164'), targetAuthorUuid: reaction.get('targetAuthorUuid'), targetTimestamp: reaction.get('targetTimestamp'), emoji: reaction.get('emoji'), diff --git a/js/read_receipts.js b/js/read_receipts.js index 0f1b173283..7bca12c425 100644 --- a/js/read_receipts.js +++ b/js/read_receipts.js @@ -101,9 +101,7 @@ await message.setToExpire(false, { skipSave: true }); } - await window.Signal.Data.saveMessage(message.attributes, { - Message: Whisper.Message, - }); + window.Signal.Util.queueUpdateMessage(message.attributes); // notify frontend listeners const conversation = ConversationController.get( diff --git a/js/read_syncs.js b/js/read_syncs.js index ca76472f5f..c90063c652 100644 --- a/js/read_syncs.js +++ b/js/read_syncs.js @@ -69,12 +69,26 @@ if (message.isUnread()) { await message.markRead(readAt, { skipSave: true }); - // onReadMessage may result in messages older than this one being - // marked read. We want those messages to have the same expire timer - // start time as this one, so we pass the readAt value through. - const conversation = message.getConversation(); - if (conversation) { - conversation.onReadMessage(message, readAt); + const updateConversation = () => { + // onReadMessage may result in messages older than this one being + // marked read. We want those messages to have the same expire timer + // start time as this one, so we pass the readAt value through. + const conversation = message.getConversation(); + if (conversation) { + conversation.onReadMessage(message, readAt); + } + }; + + if (window.startupProcessingQueue) { + const conversation = message.getConversation(); + if (conversation) { + window.startupProcessingQueue.add( + conversation.get('id'), + updateConversation + ); + } + } else { + updateConversation(); } } else { const now = Date.now(); @@ -94,9 +108,7 @@ } } - await window.Signal.Data.saveMessage(message.attributes, { - Message: Whisper.Message, - }); + window.Signal.Util.queueUpdateMessage(message.attributes); this.remove(receipt); } catch (error) { diff --git a/js/signal_protocol_store.js b/js/signal_protocol_store.js deleted file mode 100644 index 75bbc41ae1..0000000000 --- a/js/signal_protocol_store.js +++ /dev/null @@ -1,1050 +0,0 @@ -// Copyright 2016-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -/* global - dcodeIO, Backbone, _, libsignal, textsecure, ConversationController, stringObject */ - -/* eslint-disable no-proto */ - -// eslint-disable-next-line func-names -(function () { - const TIMESTAMP_THRESHOLD = 5 * 1000; // 5 seconds - const Direction = { - SENDING: 1, - RECEIVING: 2, - }; - - const VerifiedStatus = { - DEFAULT: 0, - VERIFIED: 1, - UNVERIFIED: 2, - }; - - function validateVerifiedStatus(status) { - if ( - status === VerifiedStatus.DEFAULT || - status === VerifiedStatus.VERIFIED || - status === VerifiedStatus.UNVERIFIED - ) { - return true; - } - return false; - } - - const StaticByteBufferProto = new dcodeIO.ByteBuffer().__proto__; - const StaticArrayBufferProto = new ArrayBuffer().__proto__; - const StaticUint8ArrayProto = new Uint8Array().__proto__; - - function isStringable(thing) { - return ( - thing === Object(thing) && - (thing.__proto__ === StaticArrayBufferProto || - thing.__proto__ === StaticUint8ArrayProto || - thing.__proto__ === StaticByteBufferProto) - ); - } - function convertToArrayBuffer(thing) { - if (thing === undefined) { - return undefined; - } - if (thing === Object(thing)) { - if (thing.__proto__ === StaticArrayBufferProto) { - return thing; - } - // TODO: Several more cases here... - } - - if (thing instanceof Array) { - // Assuming Uint16Array from curve25519 - const res = new ArrayBuffer(thing.length * 2); - const uint = new Uint16Array(res); - for (let i = 0; i < thing.length; i += 1) { - uint[i] = thing[i]; - } - return res; - } - - let str; - if (isStringable(thing)) { - str = stringObject(thing); - } else if (typeof thing === 'string') { - str = thing; - } else { - throw new Error( - `Tried to convert a non-stringable thing of type ${typeof thing} to an array buffer` - ); - } - const res = new ArrayBuffer(str.length); - const uint = new Uint8Array(res); - for (let i = 0; i < str.length; i += 1) { - uint[i] = str.charCodeAt(i); - } - return res; - } - - function equalArrayBuffers(ab1, ab2) { - if (!(ab1 instanceof ArrayBuffer && ab2 instanceof ArrayBuffer)) { - return false; - } - if (ab1.byteLength !== ab2.byteLength) { - return false; - } - let result = 0; - const ta1 = new Uint8Array(ab1); - const ta2 = new Uint8Array(ab2); - for (let i = 0; i < ab1.byteLength; i += 1) { - // eslint-disable-next-line no-bitwise - result |= ta1[i] ^ ta2[i]; - } - return result === 0; - } - - const IdentityRecord = Backbone.Model.extend({ - storeName: 'identityKeys', - validAttributes: [ - 'id', - 'publicKey', - 'firstUse', - 'timestamp', - 'verified', - 'nonblockingApproval', - ], - validate(attrs) { - const attributeNames = _.keys(attrs); - const { validAttributes } = this; - const allValid = _.all(attributeNames, attributeName => - _.contains(validAttributes, attributeName) - ); - if (!allValid) { - return new Error('Invalid identity key attribute names'); - } - const allPresent = _.all(validAttributes, attributeName => - _.contains(attributeNames, attributeName) - ); - if (!allPresent) { - return new Error('Missing identity key attributes'); - } - - if (typeof attrs.id !== 'string') { - return new Error('Invalid identity key id'); - } - if (!(attrs.publicKey instanceof ArrayBuffer)) { - return new Error('Invalid identity key publicKey'); - } - if (typeof attrs.firstUse !== 'boolean') { - return new Error('Invalid identity key firstUse'); - } - if (typeof attrs.timestamp !== 'number' || !(attrs.timestamp >= 0)) { - return new Error('Invalid identity key timestamp'); - } - if (!validateVerifiedStatus(attrs.verified)) { - return new Error('Invalid identity key verified'); - } - if (typeof attrs.nonblockingApproval !== 'boolean') { - return new Error('Invalid identity key nonblockingApproval'); - } - - return null; - }, - }); - - async function normalizeEncodedAddress(encodedAddress) { - const [identifier, deviceId] = textsecure.utils.unencodeNumber( - encodedAddress - ); - try { - const conv = ConversationController.getOrCreate(identifier, 'private'); - return `${conv.get('id')}.${deviceId}`; - } catch (e) { - window.log.error( - `could not get conversation for identifier ${identifier}` - ); - throw e; - } - } - - function SignalProtocolStore() {} - - async function _hydrateCache(object, field, itemsPromise, idField) { - const items = await itemsPromise; - - const cache = Object.create(null); - for (let i = 0, max = items.length; i < max; i += 1) { - const item = items[i]; - const id = item[idField]; - - cache[id] = item; - } - - window.log.info(`SignalProtocolStore: Finished caching ${field} data`); - // eslint-disable-next-line no-param-reassign - object[field] = cache; - } - - SignalProtocolStore.prototype = { - constructor: SignalProtocolStore, - async hydrateCaches() { - await Promise.all([ - (async () => { - const item = await window.Signal.Data.getItemById('identityKey'); - this.ourIdentityKey = item ? item.value : undefined; - })(), - (async () => { - const item = await window.Signal.Data.getItemById('registrationId'); - this.ourRegistrationId = item ? item.value : undefined; - })(), - _hydrateCache( - this, - 'identityKeys', - window.Signal.Data.getAllIdentityKeys(), - 'id' - ), - _hydrateCache( - this, - 'sessions', - await window.Signal.Data.getAllSessions(), - 'id' - ), - _hydrateCache( - this, - 'preKeys', - window.Signal.Data.getAllPreKeys(), - 'id' - ), - _hydrateCache( - this, - 'signedPreKeys', - window.Signal.Data.getAllSignedPreKeys(), - 'id' - ), - ]); - }, - - async getIdentityKeyPair() { - return this.ourIdentityKey; - }, - async getLocalRegistrationId() { - return this.ourRegistrationId; - }, - - // PreKeys - - async loadPreKey(keyId) { - const key = this.preKeys[keyId]; - if (key) { - window.log.info('Successfully fetched prekey:', keyId); - return { - pubKey: key.publicKey, - privKey: key.privateKey, - }; - } - - window.log.error('Failed to fetch prekey:', keyId); - return undefined; - }, - async storePreKey(keyId, keyPair) { - const data = { - id: keyId, - publicKey: keyPair.pubKey, - privateKey: keyPair.privKey, - }; - - this.preKeys[keyId] = data; - await window.Signal.Data.createOrUpdatePreKey(data); - }, - async removePreKey(keyId) { - try { - this.trigger('removePreKey'); - } catch (error) { - window.log.error( - 'removePreKey error triggering removePreKey:', - error && error.stack ? error.stack : error - ); - } - - delete this.preKeys[keyId]; - await window.Signal.Data.removePreKeyById(keyId); - }, - async clearPreKeyStore() { - this.preKeys = Object.create(null); - await window.Signal.Data.removeAllPreKeys(); - }, - - // Signed PreKeys - - async loadSignedPreKey(keyId) { - const key = this.signedPreKeys[keyId]; - if (key) { - window.log.info('Successfully fetched signed prekey:', key.id); - return { - pubKey: key.publicKey, - privKey: key.privateKey, - created_at: key.created_at, - keyId: key.id, - confirmed: key.confirmed, - }; - } - - window.log.error('Failed to fetch signed prekey:', keyId); - return undefined; - }, - async loadSignedPreKeys() { - if (arguments.length > 0) { - throw new Error('loadSignedPreKeys takes no arguments'); - } - - const keys = Object.values(this.signedPreKeys); - return keys.map(prekey => ({ - pubKey: prekey.publicKey, - privKey: prekey.privateKey, - created_at: prekey.created_at, - keyId: prekey.id, - confirmed: prekey.confirmed, - })); - }, - async storeSignedPreKey(keyId, keyPair, confirmed) { - const data = { - id: keyId, - publicKey: keyPair.pubKey, - privateKey: keyPair.privKey, - created_at: Date.now(), - confirmed: Boolean(confirmed), - }; - - this.signedPreKeys[keyId] = data; - await window.Signal.Data.createOrUpdateSignedPreKey(data); - }, - async removeSignedPreKey(keyId) { - delete this.signedPreKeys[keyId]; - await window.Signal.Data.removeSignedPreKeyById(keyId); - }, - async clearSignedPreKeysStore() { - this.signedPreKeys = Object.create(null); - await window.Signal.Data.removeAllSignedPreKeys(); - }, - - // Sessions - - async loadSession(encodedAddress) { - if (encodedAddress === null || encodedAddress === undefined) { - throw new Error('Tried to get session for undefined/null number'); - } - - try { - const id = await normalizeEncodedAddress(encodedAddress); - const session = this.sessions[id]; - - if (session) { - return session.record; - } - } catch (error) { - const errorString = error && error.stack ? error.stack : error; - window.log.error( - `could not load session ${encodedAddress}: ${errorString}` - ); - } - - return undefined; - }, - async storeSession(encodedAddress, record) { - if (encodedAddress === null || encodedAddress === undefined) { - throw new Error('Tried to put session for undefined/null number'); - } - const unencoded = textsecure.utils.unencodeNumber(encodedAddress); - const deviceId = parseInt(unencoded[1], 10); - - try { - const id = await normalizeEncodedAddress(encodedAddress); - const previousData = this.sessions[id]; - - const data = { - id, - conversationId: textsecure.utils.unencodeNumber(id)[0], - deviceId, - record, - }; - - // Optimistically update in-memory cache; will revert if save fails. - this.sessions[id] = data; - - try { - await window.Signal.Data.createOrUpdateSession(data); - } catch (e) { - if (previousData) { - this.sessions[id] = previousData; - } - throw e; - } - } catch (error) { - const errorString = error && error.stack ? error.stack : error; - window.log.error( - `could not store session for ${encodedAddress}: ${errorString}` - ); - } - }, - async getDeviceIds(identifier) { - if (identifier === null || identifier === undefined) { - throw new Error('Tried to get device ids for undefined/null number'); - } - - try { - const id = ConversationController.getConversationId(identifier); - const allSessions = Object.values(this.sessions); - const sessions = allSessions.filter( - session => session.conversationId === id - ); - const openSessions = await Promise.all( - sessions.map(async session => { - const sessionCipher = new libsignal.SessionCipher( - textsecure.storage.protocol, - session.id - ); - - const hasOpenSession = await sessionCipher.hasOpenSession(); - if (hasOpenSession) { - return session; - } - - return undefined; - }) - ); - - return openSessions.filter(Boolean).map(item => item.deviceId); - } catch (error) { - window.log.error( - `could not get device ids for identifier ${identifier}`, - error && error.stack ? error.stack : error - ); - } - - return []; - }, - async removeSession(encodedAddress) { - window.log.info('removeSession: deleting session for', encodedAddress); - try { - const id = await normalizeEncodedAddress(encodedAddress); - delete this.sessions[id]; - await window.Signal.Data.removeSessionById(id); - } catch (e) { - window.log.error(`could not delete session for ${encodedAddress}`); - } - }, - async removeAllSessions(identifier) { - if (identifier === null || identifier === undefined) { - throw new Error('Tried to remove sessions for undefined/null number'); - } - - window.log.info('removeAllSessions: deleting sessions for', identifier); - - const id = ConversationController.getConversationId(identifier); - - const allSessions = Object.values(this.sessions); - - for (let i = 0, max = allSessions.length; i < max; i += 1) { - const session = allSessions[i]; - if (session.conversationId === id) { - delete this.sessions[session.id]; - } - } - - await window.Signal.Data.removeSessionsByConversation(identifier); - }, - async archiveSiblingSessions(identifier) { - window.log.info( - 'archiveSiblingSessions: archiving sibling sessions for', - identifier - ); - - const address = libsignal.SignalProtocolAddress.fromString(identifier); - - const deviceIds = await this.getDeviceIds(address.getName()); - const siblings = _.without(deviceIds, address.getDeviceId()); - - await Promise.all( - siblings.map(async deviceId => { - const sibling = new libsignal.SignalProtocolAddress( - address.getName(), - deviceId - ); - window.log.info( - 'archiveSiblingSessions: closing session for', - sibling.toString() - ); - const sessionCipher = new libsignal.SessionCipher( - textsecure.storage.protocol, - sibling - ); - await sessionCipher.closeOpenSessionForDevice(); - }) - ); - }, - async archiveAllSessions(identifier) { - window.log.info( - 'archiveAllSessions: archiving all sessions for', - identifier - ); - - const deviceIds = await this.getDeviceIds(identifier); - - await Promise.all( - deviceIds.map(async deviceId => { - const address = new libsignal.SignalProtocolAddress( - identifier, - deviceId - ); - window.log.info( - 'archiveAllSessions: closing session for', - address.toString() - ); - const sessionCipher = new libsignal.SessionCipher( - textsecure.storage.protocol, - address - ); - await sessionCipher.closeOpenSessionForDevice(); - }) - ); - }, - async clearSessionStore() { - this.sessions = Object.create(null); - window.Signal.Data.removeAllSessions(); - }, - - // Identity Keys - - getIdentityRecord(identifier) { - try { - const id = ConversationController.getConversationId(identifier); - const record = this.identityKeys[id]; - - if (record) { - return record; - } - } catch (e) { - window.log.error( - `could not get identity record for identifier ${identifier}` - ); - } - - return undefined; - }, - - async isTrustedIdentity(encodedAddress, publicKey, direction) { - if (encodedAddress === null || encodedAddress === undefined) { - throw new Error('Tried to get identity key for undefined/null key'); - } - const identifier = textsecure.utils.unencodeNumber(encodedAddress)[0]; - const ourNumber = textsecure.storage.user.getNumber(); - const ourUuid = textsecure.storage.user.getUuid(); - const isOurIdentifier = - (ourNumber && identifier === ourNumber) || - (ourUuid && identifier === ourUuid); - - const identityRecord = this.getIdentityRecord(identifier); - - if (isOurIdentifier) { - if (identityRecord && identityRecord.publicKey) { - return equalArrayBuffers(identityRecord.publicKey, publicKey); - } - window.log.warn( - 'isTrustedIdentity: No local record for our own identifier. Returning true.' - ); - return true; - } - - switch (direction) { - case Direction.SENDING: - return this.isTrustedForSending(publicKey, identityRecord); - case Direction.RECEIVING: - return true; - default: - throw new Error(`Unknown direction: ${direction}`); - } - }, - isTrustedForSending(publicKey, identityRecord) { - if (!identityRecord) { - window.log.info( - 'isTrustedForSending: No previous record, returning true...' - ); - return true; - } - - const existing = identityRecord.publicKey; - - if (!existing) { - window.log.info('isTrustedForSending: Nothing here, returning true...'); - return true; - } - if (!equalArrayBuffers(existing, publicKey)) { - window.log.info("isTrustedForSending: Identity keys don't match..."); - return false; - } - if (identityRecord.verified === VerifiedStatus.UNVERIFIED) { - window.log.error('Needs unverified approval!'); - return false; - } - if (this.isNonBlockingApprovalRequired(identityRecord)) { - window.log.error('isTrustedForSending: Needs non-blocking approval!'); - return false; - } - - return true; - }, - async loadIdentityKey(identifier) { - if (identifier === null || identifier === undefined) { - throw new Error('Tried to get identity key for undefined/null key'); - } - const id = textsecure.utils.unencodeNumber(identifier)[0]; - const identityRecord = this.getIdentityRecord(id); - - if (identityRecord) { - return identityRecord.publicKey; - } - - return undefined; - }, - async _saveIdentityKey(data) { - const { id } = data; - - const previousData = this.identityKeys[id]; - - // Optimistically update in-memory cache; will revert if save fails. - this.identityKeys[id] = data; - - try { - await window.Signal.Data.createOrUpdateIdentityKey(data); - } catch (error) { - if (previousData) { - this.identityKeys[id] = previousData; - } - - throw error; - } - }, - async saveIdentity(encodedAddress, publicKey, nonblockingApproval) { - if (encodedAddress === null || encodedAddress === undefined) { - throw new Error('Tried to put identity key for undefined/null key'); - } - if (!(publicKey instanceof ArrayBuffer)) { - // eslint-disable-next-line no-param-reassign - publicKey = convertToArrayBuffer(publicKey); - } - if (typeof nonblockingApproval !== 'boolean') { - // eslint-disable-next-line no-param-reassign - nonblockingApproval = false; - } - - const identifier = textsecure.utils.unencodeNumber(encodedAddress)[0]; - const identityRecord = this.getIdentityRecord(identifier); - const id = ConversationController.getOrCreate(identifier, 'private').get( - 'id' - ); - - if (!identityRecord || !identityRecord.publicKey) { - // Lookup failed, or the current key was removed, so save this one. - window.log.info('Saving new identity...'); - await this._saveIdentityKey({ - id, - publicKey, - firstUse: true, - timestamp: Date.now(), - verified: VerifiedStatus.DEFAULT, - nonblockingApproval, - }); - - return false; - } - - const oldpublicKey = identityRecord.publicKey; - if (!equalArrayBuffers(oldpublicKey, publicKey)) { - window.log.info('Replacing existing identity...'); - const previousStatus = identityRecord.verified; - let verifiedStatus; - if ( - previousStatus === VerifiedStatus.VERIFIED || - previousStatus === VerifiedStatus.UNVERIFIED - ) { - verifiedStatus = VerifiedStatus.UNVERIFIED; - } else { - verifiedStatus = VerifiedStatus.DEFAULT; - } - - await this._saveIdentityKey({ - id, - publicKey, - firstUse: false, - timestamp: Date.now(), - verified: verifiedStatus, - nonblockingApproval, - }); - - try { - this.trigger('keychange', identifier); - } catch (error) { - window.log.error( - 'saveIdentity error triggering keychange:', - error && error.stack ? error.stack : error - ); - } - await this.archiveSiblingSessions(encodedAddress); - - return true; - } - if (this.isNonBlockingApprovalRequired(identityRecord)) { - window.log.info('Setting approval status...'); - - identityRecord.nonblockingApproval = nonblockingApproval; - await this._saveIdentityKey(identityRecord); - - return false; - } - - return false; - }, - isNonBlockingApprovalRequired(identityRecord) { - return ( - !identityRecord.firstUse && - Date.now() - identityRecord.timestamp < TIMESTAMP_THRESHOLD && - !identityRecord.nonblockingApproval - ); - }, - async saveIdentityWithAttributes(encodedAddress, attributes) { - if (encodedAddress === null || encodedAddress === undefined) { - throw new Error('Tried to put identity key for undefined/null key'); - } - - const identifier = textsecure.utils.unencodeNumber(encodedAddress)[0]; - const identityRecord = this.getIdentityRecord(identifier); - const conv = ConversationController.getOrCreate(identifier, 'private'); - const id = conv.get('id'); - - const updates = { - id, - ...identityRecord, - ...attributes, - }; - - const model = new IdentityRecord(updates); - if (model.isValid()) { - await this._saveIdentityKey(updates); - } else { - throw model.validationError; - } - }, - async setApproval(encodedAddress, nonblockingApproval) { - if (encodedAddress === null || encodedAddress === undefined) { - throw new Error('Tried to set approval for undefined/null identifier'); - } - if (typeof nonblockingApproval !== 'boolean') { - throw new Error('Invalid approval status'); - } - - const identifier = textsecure.utils.unencodeNumber(encodedAddress)[0]; - const identityRecord = this.getIdentityRecord(identifier); - - if (!identityRecord) { - throw new Error(`No identity record for ${identifier}`); - } - - identityRecord.nonblockingApproval = nonblockingApproval; - await this._saveIdentityKey(identityRecord); - }, - async setVerified(encodedAddress, verifiedStatus, publicKey) { - if (encodedAddress === null || encodedAddress === undefined) { - throw new Error('Tried to set verified for undefined/null key'); - } - if (!validateVerifiedStatus(verifiedStatus)) { - throw new Error('Invalid verified status'); - } - if (arguments.length > 2 && !(publicKey instanceof ArrayBuffer)) { - throw new Error('Invalid public key'); - } - - const identityRecord = this.getIdentityRecord(encodedAddress); - - if (!identityRecord) { - throw new Error(`No identity record for ${encodedAddress}`); - } - - if ( - !publicKey || - equalArrayBuffers(identityRecord.publicKey, publicKey) - ) { - identityRecord.verified = verifiedStatus; - - const model = new IdentityRecord(identityRecord); - if (model.isValid()) { - await this._saveIdentityKey(identityRecord); - } else { - throw identityRecord.validationError; - } - } else { - window.log.info('No identity record for specified publicKey'); - } - }, - async getVerified(identifier) { - if (identifier === null || identifier === undefined) { - throw new Error('Tried to set verified for undefined/null key'); - } - - const identityRecord = this.getIdentityRecord(identifier); - if (!identityRecord) { - throw new Error(`No identity record for ${identifier}`); - } - - const verifiedStatus = identityRecord.verified; - if (validateVerifiedStatus(verifiedStatus)) { - return verifiedStatus; - } - - return VerifiedStatus.DEFAULT; - }, - // Resolves to true if a new identity key was saved - processContactSyncVerificationState(identifier, verifiedStatus, publicKey) { - if (verifiedStatus === VerifiedStatus.UNVERIFIED) { - return this.processUnverifiedMessage( - identifier, - verifiedStatus, - publicKey - ); - } - return this.processVerifiedMessage(identifier, verifiedStatus, publicKey); - }, - // This function encapsulates the non-Java behavior, since the mobile apps don't - // currently receive contact syncs and therefore will see a verify sync with - // UNVERIFIED status - async processUnverifiedMessage(identifier, verifiedStatus, publicKey) { - if (identifier === null || identifier === undefined) { - throw new Error('Tried to set verified for undefined/null key'); - } - if (publicKey !== undefined && !(publicKey instanceof ArrayBuffer)) { - throw new Error('Invalid public key'); - } - - const identityRecord = this.getIdentityRecord(identifier); - - const isPresent = Boolean(identityRecord); - let isEqual = false; - - if (isPresent && publicKey) { - isEqual = equalArrayBuffers(publicKey, identityRecord.publicKey); - } - - if ( - isPresent && - isEqual && - identityRecord.verified !== VerifiedStatus.UNVERIFIED - ) { - await textsecure.storage.protocol.setVerified( - identifier, - verifiedStatus, - publicKey - ); - return false; - } - - if (!isPresent || !isEqual) { - await textsecure.storage.protocol.saveIdentityWithAttributes( - identifier, - { - publicKey, - verified: verifiedStatus, - firstUse: false, - timestamp: Date.now(), - nonblockingApproval: true, - } - ); - - if (isPresent && !isEqual) { - try { - this.trigger('keychange', identifier); - } catch (error) { - window.log.error( - 'processUnverifiedMessage error triggering keychange:', - error && error.stack ? error.stack : error - ); - } - - await this.archiveAllSessions(identifier); - - return true; - } - } - - // The situation which could get us here is: - // 1. had a previous key - // 2. new key is the same - // 3. desired new status is same as what we had before - return false; - }, - // This matches the Java method as of - // https://github.com/signalapp/Signal-Android/blob/d0bb68e1378f689e4d10ac6a46014164992ca4e4/src/org/thoughtcrime/securesms/util/IdentityUtil.java#L188 - async processVerifiedMessage(identifier, verifiedStatus, publicKey) { - if (identifier === null || identifier === undefined) { - throw new Error('Tried to set verified for undefined/null key'); - } - if (!validateVerifiedStatus(verifiedStatus)) { - throw new Error('Invalid verified status'); - } - if (publicKey !== undefined && !(publicKey instanceof ArrayBuffer)) { - throw new Error('Invalid public key'); - } - - const identityRecord = this.getIdentityRecord(identifier); - - const isPresent = Boolean(identityRecord); - let isEqual = false; - - if (isPresent && publicKey) { - isEqual = equalArrayBuffers(publicKey, identityRecord.publicKey); - } - - if (!isPresent && verifiedStatus === VerifiedStatus.DEFAULT) { - window.log.info('No existing record for default status'); - return false; - } - - if ( - isPresent && - isEqual && - identityRecord.verified !== VerifiedStatus.DEFAULT && - verifiedStatus === VerifiedStatus.DEFAULT - ) { - await textsecure.storage.protocol.setVerified( - identifier, - verifiedStatus, - publicKey - ); - return false; - } - - if ( - verifiedStatus === VerifiedStatus.VERIFIED && - (!isPresent || - (isPresent && !isEqual) || - (isPresent && identityRecord.verified !== VerifiedStatus.VERIFIED)) - ) { - await textsecure.storage.protocol.saveIdentityWithAttributes( - identifier, - { - publicKey, - verified: verifiedStatus, - firstUse: false, - timestamp: Date.now(), - nonblockingApproval: true, - } - ); - - if (isPresent && !isEqual) { - try { - this.trigger('keychange', identifier); - } catch (error) { - window.log.error( - 'processVerifiedMessage error triggering keychange:', - error && error.stack ? error.stack : error - ); - } - - await this.archiveAllSessions(identifier); - - // true signifies that we overwrote a previous key with a new one - return true; - } - } - - // We get here if we got a new key and the status is DEFAULT. If the - // message is out of date, we don't want to lose whatever more-secure - // state we had before. - return false; - }, - isUntrusted(identifier) { - if (identifier === null || identifier === undefined) { - throw new Error('Tried to set verified for undefined/null key'); - } - - const identityRecord = this.getIdentityRecord(identifier); - if (!identityRecord) { - throw new Error(`No identity record for ${identifier}`); - } - - if ( - Date.now() - identityRecord.timestamp < TIMESTAMP_THRESHOLD && - !identityRecord.nonblockingApproval && - !identityRecord.firstUse - ) { - return true; - } - - return false; - }, - async removeIdentityKey(identifier) { - const id = ConversationController.getConversationId(identifier); - if (id) { - delete this.identityKeys[id]; - await window.Signal.Data.removeIdentityKeyById(id); - await textsecure.storage.protocol.removeAllSessions(id); - } - }, - - // Not yet processed messages - for resiliency - getUnprocessedCount() { - return window.Signal.Data.getUnprocessedCount(); - }, - getAllUnprocessed() { - return window.Signal.Data.getAllUnprocessed(); - }, - getUnprocessedById(id) { - return window.Signal.Data.getUnprocessedById(id); - }, - addUnprocessed(data) { - // We need to pass forceSave because the data has an id already, which will cause - // an update instead of an insert. - return window.Signal.Data.saveUnprocessed(data, { - forceSave: true, - }); - }, - addMultipleUnprocessed(array) { - // We need to pass forceSave because the data has an id already, which will cause - // an update instead of an insert. - return window.Signal.Data.saveUnprocesseds(array, { - forceSave: true, - }); - }, - updateUnprocessedAttempts(id, attempts) { - return window.Signal.Data.updateUnprocessedAttempts(id, attempts); - }, - updateUnprocessedWithData(id, data) { - return window.Signal.Data.updateUnprocessedWithData(id, data); - }, - updateUnprocessedsWithData(items) { - return window.Signal.Data.updateUnprocessedsWithData(items); - }, - removeUnprocessed(idOrArray) { - return window.Signal.Data.removeUnprocessed(idOrArray); - }, - removeAllUnprocessed() { - return window.Signal.Data.removeAllUnprocessed(); - }, - async removeAllData() { - await window.Signal.Data.removeAll(); - await this.hydrateCaches(); - - window.storage.reset(); - await window.storage.fetch(); - - ConversationController.reset(); - await ConversationController.load(); - }, - async removeAllConfiguration() { - await window.Signal.Data.removeAllConfiguration(); - await this.hydrateCaches(); - - window.storage.reset(); - await window.storage.fetch(); - }, - }; - _.extend(SignalProtocolStore.prototype, Backbone.Events); - - window.SignalProtocolStore = SignalProtocolStore; - window.SignalProtocolStore.prototype.Direction = Direction; - window.SignalProtocolStore.prototype.VerifiedStatus = VerifiedStatus; -})(); diff --git a/js/views/banner_view.js b/js/views/banner_view.js index 70eea630bf..92e9e6f06f 100644 --- a/js/views/banner_view.js +++ b/js/views/banner_view.js @@ -1,7 +1,7 @@ // Copyright 2017-2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global Whisper */ +/* global Whisper, $ */ // eslint-disable-next-line func-names (function () { @@ -9,7 +9,7 @@ Whisper.BannerView = Whisper.View.extend({ className: 'banner', - templateName: 'banner', + template: () => $('#banner').html(), events: { 'click .dismiss': 'onDismiss', 'click .body': 'onClick', diff --git a/js/views/clear_data_view.js b/js/views/clear_data_view.js index ebdae23e46..b09acaac86 100644 --- a/js/views/clear_data_view.js +++ b/js/views/clear_data_view.js @@ -1,10 +1,7 @@ // Copyright 2018-2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global i18n: false */ -/* global Whisper: false */ - -/* eslint-disable no-new */ +/* global i18n, Whisper, $ */ // eslint-disable-next-line func-names (function () { @@ -16,7 +13,7 @@ DELETING: 2, }; window.Whisper.ClearDataView = Whisper.View.extend({ - templateName: 'clear-data', + template: () => $('#clear-data').html(), className: 'full-screen-flow overlay', events: { 'click .cancel': 'onCancel', diff --git a/js/views/contact_list_view.js b/js/views/contact_list_view.js index fdbe5d2b69..39c32e6133 100644 --- a/js/views/contact_list_view.js +++ b/js/views/contact_list_view.js @@ -1,8 +1,7 @@ // Copyright 2015-2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global Whisper: false */ -/* global textsecure: false */ +/* global Whisper, textsecure, $ */ // eslint-disable-next-line func-names (function () { @@ -13,7 +12,7 @@ itemView: Whisper.View.extend({ tagName: 'div', className: 'contact', - templateName: 'contact', + template: () => $('#contact').html(), initialize(options) { this.ourNumber = textsecure.storage.user.getNumber(); this.listenBack = options.listenBack; diff --git a/js/views/debug_log_view.js b/js/views/debug_log_view.js index c4b737679a..239ae7da36 100644 --- a/js/views/debug_log_view.js +++ b/js/views/debug_log_view.js @@ -1,8 +1,7 @@ // Copyright 2015-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global i18n: false */ -/* global Whisper: false */ +/* global i18n, Whisper, $ */ // eslint-disable-next-line func-names (function () { @@ -31,7 +30,7 @@ }); Whisper.DebugLogLinkView = Whisper.View.extend({ - templateName: 'debug-log-link', + template: () => $('#debug-log-link').html(), initialize(options) { this.url = options.url; }, @@ -66,7 +65,7 @@ * edit them in their own editor. This is mostly a stopgap solution. */ Whisper.DebugLogView = Whisper.View.extend({ - templateName: 'debug-log', + template: () => $('#debug-log').html(), className: 'debug-log modal', initialize() { this.render(); diff --git a/js/views/group_member_list_view.js b/js/views/group_member_list_view.js index d0c75b6161..2ade0085aa 100644 --- a/js/views/group_member_list_view.js +++ b/js/views/group_member_list_view.js @@ -1,16 +1,15 @@ // Copyright 2015-2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global Whisper, i18n */ +/* global Whisper, i18n, $ */ // eslint-disable-next-line func-names (function () { window.Whisper = window.Whisper || {}; - // TODO: take a title string which could replace the 'members' header Whisper.GroupMemberList = Whisper.View.extend({ className: 'group-member-list panel', - templateName: 'group-member-list', + template: () => $('#group-member-list').html(), initialize(options) { this.needVerify = options.needVerify; diff --git a/js/views/identicon_svg_view.js b/js/views/identicon_svg_view.js index 60b51999dd..7bad681e71 100644 --- a/js/views/identicon_svg_view.js +++ b/js/views/identicon_svg_view.js @@ -1,7 +1,7 @@ -// Copyright 2015-2020 Signal Messenger, LLC +// Copyright 2015-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global Whisper, loadImage */ +/* global Whisper, loadImage, $ */ // eslint-disable-next-line func-names (function () { @@ -11,7 +11,7 @@ * Render an avatar identicon to an svg for use in a notification. */ Whisper.IdenticonSVGView = Whisper.View.extend({ - templateName: 'identicon-svg', + template: () => $('#identicon-svg').html(), initialize(options) { this.render_attributes = options; this.render_attributes.color = COLORS[this.render_attributes.color]; @@ -21,7 +21,7 @@ const svg = new Blob([html], { type: 'image/svg+xml;charset=utf-8' }); return URL.createObjectURL(svg); }, - getDataUrl() { + getDataUrl() /* : Promise */ { const svgurl = this.getSVGUrl(); return new Promise(resolve => { const img = document.createElement('img'); @@ -36,6 +36,11 @@ URL.revokeObjectURL(svgurl); resolve(canvas.toDataURL('image/png')); }; + img.onerror = () => { + URL.revokeObjectURL(svgurl); + // If this fails for some reason, we'd rather continue on than reject. + resolve(undefined); + }; img.src = svgurl; }); diff --git a/js/views/inbox_view.js b/js/views/inbox_view.js index 535a241909..f6db2621e6 100644 --- a/js/views/inbox_view.js +++ b/js/views/inbox_view.js @@ -5,7 +5,8 @@ ConversationController, i18n, Whisper, - Signal + Signal, + $ */ // eslint-disable-next-line func-names @@ -60,7 +61,7 @@ }); Whisper.AppLoadingScreen = Whisper.View.extend({ - templateName: 'app-loading-screen', + template: () => $('#app-loading-screen').html(), className: 'app-loading-screen', updateProgress(count) { if (count > 0) { @@ -74,7 +75,7 @@ }); Whisper.InboxView = Whisper.View.extend({ - templateName: 'two-column', + template: () => $('#two-column').html(), className: 'inbox index', initialize(options = {}) { this.ready = false; @@ -212,7 +213,7 @@ const { openConversationExternal } = window.reduxActions.conversations; if (openConversationExternal) { - openConversationExternal(id, messageId); + openConversationExternal(conversation.id, messageId); } this.conversation_stack.open(conversation, messageId); diff --git a/js/views/install_view.js b/js/views/install_view.js index 221ccb888f..58c4d796d5 100644 --- a/js/views/install_view.js +++ b/js/views/install_view.js @@ -24,7 +24,7 @@ const TOO_OLD = 409; Whisper.InstallView = Whisper.View.extend({ - templateName: 'link-flow-template', + template: () => $('#link-flow-template').html(), className: 'main full-screen-flow', events: { 'click .try-again': 'connect', @@ -193,17 +193,7 @@ this.setDeviceNameDefault(); return new Promise(resolve => { - this.$('#link-phone').submit(e => { - e.stopPropagation(); - e.preventDefault(); - - let name = this.$(DEVICE_NAME_SELECTOR).val(); - name = name.replace(/\0/g, ''); // strip unicode null - if (name.trim().length === 0) { - this.$(DEVICE_NAME_SELECTOR).focus(); - return null; - } - + const onDeviceName = name => { this.selectStep(Steps.PROGRESS_BAR); const finish = () => { @@ -227,6 +217,26 @@ ); return finish(); }); + }; + + if (window.CI) { + onDeviceName(window.CI.deviceName); + return; + } + + // eslint-disable-next-line consistent-return + this.$('#link-phone').submit(e => { + e.stopPropagation(); + e.preventDefault(); + + let name = this.$(DEVICE_NAME_SELECTOR).val(); + name = name.replace(/\0/g, ''); // strip unicode null + if (name.trim().length === 0) { + this.$(DEVICE_NAME_SELECTOR).focus(); + return null; + } + + onDeviceName(name); }); }); }, diff --git a/js/views/key_verification_view.js b/js/views/key_verification_view.js index 116f3cce94..d5c49a68e7 100644 --- a/js/views/key_verification_view.js +++ b/js/views/key_verification_view.js @@ -1,9 +1,7 @@ // Copyright 2015-2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global Backbone, Signal, Whisper */ - -/* eslint-disable more/no-then */ +/* global Backbone, Signal, Whisper, $ */ // eslint-disable-next-line func-names (function () { @@ -11,7 +9,7 @@ Whisper.KeyVerificationPanelView = Whisper.View.extend({ className: 'panel', - templateName: 'key-verification', + template: () => $('#key-verification').html(), initialize(options) { this.render(); diff --git a/js/views/phone-input-view.js b/js/views/phone-input-view.js index 8e185fcc96..1b74d6500d 100644 --- a/js/views/phone-input-view.js +++ b/js/views/phone-input-view.js @@ -1,7 +1,7 @@ // Copyright 2015-2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global libphonenumber, Whisper */ +/* global libphonenumber, Whisper, $ */ // eslint-disable-next-line func-names (function () { @@ -10,7 +10,7 @@ Whisper.PhoneInputView = Whisper.View.extend({ tagName: 'div', className: 'phone-input', - templateName: 'phone-number', + template: () => $('#phone-number').html(), initialize() { this.$('input.number').intlTelInput(); }, diff --git a/js/views/recorder_view.js b/js/views/recorder_view.js index b481da405c..a982f9f4ff 100644 --- a/js/views/recorder_view.js +++ b/js/views/recorder_view.js @@ -11,7 +11,7 @@ Whisper.RecorderView = Whisper.View.extend({ className: 'recorder clearfix', - templateName: 'recorder', + template: () => $('#recorder').html(), initialize() { this.startTime = Date.now(); this.interval = setInterval(this.updateTime.bind(this), 1000); diff --git a/js/views/safety_number_change_dialog_view.js b/js/views/safety_number_change_dialog_view.js index 64ff820016..792ae258e1 100644 --- a/js/views/safety_number_change_dialog_view.js +++ b/js/views/safety_number_change_dialog_view.js @@ -1,14 +1,14 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global Whisper, Signal */ +/* global Whisper, Signal, $ */ // eslint-disable-next-line func-names (function () { window.Whisper = window.Whisper || {}; Whisper.SafetyNumberChangeDialogView = Whisper.View.extend({ - templateName: 'safety-number-change-dialog', + template: () => $('#safety-number-change-dialog').html(), initialize(options) { const dialog = new Whisper.ReactWrapperView({ Component: window.Signal.Components.SafetyNumberChangeDialog, diff --git a/js/views/settings_view.js b/js/views/settings_view.js index d5eff2de3e..ebc652996d 100644 --- a/js/views/settings_view.js +++ b/js/views/settings_view.js @@ -91,7 +91,7 @@ }); Whisper.SettingsView = Whisper.View.extend({ className: 'settings modal expand', - templateName: 'settings', + template: () => $('#settings').html(), initialize() { this.render(); new RadioButtonGroupView({ @@ -271,7 +271,7 @@ }); const SyncView = Whisper.View.extend({ - templateName: 'syncSettings', + template: () => $('#syncSettings').html(), className: 'syncSettings', events: { 'click .sync': 'sync', diff --git a/js/views/standalone_registration_view.js b/js/views/standalone_registration_view.js index 3df07054c2..d5dfe9251f 100644 --- a/js/views/standalone_registration_view.js +++ b/js/views/standalone_registration_view.js @@ -10,7 +10,7 @@ window.Whisper = window.Whisper || {}; Whisper.StandaloneRegistrationView = Whisper.View.extend({ - templateName: 'standalone', + template: () => $('#standalone').html(), className: 'full-screen-flow', initialize() { window.readyForUpdates(); diff --git a/js/views/toast_view.js b/js/views/toast_view.js deleted file mode 100644 index 51e14a2132..0000000000 --- a/js/views/toast_view.js +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2015-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -/* global Whisper, Mustache, _ */ - -// eslint-disable-next-line func-names -(function () { - window.Whisper = window.Whisper || {}; - - Whisper.ToastView = Whisper.View.extend({ - className: 'toast', - templateName: 'toast', - initialize() { - this.$el.hide(); - this.timeout = 2000; - }, - - close() { - this.$el.fadeOut(this.remove.bind(this)); - }, - - render() { - this.$el.html( - Mustache.render( - _.result(this, 'template', ''), - _.result(this, 'render_attributes', '') - ) - ); - this.$el.attr('tabIndex', 0); - this.$el.show(); - setTimeout(this.close.bind(this), this.timeout); - }, - }); - - Whisper.ToastView.show = (View, el) => { - const toast = new View(); - toast.$el.appendTo(el); - toast.render(); - }; -})(); diff --git a/js/views/whisper_view.js b/js/views/whisper_view.js deleted file mode 100644 index 4fb4fbaddf..0000000000 --- a/js/views/whisper_view.js +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright 2015-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -/* global Whisper, Backbone, Mustache, _, $ */ - -/* - * Whisper.View - * - * This is the base for most of our views. The Backbone view is extended - * with some conveniences: - * - * 1. Pre-parses all our mustache templates for performance. - * https://github.com/janl/mustache.js#pre-parsing-and-caching-templates - * - * 2. Defines a default definition for render() which allows sub-classes - * to simply specify a templateName and renderAttributes which are plugged - * into Mustache.render - * - * 3. Makes all the templates available for rendering as partials. - * https://github.com/janl/mustache.js#partials - * - * 4. Provides some common functionality, e.g. confirmation dialog - * - */ - -// eslint-disable-next-line func-names -(function () { - window.Whisper = window.Whisper || {}; - - Whisper.View = Backbone.View.extend( - { - constructor(...params) { - Backbone.View.call(this, ...params); - Mustache.parse(_.result(this, 'template')); - }, - render_attributes() { - return _.result(this.model, 'attributes', {}); - }, - render_partials() { - return Whisper.View.Templates; - }, - template() { - if (this.templateName) { - return Whisper.View.Templates[this.templateName]; - } - return ''; - }, - render() { - const attrs = _.result(this, 'render_attributes', {}); - const template = _.result(this, 'template', ''); - const partials = _.result(this, 'render_partials', ''); - this.$el.html(Mustache.render(template, attrs, partials)); - return this; - }, - confirm(message, okText) { - return new Promise((resolve, reject) => { - window.showConfirmationDialog({ - message, - okText, - resolve, - reject, - }); - }); - }, - }, - { - // Class attributes - Templates: (() => { - const templates = {}; - $('script[type="text/x-tmpl-mustache"]').each((i, el) => { - const $el = $(el); - const id = $el.attr('id'); - templates[id] = $el.html(); - }); - return templates; - })(), - } - ); -})(); diff --git a/libtextsecure/libsignal-protocol.js b/libtextsecure/libsignal-protocol.js index 28831add46..1fe5fd7338 100644 --- a/libtextsecure/libsignal-protocol.js +++ b/libtextsecure/libsignal-protocol.js @@ -23897,29 +23897,22 @@ var Internal = Internal || {}; crypto.getRandomValues(array); return array.buffer; }, + encrypt: function(key, data, iv) { - return crypto.subtle.importKey('raw', key, {name: 'AES-CBC'}, false, ['encrypt']).then(function(key) { - return crypto.subtle.encrypt({name: 'AES-CBC', iv: new Uint8Array(iv)}, key, data); - }); + return Promise.resolve(window.synchronousCrypto.encrypt(key, data, iv)); }, decrypt: function(key, data, iv) { - return crypto.subtle.importKey('raw', key, {name: 'AES-CBC'}, false, ['decrypt']).then(function(key) { - return crypto.subtle.decrypt({name: 'AES-CBC', iv: new Uint8Array(iv)}, key, data); - }); + return Promise.resolve(window.synchronousCrypto.decrypt(key, data, iv)); }, sign: function(key, data) { - return crypto.subtle.importKey('raw', key, {name: 'HMAC', hash: {name: 'SHA-256'}}, false, ['sign']).then(function(key) { - return crypto.subtle.sign( {name: 'HMAC', hash: 'SHA-256'}, key, data); - }); + return Promise.resolve(window.synchronousCrypto.sign(key, data)); }, hash: function(data) { - return crypto.subtle.digest({name: 'SHA-512'}, data); + return Promise.resolve(window.synchronousCrypto.hash(data)); }, HKDF: function(input, salt, info) { - // Specific implementation of RFC 5869 that only returns the first 3 32-byte chunks - // TODO: We dont always need the third chunk, we might skip it return Internal.crypto.sign(salt, input).then(function(PRK) { var infoBuffer = new ArrayBuffer(info.byteLength + 1 + 32); var infoArray = new Uint8Array(infoBuffer); @@ -23980,12 +23973,6 @@ var Internal = Internal || {}; }); }; - libsignal.HKDF = { - deriveSecrets: function(input, salt, info) { - return Internal.HKDF(input, salt, info); - } - }; - libsignal.crypto = { encrypt: function(key, data, iv) { return Internal.crypto.encrypt(key, data, iv); diff --git a/libtextsecure/test/_test.js b/libtextsecure/test/_test.js index b51fa47d9b..54b1b7faf2 100644 --- a/libtextsecure/test/_test.js +++ b/libtextsecure/test/_test.js @@ -54,8 +54,9 @@ window.assertEqualArrayBuffers = (ab1, ab2) => { window.hexToArrayBuffer = str => { const ret = new ArrayBuffer(str.length / 2); const array = new Uint8Array(ret); - for (let i = 0; i < str.length / 2; i += 1) + for (let i = 0; i < str.length / 2; i += 1) { array[i] = parseInt(str.substr(i * 2, 2), 16); + } return ret; }; @@ -66,3 +67,16 @@ window.Whisper.events = { on() {}, trigger() {}, }; + +before(async () => { + try { + window.log.info('Initializing SQL in renderer'); + await window.sqlInitializer.initialize(); + window.log.info('SQL initialized in renderer'); + } catch (err) { + window.log.error( + 'SQL failed to initialize', + err && err.stack ? err.stack : err + ); + } +}); diff --git a/libtextsecure/test/fake_web_api.js b/libtextsecure/test/fake_web_api.js index 63214c4295..3c9864ca88 100644 --- a/libtextsecure/test/fake_web_api.js +++ b/libtextsecure/test/fake_web_api.js @@ -14,7 +14,7 @@ const fakeAPI = { getAvatar: fakeCall, getDevices: fakeCall, // getKeysForIdentifier : fakeCall, - getMessageSocket: fakeCall, + getMessageSocket: () => new window.MockSocket('ws://localhost:8081/'), getMyKeys: fakeCall, getProfile: fakeCall, getProvisioningSocket: fakeCall, @@ -45,8 +45,9 @@ const fakeAPI = { msg.timestamp === undefined || msg.relay !== undefined || msg.destination !== undefined - ) + ) { throw new Error('Invalid message'); + } messagesSentMap[ `${destination}.${messageArray[i].destinationDeviceId}` diff --git a/libtextsecure/test/in_memory_signal_protocol_store.js b/libtextsecure/test/in_memory_signal_protocol_store.js index 72f48e3deb..801f726f1e 100644 --- a/libtextsecure/test/in_memory_signal_protocol_store.js +++ b/libtextsecure/test/in_memory_signal_protocol_store.js @@ -25,21 +25,24 @@ SignalProtocolStore.prototype = { value === undefined || key === null || value === null - ) + ) { throw new Error('Tried to store undefined/null'); + } this.store[key] = value; }, get(key, defaultValue) { - if (key === null || key === undefined) + if (key === null || key === undefined) { throw new Error('Tried to get value for undefined/null key'); + } if (key in this.store) { return this.store[key]; } return defaultValue; }, remove(key) { - if (key === null || key === undefined) + if (key === null || key === undefined) { throw new Error('Tried to remove value for undefined/null key'); + } delete this.store[key]; }, @@ -57,15 +60,17 @@ SignalProtocolStore.prototype = { return Promise.resolve(identityKey === trusted); }, loadIdentityKey(identifier) { - if (identifier === null || identifier === undefined) + if (identifier === null || identifier === undefined) { throw new Error('Tried to get identity key for undefined/null key'); + } return new Promise(resolve => { resolve(this.get(`identityKey${identifier}`)); }); }, saveIdentity(identifier, identityKey) { - if (identifier === null || identifier === undefined) + if (identifier === null || identifier === undefined) { throw new Error('Tried to put identity key for undefined/null key'); + } return new Promise(resolve => { const existing = this.get(`identityKey${identifier}`); this.put(`identityKey${identifier}`, identityKey); @@ -161,4 +166,15 @@ SignalProtocolStore.prototype = { resolve(deviceIds); }); }, + + getUnprocessedCount: () => Promise.resolve(0), + getAllUnprocessed: () => Promise.resolve([]), + getUnprocessedById: () => Promise.resolve(null), + addUnprocessed: () => Promise.resolve(), + addMultipleUnprocessed: () => Promise.resolve(), + updateUnprocessedAttempts: () => Promise.resolve(), + updateUnprocessedWithData: () => Promise.resolve(), + updateUnprocessedsWithData: () => Promise.resolve(), + removeUnprocessed: () => Promise.resolve(), + removeAllUnprocessed: () => Promise.resolve(), }; diff --git a/libtextsecure/test/index.html b/libtextsecure/test/index.html index 0f6b58901f..5fe7f3dcf1 100644 --- a/libtextsecure/test/index.html +++ b/libtextsecure/test/index.html @@ -24,6 +24,7 @@ + @@ -31,10 +32,7 @@ - - - @@ -44,6 +42,7 @@ + diff --git a/libtextsecure/test/message_receiver_test.js b/libtextsecure/test/message_receiver_test.js index 73b606deb7..0ded6a0c28 100644 --- a/libtextsecure/test/message_receiver_test.js +++ b/libtextsecure/test/message_receiver_test.js @@ -1,10 +1,9 @@ // Copyright 2015-2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global libsignal, textsecure, SignalProtocolStore */ +/* global libsignal, textsecure */ describe('MessageReceiver', () => { - textsecure.storage.impl = new SignalProtocolStore(); const { WebSocket } = window; const number = '+19999999999'; const uuid = 'AAAAAAAA-BBBB-4CCC-9DDD-EEEEEEEEEEEE'; @@ -12,6 +11,7 @@ describe('MessageReceiver', () => { const signalingKey = libsignal.crypto.getRandomBytes(32 + 20); before(() => { + localStorage.clear(); window.WebSocket = MockSocket; textsecure.storage.user.setNumberAndDeviceId(number, deviceId, 'name'); textsecure.storage.user.setUuidAndDeviceId(uuid, deviceId); @@ -19,94 +19,126 @@ describe('MessageReceiver', () => { textsecure.storage.put('signaling_key', signalingKey); }); after(() => { + localStorage.clear(); window.WebSocket = WebSocket; }); describe('connecting', () => { - const attrs = { - type: textsecure.protobuf.Envelope.Type.CIPHERTEXT, - source: number, - sourceUuid: uuid, - sourceDevice: deviceId, - timestamp: Date.now(), - }; - const websocketmessage = new textsecure.protobuf.WebSocketMessage({ - type: textsecure.protobuf.WebSocketMessage.Type.REQUEST, - request: { verb: 'PUT', path: '/messages' }, + let attrs; + let websocketmessage; + + before(() => { + attrs = { + type: textsecure.protobuf.Envelope.Type.CIPHERTEXT, + source: number, + sourceUuid: uuid, + sourceDevice: deviceId, + timestamp: Date.now(), + content: libsignal.crypto.getRandomBytes(200), + }; + const body = new textsecure.protobuf.Envelope(attrs).toArrayBuffer(); + + websocketmessage = new textsecure.protobuf.WebSocketMessage({ + type: textsecure.protobuf.WebSocketMessage.Type.REQUEST, + request: { verb: 'PUT', path: '/api/v1/message', body }, + }); }); - before(done => { - const signal = new textsecure.protobuf.Envelope(attrs).toArrayBuffer(); - - const aesKey = signalingKey.slice(0, 32); - const macKey = signalingKey.slice(32, 32 + 20); - - window.crypto.subtle - .importKey('raw', aesKey, { name: 'AES-CBC' }, false, ['encrypt']) - .then(key => { - const iv = libsignal.crypto.getRandomBytes(16); - window.crypto.subtle - .encrypt({ name: 'AES-CBC', iv: new Uint8Array(iv) }, key, signal) - .then(ciphertext => { - window.crypto.subtle - .importKey( - 'raw', - macKey, - { name: 'HMAC', hash: { name: 'SHA-256' } }, - false, - ['sign'] - ) - .then(innerKey => { - window.crypto.subtle - .sign({ name: 'HMAC', hash: 'SHA-256' }, innerKey, signal) - .then(mac => { - const version = new Uint8Array([1]); - const message = dcodeIO.ByteBuffer.concat([ - version, - iv, - ciphertext, - mac, - ]); - websocketmessage.request.body = message.toArrayBuffer(); - done(); - }); - }); - }); - }); - }); - - it('connects', done => { - const mockServer = new MockServer( - `ws://localhost:8080/v1/websocket/?login=${encodeURIComponent( - uuid - )}.1&password=password` - ); + it('generates light-session-reset event when it cannot decrypt', done => { + const mockServer = new MockServer('ws://localhost:8081/'); mockServer.on('connection', server => { - server.send(new Blob([websocketmessage.toArrayBuffer()])); + setTimeout(() => { + server.send(new Blob([websocketmessage.toArrayBuffer()])); + }, 1); }); - window.addEventListener('textsecure:message', ev => { - const signal = ev.proto; - const keys = Object.keys(attrs); - - for (let i = 0, max = keys.length; i < max; i += 1) { - const key = keys[i]; - assert.strictEqual(attrs[key], signal[key]); - } - assert.strictEqual(signal.message.body, 'hello'); - mockServer.close(); - - done(); - }); - - window.messageReceiver = new textsecure.MessageReceiver( + const messageReceiver = new textsecure.MessageReceiver( + 'oldUsername', 'username', 'password', - 'signalingKey' - // 'ws://localhost:8080', - // window, + 'signalingKey', + { + serverTrustRoot: 'AAAAAAAA', + } ); + + messageReceiver.addEventListener('light-session-reset', done()); + }); + }); + + describe('methods', () => { + let messageReceiver; + let mockServer; + + beforeEach(() => { + // Necessary to populate the server property inside of MockSocket. Without it, we + // crash when doing any number of things to a MockSocket instance. + mockServer = new MockServer('ws://localhost:8081'); + + messageReceiver = new textsecure.MessageReceiver( + 'oldUsername', + 'username', + 'password', + 'signalingKey', + { + serverTrustRoot: 'AAAAAAAA', + } + ); + }); + afterEach(() => { + mockServer.close(); + }); + + describe('#isOverHourIntoPast', () => { + it('returns false for now', () => { + assert.isFalse(messageReceiver.isOverHourIntoPast(Date.now())); + }); + it('returns false for 5 minutes ago', () => { + const fiveMinutesAgo = Date.now() - 5 * 60 * 1000; + assert.isFalse(messageReceiver.isOverHourIntoPast(fiveMinutesAgo)); + }); + it('returns true for 65 minutes ago', () => { + const sixtyFiveMinutesAgo = Date.now() - 65 * 60 * 1000; + assert.isTrue(messageReceiver.isOverHourIntoPast(sixtyFiveMinutesAgo)); + }); + }); + + describe('#cleanupSessionResets', () => { + it('leaves empty object alone', () => { + window.storage.put('sessionResets', {}); + messageReceiver.cleanupSessionResets(); + const actual = window.storage.get('sessionResets'); + + const expected = {}; + assert.deepEqual(actual, expected); + }); + it('filters out any timestamp older than one hour', () => { + const startValue = { + one: Date.now() - 1, + two: Date.now(), + three: Date.now() - 65 * 60 * 1000, + }; + window.storage.put('sessionResets', startValue); + messageReceiver.cleanupSessionResets(); + const actual = window.storage.get('sessionResets'); + + const expected = window._.pick(startValue, ['one', 'two']); + assert.deepEqual(actual, expected); + }); + it('filters out falsey items', () => { + const startValue = { + one: 0, + two: false, + three: Date.now(), + }; + window.storage.put('sessionResets', startValue); + messageReceiver.cleanupSessionResets(); + const actual = window.storage.get('sessionResets'); + + const expected = window._.pick(startValue, ['three']); + assert.deepEqual(actual, expected); + }); }); }); }); diff --git a/main.js b/main.js index ecafd86190..9a59c9ed04 100644 --- a/main.js +++ b/main.js @@ -19,6 +19,8 @@ const electron = require('electron'); const packageJson = require('./package.json'); const GlobalErrors = require('./app/global_errors'); const { setup: setupSpellChecker } = require('./app/spell_check'); +const { redactAll } = require('./js/modules/privacy'); +const removeUserConfig = require('./app/user_config').remove; GlobalErrors.addHandler(); @@ -30,6 +32,7 @@ const getRealPath = pify(fs.realpath); const { app, BrowserWindow, + clipboard, dialog, ipcMain: ipc, Menu, @@ -78,6 +81,8 @@ const importMode = const development = config.environment === 'development' || config.environment === 'staging'; +const enableCI = Boolean(config.get('enableCI')); + // We generally want to pull in our own modules after this point, after the user // data directory has been set. const attachments = require('./app/attachments'); @@ -112,9 +117,16 @@ const { getTitleBarVisibility, TitleBarVisibility, } = require('./ts/types/Settings'); +const { Environment } = require('./ts/environment'); let appStartInitialSpellcheckSetting = true; +const defaultWebPrefs = { + devTools: + process.argv.some(arg => arg === '--enable-dev-tools') || + config.environment !== Environment.Production, +}; + async function getSpellCheckSetting() { const json = await sql.getItemById('spell-check'); @@ -211,7 +223,8 @@ function prepareURL(pathSegments, moreKeys) { cdnUrl0: config.get('cdn').get('0'), cdnUrl2: config.get('cdn').get('2'), certificateAuthority: config.get('certificateAuthority'), - environment: config.environment, + environment: enableCI ? 'production' : config.environment, + enableCI: enableCI ? true : undefined, node_version: process.versions.node, hostname: os.hostname(), appInstance: process.env.NODE_APP_INSTANCE, @@ -313,6 +326,7 @@ async function createWindow() { ? '#ffffff' // Tests should always be rendered on a white background : '#3a76f0', webPreferences: { + ...defaultWebPrefs, nodeIntegration: false, nodeIntegrationInWorker: false, contextIsolation: false, @@ -421,7 +435,7 @@ async function createWindow() { mainWindow.loadURL(prepareURL([__dirname, 'background.html'], moreKeys)); } - if (config.get('openDevTools')) { + if (!enableCI && config.get('openDevTools')) { // Open the DevTools. mainWindow.webContents.openDevTools(); } @@ -667,6 +681,7 @@ function showAbout() { backgroundColor: '#3a76f0', show: false, webPreferences: { + ...defaultWebPrefs, nodeIntegration: false, nodeIntegrationInWorker: false, contextIsolation: false, @@ -721,6 +736,7 @@ function showSettingsWindow() { show: false, modal: true, webPreferences: { + ...defaultWebPrefs, nodeIntegration: false, nodeIntegrationInWorker: false, contextIsolation: false, @@ -790,6 +806,7 @@ async function showStickerCreator() { backgroundColor: '#3a76f0', show: false, webPreferences: { + ...defaultWebPrefs, nodeIntegration: false, nodeIntegrationInWorker: false, contextIsolation: false, @@ -844,6 +861,7 @@ async function showDebugLogWindow() { show: false, modal: true, webPreferences: { + ...defaultWebPrefs, nodeIntegration: false, nodeIntegrationInWorker: false, contextIsolation: false, @@ -894,6 +912,7 @@ function showPermissionsPopupWindow(forCalling, forCamera) { show: false, modal: true, webPreferences: { + ...defaultWebPrefs, nodeIntegration: false, nodeIntegrationInWorker: false, contextIsolation: false, @@ -940,7 +959,12 @@ app.on('ready', async () => { // We use this event only a single time to log the startup time of the app // from when it's first ready until the loading screen disappears. ipc.once('signal-app-loaded', () => { - console.log('App has finished loading in:', Date.now() - startTime); + const loadTime = Date.now() - startTime; + console.log('App loaded - time:', loadTime); + + if (enableCI) { + console._log('ci: app_loaded=%j', { loadTime }); + } }); const userDataPath = await getRealPath(app.getPath('userData')); @@ -1027,6 +1051,7 @@ app.on('ready', async () => { frame: false, backgroundColor: '#3a76f0', webPreferences: { + ...defaultWebPrefs, nodeIntegration: false, preload: path.join(__dirname, 'loading_preload.js'), }, @@ -1044,12 +1069,37 @@ app.on('ready', async () => { loadingWindow.loadURL(prepareURL([__dirname, 'loading.html'])); }); - const success = await sqlInitPromise; - - if (!success) { + try { + await sqlInitPromise; + } catch (error) { console.log('sql.initialize was unsuccessful; returning early'); + const buttonIndex = dialog.showMessageBoxSync({ + buttons: [ + locale.messages.copyErrorAndQuit.message, + locale.messages.deleteAndRestart.message, + ], + defaultId: 0, + detail: redactAll(error.stack), + message: locale.messages.databaseError.message, + noLink: true, + type: 'error', + }); + + if (buttonIndex === 0) { + clipboard.writeText( + `Database startup error:\n\n${redactAll(error.stack)}` + ); + } else { + await sql.removeDB(); + removeUserConfig(); + app.relaunch(); + } + + app.exit(1); + return; } + // eslint-disable-next-line more/no-then appStartInitialSpellcheckSetting = await getSpellCheckSetting(); await sqlChannels.initialize(); @@ -1061,10 +1111,10 @@ app.on('ready', async () => { await sql.removeIndexedDBFiles(); await sql.removeItemById(IDB_KEY); } - } catch (error) { + } catch (err) { console.log( '(ready event handler) error deleting IndexedDB:', - error && error.stack ? error.stack : error + err && err.stack ? err.stack : err ); } @@ -1099,10 +1149,10 @@ app.on('ready', async () => { try { await attachments.clearTempPath(userDataPath); - } catch (error) { + } catch (err) { logger.error( 'main/ready: Error deleting temp dir:', - error && error.stack ? error.stack : error + err && err.stack ? err.stack : err ); } await attachmentChannel.initialize({ @@ -1119,7 +1169,12 @@ app.on('ready', async () => { setupMenu(); - ensureFilePermissions(['config.json', 'sql/db.sqlite']); + ensureFilePermissions([ + 'config.json', + 'sql/db.sqlite', + 'sql/db.sqlite-wal', + 'sql/db.sqlite-shm', + ]); }); function setupMenu(options) { @@ -1128,6 +1183,7 @@ function setupMenu(options) { ...options, development, isBeta: isBeta(app.getVersion()), + devTools: defaultWebPrefs.devTools, showDebugLog: showDebugLogWindow, showKeyboardShortcuts, showWindow, @@ -1249,6 +1305,12 @@ app.on('will-finish-launching', () => { }); }); +if (enableCI) { + ipc.on('set-provisioning-url', (event, provisioningURL) => { + console._log('ci: provisioning_url=%j', provisioningURL); + }); +} + ipc.on('set-badge-count', (event, count) => { app.badgeCount = count; }); @@ -1443,6 +1505,16 @@ ipc.on('locale-data', event => { event.returnValue = locale.messages; }); +ipc.on('user-config-key', event => { + // eslint-disable-next-line no-param-reassign + event.returnValue = userConfig.get('key'); +}); + +ipc.on('get-user-data-path', event => { + // eslint-disable-next-line no-param-reassign + event.returnValue = app.getPath('userData'); +}); + function getDataFromMainWindow(name, callback) { ipc.once(`get-success-${name}`, (_event, error, value) => callback(error, value) diff --git a/package.json b/package.json index dab44334a9..4ec8c08036 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Private messaging from your desktop", "desktopName": "signal.desktop", "repository": "https://github.com/signalapp/Signal-Desktop.git", - "version": "1.40.1", + "version": "5.0.0-beta.4", "license": "AGPL-3.0-only", "author": { "name": "Open Whisper Systems", @@ -31,20 +31,22 @@ "test-electron": "yarn grunt test", "test-node": "electron-mocha --file test/setup-test-node.js --recursive test/app test/modules ts/test-node ts/test-both", "test-node-coverage": "nyc --reporter=lcov --reporter=text mocha --recursive test/app test/modules ts/test-node ts/test-both", - "eslint": "eslint .", + "eslint": "eslint --cache .", "lint": "yarn format --list-different && yarn eslint", "lint-deps": "node ts/util/lint/linter.js", "lint-license-comments": "ts-node ts/util/lint/license_comments.ts", "format": "prettier --write \"*.{css,js,json,md,scss,ts,tsx}\" \"./**/*.{css,js,json,md,scss,ts,tsx}\"", "transpile": "tsc", - "clean-transpile": "rimraf ts/**/*.js && rimraf ts/*.js", + "clean-transpile": "rimraf ts/**/*.js && rimraf ts/*.js tsconfig.tsbuildinfo", "open-coverage": "open coverage/lcov-report/index.html", "ready": "npm-run-all --print-label clean-transpile grunt --parallel lint lint-deps test-node test-electron", "dev": "run-p --print-label dev:*", "dev:grunt": "yarn grunt dev", + "dev:transpile": "yarn run transpile --watch --preserveWatchOutput", "dev:webpack": "cross-env NODE_ENV=development webpack-dev-server --hot", "dev:typed-scss": "yarn build:typed-scss -w", "dev:storybook": "cross-env SIGNAL_ENV=storybook start-storybook -p 6006 -s ./", + "storybook:axe": "build-storybook && axe-storybook", "build": "run-s --print-label build:grunt build:typed-scss build:webpack build:release build:zip", "build:acknowledgments": "node scripts/generate-acknowledgments.js", "build:dev": "run-s --print-label build:grunt build:typed-scss build:webpack", @@ -64,6 +66,8 @@ "dependencies": { "@journeyapps/sqlcipher": "https://github.com/EvanHahn-signal/node-sqlcipher.git#16916949f0c010f6e6d3d5869b10a0ab813eae75", "@sindresorhus/is": "0.8.0", + "@types/pino": "6.3.6", + "@types/pino-multi-stream": "5.1.0", "abort-controller": "3.0.0", "array-move": "2.1.0", "backbone": "1.3.3", @@ -71,7 +75,6 @@ "blueimp-canvas-to-blob": "3.14.0", "blueimp-load-image": "5.14.0", "blurhash": "1.1.3", - "bunyan": "1.8.15", "classnames": "2.2.5", "config": "1.28.1", "copy-text-to-clipboard": "2.1.0", @@ -93,15 +96,16 @@ "intl-tel-input": "12.1.15", "jquery": "3.5.0", "js-yaml": "3.13.1", + "libsignal-client": "https://github.com/signalapp/libsignal-client-node.git#f10fbd04eb6efb396eb12c25429761e8785dc9d0", "linkify-it": "2.2.0", "lodash": "4.17.20", + "lru-cache": "6.0.0", "memoizee": "0.4.14", "mkdirp": "0.5.2", "moment": "2.21.0", "mustache": "2.3.0", "node-fetch": "2.6.1", "node-forge": "0.10.0", - "node-gyp": "5.0.3", "normalize-path": "3.0.0", "os-locale": "3.0.1", "p-map": "2.1.0", @@ -109,8 +113,10 @@ "p-queue": "6.2.1", "parchment": "1.1.4", "pify": "3.0.0", + "pino": "6.11.1", + "pino-multi-stream": "5.3.0", "popper.js": "1.15.0", - "protobufjs": "6.8.6", + "protobufjs": "6.10.2", "proxy-agent": "3.1.1", "quill": "1.3.7", "quill-delta": "4.0.1", @@ -135,7 +141,8 @@ "redux-ts-utils": "3.2.2", "reselect": "4.0.0", "rimraf": "2.6.2", - "ringrtc": "https://github.com/signalapp/signal-ringrtc-node.git#552582f97102be4baf27a81fdaab1fba46fc6595", + "ringrtc": "https://github.com/signalapp/signal-ringrtc-node.git#b43c6b728d62b6d386d95705e128f32f44edb650", + "rotating-file-stream": "2.1.5", "sanitize-filename": "1.6.3", "sanitize.css": "11.0.0", "semver": "5.4.1", @@ -149,21 +156,22 @@ "uuid": "3.3.2", "websocket": "1.0.28", "zkgroup": "https://github.com/signalapp/signal-zkgroup-node.git#2d7db946cc88492b65cc66e9aa9de0c9e664fd8d", - "libsignal-client": "https://github.com/signalapp/libsignal-client-node.git#23edaad30ddec4d52a5634b30090563df1351337" + "zod": "1.11.13" }, "devDependencies": { "@babel/core": "7.7.7", "@babel/plugin-proposal-class-properties": "7.7.4", + "@babel/plugin-proposal-optional-chaining": "7.13.8", "@babel/plugin-transform-runtime": "7.8.3", "@babel/preset-react": "7.7.4", "@babel/preset-typescript": "7.7.7", + "@chanzuckerberg/axe-storybook-testing": "3.0.1", "@storybook/addon-actions": "5.1.11", "@storybook/addon-knobs": "5.1.11", "@storybook/addons": "5.1.11", "@storybook/react": "5.1.11", "@types/backbone": "1.4.3", "@types/blueimp-load-image": "5.14.1", - "@types/bunyan": "1.8.6", "@types/chai": "4.1.2", "@types/classnames": "2.2.3", "@types/config": "0.0.34", @@ -179,6 +187,7 @@ "@types/linkify-it": "2.1.0", "@types/lodash": "4.14.106", "@types/long": "4.0.1", + "@types/lru-cache": "5.1.0", "@types/memoizee": "0.4.2", "@types/mkdirp": "0.5.2", "@types/mocha": "5.0.0", @@ -215,7 +224,7 @@ "babel-loader": "8.0.6", "babel-plugin-lodash": "3.3.4", "chai": "4.1.2", - "core-js": "2.4.1", + "core-js": "2.6.9", "cross-env": "5.2.0", "css-loader": "3.2.0", "electron": "11.2.3", @@ -242,6 +251,7 @@ "jsdoc": "3.6.2", "mocha": "4.1.0", "mocha-testcheck": "1.0.0-rc.0", + "node-gyp": "5.0.3", "node-sass": "4.14.1", "node-sass-import-once": "1.2.0", "npm-run-all": "4.1.5", @@ -254,6 +264,7 @@ "snyk": "1.316.1", "spectron": "5.0.0", "style-loader": "1.0.0", + "ts-dedent": "2.0.0", "ts-loader": "4.1.0", "ts-node": "8.3.0", "typed-scss-modules": "0.0.11", @@ -340,7 +351,6 @@ "deb": { "depends": [ "libnotify4", - "libappindicator1", "libxtst6", "libnss3", "libasound2", @@ -392,7 +402,7 @@ "!node_modules/emoji-datasource-apple/emoji_pretty.json", "!node_modules/emoji-datasource-apple/img/apple/sheets*", "!node_modules/spellchecker/vendor/hunspell/**/*", - "!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme,test,__tests__,tests,powered-test,example,examples,*.d.ts,.snyk-*.flag}", + "!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme,test,__tests__,tests,powered-test,example,examples,*.d.ts,.snyk-*.flag,benchmark}", "!**/node_modules/.bin", "!**/node_modules/*/build/**", "!**/*.{o,hprof,orig,pyc,pyo,rbc}", @@ -408,13 +418,15 @@ "node_modules/socks/build/client/*.js", "node_modules/smart-buffer/build/*.js", "node_modules/sharp/build/**", - "!node_modules/sharp/{install,src,vendor/include}", + "!node_modules/sharp/{install,src,vendor/include,vendor/*/include}", "!node_modules/@journeyapps/sqlcipher/deps/*", "!node_modules/@journeyapps/sqlcipher/build/*", "!node_modules/@journeyapps/sqlcipher/build-tmp-napi-*", "!node_modules/@journeyapps/sqlcipher/lib/binding/node-*", - "node_modules/libsignal-client/build/*.node", - "node_modules/ringrtc/build/${platform}/**" + "node_modules/libsignal-client/build/*${platform}*.node", + "node_modules/ringrtc/build/${platform}/**", + "!**/node_modules/ffi-napi/deps", + "!**/node_modules/react-dom/*/*.development.js" ] } } diff --git a/permissions_popup.html b/permissions_popup.html index da6d2cd1b7..14b6103a71 100644 --- a/permissions_popup.html +++ b/permissions_popup.html @@ -25,7 +25,6 @@ - diff --git a/permissions_popup_preload.js b/permissions_popup_preload.js index 63730f95db..ee3ccd02b1 100644 --- a/permissions_popup_preload.js +++ b/permissions_popup_preload.js @@ -49,7 +49,7 @@ window.subscribeToSystemThemeChange = fn => { }); }; -require('./ts/logging/set_up_renderer_logging'); +require('./ts/logging/set_up_renderer_logging').initialize(); window.closePermissionsPopup = () => ipcRenderer.send('close-permissions-popup'); diff --git a/preload.js b/preload.js index 581f47e125..8fb31da557 100644 --- a/preload.js +++ b/preload.js @@ -22,6 +22,8 @@ try { const { app } = remote; const { nativeTheme } = remote.require('electron'); + window.sqlInitializer = require('./ts/sql/initialize'); + window.PROTO_ROOT = 'protos'; const config = require('url').parse(window.location.toString(), true).query; @@ -45,6 +47,7 @@ try { window.platform = process.platform; window.getTitle = () => title; + window.getLocale = () => config.locale; window.getEnvironment = getEnvironment; window.getAppInstance = () => config.appInstance; window.getVersion = () => config.version; @@ -399,7 +402,7 @@ try { // We pull these dependencies in now, from here, because they have Node.js dependencies - require('./ts/logging/set_up_renderer_logging'); + require('./ts/logging/set_up_renderer_logging').initialize(); if (config.proxyUrl) { window.log.info('Using provided proxy url'); @@ -408,6 +411,7 @@ try { window.nodeSetImmediate = setImmediate; window.textsecure = require('./ts/textsecure').default; + window.synchronousCrypto = require('./ts/util/synchronousCrypto'); window.WebAPI = window.textsecure.WebAPI.initialize({ url: config.serverUrl, @@ -514,11 +518,23 @@ try { getRegionCode: () => window.storage.get('regionCode'), logger: window.log, }); + window.CI = config.enableCI + ? { + setProvisioningURL: url => ipc.send('set-provisioning-url', url), + deviceName: title, + } + : undefined; // these need access to window.Signal: require('./ts/models/messages'); require('./ts/models/conversations'); + require('./ts/backbone/views/whisper_view'); + require('./ts/backbone/views/toast_view'); + require('./ts/views/conversation_view'); + require('./ts/LibSignalStore'); + require('./ts/background'); + function wrapWithPromise(fn) { return (...args) => Promise.resolve(fn(...args)); } @@ -626,6 +642,18 @@ try { window.libsignal.externalCurve = externalCurve; window.libsignal.externalCurveAsync = externalCurveAsync; + window.libsignal.HKDF = {}; + window.libsignal.HKDF.deriveSecrets = (input, salt, info) => { + const hkdf = client.HKDF.new(3); + const output = hkdf.deriveSecrets( + 3 * 32, + Buffer.from(input), + Buffer.from(info), + Buffer.from(salt) + ); + return [output.slice(0, 32), output.slice(32, 64), output.slice(64, 96)]; + }; + // Pulling these in separately since they access filesystem, electron window.Signal.Backup = require('./js/modules/backup'); window.Signal.Debug = require('./js/modules/debug'); diff --git a/protos/LibSignal-Client.proto b/protos/LibSignal-Client.proto new file mode 100644 index 0000000000..634a29eba7 --- /dev/null +++ b/protos/LibSignal-Client.proto @@ -0,0 +1,107 @@ +syntax = "proto3"; + +// +// Copyright 2020-2021 Signal Messenger, LLC. +// SPDX-License-Identifier: AGPL-3.0-only +// + +package signal.proto.storage; + +message SessionStructure { + message Chain { + bytes sender_ratchet_key = 1; + bytes sender_ratchet_key_private = 2; + + message ChainKey { + uint32 index = 1; + bytes key = 2; + } + + ChainKey chain_key = 3; + + message MessageKey { + uint32 index = 1; + bytes cipher_key = 2; + bytes mac_key = 3; + bytes iv = 4; + } + + repeated MessageKey message_keys = 4; + } + + message PendingPreKey { + uint32 pre_key_id = 1; + int32 signed_pre_key_id = 3; + bytes base_key = 2; + } + + uint32 session_version = 1; + bytes local_identity_public = 2; + bytes remote_identity_public = 3; + + bytes root_key = 4; + uint32 previous_counter = 5; + + Chain sender_chain = 6; + // The order is significant; keys at the end are "older" and will get trimmed. + repeated Chain receiver_chains = 7; + + PendingPreKey pending_pre_key = 9; + + uint32 remote_registration_id = 10; + uint32 local_registration_id = 11; + + bool needs_refresh = 12; + bytes alice_base_key = 13; +} + +message RecordStructure { + SessionStructure current_session = 1; + // The order is significant; sessions at the end are "older" and will get trimmed. + repeated SessionStructure previous_sessions = 2; +} + +message PreKeyRecordStructure { + uint32 id = 1; + bytes public_key = 2; + bytes private_key = 3; +} + +message SignedPreKeyRecordStructure { + uint32 id = 1; + bytes public_key = 2; + bytes private_key = 3; + bytes signature = 4; + fixed64 timestamp = 5; +} + +message IdentityKeyPairStructure { + bytes public_key = 1; + bytes private_key = 2; +} + +message SenderKeyStateStructure { + message SenderChainKey { + uint32 iteration = 1; + bytes seed = 2; + } + + message SenderMessageKey { + uint32 iteration = 1; + bytes seed = 2; + } + + message SenderSigningKey { + bytes public = 1; + bytes private = 2; + } + + uint32 sender_key_id = 1; + SenderChainKey sender_chain_key = 2; + SenderSigningKey sender_signing_key = 3; + repeated SenderMessageKey sender_message_keys = 4; +} + +message SenderKeyRecordStructure { + repeated SenderKeyStateStructure sender_key_states = 1; +} \ No newline at end of file diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 0ec2fbe129..d9a349142d 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -209,7 +209,7 @@ message DataMessage { message Reaction { optional string emoji = 1; optional bool remove = 2; - optional string targetAuthorE164 = 3; + reserved /* targetAuthorE164 */ 3; // removed optional string targetAuthorUuid = 4; optional uint64 targetTimestamp = 5; } @@ -428,6 +428,8 @@ message SyncMessage { message AttachmentPointer { enum Flags { VOICE_MESSAGE = 1; + BORDERLESS = 2; + GIF = 3; } oneof attachment_identifier { diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 8f282dffc8..9fc49d2b86 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -93,6 +93,12 @@ message GroupV2Record { } message AccountRecord { + enum PhoneNumberSharingMode { + EVERYBODY = 0; + CONTACTS_ONLY = 1; + NOBODY = 2; + } + message PinnedConversation { message Contact { optional string uuid = 1; @@ -106,16 +112,18 @@ message AccountRecord { } } - optional bytes profileKey = 1; - optional string givenName = 2; - optional string familyName = 3; - optional string avatarUrl = 4; - optional bool noteToSelfArchived = 5; - optional bool readReceipts = 6; - optional bool sealedSenderIndicators = 7; - optional bool typingIndicators = 8; - optional bool proxiedLinkPreviews = 9; - optional bool noteToSelfMarkedUnread = 10; - optional bool linkPreviews = 11; - repeated PinnedConversation pinnedConversations = 14; + optional bytes profileKey = 1; + optional string givenName = 2; + optional string familyName = 3; + optional string avatarUrl = 4; + optional bool noteToSelfArchived = 5; + optional bool readReceipts = 6; + optional bool sealedSenderIndicators = 7; + optional bool typingIndicators = 8; + optional bool proxiedLinkPreviews = 9; + optional bool noteToSelfMarkedUnread = 10; + optional bool linkPreviews = 11; + optional PhoneNumberSharingMode phoneNumberSharingMode = 12; + optional bool notDiscoverableByPhoneNumber = 13; + repeated PinnedConversation pinnedConversations = 14; } diff --git a/settings.html b/settings.html index 3c37c5e3f1..fde6bcf2e9 100644 --- a/settings.html +++ b/settings.html @@ -168,7 +168,6 @@ - diff --git a/settings_preload.js b/settings_preload.js index ca280c26c1..5a138750e2 100644 --- a/settings_preload.js +++ b/settings_preload.js @@ -126,6 +126,7 @@ function makeSetter(name) { }); } -require('./ts/logging/set_up_renderer_logging'); - window.Backbone = require('backbone'); +require('./ts/backbone/views/whisper_view'); +require('./ts/backbone/views/toast_view'); +require('./ts/logging/set_up_renderer_logging').initialize(); diff --git a/sticker-creator/app/stages/UploadStage.tsx b/sticker-creator/app/stages/UploadStage.tsx index 6dd3fa808d..59ca670cd9 100644 --- a/sticker-creator/app/stages/UploadStage.tsx +++ b/sticker-creator/app/stages/UploadStage.tsx @@ -42,6 +42,7 @@ export const UploadStage: React.ComponentType = () => { actions.setPackMeta(packMeta); history.push('/share'); } catch (e) { + window.log.error('Error uploading image:', e); actions.addToast({ key: 'StickerCreator--Toasts--errorUploading', subs: [e.message], diff --git a/sticker-creator/index.html b/sticker-creator/index.html index d1051b5403..5c683eaee1 100644 --- a/sticker-creator/index.html +++ b/sticker-creator/index.html @@ -9,7 +9,6 @@
- diff --git a/sticker-creator/preload.js b/sticker-creator/preload.js index 0ee8159f0b..0abe6eb402 100644 --- a/sticker-creator/preload.js +++ b/sticker-creator/preload.js @@ -9,6 +9,7 @@ const { readFile } = require('fs'); const config = require('url').parse(window.location.toString(), true).query; const { noop, uniqBy } = require('lodash'); const pMap = require('p-map'); +const client = require('libsignal-client'); const { deriveStickerPackKey } = require('../ts/Crypto'); const { getEnvironment, @@ -28,6 +29,8 @@ const MAX_ANIMATED_STICKER_BYTE_LENGTH = 300 * 1024; setEnvironment(parseEnvironment(config.environment)); +window.sqlInitializer = require('../ts/sql/initialize'); + window.ROOT_PATH = window.location.href.startsWith('file') ? '../../' : '/'; window.PROTO_ROOT = '../../protos'; window.getEnvironment = getEnvironment; @@ -38,7 +41,9 @@ window.Backbone = require('backbone'); window.localeMessages = ipc.sendSync('locale-data'); -require('../ts/logging/set_up_renderer_logging'); +require('../ts/logging/set_up_renderer_logging').initialize(); + +require('../ts/LibSignalStore'); window.log.info('sticker-creator starting up...'); @@ -47,6 +52,20 @@ const Signal = require('../js/modules/signal'); window.Signal = Signal.setup({}); window.textsecure = require('../ts/textsecure').default; +window.libsignal = window.libsignal || {}; +window.libsignal.HKDF = {}; +window.libsignal.HKDF.deriveSecrets = (input, salt, info) => { + const hkdf = client.HKDF.new(3); + const output = hkdf.deriveSecrets( + 3 * 32, + Buffer.from(input), + Buffer.from(info), + Buffer.from(salt) + ); + return [output.slice(0, 32), output.slice(32, 64), output.slice(64, 96)]; +}; +window.synchronousCrypto = require('../ts/util/synchronousCrypto'); + const { initialize: initializeWebAPI } = require('../ts/textsecure/WebAPI'); const { getAnimatedPngDataIfExists, @@ -161,6 +180,7 @@ window.encryptAndUpload = async ( cover, onProgress = noop ) => { + window.sqlInitializer.goBackToMainProcess(); const usernameItem = await window.Signal.Data.getItemById('uuid_id'); const oldUsernameItem = await window.Signal.Data.getItemById('number_id'); const passwordItem = await window.Signal.Data.getItemById('password'); diff --git a/stylesheets/_global.scss b/stylesheets/_global.scss index f373f55a66..1513e15ae7 100644 --- a/stylesheets/_global.scss +++ b/stylesheets/_global.scss @@ -21,8 +21,10 @@ body { // It'd be great if we could use the `:fullscreen` selector here, but that does not seem // to work with Electron, at least on macOS. --title-bar-drag-area-height: 0px; // Needs to have a unit to work with `calc()`. + --draggable-app-region: initial; &.os-macos:not(.full-screen) { --title-bar-drag-area-height: 28px; + --draggable-app-region: drag; } } diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index 9aa5e1a8a7..5d7c346f1d 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -1,11 +1,11 @@ -// Copyright 2016-2020 Signal Messenger, LLC +// Copyright 2016-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // Fonts @mixin font-title-1 { font-family: $inter; - font-weight: bolder; + font-weight: 700; font-size: 28px; line-height: 34px; letter-spacing: -0.56px; @@ -13,7 +13,7 @@ @mixin font-title-2 { font-family: $inter; - font-weight: bolder; + font-weight: 700; font-size: 20px; line-height: 26px; letter-spacing: -0.34px; @@ -21,13 +21,13 @@ @mixin font-body-1 { font-family: $inter; - font-size: 15px; - line-height: 21px; - letter-spacing: -0.14px; + font-size: 14px; + line-height: 20px; + letter-spacing: -0.08px; } @mixin font-body-1-bold { @include font-body-1; - font-weight: bold; + font-weight: 600; } @mixin font-body-1-italic { @include font-body-1; @@ -35,7 +35,7 @@ } @mixin font-body-1-bold-italic { @include font-body-1; - font-weight: bold; + font-weight: 600; font-style: italic; } @@ -47,7 +47,7 @@ } @mixin font-body-2-bold { @include font-body-2; - font-weight: bold; + font-weight: 600; } @mixin font-body-2-italic { @include font-body-2; @@ -55,30 +55,30 @@ } @mixin font-body-2-bold-italic { @include font-body-2; - font-weight: bold; + font-weight: 600; font-style: italic; } @mixin font-subtitle { font-family: $inter; - font-size: 12px; + font-size: 11px; line-height: 16px; - letter-spacing: 0px; + letter-spacing: 0; } @mixin font-caption { font-family: $inter; font-size: 11px; - line-height: 16px; + line-height: 14px; letter-spacing: 0.06px; } @mixin font-caption-bold { @include font-caption; - font-weight: bold; + font-weight: 600; } @mixin font-caption-bold-italic { @include font-caption; - font-weight: bold; + font-weight: 600; font-style: italic; } @@ -106,6 +106,52 @@ } } +// Smooth scrolling + +@mixin smooth-scroll() { + scroll-behavior: smooth; + + @media (prefers-reduced-motion) { + scroll-behavior: auto; + } +} + +// Search results loading + +@mixin search-results-loading-pulsating-background { + animation: search-results-loading-pulsating-background-animation 2s infinite; + + @media (prefers-reduced-motion) { + animation: none; + } + + @include light-theme { + background: $color-gray-05; + } + @include dark-theme { + background: $color-gray-65; + } +} + +@keyframes search-results-loading-pulsating-background-animation { + 0% { + opacity: 1; + } + 50% { + opacity: 0.55; + } + 100% { + opacity: 1; + } +} + +@mixin search-results-loading-box($width) { + width: $width; + height: 12px; + border-radius: 4px; + @include search-results-loading-pulsating-background; +} + // Icons @mixin color-svg($svg, $color, $stretch: true) { @@ -429,3 +475,56 @@ border-radius: 9999px; // This ensures the borders are completely rounded. (A value like 100% would make it an ellipse.) padding: 7px 14px; } + +// Modals + +@mixin modal-reset { + @include popper-shadow(); + border-radius: 8px; + margin: 0 auto; + max-height: 100%; + max-width: 360px; + padding: 16px; + position: relative; + width: 95%; + display: flex; + flex-direction: column; + + @include light-theme() { + background: $color-white; + color: $color-gray-90; + } + + @include dark-theme() { + background: $color-gray-95; + color: $color-gray-05; + } +} + +@mixin modal-close-button { + @include button-reset; + + position: absolute; + right: 12px; + top: 12px; + + height: 24px; + width: 24px; + + @include light-theme { + @include color-svg('../images/icons/v2/x-24.svg', $color-gray-75); + } + + @include dark-theme { + @include color-svg('../images/icons/v2/x-24.svg', $color-gray-15); + } + + &:focus { + @include keyboard-mode { + background-color: $ultramarine-ui-light; + } + @include dark-keyboard-mode { + background-color: $ultramarine-ui-dark; + } + } +} diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index b0b0e733b9..38587ebc99 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -52,7 +52,7 @@ // Module: Message // Note: this does the same thing as module-timeline__message-container but -// can be used outside tht Timeline contact more easily. +// can be used outside the Timeline contact more easily. .module-message-container { @include button-reset; @@ -310,6 +310,7 @@ line-height: 0; display: flex; flex-direction: column; + width: 100%; } .module-message__container { position: relative; @@ -320,6 +321,7 @@ padding-top: 10px; padding-bottom: 10px; min-width: 0px; + width: 100%; overflow: hidden; @include light-theme { @@ -378,7 +380,11 @@ @include dark-theme { border: none; } - padding-bottom: 0px; + + /* Leave some padding to eat the negative margin-bottom from + * .module-message__metadata + */ + padding-bottom: 3px; } .module-message__container--outgoing { @@ -686,18 +692,6 @@ cursor: pointer; } -.module-message__audio-attachment { - margin-top: 2px; -} - -.module-message__audio-attachment--with-content-below { - margin-bottom: 5px; -} - -.module-message__audio-attachment--with-content-above { - margin-top: 6px; -} - .module-message__generic-attachment { @include button-reset; @@ -859,7 +853,7 @@ } .module-message__link-preview { - @include button-reset; + cursor: pointer; &--nonclickable { cursor: inherit; @@ -962,6 +956,7 @@ > *:not(:first-child) { display: flex; + flex-grow: 1; &:before { content: '•'; @@ -2853,336 +2848,6 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05', } } -// Module: Conversation Header - -.module-conversation-header { - padding-left: 16px; - padding-right: 16px; - padding-top: var(--title-bar-drag-area-height); - display: flex; - flex-direction: row; - align-items: center; - - height: calc(#{$header-height} + var(--title-bar-drag-area-height)); - - @include light-theme { - color: $color-gray-90; - background-color: $color-white; - } - @include dark-theme { - color: $color-gray-02; - background-color: $color-gray-95; - } -} - -.module-conversation-header__back-icon { - $transition: 250ms ease-out; - - display: inline-block; - margin-left: -10px; - margin-right: -10px; - width: 24px; - height: 24px; - min-width: 24px; - vertical-align: text-bottom; - border: none; - opacity: 0; - transition: margin-right $transition, opacity $transition; - - &:disabled { - cursor: default; - } - - &--show { - opacity: 1; - margin-right: 6px; - } - - @include light-theme { - @include color-svg( - '../images/icons/v2/chevron-left-24.svg', - $color-gray-90 - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v2/chevron-left-24.svg', - $color-gray-02 - ); - } -} - -.module-conversation-header__title-container { - flex-grow: 1; - flex-shrink: 1; - min-width: 0; - display: block; - - height: $header-height; -} - -.module-conversation-header__title-flex { - margin-left: auto; - margin-right: auto; - display: inline-flex; - flex-direction: row; - align-items: center; - height: $header-height; - max-width: 100%; -} - -.module-conversation-header__title-clickable { - cursor: pointer; - - &:focus { - @include mouse-mode { - outline: none; - } - } -} - -.module-conversation-header__note-to-self { - @include dark-theme { - color: $color-gray-02; - } -} - -.module-conversation-header__avatar { - min-width: 32px; - margin-right: 4px; -} - -.module-conversation-header__title { - margin-left: 6px; - min-width: 0; - - @include font-body-1-bold; - - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - -webkit-user-select: text; - - @include light-theme { - color: $color-gray-90; - } - @include dark-theme { - color: $color-gray-02; - } -} - -.module-conversation-header__contacts-icon { - display: inline-block; - height: 15px; - width: 15px; - - margin-bottom: 3px; - vertical-align: middle; - - @include light-theme { - @include color-svg( - '../images/icons/v2/profile-circle-outline-24.svg', - $color-gray-60 - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v2/profile-circle-outline-24.svg', - $color-gray-25 - ); - } - - @include keyboard-mode { - &:focus { - @include color-svg( - '../images/icons/v2/profile-circle-outline-24.svg', - $ultramarine-ui-light - ); - } - } -} - -.module-conversation-header__title__profile-name { - @include font-body-1-bold-italic; -} - -.module-conversation-header__title__verified-icon { - display: inline-block; - width: 1.25em; - height: 1.25em; - vertical-align: text-bottom; - - @include light-theme { - @include color-svg('../images/icons/v2/check-24.svg', $color-gray-90); - } - @include dark-theme { - @include color-svg('../images/icons/v2/check-24.svg', $color-gray-02); - } -} - -.module-conversation-header__expiration { - display: flex; - flex-direction: row; - align-items: center; - padding-left: 8px; - padding-right: 8px; - transition: opacity 250ms ease-out; - - &--hidden { - opacity: 0; - } -} - -.module-conversation-header__expiration__clock-icon { - height: 24px; - width: 24px; - display: inline-block; - - @include light-theme { - @include color-svg('../images/icons/v2/timer-24.svg', $color-gray-60); - } - @include dark-theme { - @include color-svg('../images/icons/v2/timer-24.svg', $color-gray-25); - } -} - -.module-conversation-header__expiration__setting { - margin-left: 5px; - text-align: center; -} - -.module-conversation-header__more-button { - height: 24px; - width: 24px; - margin-left: 12px; - border: none; - opacity: 0; - transition: opacity 250ms ease-out; - - &:disabled { - cursor: default; - } - - &--show { - opacity: 1; - } - - @include light-theme { - @include color-svg( - '../images/icons/v2/chevron-down-24.svg', - $color-gray-75 - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v2/chevron-down-24.svg', - $color-gray-15 - ); - } -} - -.module-conversation-header__search-button { - height: 24px; - width: 24px; - margin-left: 12px; - border: none; - opacity: 0; - transition: opacity 250ms ease-out; - - &:disabled { - cursor: default; - } - - &--show { - opacity: 1; - } - - @include light-theme { - @include color-svg('../images/icons/v2/search-24.svg', $color-gray-75); - } - @include dark-theme { - @include color-svg('../images/icons/v2/search-24.svg', $color-gray-15); - } -} - -.module-conversation-header__calling-button { - $icon-size: 24px; - - margin-left: 12px; - border: none; - opacity: 0; - transition: opacity 250ms ease-out; - - &:disabled { - cursor: default; - } - - &--show { - opacity: 1; - } - - &--video { - @include light-theme { - @include color-svg( - '../images/icons/v2/video-outline-24.svg', - $color-gray-75 - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v2/video-solid-24.svg', - $color-gray-15 - ); - } - height: $icon-size; - width: $icon-size; - } - - &--audio { - @include light-theme { - @include color-svg( - '../images/icons/v2/phone-right-outline-24.svg', - $color-gray-75 - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v2/phone-right-solid-24.svg', - $color-gray-15 - ); - } - height: $icon-size; - width: $icon-size; - } - - &--join { - @include font-body-1; - align-items: center; - background-color: $color-accent-green; - border-radius: 9999px; // This ensures the borders are completely rounded. (A value like 100% would make it an ellipse.) - color: $color-white; - display: flex; - outline: none; - padding: 5px 18px; - - @include keyboard-mode { - &:focus { - box-shadow: 0px 0px 0px 4px $ultramarine-ui-light; - } - } - - &:before { - @include color-svg('../images/icons/v2/video-solid-24.svg', $color-white); - content: ''; - display: block; - height: $icon-size; - margin-right: 5px; - width: $icon-size; - } - } -} - // Module: Conversation Details .conversation-details-panel { @@ -3201,18 +2866,31 @@ button.module-conversation-details__action-button { .module-conversation-details { &-header { - &__root { + &__root, + &__root--editable { align-items: center; + background: none; + border: none; + color: inherit; display: flex; flex-direction: column; - padding-bottom: 24px; + margin: 0; + outline: inherit; + padding: 0 0 24px 0; text-align: center; + width: 100%; + } + + &__root--editable { + cursor: pointer; } &__title { @include font-title-1; - padding-top: 12px; + align-items: center; + display: flex; padding-bottom: 8px; + padding-top: 12px; } &__subtitle { @@ -3224,10 +2902,80 @@ button.module-conversation-details__action-button { color: $color-gray-25; } } + + &__root--editable &__title { + $icon: '../images/icons/v2/compose-solid-24.svg'; + + &::after { + $size: 24px; + + content: ''; + height: $size; + left: $size + 13px; + margin-left: -$size; + opacity: 0; + position: relative; + transition: opacity 100ms ease-out; + width: $size; + + @include light-theme { + @include color-svg($icon, $color-gray-60); + } + @include dark-theme { + @include color-svg($icon, $color-gray-25); + } + } + } + + &__root--editable:hover &__title::after { + opacity: 1; + } + } + + &-membership-list { + &__add-members-icon { + @mixin plus-icon($color) { + @include color-svg('../images/icons/v2/plus-24.svg', $color); + content: ''; + display: block; + height: 16px; + width: 16px; + } + + align-items: center; + border-radius: 100%; + display: flex; + height: 32px; + justify-content: center; + width: 32px; + + @include light-theme { + background: $color-gray-02; + &::before { + @include plus-icon($color-black); + } + } + @include dark-theme { + background: $color-gray-90; + &::before { + @include plus-icon($color-gray-15); + } + } + } } &__leave-group { color: $color-accent-red; + + &--disabled { + @include light-theme { + color: $color-gray-60; + } + + @include dark-theme { + color: $color-gray-25; + } + } } &__block-group { @@ -3442,6 +3190,16 @@ button.module-conversation-details__action-button { -webkit-mask: url(../images/icons/v2/leave-24.svg) no-repeat center; background-color: $color-accent-red; } + + &--disabled::after { + @include light-theme { + background-color: $color-gray-60; + } + + @include dark-theme { + background-color: $color-gray-25; + } + } } &--block { @@ -3500,6 +3258,8 @@ button.module-conversation-details__action-button { &-panel-row { &__root { align-items: center; + border-radius: 5px; + border: 2px solid transparent; display: flex; padding: 16px 24px; user-select: none; @@ -3508,21 +3268,37 @@ button.module-conversation-details__action-button { &--button { color: inherit; background: none; - border: none; + + &:hover:not(:disabled) { + @include light-theme { + background-color: $color-gray-02; + } + + @include dark-theme { + background-color: $color-gray-90; + } + + & .module-conversation-details-panel-row__actions { + opacity: 1; + } + } } - &:hover { - @include light-theme { - background-color: $color-gray-02; - } + &:focus { + outline: none; + } - @include dark-theme { - background-color: $color-gray-90; + @mixin keyboard-focus-state($color) { + &:focus { + border-color: $color; } + } - & .module-conversation-details-panel-row__actions { - opacity: 1; - } + @include keyboard-mode { + @include keyboard-focus-state($ultramarine-ui-light); + } + @include dark-keyboard-mode { + @include keyboard-focus-state($ultramarine-ui-dark); } } @@ -3539,8 +3315,15 @@ button.module-conversation-details__action-button { &__info { @include font-body-2; - color: $color-gray-60; margin-top: 4px; + + @include light-theme { + color: $color-gray-60; + } + + @include dark-theme { + color: $color-gray-25; + } } &__right { @@ -4228,300 +4011,6 @@ button.module-conversation-details__action-button { } } -// Module: Conversation List Item - -.module-conversation-list-item { - @include button-reset; - - width: 100%; - - display: flex; - flex-direction: row; - padding-right: 16px; - padding-left: 16px; - align-items: center; - - &:hover, - &:focus { - @include light-theme { - background-color: $color-gray-05; - } - @include dark-theme { - background-color: $color-gray-75; - } - } -} - -.module-conversation-list-item__muted { - display: inline-block; - height: 14px; - margin-right: 4px; - vertical-align: middle; - width: 14px; - - @include light-theme { - @include color-svg( - '../images/icons/v2/sound-off-outline-24.svg', - $color-gray-60 - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v2/sound-off-outline-24.svg', - $color-gray-25 - ); - } -} - -.module-conversation-list-item--has-unread { - padding-left: 12px; - - @include light-theme { - border-left: 4px solid $ultramarine-ui-light; - } - - @include dark-theme { - border-left: 4px solid $ultramarine-ui-dark; - } -} - -.module-conversation-list-item--is-selected { - @include light-theme { - background-color: $color-gray-15; - } - @include dark-theme { - background-color: $color-gray-65; - } -} - -.module-conversation-list-item__avatar-container { - position: relative; - margin-top: 8px; - margin-bottom: 8px; -} - -.module-conversation-list-item__unread-count { - text-align: center; - - padding-left: 3px; - padding-right: 3px; - - position: absolute; - right: -6px; - top: 0px; - - @include font-caption-bold; - - height: 20px; - min-width: 20px; - line-height: 20px; - border-radius: 10px; - - color: $color-white; - - @include light-theme { - background-color: $ultramarine-ui-light; - box-shadow: 0px 0px 0px 1px $color-gray-02; - } - @include dark-theme { - background-color: $ultramarine-ui-dark; - box-shadow: 0px 0px 0px 1px $color-gray-90; - } -} - -.module-conversation-list-item__content { - flex-grow: 1; - margin-left: 12px; - // parent - 52px (for avatar) - 12p (margin to avatar) - max-width: calc(100% - 64px); - - display: flex; - flex-direction: column; - align-items: stretch; -} - -.module-conversation-list-item__header { - display: flex; - flex-direction: row; - align-items: center; -} - -.module-conversation-list-item__header__name { - flex-grow: 1; - flex-shrink: 1; - - @include font-body-1-bold; - - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - - @include light-theme { - color: $color-gray-90; - } - @include dark-theme { - color: $color-gray-05; - } -} - -.module-conversation-list-item__header__timestamp { - flex-shrink: 0; - margin-left: 6px; - - @include font-caption; - - overflow-x: hidden; - white-space: nowrap; - text-overflow: ellipsis; - - @include light-theme { - color: $color-gray-60; - } - @include dark-theme { - color: $color-gray-25; - } -} - -.module-conversation-list-item__header__timestamp--with-unread { - @include font-caption-bold; -} - -.module-conversation-list-item__header__date--has-unread { - @include font-caption-bold; - - @include light-theme { - color: $color-gray-90; - } - @include dark-theme { - color: $color-gray-05; - } -} - -.module-conversation-list-item__message { - display: flex; - flex-direction: row; - align-items: center; -} - -.module-conversation-list-item__message-request { - @include font-body-2-bold; - - @include light-theme { - color: $color-gray-60; - } - @include dark-theme { - color: $color-gray-25; - } -} - -.module-conversation-list-item__message__text { - flex-grow: 1; - flex-shrink: 1; - - @include font-body-2; - - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - text-align: left; - - @include light-theme { - color: $color-gray-60; - } - @include dark-theme { - color: $color-gray-25; - } -} - -.module-conversation-list-item__message__text--has-unread { - @include font-body-2-bold; - - @include light-theme { - color: $color-gray-90; - } - @include dark-theme { - color: $color-gray-05; - } -} - -.module-conversation-list-item__message { - &__draft-prefix, - &__deleted-for-everyone { - font-style: italic; - margin-right: 3px; - } -} - -.module-conversation-list-item__message__status-icon { - flex-shrink: 0; - - margin-top: 2px; - width: 12px; - height: 12px; - display: inline-block; - margin-left: 6px; -} - -.module-conversation-list-item__message__status-icon--sending { - animation: module-conversation-list-item__message__status-icon--spinning 4s - linear infinite; - @include light-theme { - @include color-svg('../images/sending.svg', $color-gray-60); - } - @include dark-theme { - @include color-svg('../images/sending.svg', $color-gray-45); - } -} - -@keyframes module-conversation-list-item__message__status-icon--spinning { - 100% { - -webkit-transform: rotate(360deg); - transform: rotate(360deg); - } -} - -.module-conversation-list-item__message__status-icon--sent { - @include light-theme { - @include color-svg('../images/check-circle-outline.svg', $color-gray-25); - } - @include dark-theme { - @include color-svg('../images/check-circle-outline.svg', $color-gray-45); - } -} -.module-conversation-list-item__message__status-icon--delivered { - @include light-theme { - @include color-svg('../images/double-check.svg', $color-gray-25); - } - @include dark-theme { - @include color-svg('../images/double-check.svg', $color-gray-45); - } - width: 18px; -} -.module-conversation-list-item__message__status-icon--read { - @include light-theme { - @include color-svg('../images/read.svg', $color-gray-25); - } - @include dark-theme { - @include color-svg('../images/read.svg', $color-gray-45); - } - width: 18px; -} -.module-conversation-list-item__message__status-icon--error, -.module-conversation-list-item__message__status-icon--partial-sent { - @include light-theme { - @include color-svg( - '../images/icons/v2/error-outline-12.svg', - $color-accent-red - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v2/error-solid-12.svg', - $color-accent-red - ); - } -} - // Module: Avatar .module-avatar { @@ -4601,6 +4090,7 @@ button.module-conversation-details__action-button { } .module-avatar--28 { + min-width: 28px; height: 28px; width: 28px; @@ -4648,6 +4138,7 @@ button.module-conversation-details__action-button { .module-avatar--32 { height: 32px; width: 32px; + min-width: 32px; img { height: 32px; @@ -4693,6 +4184,7 @@ button.module-conversation-details__action-button { .module-avatar--52 { height: 52px; width: 52px; + min-width: 52px; img { height: 52px; @@ -4719,6 +4211,7 @@ button.module-conversation-details__action-button { .module-avatar--80 { height: 80px; width: 80px; + min-width: 80px; img { height: 80px; @@ -4745,6 +4238,7 @@ button.module-conversation-details__action-button { .module-avatar--96 { height: 96px; width: 96px; + min-width: 96px; img { height: 96px; @@ -4766,6 +4260,7 @@ button.module-conversation-details__action-button { .module-avatar--112 { height: 112px; width: 112px; + min-width: 112px; img { height: 112px; @@ -4835,6 +4330,8 @@ button.module-conversation-details__action-button { // Module: Main Header .module-main-header { + -webkit-app-region: var(--draggable-app-region); + height: calc(#{$header-height} + var(--title-bar-drag-area-height)); width: 100%; @@ -4854,14 +4351,19 @@ button.module-conversation-details__action-button { } } + &__avatar { + -webkit-app-region: no-drag; + } + &__search { flex-grow: 1; position: relative; display: flex; flex-direction: row; + -webkit-app-region: no-drag; &__input { - flex-grow: 1; + width: 100%; height: 28px; padding-left: 30px; @@ -5000,6 +4502,25 @@ button.module-conversation-details__action-button { } } } + + &__compose-icon { + $icon: '../images/icons/v2/compose-outline-24.svg'; + + width: 24px; + height: 24px; + -webkit-app-region: no-drag; + + @include light-theme { + @include color-svg($icon, $color-gray-90); + } + @include dark-theme { + @include color-svg($icon, $color-gray-02); + } + + &:focus { + @include color-svg($icon, $ultramarine-ui-light); + } + } } // Module: Image @@ -6066,176 +5587,6 @@ button.module-image__border-overlay:focus { } } -// Module: Search Results - -.module-search-results { - outline: none; - overflow: hidden; - flex-grow: 1; -} - -.module-search-results__conversations-header, -.module-search-results__contacts-header, -.module-search-results__messages-header { - @include font-body-1-bold; - - height: 52px; - margin-left: 16px; - padding-bottom: 8px; - padding-top: 8px; - - @include dark-theme { - color: $color-gray-05; - } -} - -.module-search-results__sms-not-supported { - font-size: 14px; - padding-top: 12px; - text-align: center; - - @include light-theme { - color: $color-gray-60; - } - @include dark-theme { - color: $color-gray-25; - } -} - -.module-search-results__no-results { - margin-top: 27px; - padding-left: 1em; - padding-right: 1em; - width: 100%; - text-align: center; - outline: none; -} - -.module-search-results__spinner-container { - width: 100%; - padding: 10px; - - text-align: center; -} - -// Module: Message Search Result - -.module-message-search-result { - @include button-reset; - - width: 100%; - - padding: 8px; - padding-left: 16px; - padding-right: 16px; - min-height: 64px; - max-width: $left-pane-width; - - display: flex; - flex-direction: row; - align-items: flex-start; - - &:hover, - &:focus { - @include light-theme { - background-color: $color-gray-05; - } - @include dark-theme { - background-color: $color-gray-75; - } - } -} - -.module-message-search-result--is-selected { - @include light-theme { - background-color: $color-gray-15; - } - @include dark-theme { - background-color: $color-gray-65; - } -} - -.module-message-search-result__text { - flex-grow: 1; - margin-left: 12px; - // parent - 48px (for avatar) - 16px (our right margin) - max-width: calc(100% - 64px); - - display: inline-flex; - flex-direction: column; - align-items: stretch; -} - -.module-message-search-result__header { - display: flex; - flex-direction: row; - align-items: center; -} - -.module-message-search-result__header__from { - @include font-body-1; - - flex-grow: 1; - flex-shrink: 1; - - overflow-x: hidden; - white-space: nowrap; - text-overflow: ellipsis; - - @include light-theme { - color: $color-gray-90; - } - @include dark-theme { - color: $color-gray-05; - } -} - -.module-message-search-result__header__timestamp { - flex-shrink: 0; - margin-left: 6px; - - @include font-caption; - - overflow-x: hidden; - white-space: nowrap; - text-overflow: ellipsis; - - text-transform: uppercase; - - @include light-theme { - color: $color-gray-60; - } - @include dark-theme { - color: $color-gray-25; - } -} - -.module-message-search-result__body { - @include font-body-2; - - margin-top: 1px; - flex-grow: 1; - flex-shrink: 1; - - @include light-theme { - color: $color-gray-60; - } - @include dark-theme { - color: $color-gray-15; - } - - overflow: hidden; - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - - // Note: -webkit-line-clamp doesn't work for RTL text, and it forces you to use - // ... as the truncation indicator. That's not a solution that works well for - // all languages. More resources: - // - http://hackingui.com/front-end/a-pure-css-solution-for-multiline-text-truncation/ - // - https://medium.com/mofed/css-line-clamp-the-good-the-bad-and-the-straight-up-broken-865413f16e5 -} - // Module: Reaction Viewer .module-reaction-viewer { @@ -6991,12 +6342,13 @@ button.module-image__border-overlay:focus { &__scroll-marker { $scroll-marker-selector: &; + @include smooth-scroll; + display: flex; justify-content: center; left: 0; opacity: 1; position: absolute; - scroll-behavior: smooth; transition: opacity 200ms ease-out; width: 100%; z-index: 1; @@ -7576,6 +6928,498 @@ button.module-image__border-overlay:focus { } } +// Module: conversation list + +.module-conversation-list { + &--scroll-behavior { + &-default { + @include smooth-scroll; + } + } + + &__item { + &--archive-button { + @include button-reset; + + @include font-body-1-bold; + + height: 64px; + line-height: 64px; + text-align: center; + width: 100%; + + @include light-theme { + color: $color-gray-60; + + &:hover, + &:focus { + background-color: $color-gray-05; + } + } + @include dark-theme { + color: $color-gray-25; + &:hover, + &:focus { + background-color: $color-gray-75; + } + } + + &__archived-count { + @include font-body-2-bold; + + padding: 6px; + padding-top: 1px; + padding-bottom: 1px; + border-radius: 10px; + + @include light-theme { + color: $color-gray-60; + background-color: $color-gray-05; + } + @include dark-theme { + color: $color-gray-25; + background-color: $color-gray-75; + } + } + } + + &--contact-or-conversation { + @include button-reset; + + align-items: center; + cursor: inherit; + display: flex; + flex-direction: row; + padding-left: 16px; + padding-right: 16px; + user-select: none; + width: 100%; + + &--is-button { + cursor: pointer; + + &:disabled { + cursor: inherit; + } + + &:hover:not(:disabled), + &:focus:not(:disabled) { + @include light-theme { + background-color: $color-gray-05; + } + @include dark-theme { + background-color: $color-gray-75; + } + } + } + + &--is-checkbox { + cursor: pointer; + + &--disabled { + cursor: not-allowed; + } + + $disabled-selector: '#{&}--disabled'; + &:hover:not(#{$disabled-selector}), + &:focus:not(#{$disabled-selector}) { + @include light-theme { + background-color: $color-gray-05; + } + @include dark-theme { + background-color: $color-gray-75; + } + } + } + + &--has-unread { + padding-left: 12px; + + @include light-theme { + border-left: 4px solid $ultramarine-ui-light; + } + + @include dark-theme { + border-left: 4px solid $ultramarine-ui-dark; + } + } + + &--is-selected { + @include light-theme { + background-color: $color-gray-15; + } + @include dark-theme { + background-color: $color-gray-65; + } + } + + &__avatar-container { + position: relative; + margin-top: 8px; + margin-bottom: 8px; + } + + &__unread-count { + text-align: center; + + padding-left: 3px; + padding-right: 3px; + + position: absolute; + right: -6px; + top: 0px; + + @include font-caption-bold; + + height: 20px; + min-width: 20px; + line-height: 20px; + border-radius: 10px; + + color: $color-white; + + @include light-theme { + background-color: $ultramarine-ui-light; + box-shadow: 0px 0px 0px 1px $color-gray-02; + } + @include dark-theme { + background-color: $ultramarine-ui-dark; + box-shadow: 0px 0px 0px 1px $color-gray-90; + } + } + + &__content { + flex-grow: 1; + margin-left: 12px; + display: flex; + flex-direction: column; + align-items: stretch; + overflow: hidden; + + &--disabled { + opacity: 0.5; + } + + &__header { + display: flex; + flex-direction: row; + align-items: center; + + &__name { + flex-grow: 1; + flex-shrink: 1; + + @include font-body-1-bold; + + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + @include light-theme { + color: $color-gray-90; + } + @include dark-theme { + color: $color-gray-05; + } + } + + &__date { + display: inline-block; + flex-shrink: 0; + + &--has-unread { + @include font-caption-bold; + + @include light-theme { + color: $color-gray-90; + } + @include dark-theme { + color: $color-gray-05; + } + } + + &__timestamp { + flex-shrink: 0; + margin-left: 6px; + + @include font-caption; + + overflow-x: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-25; + } + + &--with-unread { + @include font-caption-bold; + } + } + } + } + + &__message { + display: flex; + flex-direction: row; + align-items: center; + + &__text { + flex-grow: 1; + flex-shrink: 1; + + @include font-body-2; + + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + text-align: left; + + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-25; + } + + &--has-unread { + @include font-body-2-bold; + + @include light-theme { + color: $color-gray-90; + } + @include dark-theme { + color: $color-gray-05; + } + } + + &__muted { + display: inline-block; + height: 14px; + margin-right: 4px; + vertical-align: middle; + width: 14px; + + @include light-theme { + @include color-svg( + '../images/icons/v2/sound-off-outline-24.svg', + $color-gray-60 + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/sound-off-outline-24.svg', + $color-gray-25 + ); + } + } + + &__message-request { + @include font-body-2-bold; + + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-25; + } + } + + &__draft-prefix, + &__deleted-for-everyone { + font-style: italic; + margin-right: 3px; + } + + &__status-icon { + flex-shrink: 0; + + margin-top: 2px; + width: 12px; + height: 12px; + display: inline-block; + margin-left: 6px; + + @mixin normal-status-icon($icon) { + @include light-theme { + @include color-svg($icon, $color-gray-25); + } + @include dark-theme { + @include color-svg($icon, $color-gray-45); + } + } + + &--sending { + animation: module-conversation-list__item--contact-or-conversation__contact__message__text__status-icon--spinning + 4s linear infinite; + @include light-theme { + @include color-svg('../images/sending.svg', $color-gray-60); + } + @include dark-theme { + @include color-svg('../images/sending.svg', $color-gray-45); + } + } + + &--sent { + @include normal-status-icon( + '../images/check-circle-outline.svg' + ); + } + + &--delivered { + @include normal-status-icon('../images/double-check.svg'); + width: 18px; + } + + &--read { + @include normal-status-icon('../images/read.svg'); + width: 18px; + } + + &--error, + &--partial-sent { + @include light-theme { + @include color-svg( + '../images/icons/v2/error-outline-12.svg', + $color-accent-red + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/error-solid-12.svg', + $color-accent-red + ); + } + } + } + + &__message-search-result-contents { + display: -webkit-box; + white-space: initial; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + + // Note: -webkit-line-clamp doesn't work for RTL text, and it forces you to use + // ... as the truncation indicator. That's not a solution that works well for + // all languages. More resources: + // - http://hackingui.com/front-end/a-pure-css-solution-for-multiline-text-truncation/ + // - https://medium.com/mofed/css-line-clamp-the-good-the-bad-and-the-straight-up-broken-865413f16e5 + } + + &__start-new-conversation { + @include font-body-1-italic; + } + } + } + } + + &__checkbox { + -webkit-appearance: none; + background: $color-white; + border-radius: 100%; + height: 20px; + margin-left: 16px; + margin-right: 16px; + width: 20px; + min-width: 20px; + pointer-events: none; + + @include light-theme { + border: 1px solid $color-gray-15; + } + @include dark-theme { + border: 1px solid $color-gray-80; + } + + &:focus { + outline: none; + } + + @include keyboard-mode { + &:focus { + border-width: 2px; + border-color: $ultramarine-ui-light; + &:checked { + box-shadow: inset 0 0 0px 1px $color-white; + } + } + } + @include dark-keyboard-mode { + &:focus { + border-width: 2px; + border-color: $ultramarine-ui-dark; + + &:checked { + box-shadow: inset 0 0 0px 1px $color-black; + } + } + } + + &:disabled:not(:checked) { + opacity: 0.5; + } + + &:checked { + $icon: '../images/icons/v2/check-24.svg'; + + background: $ultramarine-ui-light; + display: flex; + align-items: center; + justify-content: center; + + &::before { + content: ''; + display: block; + @include color-svg($icon, $color-white); + width: 13px; + height: 13px; + } + + @include light-theme { + &:disabled { + background: $color-gray-15; + } + } + @include dark-theme { + &:disabled { + background: $color-gray-45; + } + } + } + } + } + + &--header { + @include font-body-1-bold; + + display: inline-flex; + align-items: center; + padding-left: 16px; + + @include dark-theme { + color: $color-gray-05; + } + } + + &--spinner { + width: 100%; + padding: 10px; + + text-align: center; + } + } +} + +@keyframes module-conversation-list__item--contact-or-conversation__contact__message__text__status-icon--spinning { + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} + // Module: Left Pane .module-left-pane { @@ -7584,83 +7428,81 @@ button.module-image__border-overlay:focus { width: $left-pane-width; height: 100%; + position: relative; } .module-left-pane__header { flex-grow: 0; flex-shrink: 0; -} -.module-left-pane__archive-header { - height: calc(#{$header-height} + var(--title-bar-drag-area-height)); - width: 100%; + &__contents { + height: calc(#{$header-height} + var(--title-bar-drag-area-height)); + width: 100%; - display: inline-flex; - flex-direction: row; - align-items: center; - padding-top: var(--title-bar-drag-area-height); -} + display: inline-flex; + flex-direction: row; + align-items: center; + padding-top: var(--title-bar-drag-area-height); -.module-left-pane__header-row { - @include font-body-1-bold; + &__back-button { + @include button-reset; - display: inline-flex; - align-items: center; - padding-left: 16px; + margin-left: 7px; + margin-right: 5px; - @include dark-theme { - color: $color-gray-05; - } -} + width: 24px; + height: 24px; -.module-left-pane__to-inbox-button { - @include button-reset; + &:disabled { + cursor: not-allowed; + } - margin-left: 7px; - margin-right: 5px; + @include light-theme { + @include color-svg( + '../images/icons/v2/chevron-left-24.svg', + $color-gray-60 + ); + } + @include keyboard-mode { + &:focus { + @include color-svg( + '../images/icons/v2/chevron-left-24.svg', + $ultramarine-ui-light + ); + } + } - width: 24px; - height: 24px; + @include dark-theme { + @include color-svg( + '../images/icons/v2/chevron-left-24.svg', + $color-gray-25 + ); + } + @include dark-keyboard-mode { + &:hover { + @include color-svg( + '../images/icons/v2/chevron-left-24.svg', + $ultramarine-ui-dark + ); + } + } + } - @include light-theme { - @include color-svg( - '../images/icons/v2/chevron-left-24.svg', - $color-gray-60 - ); - } - @include keyboard-mode { - &:focus { - @include color-svg( - '../images/icons/v2/chevron-left-24.svg', - $ultramarine-ui-light - ); + &__text { + @include font-body-1-bold; + + @include light-theme { + color: $color-gray-90; + } + @include dark-theme { + color: $color-gray-05; + } } } - @include dark-theme { - @include color-svg( - '../images/icons/v2/chevron-left-24.svg', - $color-gray-25 - ); - } - @include dark-keyboard-mode { - &:hover { - @include color-svg( - '../images/icons/v2/chevron-left-24.svg', - $ultramarine-ui-dark - ); - } - } -} - -.module-left-pane__archive-header-text { - @include font-body-1-bold; - - @include light-theme { - color: $color-gray-90; - } - @include dark-theme { - color: $color-gray-05; + &__form { + display: flex; + flex-direction: column; } } @@ -7682,6 +7524,54 @@ button.module-image__border-overlay:focus { } } +.module-left-pane__no-search-results, +.module-left-pane__compose-no-contacts { + flex-grow: 1; + margin-top: 27px; + padding-left: 1em; + padding-right: 1em; + width: 100%; + text-align: center; + outline: none; +} + +.module-left-pane__compose-search-form { + display: flex; + padding: 8px 16px; + margin-bottom: 8px; + + &__input { + flex-grow: 1; + + padding: 5px 12px; + + border-radius: 17px; + border: none; + + @include font-body-1; + + @include light-theme { + background-color: $color-gray-05; + color: $color-gray-90; + border: solid 1px $color-gray-02; + } + @include dark-theme { + color: $color-gray-05; + background-color: $color-gray-95; + border: solid 1px $color-gray-80; + } + + &:placeholder { + color: $color-gray-45; + } + + &:focus { + border: solid 1px $ultramarine-ui-light; + outline: none; + } + } +} + .module-left-pane__list--measure { flex-grow: 1; flex-shrink: 1; @@ -7694,105 +7584,25 @@ button.module-image__border-overlay:focus { .module-left-pane__list { position: absolute; -} - -.module-left-pane__virtual-list { outline: none; } -.module-left-pane__archived-button { - @include button-reset; - - @include font-body-1-bold; - - height: 64px; - line-height: 64px; - text-align: center; - width: 100%; - - @include light-theme { - color: $color-gray-60; - - &:hover, - &:focus { - background-color: $color-gray-05; - } - } - @include dark-theme { - color: $color-gray-25; - &:hover, - &:focus { - background-color: $color-gray-75; - } - } -} - -.module-left-pane__archived-button__archived-count { - @include font-body-2-bold; - - padding: 6px; - padding-top: 1px; - padding-bottom: 1px; - border-radius: 10px; - - @include light-theme { - color: $color-gray-60; - background-color: $color-gray-05; - } - @include dark-theme { - color: $color-gray-25; - background-color: $color-gray-75; - } -} - -// Module: Start New Conversation - -.module-start-new-conversation { - @include button-reset; - - width: 100%; - +.module-left-pane__footer { + bottom: 0; display: flex; flex-direction: row; - align-items: center; - - padding-top: 8px; - padding-bottom: 8px; - padding-left: 16px; - - &:hover, - &:focus { - @include light-theme { - background-color: $color-gray-05; - } - @include dark-theme { - background-color: $color-gray-75; - } - } -} - -.module-start-new-conversation__content { - margin-left: 12px; -} - -.module-start-new-conversation__number { - font-weight: bold; - - @include dark-theme { - color: $color-gray-05; - } -} - -.module-start-new-conversation__text { - margin-top: 3px; - - @include font-body-1-italic; + justify-content: flex-end; + left: 0; + padding: 12px; + position: absolute; + width: 100%; @include light-theme { - color: $color-gray-60; + background: linear-gradient(transparent, $color-gray-02); } + @include dark-theme { - color: $color-gray-45; + background: linear-gradient(transparent, $color-gray-80); } } @@ -10167,10 +9977,10 @@ button.module-image__border-overlay:focus { } .module-avatar-popup__profile__name { - @include font-body-2-bold; + @include font-body-1-bold; } .module-avatar-popup__profile__number { - @include font-caption; + @include font-subtitle; @include light-theme { color: $color-gray-60; @@ -10800,143 +10610,6 @@ button.module-image__border-overlay:focus { padding: 20px; } -// Module: GV1 Migration Dialog - -.module-group-v2-migration-dialog { - @include font-body-1; - border-radius: 8px; - width: 360px; - margin-left: auto; - margin-right: auto; - padding: 20px; - - max-height: 100%; - - display: flex; - flex-direction: column; - - position: relative; - - @include light-theme { - background-color: $color-white; - } - @include dark-theme { - background-color: $color-gray-95; - } -} -.module-group-v2-migration-dialog__close-button { - @include button-reset; - - position: absolute; - right: 12px; - top: 12px; - - height: 24px; - width: 24px; - - @include light-theme { - @include color-svg('../images/icons/v2/x-24.svg', $color-gray-75); - } - - @include dark-theme { - @include color-svg('../images/icons/v2/x-24.svg', $color-gray-15); - } - - &:focus { - @include keyboard-mode { - background-color: $ultramarine-ui-light; - } - @include dark-keyboard-mode { - background-color: $ultramarine-ui-dark; - } - } -} -.module-group-v2-migration-dialog__title { - @include font-title-2; - text-align: center; - margin-bottom: 20px; - - flex-grow: 0; - flex-shrink: 0; -} -.module-group-v2-migration-dialog__scrollable { - overflow-x: scroll; - flex-grow: 1; - flex-shrink: 1; -} -.module-group-v2-migration-dialog__item { - display: flex; - flex-direction: row; - align-items: start; - - &:not(:last-of-type) { - margin-bottom: 16px; - } -} -.module-group-v2-migration-dialog__item__bullet { - width: 4px; - height: 11px; - flex-grow: 0; - flex-shrink: 0; - - margin-top: 5px; - - @include light-theme { - background-color: $color-gray-15; - } - @include dark-theme { - background-color: $color-gray-65; - } -} -.module-group-v2-migration-dialog__item__content { - margin-left: 16px; -} -.module-group-v2-migration-dialog__member { - margin-top: 16px; -} -.module-group-v2-migration-dialog__member__name { - margin-left: 6px; -} - -.module-group-v2-migration-dialog__buttons { - margin-top: 16px; - - text-align: center; - flex-grow: 0; - flex-shrink: 0; - - display: flex; -} -.module-group-v2-migration-dialog__buttons--narrow { - margin-left: auto; - margin-right: auto; - width: 152px; -} -.module-group-v2-migration-dialog__button { - @include button-reset; - @include font-body-1-bold; - - // Start flex basis at zero so text width doesn't affect layout. We want the buttons - // evenly distributed. - flex: 1 1 0px; - - border-radius: 4px; - - padding: 8px; - padding-left: 30px; - padding-right: 30px; - - @include button-primary; - - &:not(:first-of-type) { - margin-left: 16px; - } -} - -.module-group-v2-migration-dialog__button--secondary { - @include button-secondary; -} - // Module: GroupV2 Join Dialog .module-group-v2-join-dialog { @@ -11006,30 +10679,15 @@ button.module-image__border-overlay:focus { display: flex; } .module-group-v2-join-dialog__button { - @include button-reset; - @include font-body-1-bold; - // Start flex basis at zero so text width doesn't affect layout. We want the buttons // evenly distributed. flex: 1 1 0px; - border-radius: 4px; - - padding: 8px; - padding-left: 15px; - padding-right: 15px; - - @include button-primary; - &:not(:first-of-type) { margin-left: 16px; } } -.module-group-v2-join-dialog__button--secondary { - @include button-secondary; -} - // Module: Progress Dialog .module-progress-dialog { @@ -11453,6 +11111,88 @@ $contact-modal-padding: 18px; } } +// Module: Chat Session Refreshed Notification + +.module-chat-session-refreshed-notification { + @include font-body-2; + display: flex; + flex-direction: column; + align-items: center; +} + +.module-chat-session-refreshed-notification__first-line { + margin-bottom: 12px; + display: flex; + flex-direction: row; + align-items: center; + + margin-left: auto; + margin-right: auto; +} +.module-chat-session-refreshed-notification__icon { + height: 16px; + width: 16px; + display: inline-block; + margin-right: 8px; + + @include light-theme { + @include color-svg('../images/icons/v2/refresh-16.svg', $color-gray-60); + } + @include dark-theme { + @include color-svg('../images/icons/v2/refresh-16.svg', $color-gray-25); + } +} +.module-chat-session-refreshed-notification__button { + @include button-reset; + @include button-light-blue-text; + @include button-small; + + @include font-body-2; + padding: 5px 12px; +} + +// Module: Chat Session Refreshed Dialog + +.module-chat-session-refreshed-dialog { + width: 360px; + padding: 16px; + padding-top: 28px; + border-radius: 8px; + margin-left: auto; + margin-right: auto; + + @include light-theme { + background-color: $color-white; + } + @include dark-theme { + background-color: $color-gray-95; + } +} +.module-chat-session-refreshed-dialog__image { + text-align: center; +} +.module-chat-session-refreshed-dialog__title { + @include font-body-1-bold; + margin-top: 10px; + margin-bottom: 3px; +} +.module-chat-session-refreshed-dialog__buttons { + text-align: right; + margin-top: 20px; +} +.module-chat-session-refreshed-dialog__button { + @include font-body-1-bold; + @include button-reset; + @include button-primary; + + border-radius: 4px; + padding: 7px 14px; + margin-left: 12px; +} +.module-chat-session-refreshed-dialog__button--secondary { + @include button-secondary; +} + /* Third-party module: react-contextmenu*/ .react-contextmenu { @@ -11601,9 +11341,6 @@ $contact-modal-padding: 18px; // To limit messages with things forcing them wider, like long attachment names .module-message__container { - // 2px to allow for 1px border - max-width: 302px; - &--incoming { align-self: flex-start; } @@ -11738,15 +11475,20 @@ $contact-modal-padding: 18px; } } +/* Spec: container < 437px */ +@media (min-width: 0px) and (max-width: 799px) { + .module-message { + // Add 2px for 1px border + max-width: 302px; + } +} + /* Spec: container > 438px and container < 593px */ @media (min-width: 800px) and (max-width: 925px) { .module-message { // Add 2px for 1px border max-width: 376px; } - .module-message__container { - max-width: 100%; - } // Spec: container < 438px .module-message--incoming { @@ -11781,9 +11523,6 @@ $contact-modal-padding: 18px; .module-message { max-width: 66%; } - .module-message__container { - max-width: 100%; - } .module-message--incoming { margin-right: auto; diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index 7304491cf6..8542077a61 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -30,13 +30,16 @@ $color-black: #000000; $color-white-alpha-20: rgba($color-white, 0.2); $color-white-alpha-40: rgba($color-white, 0.4); $color-white-alpha-60: rgba($color-white, 0.6); +$color-white-alpha-70: rgba($color-white, 0.7); $color-white-alpha-80: rgba($color-white, 0.8); $color-white-alpha-90: rgba($color-white, 0.9); $color-black-alpha-05: rgba($color-black, 0.05); $color-black-alpha-20: rgba($color-black, 0.2); $color-black-alpha-40: rgba($color-black, 0.4); +$color-black-alpha-50: rgba($color-black, 0.5); $color-black-alpha-60: rgba($color-black, 0.6); +$color-black-alpha-80: rgba($color-black, 0.8); $ultramarine-brand-light: #3a76f0; $ultramarine-brand-dark: #1851b4; diff --git a/stylesheets/components/AddGroupMembersModal.scss b/stylesheets/components/AddGroupMembersModal.scss new file mode 100644 index 0000000000..34d9af2c94 --- /dev/null +++ b/stylesheets/components/AddGroupMembersModal.scss @@ -0,0 +1,97 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-AddGroupMembersModal { + $root-selector: &; + $padding: 16px; + + &__header { + @include font-body-1-bold; + margin: 0; + padding: 0; + } + + &__button-container { + display: flex; + justify-content: flex-end; + flex-grow: 0; + flex-shrink: 0; + padding: $padding; + + .module-Button { + &:not(:first-child) { + margin-left: 12px; + } + } + } + + &__close-button { + @include modal-close-button; + } + + &__search-input { + margin: 10px $padding; + padding: 5px 12px; + + border-radius: 17px; + border: none; + + @include font-body-2; + + @include light-theme { + background-color: $color-gray-05; + color: $color-gray-90; + border: solid 1px $color-gray-02; + } + @include dark-theme { + color: $color-gray-05; + background-color: $color-gray-95; + border: solid 1px $color-gray-80; + } + + &:placeholder { + color: $color-gray-45; + } + + &:focus { + border: solid 1px $ultramarine-ui-light; + outline: none; + } + } + + .module-ContactPills { + max-height: 50px; + } + + &__list-wrapper { + flex-grow: 1; + overflow: hidden; + } + + &__no-candidate-contacts { + flex-grow: 1; + display: flex; + align-items: center; + justify-content: center; + } + + &--choose-members { + @include modal-reset; + padding: 0; // The has its own padding, so we pad various inner elements. + height: 60vh; + min-height: 400px; + + '#{$root-selector}__header' { + padding: $padding; + } + } + + &--confirm-adds { + @include modal-reset; + + '#{$root-selector}__button-container' { + margin-top: 12px; + padding: 0; + } + } +} diff --git a/stylesheets/components/Alert.scss b/stylesheets/components/Alert.scss new file mode 100644 index 0000000000..460215176a --- /dev/null +++ b/stylesheets/components/Alert.scss @@ -0,0 +1,39 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-Alert { + @include popper-shadow(); + border-radius: 8px; + margin: 0 auto; + max-width: 360px; + padding: 16px; + width: 95%; + + @include light-theme() { + background: $color-white; + color: $color-gray-90; + } + + @include dark-theme() { + background: $color-gray-95; + color: $color-gray-05; + } + + &__title { + @include font-body-1-bold; + margin: 0 0 1em 0; + padding: 0; + } + + &__body { + @include font-body-1; + margin: 0; + padding: 0; + } + + &__button-container { + display: flex; + justify-content: flex-end; + margin-top: 16px; + } +} diff --git a/stylesheets/components/AvatarInput.scss b/stylesheets/components/AvatarInput.scss new file mode 100644 index 0000000000..c355ba9d1f --- /dev/null +++ b/stylesheets/components/AvatarInput.scss @@ -0,0 +1,92 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-AvatarInput { + @include button-reset; + + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + background: none; + + $dark-selector: '#{&}--dark'; + + &__avatar { + @include button-reset; + + margin-top: 4px; + display: flex; + border-radius: 100%; + height: 80px; + width: 80px; + transition: background-color 100ms ease-out; + + &--nothing { + align-items: stretch; + background: $color-white; + + @at-root '#{$dark-selector} #{&}' { + background: $ultramarine-ui-light; + } + + &::before { + flex-grow: 1; + content: ''; + display: block; + @include color-svg( + '../images/icons/v2/camera-outline-24.svg', + $ultramarine-ui-light, + false + ); + -webkit-mask-size: 24px 24px; + + @at-root '#{$dark-selector} #{&}' { + @include color-svg( + '../images/icons/v2/camera-outline-24.svg', + $color-white, + false + ); + } + } + } + + &--loading { + align-items: center; + background: $color-black; + } + + &--has-image { + background-size: cover; + background-position: center center; + } + } + + &__label { + @include button-reset; + @include font-body-1; + + padding-bottom: 4px; + padding-top: 4px; + + @include light-theme { + color: $ultramarine-ui-light; + } + + @include dark-theme { + color: $ultramarine-ui-dark; + } + } + + @include keyboard-mode { + &:focus { + .module-AvatarInput__avatar { + box-shadow: inset 0 0 0 2px $ultramarine-ui-light; + } + + .module-AvatarInput__label { + @include font-body-1-bold; + } + } + } +} diff --git a/stylesheets/components/Button.scss b/stylesheets/components/Button.scss new file mode 100644 index 0000000000..b4919bdb7b --- /dev/null +++ b/stylesheets/components/Button.scss @@ -0,0 +1,113 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-Button { + @mixin focus-box-shadow($inner-color, $outer-color) { + &:focus { + box-shadow: 0 0 0 1px $inner-color, 0 0 0 4px $outer-color; + } + } + + @mixin hover-and-active-states($background-color, $mix-color) { + &:hover:not(:disabled) { + background: mix($background-color, $mix-color, 85%); + } + &:active:not(:disabled) { + background: mix($background-color, $mix-color, 75%); + } + } + + @include button-reset; + @include font-body-1-bold; + + border-radius: 4px; + padding: 8px 16px; + text-align: center; + user-select: none; + + @include keyboard-mode { + @include focus-box-shadow($color-white, $ultramarine-ui-light); + } + + @include dark-keyboard-mode { + @include focus-box-shadow($color-black, $ultramarine-brand-light); + } + + &:disabled { + cursor: not-allowed; + } + + &--primary { + $color: $color-white; + $background-color: $ultramarine-ui-light; + + color: $color; + background: $background-color; + + &:disabled { + color: fade-out($color, 0.4); + background: fade-out($background-color, 0.6); + } + + @include light-theme { + @include hover-and-active-states($background-color, $color-black); + } + + @include dark-theme { + @include hover-and-active-states($background-color, $color-white); + } + } + + &--secondary { + @include light-theme { + $color: $color-gray-90; + $background-color: $color-gray-05; + + color: $color; + background: $background-color; + + &:disabled { + color: $color-black-alpha-40; + background: fade-out($background-color, 0.6); + } + + @include hover-and-active-states($background-color, $color-black); + } + + @include dark-theme { + $color: $color-gray-05; + $background-color: $color-gray-65; + + color: $color; + background: $background-color; + + &:disabled { + color: $color-white-alpha-20; + background: fade-out($background-color, 0.6); + } + + @include hover-and-active-states($background-color, $color-white); + } + } + + &--destructive { + $color: $color-white; + $background-color: $color-accent-red; + + color: $color; + background: $background-color; + + &:disabled { + color: fade-out($color, 0.4); + background: fade-out($background-color, 0.6); + } + + @include light-theme { + @include hover-and-active-states($background-color, $color-black); + } + + @include dark-theme { + @include hover-and-active-states($background-color, $color-white); + } + } +} diff --git a/stylesheets/components/ContactPill.scss b/stylesheets/components/ContactPill.scss new file mode 100644 index 0000000000..796ab2561f --- /dev/null +++ b/stylesheets/components/ContactPill.scss @@ -0,0 +1,72 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-ContactPill { + align-items: center; + border-radius: 9999px; // This ensures the borders are completely rounded. (A value like 100% would make it an ellipse.) + display: inline-flex; + user-select: none; + overflow: hidden; + + @include light-theme { + color: $color-gray-90; + background: $color-gray-05; + } + @include dark-theme { + color: $color-gray-02; + background: $color-gray-75; + } + + &__contact-name { + @include font-body-2; + padding: 0 6px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + &__remove { + $icon: '../images/icons/v2/x-24.svg'; + + @include button-reset; + height: 100%; + display: flex; + width: 28px; + justify-content: center; + align-items: center; + padding: 0 6px 0 4px; + + &::before { + content: ''; + width: 12px; + height: 12px; + display: block; + + @include light-theme { + @include color-svg($icon, $color-gray-60); + } + @include dark-theme { + @include color-svg($icon, $color-gray-25); + } + } + + @include keyboard-mode { + &:focus { + background: $color-gray-15; + + &::before { + @include color-svg($icon, $ultramarine-ui-light); + } + } + } + @include dark-keyboard-mode { + &:focus { + background: $color-gray-65; + + &::before { + @include color-svg($icon, $ultramarine-ui-dark); + } + } + } + } +} diff --git a/stylesheets/components/ContactPills.scss b/stylesheets/components/ContactPills.scss new file mode 100644 index 0000000000..a8f8275920 --- /dev/null +++ b/stylesheets/components/ContactPills.scss @@ -0,0 +1,21 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-ContactPills { + @include smooth-scroll; + + display: flex; + flex-wrap: wrap; + margin-bottom: 10px; + max-height: 88px; + overflow-x: hidden; + overflow-y: scroll; + padding-left: 12px; + + .module-ContactPill { + margin: 4px 6px; + max-width: calc( + 100% - 15px + ); // 6px for the right margin and 9px for the scrollbar + } +} diff --git a/stylesheets/components/ConversationHeader.scss b/stylesheets/components/ConversationHeader.scss new file mode 100644 index 0000000000..83f3b238c0 --- /dev/null +++ b/stylesheets/components/ConversationHeader.scss @@ -0,0 +1,311 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-ConversationHeader { + --button-spacing: 24px; + + &.module-ConversationHeader--narrow { + --button-spacing: 16px; + } + + -webkit-app-region: var(--draggable-app-region); + padding-top: var(--title-bar-drag-area-height); + display: flex; + flex-direction: row; + align-items: center; + height: calc(#{$header-height} + var(--title-bar-drag-area-height)); + + @include light-theme { + color: $color-gray-90; + background-color: $color-white; + } + @include dark-theme { + color: $color-gray-02; + background-color: $color-gray-95; + } + + &__back-icon { + $transition: 250ms ease-out; + + display: inline-block; + width: 24px; + height: 24px; + min-width: 24px; + margin-left: -24px; + vertical-align: text-bottom; + border: none; + opacity: 0; + transition: margin-left $transition, opacity $transition; + + &:disabled { + cursor: default; + } + + &--show { + opacity: 1; + margin-right: 6px; + margin-left: 12px; + } + + @include light-theme { + @include color-svg( + '../images/icons/v2/chevron-left-24.svg', + $color-gray-90 + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/chevron-left-24.svg', + $color-gray-02 + ); + } + } + + &__header { + $padding: 4px 12px; + + align-items: center; + display: flex; + flex-direction: row; + flex-grow: 1; + margin-left: 4px; + margin-right: var(--button-spacing); + padding: $padding; + overflow: hidden; + min-width: 0; + transition: margin-right 200ms ease-out; + + &--clickable { + @include button-reset; + border-radius: 4px; + -webkit-app-region: no-drag; + + // These are clobbered by button-reset: + margin-left: 4px; + margin-right: var(--button-spacing); + padding: $padding; + + @include keyboard-mode { + &:focus { + color: $ultramarine-ui-light; + } + } + @include dark-keyboard-mode { + &:focus { + color: $ultramarine-ui-dark; + } + } + } + + &__avatar { + min-width: 32px; + margin-right: 12px; + padding-top: 4px; + padding-bottom: 4px; + } + + &__info { + display: flex; + flex-direction: column; + min-width: 0; + + &__title { + @include font-body-1-bold; + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + user-select: none; + + &__in-contacts-icon { + margin-left: 4px; + } + } + + &__subtitle { + display: flex; + @include font-body-2; + + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-25; + } + + @mixin subtitle-element($icon) { + display: flex; + align-items: center; + user-select: none; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + + &::before { + content: ''; + width: 13px; + height: 13px; + display: block; + margin-right: 4px; + + @include light-theme { + @include color-svg($icon, $color-gray-60); + } + @include dark-theme { + @include color-svg($icon, $color-gray-25); + } + } + } + + &__expiration { + @include subtitle-element('../images/icons/v2/timer-24.svg'); + margin-right: 12px; + } + + &__verified { + @include subtitle-element('../images/icons/v2/check-24.svg'); + } + } + } + } + + &__button { + $icon-size: 32px; + + -webkit-app-region: no-drag; + @include button-reset; + align-items: stretch; + border-radius: 4px; + border: 2px solid transparent; + display: flex; + height: $icon-size; + margin-right: var(--button-spacing); + min-width: $icon-size; + opacity: 0; + padding: 2px; + transition: margin-right 200ms ease-out, opacity 200ms ease-out, + background 100ms ease-out; + width: $icon-size; + + &:disabled { + cursor: default; + } + + &--show { + opacity: 1; + } + + @include light-theme { + &:hover, + &:focus { + background: $color-gray-02; + } + &:active { + background: $color-gray-05; + } + } + @include dark-theme { + &:hover, + &:focus { + background: $color-gray-80; + } + &:active { + background: $color-gray-75; + } + } + + @include keyboard-mode { + &:focus { + border-color: $ultramarine-ui-light; + } + } + @include dark-keyboard-mode { + &:focus { + border-color: $ultramarine-ui-dark; + } + } + + @mixin normal-button($light-icon, $dark-icon) { + &::before { + content: ''; + display: block; + flex-grow: 1; + @include light-theme { + @include color-svg($light-icon, $color-gray-75); + &:hover, + &:active, + &:focus { + @include color-svg($light-icon, $color-gray-90); + } + } + @include dark-theme { + @include color-svg($dark-icon, $color-gray-15); + &:hover, + &:active, + &:focus { + @include color-svg($dark-icon, $color-gray-02); + } + } + } + } + + &--video { + @include normal-button( + '../images/icons/v2/video-outline-24.svg', + '../images/icons/v2/video-solid-24.svg' + ); + } + + &--audio { + @include normal-button( + '../images/icons/v2/phone-right-outline-24.svg', + '../images/icons/v2/phone-right-solid-24.svg' + ); + } + + &--search { + @include normal-button( + '../images/icons/v2/search-24.svg', + '../images/icons/v2/search-24.svg' + ); + } + + &--more { + @include normal-button( + '../images/icons/v2/chevron-down-24.svg', + '../images/icons/v2/chevron-down-24.svg' + ); + } + + &--join-call { + @include font-body-1; + align-items: center; + background-color: $color-accent-green; + border-radius: 9999px; // This ensures the borders are completely rounded. (A value like 100% would make it an ellipse.) + color: $color-white; + display: flex; + outline: none; + overflow: hidden; + padding: 5px 18px; + text-overflow: ellipsis; + white-space: nowrap; + user-select: none; + width: auto; + + &:before { + $icon-size: 24px; + + @include color-svg( + '../images/icons/v2/video-solid-24.svg', + $color-white + ); + content: ''; + display: block; + height: $icon-size; + margin-right: 5px; + min-width: $icon-size; + width: $icon-size; + } + } + } +} diff --git a/stylesheets/components/EditConversationAttributesModal.scss b/stylesheets/components/EditConversationAttributesModal.scss new file mode 100644 index 0000000000..42c771034d --- /dev/null +++ b/stylesheets/components/EditConversationAttributesModal.scss @@ -0,0 +1,38 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-EditConversationAttributesModal { + @include modal-reset; + + &__close-button { + @include modal-close-button; + } + + &__header { + @include font-body-1-bold; + margin: 0; + } + + .module-AvatarInput { + margin: 40px 0 24px 0; + } + + &__error-message { + @include font-body-1; + margin: 16px 0; + } + + &__button-container { + display: flex; + justify-content: flex-end; + margin-top: 16px; + flex-grow: 0; + flex-shrink: 0; + + .module-Button { + &:not(:first-child) { + margin-left: 12px; + } + } + } +} diff --git a/stylesheets/components/GroupDialog.scss b/stylesheets/components/GroupDialog.scss new file mode 100644 index 0000000000..5ec114a9e6 --- /dev/null +++ b/stylesheets/components/GroupDialog.scss @@ -0,0 +1,121 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-GroupDialog { + @include popper-shadow(); + border-radius: 8px; + margin: 0 auto; + max-height: 100%; + max-width: 360px; + padding: 16px; + position: relative; + width: 95%; + display: flex; + flex-direction: column; + + @include light-theme() { + background: $color-white; + color: $color-gray-90; + } + + @include dark-theme() { + background: $color-gray-95; + color: $color-gray-05; + } + + &__close-button { + @include button-reset; + + position: absolute; + right: 12px; + top: 12px; + + height: 24px; + width: 24px; + + @include light-theme { + @include color-svg('../images/icons/v2/x-24.svg', $color-gray-75); + } + + @include dark-theme { + @include color-svg('../images/icons/v2/x-24.svg', $color-gray-15); + } + + &:focus { + @include keyboard-mode { + background-color: $ultramarine-ui-light; + } + @include dark-keyboard-mode { + background-color: $ultramarine-ui-dark; + } + } + } + + &__title { + @include font-title-2; + text-align: center; + margin-bottom: 20px; + + flex-grow: 0; + flex-shrink: 0; + } + + &__body { + overflow-x: scroll; + flex-grow: 1; + flex-shrink: 1; + } + + &__paragraph, + &__contacts { + margin: 0 0 16px 0; + padding: 0 16px 0 28px; + position: relative; + + &::before { + content: ''; + display: block; + height: 11px; + left: 4px; + position: absolute; + top: 4px; + width: 4px; + + @include light-theme { + background-color: $color-gray-15; + } + @include dark-theme { + background-color: $color-gray-65; + } + } + } + + &__contacts { + list-style-type: none; + + &__contact { + margin-top: 16px; + } + + &__contact__name { + margin-left: 8px; + } + } + + &__button-container { + display: flex; + justify-content: center; + margin-top: 16px; + flex-grow: 0; + flex-shrink: 0; + + .module-Button { + flex-grow: 1; + max-width: 152px; + + &:not(:first-child) { + margin-left: 16px; + } + } + } +} diff --git a/stylesheets/components/MessageAudio.scss b/stylesheets/components/MessageAudio.scss new file mode 100644 index 0000000000..21daf7e107 --- /dev/null +++ b/stylesheets/components/MessageAudio.scss @@ -0,0 +1,295 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-message__audio-attachment { + display: flex; + + flex-direction: row; + align-items: center; + + margin-top: 2px; +} + +/* The separator between audio and text */ +.module-message__audio-attachment--with-content-below { + border-bottom: 1px solid $color-white-alpha-20; + padding-bottom: 12px; + margin-bottom: 7px; + + .module-message__audio-attachment--incoming & { + @mixin android { + border-color: $color-white-alpha-20; + } + + @include light-theme { + @include android; + } + @include dark-theme { + @include android; + } + @include ios-theme { + border-color: $color-black-alpha-20; + } + @include ios-dark-theme { + border-color: $color-white-alpha-20; + } + } + + .module-message__container--outgoing & { + @mixin ios { + border-color: $color-white-alpha-20; + } + + @include light-theme { + border-color: $color-black-alpha-20; + } + @include dark-theme { + border-color: $color-white-alpha-20; + } + @include ios-theme { + @include ios; + } + @include ios-dark-theme { + @include ios; + } + } +} + +.module-message__audio-attachment--with-content-above { + margin-top: 6px; +} + +.module-message__audio-attachment__button, +.module-message__audio-attachment__spinner { + flex-shrink: 0; + width: 36px; + height: 36px; + + @include button-reset; + + outline: none; + border-radius: 18px; + + &::before { + display: block; + height: 100%; + content: ''; + } + + @mixin audio-icon($name, $icon, $color) { + &--#{$name}::before { + @include color-svg('../images/icons/v2/#{$icon}.svg', $color, false); + } + } + + @mixin all-audio-icons($color) { + @include audio-icon(play, play-solid-20, $color); + @include audio-icon(pause, pause-solid-20, $color); + @include audio-icon(download, arrow-down-20, $color); + @include audio-icon(pending, audio-spinner-arc-22, $color); + } + + &--pending { + cursor: auto; + } + + &--pending::before { + animation: spinner-arc-animation 1000ms linear infinite; + } + + .module-message__audio-attachment--incoming & { + @mixin android { + background: $color-white-alpha-20; + + @include all-audio-icons($color-white); + } + + @include light-theme { + @include android; + } + @include dark-theme { + @include android; + } + @include ios-theme { + background: $color-white; + + @include all-audio-icons($color-gray-60); + } + @include ios-dark-theme { + background: $color-gray-60; + + @include all-audio-icons($color-gray-15); + } + } + + .module-message__audio-attachment--outgoing & { + @mixin android { + background: $color-white; + + @include all-audio-icons($color-gray-60); + } + + @mixin ios { + background: $color-white-alpha-20; + + @include all-audio-icons($color-white); + } + + @include light-theme { + @include android; + } + @include dark-theme { + @include android; + } + @include ios-theme { + @include ios; + } + @include ios-dark-theme { + @include ios; + } + } +} + +.module-message__audio-attachment__waveform { + flex-shrink: 0; + margin-left: 12px; + + display: flex; + align-items: center; + cursor: pointer; + + outline: 0; +} + +.module-message__audio-attachment__waveform__bar { + display: inline-block; + + width: 2px; + border-radius: 2px; + transition: height 250ms, background 250ms; + + &:not(:first-of-type) { + margin-left: 2px; + } + + .module-message__audio-attachment--incoming & { + @mixin android { + background: $color-white-alpha-40; + &--active { + background: $color-white-alpha-80; + } + } + + @include light-theme { + @include android; + } + @include dark-theme { + @include android; + } + @include ios-theme { + background: $color-black-alpha-40; + &--active { + background: $color-black-alpha-80; + } + } + @include ios-dark-theme { + background: $color-white-alpha-40; + &--active { + background: $color-white-alpha-70; + } + } + } + + .module-message__audio-attachment--outgoing & { + @mixin ios { + background: $color-white-alpha-40; + &--active { + background: $color-white-alpha-80; + } + } + + @include light-theme { + background: $color-black-alpha-20; + &--active { + background: $color-black-alpha-50; + } + } + @include dark-theme { + background: $color-white-alpha-40; + &--active { + background: $color-white-alpha-80; + } + } + @include ios-theme { + @include ios; + } + @include ios-dark-theme { + @include ios; + } + } +} + +.module-message__audio-attachment__countdown { + flex-shrink: 1; + + /* Prevent text from jumping when countdown changes */ + min-width: 32px; + text-align: right; + + user-select: none; + + @include font-caption; + + .module-message__audio-attachment--incoming & { + @mixin android { + color: $color-white-alpha-80; + } + @include light-theme { + @include android; + } + @include dark-theme { + @include android; + } + @include ios-theme { + color: $color-black-alpha-60; + } + @include ios-dark-theme { + color: $color-white-alpha-80; + } + } + + .module-message__audio-attachment--outgoing & { + @mixin ios { + color: $color-white-alpha-80; + } + + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-white-alpha-80; + } + @include ios-theme { + @include ios; + } + @include ios-dark-theme { + @include ios; + } + } +} + +@media (min-width: 0px) and (max-width: 799px) { + .module-message__audio-attachment__waveform { + margin-left: 4px; + } + + /* Clip the countdown text when it is too long on small screens */ + .module-message__audio-attachment__countdown { + margin-left: 4px; + + max-width: 46px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} diff --git a/stylesheets/components/SearchResultsLoadingFakeHeader.scss b/stylesheets/components/SearchResultsLoadingFakeHeader.scss new file mode 100644 index 0000000000..c1ba071c57 --- /dev/null +++ b/stylesheets/components/SearchResultsLoadingFakeHeader.scss @@ -0,0 +1,16 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +// These styles should match the "real" header. +.module-SearchResultsLoadingFakeHeader { + display: flex; + flex-direction: column; + justify-content: center; + padding-left: 16px; + + &::before { + content: ''; + display: block; + @include search-results-loading-box(25%); + } +} diff --git a/stylesheets/components/SearchResultsLoadingFakeRow.scss b/stylesheets/components/SearchResultsLoadingFakeRow.scss new file mode 100644 index 0000000000..2a2203ee52 --- /dev/null +++ b/stylesheets/components/SearchResultsLoadingFakeRow.scss @@ -0,0 +1,35 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +// These styles should match the "real" contact/conversation row. +.module-SearchResultsLoadingFakeRow { + display: flex; + align-items: center; + justify-content: center; + padding-left: 16px; + padding-right: 16px; + + &__avatar { + width: 52px; + height: 52px; + border-radius: 100%; + @include search-results-loading-pulsating-background; + } + + &__content { + display: flex; + flex-direction: column; + flex-grow: 1; + justify-content: center; + margin-left: 12px; + + &__header { + @include search-results-loading-box(50%); + margin-bottom: 8px; + } + + &__message { + @include search-results-loading-box(90%); + } + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 499a676437..efef193e5b 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -1,4 +1,4 @@ -// Copyright 2014-2020 Signal Messenger, LLC +// Copyright 2014-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // Global Settings, Variables, and Mixins @@ -7,7 +7,7 @@ @import 'mixins'; @import 'global'; -// Components +// Old style: components @import 'progress'; @import 'modal'; @import 'debugLog'; @@ -16,12 +16,27 @@ @import 'emoji'; @import 'settings'; -// Build the main view +// Old style: main view @import 'index'; @import 'conversation'; -// New CSS +// Old style: modules @import 'modules'; -// Installer +// Old style: installer @import 'options'; + +// New style: components +@import './components/AddGroupMembersModal.scss'; +@import './components/Alert.scss'; +@import './components/AvatarInput.scss'; +@import './components/Button.scss'; +@import './components/ContactPill.scss'; +@import './components/ContactPills.scss'; +@import './components/ConversationHeader.scss'; +@import './components/EditConversationAttributesModal.scss'; +@import './components/GroupDialog.scss'; +@import './components/GroupTitleInput.scss'; +@import './components/MessageAudio.scss'; +@import './components/SearchResultsLoadingFakeHeader.scss'; +@import './components/SearchResultsLoadingFakeRow.scss'; diff --git a/test/_test.js b/test/_test.js index 12a61bf8f1..ffe8e55783 100644 --- a/test/_test.js +++ b/test/_test.js @@ -76,6 +76,16 @@ function deleteIndexedDB() { /* Delete the database before running any tests */ before(async () => { await deleteIndexedDB(); + try { + window.log.info('Initializing SQL in renderer'); + await window.sqlInitializer.initialize(); + window.log.info('SQL initialized in renderer'); + } catch (err) { + window.log.error( + 'SQL failed to initialize', + err && err.stack ? err.stack : err + ); + } await window.Signal.Data.removeAll(); await window.storage.fetch(); }); diff --git a/test/app/menu_test.js b/test/app/menu_test.js index a9aa783193..4e3feb7a32 100644 --- a/test/app/menu_test.js +++ b/test/app/menu_test.js @@ -50,6 +50,7 @@ describe('SignalMenu', () => { }; const options = { isBeta: false, + devTools: true, openContactUs: null, openForums: null, openJoinTheBeta: null, diff --git a/test/backup_test.js b/test/backup_test.js index e712f790ec..87a02ffe43 100644 --- a/test/backup_test.js +++ b/test/backup_test.js @@ -552,6 +552,7 @@ describe('Backup', () => { profileKey: 'BASE64KEY', profileName: 'Someone! 🤔', profileSharing: true, + profileLastFetchedAt: 1524185933350, timestamp: 1524185933350, type: 'private', unreadCount: 0, diff --git a/test/index.html b/test/index.html index a95268288a..7c179b89d0 100644 --- a/test/index.html +++ b/test/index.html @@ -337,15 +337,11 @@ - - - - @@ -353,26 +349,17 @@ - - - - - - - - - diff --git a/test/setup-test-node.js b/test/setup-test-node.js index 43b0d1b86c..0441fa01d1 100644 --- a/test/setup-test-node.js +++ b/test/setup-test-node.js @@ -3,6 +3,7 @@ /* eslint-disable no-console */ +const ByteBuffer = require('../components/bytebuffer/dist/ByteBufferAB.js'); const { setEnvironment, Environment } = require('../ts/environment'); before(() => { @@ -17,6 +18,9 @@ global.window = { error: (...args) => console.error(...args), }, i18n: key => `i18n(${key})`, + dcodeIO: { + ByteBuffer, + }, }; // For ducks/network.getEmptyState() diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index f891fd8698..bc9f5fafb1 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { debounce, reduce, uniq, without } from 'lodash'; @@ -13,6 +13,7 @@ import { import { SendOptionsType, CallbackResultType } from './textsecure/SendMessage'; import { ConversationModel } from './models/conversations'; import { maybeDeriveGroupV2Id } from './groups'; +import { assert } from './util/assert'; const MAX_MESSAGE_BODY_LENGTH = 64 * 1024; @@ -61,15 +62,18 @@ export function start(): void { // we can reset the mute state on the model. If the mute has already expired // then we reset the state right away. initMuteExpirationTimer(model: ConversationModel): void { - if (model.isMuted()) { + const muteExpiresAt = model.get('muteExpiresAt'); + // This check for `muteExpiresAt` is likely redundant, but is needed to appease + // TypeScript. + if (model.isMuted() && muteExpiresAt) { window.Signal.Services.onTimeout( - model.get('muteExpiresAt'), + muteExpiresAt, () => { model.set({ muteExpiresAt: undefined }); }, model.getMuteTimeoutId() ); - } else if (model.get('muteExpiresAt')) { + } else if (muteExpiresAt) { model.set({ muteExpiresAt: undefined }); } }, @@ -121,11 +125,11 @@ export function start(): void { } export class ConversationController { - _initialFetchComplete: boolean | undefined; + private _initialFetchComplete: boolean | undefined; - _initialPromise: Promise = Promise.resolve(); + private _initialPromise: Promise = Promise.resolve(); - _conversations: ConversationModelCollectionType; + private _conversations: ConversationModelCollectionType; constructor(conversations?: ConversationModelCollectionType) { if (!conversations) { @@ -146,6 +150,10 @@ export class ConversationController { return this._conversations.get(id as string); } + getAll(): Array { + return this._conversations.models; + } + dangerouslyCreateAndAdd( attributes: Partial ): ConversationModel { @@ -442,7 +450,7 @@ export class ConversationController { convoUuid.updateE164(e164); // `then` is used to trigger async updates, not affecting return value // eslint-disable-next-line more/no-then - this.combineContacts(convoUuid, convoE164) + this.combineConversations(convoUuid, convoE164) .then(() => { // If the old conversation was currently displayed, we load the new one window.Whisper.events.trigger('refreshConversation', { @@ -465,14 +473,21 @@ export class ConversationController { window.log.info('checkForConflicts: starting...'); const byUuid = Object.create(null); const byE164 = Object.create(null); + const byGroupV2Id = Object.create(null); + // We also want to find duplicate GV1 IDs. You might expect to see a "byGroupV1Id" map + // here. Instead, we check for duplicates on the derived GV2 ID. + + const { models } = this._conversations; // We iterate from the oldest conversations to the newest. This allows us, in a // conflict case, to keep the one with activity the most recently. - const models = [...this._conversations.models.reverse()]; - - const max = models.length; - for (let i = 0; i < max; i += 1) { + for (let i = models.length - 1; i >= 0; i -= 1) { const conversation = models[i]; + assert( + conversation, + 'Expected conversation to be found in array during iteration' + ); + const uuid = conversation.get('uuid'); const e164 = conversation.get('e164'); @@ -489,12 +504,12 @@ export class ConversationController { if (conversation.get('e164')) { // Keep new one // eslint-disable-next-line no-await-in-loop - await this.combineContacts(conversation, existing); + await this.combineConversations(conversation, existing); byUuid[uuid] = conversation; } else { // Keep existing - note that this applies if neither had an e164 // eslint-disable-next-line no-await-in-loop - await this.combineContacts(existing, conversation); + await this.combineConversations(existing, conversation); } } } @@ -531,12 +546,49 @@ export class ConversationController { if (conversation.get('uuid')) { // Keep new one // eslint-disable-next-line no-await-in-loop - await this.combineContacts(conversation, existing); + await this.combineConversations(conversation, existing); byE164[e164] = conversation; } else { // Keep existing - note that this applies if neither had a UUID // eslint-disable-next-line no-await-in-loop - await this.combineContacts(existing, conversation); + await this.combineConversations(existing, conversation); + } + } + } + + let groupV2Id: undefined | string; + if (conversation.isGroupV1()) { + // eslint-disable-next-line no-await-in-loop + await maybeDeriveGroupV2Id(conversation); + groupV2Id = conversation.get('derivedGroupV2Id'); + assert( + groupV2Id, + 'checkForConflicts: expected the group V2 ID to have been derived, but it was falsy' + ); + } else if (conversation.isGroupV2()) { + groupV2Id = conversation.get('groupId'); + } + + if (groupV2Id) { + const existing = byGroupV2Id[groupV2Id]; + if (!existing) { + byGroupV2Id[groupV2Id] = conversation; + } else { + const logParenthetical = conversation.isGroupV1() + ? ' (derived from a GV1 group ID)' + : ''; + window.log.warn( + `checkForConflicts: Found conflict with group V2 ID ${groupV2Id}${logParenthetical}` + ); + + // Prefer the GV2 group. + if (conversation.isGroupV2() && !existing.isGroupV2()) { + // eslint-disable-next-line no-await-in-loop + await this.combineConversations(conversation, existing); + byGroupV2Id[groupV2Id] = conversation; + } else { + // eslint-disable-next-line no-await-in-loop + await this.combineConversations(existing, conversation); } } } @@ -545,82 +597,94 @@ export class ConversationController { window.log.info('checkForConflicts: complete!'); } - async combineContacts( + async combineConversations( current: ConversationModel, obsolete: ConversationModel ): Promise { + const conversationType = current.get('type'); + + if (obsolete.get('type') !== conversationType) { + assert( + false, + 'combineConversations cannot combine a private and group conversation. Doing nothing' + ); + return; + } + const obsoleteId = obsolete.get('id'); const currentId = current.get('id'); - window.log.warn('combineContacts: Combining two conversations', { + window.log.warn('combineConversations: Combining two conversations', { obsolete: obsoleteId, current: currentId, }); - if (!current.get('profileKey') && obsolete.get('profileKey')) { + if (conversationType === 'private') { + if (!current.get('profileKey') && obsolete.get('profileKey')) { + window.log.warn( + 'combineConversations: Copying profile key from old to new contact' + ); + + const profileKey = obsolete.get('profileKey'); + + if (profileKey) { + await current.setProfileKey(profileKey); + } + } + window.log.warn( - 'combineContacts: Copying profile key from old to new contact' + 'combineConversations: Delete all sessions tied to old conversationId' + ); + const deviceIds = await window.textsecure.storage.protocol.getDeviceIds( + obsoleteId + ); + await Promise.all( + deviceIds.map(async deviceId => { + await window.textsecure.storage.protocol.removeSession( + `${obsoleteId}.${deviceId}` + ); + }) ); - const profileKey = obsolete.get('profileKey'); + window.log.warn( + 'combineConversations: Delete all identity information tied to old conversationId' + ); + await window.textsecure.storage.protocol.removeIdentityKey(obsoleteId); - if (profileKey) { - await current.setProfileKey(profileKey); - } - } + window.log.warn( + 'combineConversations: Ensure that all V1 groups have new conversationId instead of old' + ); + const groups = await this.getAllGroupsInvolvingId(obsoleteId); + groups.forEach(group => { + const members = group.get('members'); + const withoutObsolete = without(members, obsoleteId); + const currentAdded = uniq([...withoutObsolete, currentId]); - window.log.warn( - 'combineContacts: Delete all sessions tied to old conversationId' - ); - const deviceIds = await window.textsecure.storage.protocol.getDeviceIds( - obsoleteId - ); - await Promise.all( - deviceIds.map(async deviceId => { - await window.textsecure.storage.protocol.removeSession( - `${obsoleteId}.${deviceId}` - ); - }) - ); - - window.log.warn( - 'combineContacts: Delete all identity information tied to old conversationId' - ); - await window.textsecure.storage.protocol.removeIdentityKey(obsoleteId); - - window.log.warn( - 'combineContacts: Ensure that all V1 groups have new conversationId instead of old' - ); - const groups = await this.getAllGroupsInvolvingId(obsoleteId); - groups.forEach(group => { - const members = group.get('members'); - const withoutObsolete = without(members, obsoleteId); - const currentAdded = uniq([...withoutObsolete, currentId]); - - group.set({ - members: currentAdded, + group.set({ + members: currentAdded, + }); + updateConversation(group.attributes); }); - updateConversation(group.attributes); - }); + } // Note: we explicitly don't want to update V2 groups window.log.warn( - 'combineContacts: Delete the obsolete conversation from the database' + 'combineConversations: Delete the obsolete conversation from the database' ); await removeConversation(obsoleteId, { Conversation: window.Whisper.Conversation, }); - window.log.warn('combineContacts: Update messages table'); + window.log.warn('combineConversations: Update messages table'); await migrateConversationMessages(obsoleteId, currentId); window.log.warn( - 'combineContacts: Eliminate old conversation from ConversationController lookups' + 'combineConversations: Eliminate old conversation from ConversationController lookups' ); this._conversations.remove(obsolete); this._conversations.resetLookups(); - window.log.warn('combineContacts: Complete!', { + window.log.warn('combineConversations: Complete!', { obsolete: obsoleteId, current: currentId, }); @@ -758,6 +822,9 @@ export class ConversationController { await Promise.all( this._conversations.map(async conversation => { try { + // Hydrate contactCollection, now that initial fetch is complete + conversation.fetchContacts(); + const isChanged = await maybeDeriveGroupV2Id(conversation); if (isChanged) { updateConversation(conversation.attributes); diff --git a/ts/Crypto.ts b/ts/Crypto.ts index db689a7c16..45428a5d7b 100644 --- a/ts/Crypto.ts +++ b/ts/Crypto.ts @@ -4,10 +4,23 @@ import pProps from 'p-props'; import { chunk } from 'lodash'; -export function typedArrayToArrayBuffer(typedArray: Uint8Array): ArrayBuffer { - const { buffer, byteOffset, byteLength } = typedArray; +import { + CipherType, + encrypt, + decrypt, + HashType, + hash, + sign, +} from './util/synchronousCrypto'; - return buffer.slice(byteOffset, byteLength + byteOffset) as typeof typedArray; +export function typedArrayToArrayBuffer(typedArray: Uint8Array): ArrayBuffer { + const ab = new ArrayBuffer(typedArray.length); + // Create a new Uint8Array backed by the ArrayBuffer and copy all values from + // the `typedArray` into it by calling `.set()` method. Note that raw + // ArrayBuffer doesn't offer this API, because it is supposed to be used with + // concrete data view (i.e. Uint8Array, Float64Array, and so on.) + new Uint8Array(ab).set(typedArray, 0); + return ab; } export function arrayBufferToBase64(arrayBuffer: ArrayBuffer): string { @@ -75,8 +88,8 @@ export async function deriveMasterKeyFromGroupV1( } export async function computeHash(data: ArrayBuffer): Promise { - const hash = await crypto.subtle.digest({ name: 'SHA-512' }, data); - return arrayBufferToBase64(hash); + const digest = await crypto.subtle.digest({ name: 'SHA-512' }, data); + return arrayBufferToBase64(digest); } // High-level Operations @@ -327,21 +340,7 @@ export async function hmacSha256( key: ArrayBuffer, plaintext: ArrayBuffer ): Promise { - const algorithm: HmacImportParams = { - name: 'HMAC', - hash: 'SHA-256', - }; - const extractable = false; - - const cryptoKey = await window.crypto.subtle.importKey( - 'raw', - key, - algorithm, - extractable, - ['sign'] - ); - - return window.crypto.subtle.sign(algorithm, cryptoKey, plaintext); + return sign(key, plaintext); } export async function _encryptAes256CbcPkcsPadding( @@ -401,32 +400,7 @@ export async function encryptAesCtr( plaintext: ArrayBuffer, counter: ArrayBuffer ): Promise { - const extractable = false; - const algorithm = { - name: 'AES-CTR', - counter: new Uint8Array(counter), - length: 128, - }; - - const cryptoKey = await crypto.subtle.importKey( - 'raw', - key, - // `algorithm` appears to be an instance of AesCtrParams, - // which is not in the param's types, so we need to pass as `any`. - // TODO: just pass the string "AES-CTR", per the docs? - // eslint-disable-next-line @typescript-eslint/no-explicit-any - algorithm as any, - extractable, - ['encrypt'] - ); - - const ciphertext = await crypto.subtle.encrypt( - algorithm, - cryptoKey, - plaintext - ); - - return ciphertext; + return encrypt(key, plaintext, counter, CipherType.AES256CTR); } export async function decryptAesCtr( @@ -434,31 +408,7 @@ export async function decryptAesCtr( ciphertext: ArrayBuffer, counter: ArrayBuffer ): Promise { - const extractable = false; - const algorithm = { - name: 'AES-CTR', - counter: new Uint8Array(counter), - length: 128, - }; - - const cryptoKey = await crypto.subtle.importKey( - 'raw', - key, - // `algorithm` appears to be an instance of AesCtrParams, - // which is not in the param's types, so we need to pass as `any`. - // TODO: just pass the string "AES-CTR", per the docs? - // eslint-disable-next-line @typescript-eslint/no-explicit-any - algorithm as any, - extractable, - ['decrypt'] - ); - const plaintext = await crypto.subtle.decrypt( - algorithm, - cryptoKey, - ciphertext - ); - - return plaintext; + return decrypt(key, ciphertext, counter, CipherType.AES256CTR); } export async function encryptAesGcm( @@ -521,8 +471,8 @@ export async function decryptAesGcm( // Hashing -export async function sha256(data: ArrayBuffer): Promise { - return crypto.subtle.digest('SHA-256', data); +export function sha256(data: ArrayBuffer): ArrayBuffer { + return hash(HashType.size256, data); } // Utility @@ -679,7 +629,7 @@ export async function encryptCdsDiscoveryRequest( }); const queryDataPlaintext = concatenateBytes(nonce, numbersArray.buffer); const queryDataKey = getRandomBytes(32); - const commitment = await sha256(queryDataPlaintext); + const commitment = sha256(queryDataPlaintext); const iv = getRandomBytes(12); const queryDataCiphertext = await encryptAesGcm( queryDataKey, diff --git a/ts/Intl.d.ts b/ts/Intl.d.ts new file mode 100644 index 0000000000..0461dfc023 --- /dev/null +++ b/ts/Intl.d.ts @@ -0,0 +1,27 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +declare namespace Intl { + type SegmenterOptions = { + granularity?: 'grapheme' | 'word' | 'sentence'; + }; + + type SegmentData = { + index: number; + input: string; + segment: string; + }; + + interface Segments { + containing(index: number): SegmentData; + + [Symbol.iterator](): Iterator; + } + + // `Intl.Segmenter` is not yet in TypeScript's type definitions, so we add it. + class Segmenter { + constructor(locale?: string, options?: SegmenterOptions); + + segment(str: string): Segments; + } +} diff --git a/ts/LibSignalStore.ts b/ts/LibSignalStore.ts new file mode 100644 index 0000000000..69355add26 --- /dev/null +++ b/ts/LibSignalStore.ts @@ -0,0 +1,1234 @@ +// Copyright 2016-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +/* eslint-disable class-methods-use-this */ + +import { fromEncodedBinaryToArrayBuffer, constantTimeEqual } from './Crypto'; +import { isNotNil } from './util/isNotNil'; +import { isMoreRecentThan } from './util/timestamp'; + +const TIMESTAMP_THRESHOLD = 5 * 1000; // 5 seconds +const Direction = { + SENDING: 1, + RECEIVING: 2, +}; + +const VerifiedStatus = { + DEFAULT: 0, + VERIFIED: 1, + UNVERIFIED: 2, +}; + +function validateVerifiedStatus(status: number): boolean { + if ( + status === VerifiedStatus.DEFAULT || + status === VerifiedStatus.VERIFIED || + status === VerifiedStatus.UNVERIFIED + ) { + return true; + } + return false; +} + +const IdentityRecord = window.Backbone.Model.extend({ + storeName: 'identityKeys', + validAttributes: [ + 'id', + 'publicKey', + 'firstUse', + 'timestamp', + 'verified', + 'nonblockingApproval', + ], + validate(attrs: IdentityKeyType) { + const attributeNames = window._.keys(attrs); + const { validAttributes } = this; + const allValid = window._.all(attributeNames, attributeName => + window._.contains(validAttributes, attributeName) + ); + if (!allValid) { + return new Error('Invalid identity key attribute names'); + } + const allPresent = window._.all(validAttributes, attributeName => + window._.contains(attributeNames, attributeName) + ); + if (!allPresent) { + return new Error('Missing identity key attributes'); + } + + if (typeof attrs.id !== 'string') { + return new Error('Invalid identity key id'); + } + if (!(attrs.publicKey instanceof ArrayBuffer)) { + return new Error('Invalid identity key publicKey'); + } + if (typeof attrs.firstUse !== 'boolean') { + return new Error('Invalid identity key firstUse'); + } + if (typeof attrs.timestamp !== 'number' || !(attrs.timestamp >= 0)) { + return new Error('Invalid identity key timestamp'); + } + if (!validateVerifiedStatus(attrs.verified)) { + return new Error('Invalid identity key verified'); + } + if (typeof attrs.nonblockingApproval !== 'boolean') { + return new Error('Invalid identity key nonblockingApproval'); + } + + return null; + }, +}); + +async function normalizeEncodedAddress( + encodedAddress: string +): Promise { + const [identifier, deviceId] = window.textsecure.utils.unencodeNumber( + encodedAddress + ); + try { + const conv = window.ConversationController.getOrCreate( + identifier, + 'private' + ); + return `${conv.get('id')}.${deviceId}`; + } catch (e) { + window.log.error(`could not get conversation for identifier ${identifier}`); + throw e; + } +} + +type HasIdType = { + id: string | number; +}; + +async function _hydrateCache( + object: SignalProtocolStore, + field: keyof SignalProtocolStore, + itemsPromise: Promise> +): Promise { + const items = await itemsPromise; + + const cache: Record = Object.create(null); + for (let i = 0, max = items.length; i < max; i += 1) { + const item = items[i]; + const { id } = item; + + cache[id] = item; + } + + window.log.info(`SignalProtocolStore: Finished caching ${field} data`); + // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any + object[field] = cache as any; +} + +type KeyPairType = { + privKey: ArrayBuffer; + pubKey: ArrayBuffer; +}; + +type IdentityKeyType = { + firstUse: boolean; + id: string; + nonblockingApproval: boolean; + publicKey: ArrayBuffer; + timestamp: number; + verified: number; +}; + +type SessionType = { + conversationId: string; + deviceId: number; + id: string; + record: string; +}; + +type SignedPreKeyType = { + confirmed: boolean; + // eslint-disable-next-line camelcase + created_at: number; + id: number; + privateKey: ArrayBuffer; + publicKey: ArrayBuffer; +}; +type OuterSignedPrekeyType = { + confirmed: boolean; + // eslint-disable-next-line camelcase + created_at: number; + keyId: number; + privKey: ArrayBuffer; + pubKey: ArrayBuffer; +}; +type PreKeyType = { + id: number; + privateKey: ArrayBuffer; + publicKey: ArrayBuffer; +}; + +type UnprocessedType = { + id: string; + timestamp: number; + version: number; + attempts: number; + envelope: string; + decrypted?: string; + source?: string; + sourceDevice: string; + serverTimestamp: number; +}; + +// We add a this parameter to avoid an 'implicit any' error on the next line +const EventsMixin = (function EventsMixin(this: unknown) { + window._.assign(this, window.Backbone.Events); + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any) as typeof window.Backbone.EventsMixin; + +export class SignalProtocolStore extends EventsMixin { + // Enums used across the app + + Direction = Direction; + + VerifiedStatus = VerifiedStatus; + + // Cached values + + ourIdentityKey?: KeyPairType; + + ourRegistrationId?: number; + + identityKeys?: Record; + + sessions?: Record; + + signedPreKeys?: Record; + + preKeys?: Record; + + async hydrateCaches(): Promise { + await Promise.all([ + (async () => { + const item = await window.Signal.Data.getItemById('identityKey'); + this.ourIdentityKey = item ? item.value : undefined; + })(), + (async () => { + const item = await window.Signal.Data.getItemById('registrationId'); + this.ourRegistrationId = item ? item.value : undefined; + })(), + _hydrateCache( + this, + 'identityKeys', + window.Signal.Data.getAllIdentityKeys() + ), + _hydrateCache( + this, + 'sessions', + window.Signal.Data.getAllSessions() + ), + _hydrateCache( + this, + 'preKeys', + window.Signal.Data.getAllPreKeys() + ), + _hydrateCache( + this, + 'signedPreKeys', + window.Signal.Data.getAllSignedPreKeys() + ), + ]); + } + + async getIdentityKeyPair(): Promise { + return this.ourIdentityKey; + } + + async getLocalRegistrationId(): Promise { + return this.ourRegistrationId; + } + + // PreKeys + + async loadPreKey(keyId: string | number): Promise { + if (!this.preKeys) { + throw new Error('loadPreKey: this.preKeys not yet cached!'); + } + + const key = this.preKeys[keyId]; + if (key) { + window.log.info('Successfully fetched prekey:', keyId); + return { + pubKey: key.publicKey, + privKey: key.privateKey, + }; + } + + window.log.error('Failed to fetch prekey:', keyId); + return undefined; + } + + async storePreKey(keyId: number, keyPair: KeyPairType): Promise { + if (!this.preKeys) { + throw new Error('storePreKey: this.preKeys not yet cached!'); + } + + const data = { + id: keyId, + publicKey: keyPair.pubKey, + privateKey: keyPair.privKey, + }; + + this.preKeys[keyId] = data; + await window.Signal.Data.createOrUpdatePreKey(data); + } + + async removePreKey(keyId: number): Promise { + if (!this.preKeys) { + throw new Error('removePreKey: this.preKeys not yet cached!'); + } + + try { + this.trigger('removePreKey'); + } catch (error) { + window.log.error( + 'removePreKey error triggering removePreKey:', + error && error.stack ? error.stack : error + ); + } + + delete this.preKeys[keyId]; + await window.Signal.Data.removePreKeyById(keyId); + } + + async clearPreKeyStore(): Promise { + this.preKeys = Object.create(null); + await window.Signal.Data.removeAllPreKeys(); + } + + // Signed PreKeys + + async loadSignedPreKey( + keyId: number + ): Promise { + if (!this.signedPreKeys) { + throw new Error('loadSignedPreKey: this.signedPreKeys not yet cached!'); + } + + const key = this.signedPreKeys[keyId]; + if (key) { + window.log.info('Successfully fetched signed prekey:', key.id); + return { + pubKey: key.publicKey, + privKey: key.privateKey, + created_at: key.created_at, + keyId: key.id, + confirmed: key.confirmed, + }; + } + + window.log.error('Failed to fetch signed prekey:', keyId); + return undefined; + } + + async loadSignedPreKeys(): Promise> { + if (!this.signedPreKeys) { + throw new Error('loadSignedPreKeys: this.signedPreKeys not yet cached!'); + } + + if (arguments.length > 0) { + throw new Error('loadSignedPreKeys takes no arguments'); + } + + const keys = Object.values(this.signedPreKeys); + return keys.map(prekey => ({ + pubKey: prekey.publicKey, + privKey: prekey.privateKey, + created_at: prekey.created_at, + keyId: prekey.id, + confirmed: prekey.confirmed, + })); + } + + async storeSignedPreKey( + keyId: number, + keyPair: KeyPairType, + confirmed?: boolean + ): Promise { + if (!this.signedPreKeys) { + throw new Error('storeSignedPreKey: this.signedPreKeys not yet cached!'); + } + + const data = { + id: keyId, + publicKey: keyPair.pubKey, + privateKey: keyPair.privKey, + created_at: Date.now(), + confirmed: Boolean(confirmed), + }; + + this.signedPreKeys[keyId] = data; + await window.Signal.Data.createOrUpdateSignedPreKey(data); + } + + async removeSignedPreKey(keyId: number): Promise { + if (!this.signedPreKeys) { + throw new Error('removeSignedPreKey: this.signedPreKeys not yet cached!'); + } + + delete this.signedPreKeys[keyId]; + await window.Signal.Data.removeSignedPreKeyById(keyId); + } + + async clearSignedPreKeysStore(): Promise { + this.signedPreKeys = Object.create(null); + await window.Signal.Data.removeAllSignedPreKeys(); + } + + // Sessions + + async loadSession(encodedAddress: string): Promise { + if (!this.sessions) { + throw new Error('loadSession: this.sessions not yet cached!'); + } + + if (encodedAddress === null || encodedAddress === undefined) { + throw new Error('Tried to get session for undefined/null number'); + } + + try { + const id = await normalizeEncodedAddress(encodedAddress); + const session = this.sessions[id]; + + if (session) { + return session.record; + } + } catch (error) { + const errorString = error && error.stack ? error.stack : error; + window.log.error( + `could not load session ${encodedAddress}: ${errorString}` + ); + } + + return undefined; + } + + async storeSession(encodedAddress: string, record: string): Promise { + if (!this.sessions) { + throw new Error('storeSession: this.sessions not yet cached!'); + } + + if (encodedAddress === null || encodedAddress === undefined) { + throw new Error('Tried to put session for undefined/null number'); + } + const unencoded = window.textsecure.utils.unencodeNumber(encodedAddress); + const deviceId = parseInt(unencoded[1], 10); + + try { + const id = await normalizeEncodedAddress(encodedAddress); + const previousData = this.sessions[id]; + + const data = { + id, + conversationId: window.textsecure.utils.unencodeNumber(id)[0], + deviceId, + record, + }; + + // Optimistically update in-memory cache; will revert if save fails. + this.sessions[id] = data; + + try { + await window.Signal.Data.createOrUpdateSession(data); + } catch (e) { + if (previousData) { + this.sessions[id] = previousData; + } + throw e; + } + } catch (error) { + const errorString = error && error.stack ? error.stack : error; + window.log.error( + `could not store session for ${encodedAddress}: ${errorString}` + ); + } + } + + async getDeviceIds(identifier: string): Promise> { + if (!this.sessions) { + throw new Error('getDeviceIds: this.sessions not yet cached!'); + } + if (identifier === null || identifier === undefined) { + throw new Error('Tried to get device ids for undefined/null number'); + } + + try { + const id = window.ConversationController.getConversationId(identifier); + const allSessions = Object.values(this.sessions); + const sessions = allSessions.filter( + session => session.conversationId === id + ); + const openSessions = await Promise.all( + sessions.map(async session => { + const sessionCipher = new window.libsignal.SessionCipher( + window.textsecure.storage.protocol, + session.id + ); + + const hasOpenSession = await sessionCipher.hasOpenSession(); + if (hasOpenSession) { + return session; + } + + return undefined; + }) + ); + + return openSessions.filter(isNotNil).map(item => item.deviceId); + } catch (error) { + window.log.error( + `could not get device ids for identifier ${identifier}`, + error && error.stack ? error.stack : error + ); + } + + return []; + } + + async removeSession(encodedAddress: string): Promise { + if (!this.sessions) { + throw new Error('removeSession: this.sessions not yet cached!'); + } + + window.log.info('removeSession: deleting session for', encodedAddress); + try { + const id = await normalizeEncodedAddress(encodedAddress); + delete this.sessions[id]; + await window.Signal.Data.removeSessionById(id); + } catch (e) { + window.log.error(`could not delete session for ${encodedAddress}`); + } + } + + async removeAllSessions(identifier: string): Promise { + if (!this.sessions) { + throw new Error('removeAllSessions: this.sessions not yet cached!'); + } + + if (identifier === null || identifier === undefined) { + throw new Error('Tried to remove sessions for undefined/null number'); + } + + window.log.info('removeAllSessions: deleting sessions for', identifier); + + const id = window.ConversationController.getConversationId(identifier); + + const allSessions = Object.values(this.sessions); + + for (let i = 0, max = allSessions.length; i < max; i += 1) { + const session = allSessions[i]; + if (session.conversationId === id) { + delete this.sessions[session.id]; + } + } + + await window.Signal.Data.removeSessionsByConversation(identifier); + } + + async archiveSiblingSessions(identifier: string): Promise { + if (!this.sessions) { + throw new Error('archiveSiblingSessions: this.sessions not yet cached!'); + } + + window.log.info( + 'archiveSiblingSessions: archiving sibling sessions for', + identifier + ); + + const address = window.libsignal.SignalProtocolAddress.fromString( + identifier + ); + + const deviceIds = await this.getDeviceIds(address.getName()); + const siblings = window._.without(deviceIds, address.getDeviceId()); + + await Promise.all( + siblings.map(async deviceId => { + const sibling = new window.libsignal.SignalProtocolAddress( + address.getName(), + deviceId + ); + window.log.info( + 'archiveSiblingSessions: closing session for', + sibling.toString() + ); + const sessionCipher = new window.libsignal.SessionCipher( + window.textsecure.storage.protocol, + sibling + ); + await sessionCipher.closeOpenSessionForDevice(); + }) + ); + } + + async archiveAllSessions(identifier: string): Promise { + if (!this.sessions) { + throw new Error('archiveAllSessions: this.sessions not yet cached!'); + } + + window.log.info( + 'archiveAllSessions: archiving all sessions for', + identifier + ); + + const deviceIds = await this.getDeviceIds(identifier); + + await Promise.all( + deviceIds.map(async deviceId => { + const address = new window.libsignal.SignalProtocolAddress( + identifier, + deviceId + ); + window.log.info( + 'archiveAllSessions: closing session for', + address.toString() + ); + const sessionCipher = new window.libsignal.SessionCipher( + window.textsecure.storage.protocol, + address + ); + await sessionCipher.closeOpenSessionForDevice(); + }) + ); + } + + async clearSessionStore(): Promise { + this.sessions = Object.create(null); + window.Signal.Data.removeAllSessions(); + } + + // Identity Keys + + getIdentityRecord(identifier: string): IdentityKeyType | undefined { + if (!this.identityKeys) { + throw new Error('getIdentityRecord: this.identityKeys not yet cached!'); + } + + try { + const id = window.ConversationController.getConversationId(identifier); + if (!id) { + throw new Error( + `getIdentityRecord: No conversation id for identifier ${identifier}` + ); + } + + const record = this.identityKeys[id]; + + if (record) { + return record; + } + } catch (e) { + window.log.error( + `could not get identity record for identifier ${identifier}` + ); + } + + return undefined; + } + + async isTrustedIdentity( + encodedAddress: string, + publicKey: ArrayBuffer, + direction: number + ): Promise { + if (!this.identityKeys) { + throw new Error('getIdentityRecord: this.identityKeys not yet cached!'); + } + + if (encodedAddress === null || encodedAddress === undefined) { + throw new Error('Tried to get identity key for undefined/null key'); + } + const identifier = window.textsecure.utils.unencodeNumber( + encodedAddress + )[0]; + const ourNumber = window.textsecure.storage.user.getNumber(); + const ourUuid = window.textsecure.storage.user.getUuid(); + const isOurIdentifier = + (ourNumber && identifier === ourNumber) || + (ourUuid && identifier === ourUuid); + + const identityRecord = this.getIdentityRecord(identifier); + + if (isOurIdentifier) { + if (identityRecord && identityRecord.publicKey) { + return constantTimeEqual(identityRecord.publicKey, publicKey); + } + window.log.warn( + 'isTrustedIdentity: No local record for our own identifier. Returning true.' + ); + return true; + } + + switch (direction) { + case Direction.SENDING: + return this.isTrustedForSending(publicKey, identityRecord); + case Direction.RECEIVING: + return true; + default: + throw new Error(`Unknown direction: ${direction}`); + } + } + + isTrustedForSending( + publicKey: ArrayBuffer, + identityRecord?: IdentityKeyType + ): boolean { + if (!identityRecord) { + window.log.info( + 'isTrustedForSending: No previous record, returning true...' + ); + return true; + } + + const existing = identityRecord.publicKey; + + if (!existing) { + window.log.info('isTrustedForSending: Nothing here, returning true...'); + return true; + } + if (!constantTimeEqual(existing, publicKey)) { + window.log.info("isTrustedForSending: Identity keys don't match..."); + return false; + } + if (identityRecord.verified === VerifiedStatus.UNVERIFIED) { + window.log.error('Needs unverified approval!'); + return false; + } + if (this.isNonBlockingApprovalRequired(identityRecord)) { + window.log.error('isTrustedForSending: Needs non-blocking approval!'); + return false; + } + + return true; + } + + async loadIdentityKey(identifier: string): Promise { + if (identifier === null || identifier === undefined) { + throw new Error('Tried to get identity key for undefined/null key'); + } + const id = window.textsecure.utils.unencodeNumber(identifier)[0]; + const identityRecord = this.getIdentityRecord(id); + + if (identityRecord) { + return identityRecord.publicKey; + } + + return undefined; + } + + private async _saveIdentityKey(data: IdentityKeyType): Promise { + if (!this.identityKeys) { + throw new Error('_saveIdentityKey: this.identityKeys not yet cached!'); + } + + const { id } = data; + + const previousData = this.identityKeys[id]; + + // Optimistically update in-memory cache; will revert if save fails. + this.identityKeys[id] = data; + + try { + await window.Signal.Data.createOrUpdateIdentityKey(data); + } catch (error) { + if (previousData) { + this.identityKeys[id] = previousData; + } + + throw error; + } + } + + async saveIdentity( + encodedAddress: string, + publicKey: ArrayBuffer, + nonblockingApproval: boolean + ): Promise { + if (!this.identityKeys) { + throw new Error('saveIdentity: this.identityKeys not yet cached!'); + } + + if (encodedAddress === null || encodedAddress === undefined) { + throw new Error('Tried to put identity key for undefined/null key'); + } + if (!(publicKey instanceof ArrayBuffer)) { + // eslint-disable-next-line no-param-reassign + publicKey = fromEncodedBinaryToArrayBuffer(publicKey); + } + if (typeof nonblockingApproval !== 'boolean') { + // eslint-disable-next-line no-param-reassign + nonblockingApproval = false; + } + + const identifier = window.textsecure.utils.unencodeNumber( + encodedAddress + )[0]; + const identityRecord = this.getIdentityRecord(identifier); + const id = window.ConversationController.getOrCreate( + identifier, + 'private' + ).get('id'); + + if (!identityRecord || !identityRecord.publicKey) { + // Lookup failed, or the current key was removed, so save this one. + window.log.info('Saving new identity...'); + await this._saveIdentityKey({ + id, + publicKey, + firstUse: true, + timestamp: Date.now(), + verified: VerifiedStatus.DEFAULT, + nonblockingApproval, + }); + + return false; + } + + const oldpublicKey = identityRecord.publicKey; + if (!constantTimeEqual(oldpublicKey, publicKey)) { + window.log.info('Replacing existing identity...'); + const previousStatus = identityRecord.verified; + let verifiedStatus; + if ( + previousStatus === VerifiedStatus.VERIFIED || + previousStatus === VerifiedStatus.UNVERIFIED + ) { + verifiedStatus = VerifiedStatus.UNVERIFIED; + } else { + verifiedStatus = VerifiedStatus.DEFAULT; + } + + await this._saveIdentityKey({ + id, + publicKey, + firstUse: false, + timestamp: Date.now(), + verified: verifiedStatus, + nonblockingApproval, + }); + + try { + this.trigger('keychange', identifier); + } catch (error) { + window.log.error( + 'saveIdentity error triggering keychange:', + error && error.stack ? error.stack : error + ); + } + await this.archiveSiblingSessions(encodedAddress); + + return true; + } + if (this.isNonBlockingApprovalRequired(identityRecord)) { + window.log.info('Setting approval status...'); + + identityRecord.nonblockingApproval = nonblockingApproval; + await this._saveIdentityKey(identityRecord); + + return false; + } + + return false; + } + + isNonBlockingApprovalRequired(identityRecord: IdentityKeyType): boolean { + return ( + !identityRecord.firstUse && + isMoreRecentThan(identityRecord.timestamp, TIMESTAMP_THRESHOLD) && + !identityRecord.nonblockingApproval + ); + } + + async saveIdentityWithAttributes( + encodedAddress: string, + attributes: IdentityKeyType + ): Promise { + if (encodedAddress === null || encodedAddress === undefined) { + throw new Error('Tried to put identity key for undefined/null key'); + } + + const identifier = window.textsecure.utils.unencodeNumber( + encodedAddress + )[0]; + const identityRecord = this.getIdentityRecord(identifier); + const conv = window.ConversationController.getOrCreate( + identifier, + 'private' + ); + const id = conv.get('id'); + + const updates = { + ...identityRecord, + ...attributes, + id, + }; + + const model = new IdentityRecord(updates); + if (model.isValid()) { + await this._saveIdentityKey(updates); + } else { + throw model.validationError; + } + } + + async setApproval( + encodedAddress: string, + nonblockingApproval: boolean + ): Promise { + if (encodedAddress === null || encodedAddress === undefined) { + throw new Error('Tried to set approval for undefined/null identifier'); + } + if (typeof nonblockingApproval !== 'boolean') { + throw new Error('Invalid approval status'); + } + + const identifier = window.textsecure.utils.unencodeNumber( + encodedAddress + )[0]; + const identityRecord = this.getIdentityRecord(identifier); + + if (!identityRecord) { + throw new Error(`No identity record for ${identifier}`); + } + + identityRecord.nonblockingApproval = nonblockingApproval; + await this._saveIdentityKey(identityRecord); + } + + async setVerified( + encodedAddress: string, + verifiedStatus: number, + publicKey: ArrayBuffer + ): Promise { + if (encodedAddress === null || encodedAddress === undefined) { + throw new Error('Tried to set verified for undefined/null key'); + } + if (!validateVerifiedStatus(verifiedStatus)) { + throw new Error('Invalid verified status'); + } + if (arguments.length > 2 && !(publicKey instanceof ArrayBuffer)) { + throw new Error('Invalid public key'); + } + + const identityRecord = this.getIdentityRecord(encodedAddress); + + if (!identityRecord) { + throw new Error(`No identity record for ${encodedAddress}`); + } + + if (!publicKey || constantTimeEqual(identityRecord.publicKey, publicKey)) { + identityRecord.verified = verifiedStatus; + + const model = new IdentityRecord(identityRecord); + if (model.isValid()) { + await this._saveIdentityKey(identityRecord); + } else if (model.validationError) { + throw model.validationError; + } else { + throw new Error('setVerified: identity record data was invalid'); + } + } else { + window.log.info('No identity record for specified publicKey'); + } + } + + async getVerified(identifier: string): Promise { + if (identifier === null || identifier === undefined) { + throw new Error('Tried to set verified for undefined/null key'); + } + + const identityRecord = this.getIdentityRecord(identifier); + if (!identityRecord) { + throw new Error(`No identity record for ${identifier}`); + } + + const verifiedStatus = identityRecord.verified; + if (validateVerifiedStatus(verifiedStatus)) { + return verifiedStatus; + } + + return VerifiedStatus.DEFAULT; + } + + // Resolves to true if a new identity key was saved + processContactSyncVerificationState( + identifier: string, + verifiedStatus: number, + publicKey: ArrayBuffer + ): Promise { + if (verifiedStatus === VerifiedStatus.UNVERIFIED) { + return this.processUnverifiedMessage( + identifier, + verifiedStatus, + publicKey + ); + } + return this.processVerifiedMessage(identifier, verifiedStatus, publicKey); + } + + // This function encapsulates the non-Java behavior, since the mobile apps don't + // currently receive contact syncs and therefore will see a verify sync with + // UNVERIFIED status + async processUnverifiedMessage( + identifier: string, + verifiedStatus: number, + publicKey?: ArrayBuffer + ): Promise { + if (identifier === null || identifier === undefined) { + throw new Error('Tried to set verified for undefined/null key'); + } + if (publicKey !== undefined && !(publicKey instanceof ArrayBuffer)) { + throw new Error('Invalid public key'); + } + + const identityRecord = this.getIdentityRecord(identifier); + + let isEqual = false; + + if (identityRecord && publicKey) { + isEqual = constantTimeEqual(publicKey, identityRecord.publicKey); + } + + if ( + identityRecord && + isEqual && + identityRecord.verified !== VerifiedStatus.UNVERIFIED + ) { + await window.textsecure.storage.protocol.setVerified( + identifier, + verifiedStatus, + publicKey + ); + return false; + } + + if (publicKey && (!identityRecord || !isEqual)) { + await window.textsecure.storage.protocol.saveIdentityWithAttributes( + identifier, + { + publicKey, + verified: verifiedStatus, + firstUse: false, + timestamp: Date.now(), + nonblockingApproval: true, + } + ); + + if (identityRecord && !isEqual) { + try { + this.trigger('keychange', identifier); + } catch (error) { + window.log.error( + 'processUnverifiedMessage error triggering keychange:', + error && error.stack ? error.stack : error + ); + } + + await this.archiveAllSessions(identifier); + + return true; + } + } + + // The situation which could get us here is: + // 1. had a previous key + // 2. new key is the same + // 3. desired new status is same as what we had before + // 4. no publicKey was passed into this function + return false; + } + + // This matches the Java method as of + // https://github.com/signalapp/Signal-Android/blob/d0bb68e1378f689e4d10ac6a46014164992ca4e4/src/org/thoughtcrime/securesms/util/IdentityUtil.java#L188 + async processVerifiedMessage( + identifier: string, + verifiedStatus: number, + publicKey: ArrayBuffer + ): Promise { + if (identifier === null || identifier === undefined) { + throw new Error('Tried to set verified for undefined/null key'); + } + if (!validateVerifiedStatus(verifiedStatus)) { + throw new Error('Invalid verified status'); + } + if (publicKey !== undefined && !(publicKey instanceof ArrayBuffer)) { + throw new Error('Invalid public key'); + } + + const identityRecord = this.getIdentityRecord(identifier); + + let isEqual = false; + + if (identityRecord && publicKey) { + isEqual = constantTimeEqual(publicKey, identityRecord.publicKey); + } + + if (!identityRecord && verifiedStatus === VerifiedStatus.DEFAULT) { + window.log.info('No existing record for default status'); + return false; + } + + if ( + identityRecord && + isEqual && + identityRecord.verified !== VerifiedStatus.DEFAULT && + verifiedStatus === VerifiedStatus.DEFAULT + ) { + await window.textsecure.storage.protocol.setVerified( + identifier, + verifiedStatus, + publicKey + ); + return false; + } + + if ( + verifiedStatus === VerifiedStatus.VERIFIED && + (!identityRecord || + (identityRecord && !isEqual) || + (identityRecord && identityRecord.verified !== VerifiedStatus.VERIFIED)) + ) { + await window.textsecure.storage.protocol.saveIdentityWithAttributes( + identifier, + { + publicKey, + verified: verifiedStatus, + firstUse: false, + timestamp: Date.now(), + nonblockingApproval: true, + } + ); + + if (identityRecord && !isEqual) { + try { + this.trigger('keychange', identifier); + } catch (error) { + window.log.error( + 'processVerifiedMessage error triggering keychange:', + error && error.stack ? error.stack : error + ); + } + + await this.archiveAllSessions(identifier); + + // true signifies that we overwrote a previous key with a new one + return true; + } + } + + // We get here if we got a new key and the status is DEFAULT. If the + // message is out of date, we don't want to lose whatever more-secure + // state we had before. + return false; + } + + isUntrusted(identifier: string): boolean { + if (identifier === null || identifier === undefined) { + throw new Error('Tried to set verified for undefined/null key'); + } + + const identityRecord = this.getIdentityRecord(identifier); + if (!identityRecord) { + throw new Error(`No identity record for ${identifier}`); + } + + if ( + isMoreRecentThan(identityRecord.timestamp, TIMESTAMP_THRESHOLD) && + !identityRecord.nonblockingApproval && + !identityRecord.firstUse + ) { + return true; + } + + return false; + } + + async removeIdentityKey(identifier: string): Promise { + if (!this.identityKeys) { + throw new Error('removeIdentityKey: this.identityKeys not yet cached!'); + } + + const id = window.ConversationController.getConversationId(identifier); + if (id) { + delete this.identityKeys[id]; + await window.Signal.Data.removeIdentityKeyById(id); + await window.textsecure.storage.protocol.removeAllSessions(id); + } + } + + // Not yet processed messages - for resiliency + getUnprocessedCount(): Promise { + return window.Signal.Data.getUnprocessedCount(); + } + + getAllUnprocessed(): Promise> { + return window.Signal.Data.getAllUnprocessed(); + } + + getUnprocessedById(id: string): Promise { + return window.Signal.Data.getUnprocessedById(id); + } + + addUnprocessed(data: UnprocessedType): Promise { + // We need to pass forceSave because the data has an id already, which will cause + // an update instead of an insert. + return window.Signal.Data.saveUnprocessed(data, { + forceSave: true, + }); + } + + addMultipleUnprocessed(array: Array): Promise { + // We need to pass forceSave because the data has an id already, which will cause + // an update instead of an insert. + return window.Signal.Data.saveUnprocesseds(array, { + forceSave: true, + }); + } + + updateUnprocessedAttempts(id: string, attempts: number): Promise { + return window.Signal.Data.updateUnprocessedAttempts(id, attempts); + } + + updateUnprocessedWithData(id: string, data: UnprocessedType): Promise { + return window.Signal.Data.updateUnprocessedWithData(id, data); + } + + updateUnprocessedsWithData(items: Array): Promise { + return window.Signal.Data.updateUnprocessedsWithData(items); + } + + removeUnprocessed(idOrArray: string | Array): Promise { + return window.Signal.Data.removeUnprocessed(idOrArray); + } + + removeAllUnprocessed(): Promise { + return window.Signal.Data.removeAllUnprocessed(); + } + + async removeAllData(): Promise { + await window.Signal.Data.removeAll(); + await this.hydrateCaches(); + + window.storage.reset(); + await window.storage.fetch(); + + window.ConversationController.reset(); + await window.ConversationController.load(); + } + + async removeAllConfiguration(): Promise { + await window.Signal.Data.removeAllConfiguration(); + await this.hydrateCaches(); + + window.storage.reset(); + await window.storage.fetch(); + } +} + +window.SignalProtocolStore = SignalProtocolStore; diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index a3132c6d3c..65fb26265b 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -4,17 +4,16 @@ import { get, throttle } from 'lodash'; import { WebAPIType } from './textsecure/WebAPI'; -type ConfigKeyType = +export type ConfigKeyType = | 'desktop.cds' | 'desktop.clientExpiration' | 'desktop.disableGV1' | 'desktop.groupCalling' | 'desktop.gv2' - | 'desktop.gv2Admin' | 'desktop.mandatoryProfileSharing' | 'desktop.messageRequests' | 'desktop.storage' - | 'desktop.storageWrite2' + | 'desktop.storageWrite3' | 'global.groupsv2.maxGroupSize' | 'global.groupsv2.groupSizeHardLimit'; type ConfigValueType = { diff --git a/ts/backbone/views/toast_view.ts b/ts/backbone/views/toast_view.ts new file mode 100644 index 0000000000..1e46c68a83 --- /dev/null +++ b/ts/backbone/views/toast_view.ts @@ -0,0 +1,35 @@ +// Copyright 2015-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +window.Whisper = window.Whisper || {}; + +window.Whisper.ToastView = window.Whisper.View.extend({ + className: 'toast', + template: () => $('#toast').html(), + initialize() { + this.$el.hide(); + this.timeout = 2000; + }, + + close() { + this.$el.fadeOut(this.remove.bind(this)); + }, + + render() { + this.$el.html( + window.Mustache.render( + window._.result(this, 'template', ''), + window._.result(this, 'render_attributes', '') + ) + ); + this.$el.attr('tabIndex', 0); + this.$el.show(); + setTimeout(this.close.bind(this), this.timeout); + }, +}); + +window.Whisper.ToastView.show = (View, el) => { + const toast = new View(); + toast.$el.appendTo(el); + toast.render(); +}; diff --git a/ts/backbone/views/whisper_view.ts b/ts/backbone/views/whisper_view.ts new file mode 100644 index 0000000000..21888400ab --- /dev/null +++ b/ts/backbone/views/whisper_view.ts @@ -0,0 +1,32 @@ +// Copyright 2015-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +/* + * Defines a default definition for render() which allows sub-classes + * to simply specify a template property and renderAttributes which are plugged + * into Mustache.render + */ + +// eslint-disable-next-line func-names +(function () { + window.Whisper = window.Whisper || {}; + + window.Whisper.View = Backbone.View.extend({ + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + constructor(...params: Array) { + window.Backbone.View.call(this, ...params); + + // Checks for syntax errors + window.Mustache.parse(_.result(this, 'template')); + }, + render_attributes() { + return _.result(this.model, 'attributes', {}); + }, + render() { + const attrs = window._.result(this, 'render_attributes', {}); + const template = window._.result(this, 'template', ''); + this.$el.html(window.Mustache.render(template, attrs)); + return this; + }, + }); +})(); diff --git a/ts/background.ts b/ts/background.ts index 3549b95104..c0d4332242 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -1,15 +1,32 @@ // Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -// This allows us to pull in types despite the fact that this is not a module. We can't -// 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 DataMessageClass = import('./textsecure.d').DataMessageClass; -type WhatIsThis = import('./window.d').WhatIsThis; +import { DataMessageClass } from './textsecure.d'; +import { MessageAttributesType } from './model-types.d'; +import { WhatIsThis } from './window.d'; +import { getTitleBarVisibility, TitleBarVisibility } from './types/Settings'; +import { isWindowDragElement } from './util/isWindowDragElement'; +import { assert } from './util/assert'; +import * as refreshSenderCertificate from './refreshSenderCertificate'; +import { SenderCertificateMode } from './metadata/SecretSessionCipher'; +import { routineProfileRefresh } from './routineProfileRefresh'; +import { isMoreRecentThan, isOlderThan } from './util/timestamp'; -// eslint-disable-next-line func-names -(async function () { +const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000; + +export async function startApp(): Promise { + window.startupProcessingQueue = new window.Signal.Util.StartupQueue(); + window.attachmentDownloadQueue = []; + try { + window.log.info('Initializing SQL in renderer'); + await window.sqlInitializer.initialize(); + window.log.info('SQL initialized in renderer'); + } catch (err) { + window.log.error( + 'SQL failed to initialize', + err && err.stack ? err.stack : err + ); + } const eventHandlerQueue = new window.PQueue({ concurrency: 1, timeout: 1000 * 60 * 2, @@ -20,10 +37,11 @@ type WhatIsThis = import('./window.d').WhatIsThis; }); window.Whisper.deliveryReceiptQueue.pause(); window.Whisper.deliveryReceiptBatcher = window.Signal.Util.createBatcher({ + name: 'Whisper.deliveryReceiptBatcher', wait: 500, maxSize: 500, processBatch: async (items: WhatIsThis) => { - const byConversationId = _.groupBy(items, item => + const byConversationId = window._.groupBy(items, item => window.ConversationController.ensureContactIds({ e164: item.source, uuid: item.sourceUuid, @@ -70,16 +88,14 @@ type WhatIsThis = import('./window.d').WhatIsThis; }, }); - window.addEventListener('dblclick', (event: Event) => { - const target = event.target as HTMLElement; - const isDoubleClickOnTitleBar = Boolean( - target.classList.contains('module-title-bar-drag-area') || - target.closest('module-title-bar-drag-area') - ); - if (isDoubleClickOnTitleBar) { - window.titleBarDoubleClick(); - } - }); + if (getTitleBarVisibility() === TitleBarVisibility.Hidden) { + window.addEventListener('dblclick', (event: Event) => { + const target = event.target as HTMLElement; + if (isWindowDragElement(target)) { + window.titleBarDoubleClick(); + } + }); + } // Globally disable drag and drop document.body.addEventListener( @@ -205,12 +221,12 @@ type WhatIsThis = import('./window.d').WhatIsThis; if (messageReceiver) { return messageReceiver.getStatus(); } - if (_.isNumber(preMessageReceiverStatus)) { + if (window._.isNumber(preMessageReceiverStatus)) { return preMessageReceiverStatus; } return WebSocket.CLOSED; }; - window.Whisper.events = _.clone(window.Backbone.Events); + window.Whisper.events = window._.clone(window.Backbone.Events); let accountManager: typeof window.textsecure.AccountManager; window.getAccountManager = () => { if (!accountManager) { @@ -545,12 +561,11 @@ type WhatIsThis = import('./window.d').WhatIsThis; }; // How long since we were last running? - const now = Date.now(); const lastHeartbeat = window.storage.get('lastHeartbeat'); await window.storage.put('lastStartup', Date.now()); const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000; - if (lastHeartbeat > 0 && now - lastHeartbeat > THIRTY_DAYS) { + if (lastHeartbeat > 0 && isOlderThan(lastHeartbeat, THIRTY_DAYS)) { await unlinkAndDisconnect(); } @@ -626,7 +641,17 @@ type WhatIsThis = import('./window.d').WhatIsThis; Views.Initialization.setMessage(window.i18n('optimizingApplication')); if (newVersion) { - await window.Signal.Data.cleanupOrphanedAttachments(); + // We've received reports that this update can take longer than two minutes, so we + // allow it to continue and just move on in that timeout case. + try { + await window.Signal.Data.cleanupOrphanedAttachments(); + } catch (error) { + window.log.error( + 'background: Failed to cleanup orphaned attachments:', + error && error.stack ? error.stack : error + ); + } + // Don't block on the following operation window.Signal.Data.ensureFilePermissions(); } @@ -730,7 +755,7 @@ type WhatIsThis = import('./window.d').WhatIsThis; ), messagesByConversation: {}, messagesLookup: {}, - selectedConversation: undefined, + selectedConversationId: undefined, selectedMessage: undefined, selectedMessageCounter: 0, selectedConversationPanelDepth: 0, @@ -866,96 +891,16 @@ type WhatIsThis = import('./window.d').WhatIsThis; } }; - function getConversationsToSearch() { - const state = store.getState(); - const { - archivedConversations, - conversations: unpinnedConversations, - pinnedConversations, - } = window.Signal.State.Selectors.conversations.getLeftPaneLists(state); - - return state.conversations.showArchived - ? archivedConversations - : [...pinnedConversations, ...unpinnedConversations]; - } - - function getConversationByIndex(index: WhatIsThis) { - const conversationsToSearch = getConversationsToSearch(); - - const target = conversationsToSearch[index]; - - if (target) { - return target.id; - } - - return null; - } - - function findConversation( - conversationId: WhatIsThis, - direction: WhatIsThis, - unreadOnly: WhatIsThis - ) { - const conversationsToSearch = getConversationsToSearch(); - - const increment = direction === 'up' ? -1 : 1; - let startIndex: WhatIsThis; - - if (conversationId) { - const index = conversationsToSearch.findIndex( - (item: WhatIsThis) => item.id === conversationId - ); - if (index >= 0) { - startIndex = index + increment; - } - } else { - startIndex = direction === 'up' ? conversationsToSearch.length - 1 : 0; - } - - for ( - let i = startIndex, max = conversationsToSearch.length; - i >= 0 && i < max; - i += increment - ) { - const target = conversationsToSearch[i]; - if (!unreadOnly) { - return target.id; - } - if ((target.unreadCount || 0) > 0) { - return target.id; - } - } - - return null; - } - - const NUMBERS: Record = { - '1': 1, - '2': 2, - '3': 3, - '4': 4, - '5': 5, - '6': 6, - '7': 7, - '8': 8, - '9': 9, - }; - document.addEventListener('keydown', event => { - const { altKey, ctrlKey, key, metaKey, shiftKey } = event; + const { ctrlKey, key, metaKey, shiftKey } = event; - const optionOrAlt = altKey; const commandKey = window.platform === 'darwin' && metaKey; const controlKey = window.platform !== 'darwin' && ctrlKey; const commandOrCtrl = commandKey || controlKey; - const commandAndCtrl = commandKey && ctrlKey; const state = store.getState(); - const selectedId = state.conversations.selectedConversation; + const selectedId = state.conversations.selectedConversationId; const conversation = window.ConversationController.get(selectedId); - const isSearching = window.Signal.State.Selectors.search.isSearching( - state - ); // NAVIGATION @@ -977,8 +922,14 @@ type WhatIsThis = import('./window.d').WhatIsThis; const targets: Array = [ document.querySelector('.module-main-header .module-avatar-button'), - document.querySelector('.module-left-pane__to-inbox-button'), + document.querySelector( + '.module-left-pane__header__contents__back-button' + ), document.querySelector('.module-main-header__search__input'), + document.querySelector('.module-main-header__compose-icon'), + document.querySelector( + '.module-left-pane__compose-search-form__input' + ), document.querySelector('.module-left-pane__list'), document.querySelector('.module-search-results'), document.querySelector('.module-composition-area .ql-editor'), @@ -1129,94 +1080,6 @@ type WhatIsThis = import('./window.d').WhatIsThis; return; } - // Change currently selected conversation by index - if (!isSearching && commandOrCtrl && NUMBERS[key]) { - const targetId = getConversationByIndex( - (NUMBERS[key] as WhatIsThis) - 1 - ); - - if (targetId) { - window.Whisper.events.trigger('showConversation', targetId); - event.preventDefault(); - event.stopPropagation(); - return; - } - } - - // Change currently selected conversation - // up/previous - if ( - (!isSearching && optionOrAlt && !shiftKey && key === 'ArrowUp') || - (!isSearching && commandOrCtrl && shiftKey && key === '[') || - (!isSearching && ctrlKey && shiftKey && key === 'Tab') - ) { - const unreadOnly = false; - const targetId = findConversation( - conversation ? conversation.id : null, - 'up', - unreadOnly - ); - - if (targetId) { - window.Whisper.events.trigger('showConversation', targetId); - event.preventDefault(); - event.stopPropagation(); - return; - } - } - // down/next - if ( - (!isSearching && optionOrAlt && !shiftKey && key === 'ArrowDown') || - (!isSearching && commandOrCtrl && shiftKey && key === ']') || - (!isSearching && ctrlKey && key === 'Tab') - ) { - const unreadOnly = false; - const targetId = findConversation( - conversation ? conversation.id : null, - 'down', - unreadOnly - ); - - if (targetId) { - window.Whisper.events.trigger('showConversation', targetId); - event.preventDefault(); - event.stopPropagation(); - return; - } - } - // previous unread - if (!isSearching && optionOrAlt && shiftKey && key === 'ArrowUp') { - const unreadOnly = true; - const targetId = findConversation( - conversation ? conversation.id : null, - 'up', - unreadOnly - ); - - if (targetId) { - window.Whisper.events.trigger('showConversation', targetId); - event.preventDefault(); - event.stopPropagation(); - return; - } - } - // next unread - if (!isSearching && optionOrAlt && shiftKey && key === 'ArrowDown') { - const unreadOnly = true; - const targetId = findConversation( - conversation ? conversation.id : null, - 'down', - unreadOnly - ); - - if (targetId) { - window.Whisper.events.trigger('showConversation', targetId); - event.preventDefault(); - event.stopPropagation(); - return; - } - } - // Preferences - handled by Electron-managed keyboard shortcuts // Open the top-right menu for current conversation @@ -1227,7 +1090,7 @@ type WhatIsThis = import('./window.d').WhatIsThis; (key === 'l' || key === 'L') ) { const button = document.querySelector( - '.module-conversation-header__more-button' + '.module-ConversationHeader__more-button' ); if (!button) { return; @@ -1265,40 +1128,6 @@ type WhatIsThis = import('./window.d').WhatIsThis; return; } - // Search - if ( - commandOrCtrl && - !commandAndCtrl && - !shiftKey && - (key === 'f' || key === 'F') - ) { - const { startSearch } = actions.search; - startSearch(); - - event.preventDefault(); - event.stopPropagation(); - return; - } - - // Search in conversation - if ( - conversation && - commandOrCtrl && - !commandAndCtrl && - shiftKey && - (key === 'f' || key === 'F') - ) { - const { searchInConversation } = actions.search; - const name = conversation.isMe() - ? window.i18n('noteToSelf') - : conversation.getTitle(); - searchInConversation(conversation.id, name); - - event.preventDefault(); - event.stopPropagation(); - return; - } - // Focus composer field if ( conversation && @@ -1358,8 +1187,7 @@ type WhatIsThis = import('./window.d').WhatIsThis; ); // It's very likely that the act of archiving a conversation will set focus to - // 'none,' or the top-level body element. This resets it to the left pane, - // whether in the normal conversation list or search results. + // 'none,' or the top-level body element. This resets it to the left pane. if (document.activeElement === document.body) { const leftPaneEl: HTMLElement | null = document.querySelector( '.module-left-pane__list' @@ -1367,13 +1195,6 @@ type WhatIsThis = import('./window.d').WhatIsThis; if (leftPaneEl) { leftPaneEl.focus(); } - - const searchResultsEl: HTMLElement | null = document.querySelector( - '.module-search-results' - ); - if (searchResultsEl) { - searchResultsEl.focus(); - } } event.preventDefault(); @@ -1814,6 +1635,9 @@ type WhatIsThis = import('./window.d').WhatIsThis; let connectCount = 0; let connecting = false; async function connect(firstRun?: boolean) { + window.receivedAtCounter = + window.storage.get('lastReceivedAtCounter') || Date.now(); + if (connecting) { window.log.warn('connect already running', { connectCount }); return; @@ -1972,6 +1796,7 @@ type WhatIsThis = import('./window.d').WhatIsThis; addQueuedEventListener('read', onReadReceipt); addQueuedEventListener('verified', onVerified); addQueuedEventListener('error', onError); + addQueuedEventListener('light-session-reset', onLightSessionReset); addQueuedEventListener('empty', onEmpty); addQueuedEventListener('reconnect', onReconnect); addQueuedEventListener('configuration', onConfiguration); @@ -2098,7 +1923,10 @@ type WhatIsThis = import('./window.d').WhatIsThis; !hasThemeSetting && window.textsecure.storage.get('userAgent') === 'OWI' ) { - window.storage.put('theme-setting', 'ios'); + window.storage.put( + 'theme-setting', + await window.Events.getThemeSetting() + ); onChangeTheme(); } const syncRequest = new window.textsecure.SyncRequest( @@ -2150,6 +1978,22 @@ type WhatIsThis = import('./window.d').WhatIsThis; window.storage.onready(async () => { idleDetector.start(); + + // Kick off a profile refresh if necessary, but don't wait for it, as failure is + // tolerable. + const ourConversationId = window.ConversationController.getOurConversationId(); + if (ourConversationId) { + routineProfileRefresh({ + allConversations: window.ConversationController.getAll(), + ourConversationId, + storage: window.storage, + }); + } else { + assert( + false, + 'Failed to fetch our conversation ID. Skipping routine profile refresh' + ); + } }); } finally { connecting = false; @@ -2215,7 +2059,7 @@ type WhatIsThis = import('./window.d').WhatIsThis; async function onEmpty() { await Promise.all([ window.waitForAllBatchers(), - window.waitForAllWaitBatchers(), + window.flushAllWaitBatchers(), ]); window.log.info('onEmpty: All outstanding database requests complete'); initialLoadComplete = true; @@ -2226,26 +2070,80 @@ type WhatIsThis = import('./window.d').WhatIsThis; window.Whisper.events, newVersion ); - window.Signal.RefreshSenderCertificate.initialize({ - events: window.Whisper.events, - storage: window.storage, - navigator, - logger: window.log, - }); - let interval: NodeJS.Timer | null = setInterval(() => { - const view = window.owsDesktopApp.appView; - if (view) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - clearInterval(interval!); - interval = null; - view.onEmpty(); - window.logAppLoadedEvent(); + [SenderCertificateMode.WithE164, SenderCertificateMode.WithoutE164].forEach( + mode => { + refreshSenderCertificate.initialize({ + events: window.Whisper.events, + storage: window.storage, + mode, + navigator, + }); } - }, 500); + ); window.Whisper.deliveryReceiptQueue.start(); window.Whisper.Notifications.enable(); + + const view = window.owsDesktopApp.appView; + if (!view) { + throw new Error('Expected `appView` to be initialized'); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + view.onEmpty(); + + window.logAppLoadedEvent(); + if (messageReceiver) { + window.log.info( + 'App loaded - messages:', + messageReceiver.getProcessedCount() + ); + } + + await window.sqlInitializer.goBackToMainProcess(); + window.Signal.Util.setBatchingStrategy(false); + + const attachmentDownloadQueue = window.attachmentDownloadQueue || []; + + // NOTE: ts/models/messages.ts expects this global to become undefined + // once we stop processing the queue. + window.attachmentDownloadQueue = undefined; + + const MAX_ATTACHMENT_MSGS_TO_DOWNLOAD = 250; + const attachmentsToDownload = attachmentDownloadQueue.filter( + (message, index) => + index <= MAX_ATTACHMENT_MSGS_TO_DOWNLOAD || + isMoreRecentThan( + message.getReceivedAt(), + MAX_ATTACHMENT_DOWNLOAD_AGE + ) || + // Stickers and long text attachments has to be downloaded for UI + // to display the message properly. + message.hasRequiredAttachmentDownloads() + ); + window.log.info( + 'Downloading recent attachments of total attachments', + attachmentsToDownload.length, + attachmentDownloadQueue.length + ); + + if (window.startupProcessingQueue) { + window.startupProcessingQueue.flush(); + window.startupProcessingQueue = undefined; + } + + const messagesWithDownloads = await Promise.all( + attachmentsToDownload.map(message => message.queueAttachmentDownloads()) + ); + const messagesToSave: Array = []; + messagesWithDownloads.forEach((shouldSave, messageKey) => { + if (shouldSave) { + const message = attachmentsToDownload[messageKey]; + messagesToSave.push(message.attributes); + } + }); + await window.Signal.Data.saveMessages(messagesToSave, {}); } function onReconnect() { // We disable notifications on first connect, but the same applies to reconnect. In @@ -2469,8 +2367,6 @@ type WhatIsThis = import('./window.d').WhatIsThis; details.profileKey ); conversation.setProfileKey(profileKey); - } else { - conversation.dropProfileKey(); } if (typeof details.blocked !== 'undefined') { @@ -2712,7 +2608,6 @@ type WhatIsThis = import('./window.d').WhatIsThis; const reactionModel = window.Whisper.Reactions.add({ emoji: reaction.emoji, remove: reaction.remove, - targetAuthorE164: reaction.targetAuthorE164, targetAuthorUuid: reaction.targetAuthorUuid, targetTimestamp: reaction.targetTimestamp, timestamp: Date.now(), @@ -2825,7 +2720,7 @@ type WhatIsThis = import('./window.d').WhatIsThis; sentTo = data.unidentifiedStatus.map( (item: WhatIsThis) => item.destinationUuid || item.destination ); - const unidentified = _.filter(data.unidentifiedStatus, item => + const unidentified = window._.filter(data.unidentifiedStatus, item => Boolean(item.unidentified) ); // eslint-disable-next-line no-param-reassign @@ -2841,14 +2736,15 @@ type WhatIsThis = import('./window.d').WhatIsThis; sent_at: data.timestamp, serverTimestamp: data.serverTimestamp, sent_to: sentTo, - received_at: now, + received_at: data.receivedAtCounter, + received_at_ms: data.receivedAtDate, conversationId: descriptor.id, type: 'outgoing', sent: true, unidentifiedDeliveries: data.unidentifiedDeliveries || [], expirationStartTimestamp: Math.min( - data.expirationStartTimestamp || data.timestamp || Date.now(), - Date.now() + data.expirationStartTimestamp || data.timestamp || now, + now ), } as WhatIsThis); } @@ -3002,7 +2898,6 @@ type WhatIsThis = import('./window.d').WhatIsThis; const reactionModel = window.Whisper.Reactions.add({ emoji: reaction.emoji, remove: reaction.remove, - targetAuthorE164: reaction.targetAuthorE164, targetAuthorUuid: reaction.targetAuthorUuid, targetTimestamp: reaction.targetTimestamp, timestamp: Date.now(), @@ -3051,13 +2946,18 @@ type WhatIsThis = import('./window.d').WhatIsThis; data: WhatIsThis, descriptor: MessageDescriptor ) { + assert( + Boolean(data.receivedAtCounter), + `Did not receive receivedAtCounter for message: ${data.timestamp}` + ); return new window.Whisper.Message({ source: data.source, sourceUuid: data.sourceUuid, sourceDevice: data.sourceDevice, sent_at: data.timestamp, serverTimestamp: data.serverTimestamp, - received_at: Date.now(), + received_at: data.receivedAtCounter, + received_at_ms: data.receivedAtDate, conversationId: descriptor.id, unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived, type: 'incoming', @@ -3156,7 +3056,8 @@ type WhatIsThis = import('./window.d').WhatIsThis; error.name === 'HTTPError' && (error.code === 401 || error.code === 403) ) { - return unlinkAndDisconnect(); + unlinkAndDisconnect(); + return; } if ( @@ -3171,101 +3072,40 @@ type WhatIsThis = import('./window.d').WhatIsThis; window.Whisper.events.trigger('reconnectTimer'); } - return Promise.resolve(); + return; } - if (ev.proto) { - if (error && error.name === 'MessageCounterError') { - if (ev.confirm) { - ev.confirm(); - } - // Ignore this message. It is likely a duplicate delivery - // because the server lost our ack the first time. - return Promise.resolve(); - } - const envelope = ev.proto; - const id = window.ConversationController.ensureContactIds({ - e164: envelope.source, - uuid: envelope.sourceUuid, - }); - if (!id) { - throw new Error('onError: ensureContactIds returned falsey id!'); - } - const message = initIncomingMessage(envelope, { - type: Message.PRIVATE, - id, - }); + window.log.warn('background onError: Doing nothing with incoming error'); + } - const conversationId = message.get('conversationId'); - const conversation = window.ConversationController.get(conversationId); + type LightSessionResetEventType = { + senderUuid: string; + }; - if (!conversation) { - window.log.warn( - 'onError: No conversation id, cannot save error bubble' - ); - ev.confirm(); - return Promise.resolve(); - } + function onLightSessionReset(event: LightSessionResetEventType) { + const conversationId = window.ConversationController.ensureContactIds({ + uuid: event.senderUuid, + }); - // This matches the queueing behavior used in Message.handleDataMessage - conversation.queueJob(async () => { - const existingMessage = await window.Signal.Data.getMessageBySender( - message.attributes, - { - Message: window.Whisper.Message, - } - ); - if (existingMessage) { - ev.confirm(); - window.log.warn( - `Got duplicate error for message ${message.idForLogging()}` - ); - return; - } + if (!conversationId) { + window.log.warn( + 'onLightSessionReset: No conversation id, cannot add message to timeline' + ); + return; + } + const conversation = window.ConversationController.get(conversationId); - const model = new window.Whisper.Message({ - ...message.attributes, - id: window.getGuid(), - }); - await model.saveErrors(error || new Error('Error was null'), { - skipSave: true, - }); - - window.MessageController.register(model.id, model); - await window.Signal.Data.saveMessage(model.attributes, { - Message: window.Whisper.Message, - forceSave: true, - }); - - conversation.set({ - active_at: Date.now(), - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - unreadCount: conversation.get('unreadCount')! + 1, - }); - - const conversationTimestamp = conversation.get('timestamp'); - const messageTimestamp = model.get('timestamp'); - if ( - !conversationTimestamp || - messageTimestamp > conversationTimestamp - ) { - conversation.set({ timestamp: model.get('sent_at') }); - } - - conversation.trigger('newmessage', model); - conversation.notify(model); - - window.Whisper.events.trigger('incrementProgress'); - - if (ev.confirm) { - ev.confirm(); - } - - window.Signal.Data.updateConversation(conversation.attributes); - }); + if (!conversation) { + window.log.warn( + 'onLightSessionReset: No conversation, cannot add message to timeline' + ); + return; } - throw error; + const receivedAt = Date.now(); + conversation.queueJob(async () => { + conversation.addChatSessionRefreshed(receivedAt); + }); } async function onViewSync(ev: WhatIsThis) { @@ -3537,4 +3377,6 @@ type WhatIsThis = import('./window.d').WhatIsThis; // Note: We don't wait for completion here window.Whisper.DeliveryReceipts.onReceipt(receipt); } -})(); +} + +window.startApp = startApp; diff --git a/ts/components/AddGroupMemberErrorDialog.stories.tsx b/ts/components/AddGroupMemberErrorDialog.stories.tsx new file mode 100644 index 0000000000..0e53eb033f --- /dev/null +++ b/ts/components/AddGroupMemberErrorDialog.stories.tsx @@ -0,0 +1,50 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import 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 { + AddGroupMemberErrorDialog, + AddGroupMemberErrorDialogMode, +} from './AddGroupMemberErrorDialog'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf('Components/AddGroupMemberErrorDialog', module); + +const defaultProps = { + i18n, + onClose: action('onClose'), +}; + +story.add("Can't add a contact", () => ( + +)); + +story.add('Maximum group size', () => ( + +)); + +story.add('Maximum recommended group size', () => ( + +)); diff --git a/ts/components/AddGroupMemberErrorDialog.tsx b/ts/components/AddGroupMemberErrorDialog.tsx new file mode 100644 index 0000000000..296ab088bb --- /dev/null +++ b/ts/components/AddGroupMemberErrorDialog.tsx @@ -0,0 +1,90 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { FunctionComponent, ReactNode } from 'react'; + +import { LocalizerType } from '../types/Util'; +import { Alert } from './Alert'; +import { Intl } from './Intl'; +import { ContactName } from './conversation/ContactName'; +import { missingCaseError } from '../util/missingCaseError'; + +export enum AddGroupMemberErrorDialogMode { + CantAddContact, + MaximumGroupSize, + RecommendedMaximumGroupSize, +} + +type PropsDataType = + | { + mode: AddGroupMemberErrorDialogMode.CantAddContact; + contact: { + name?: string; + phoneNumber?: string; + profileName?: string; + title: string; + }; + } + | { + mode: AddGroupMemberErrorDialogMode.MaximumGroupSize; + maximumNumberOfContacts: number; + } + | { + mode: AddGroupMemberErrorDialogMode.RecommendedMaximumGroupSize; + recommendedMaximumNumberOfContacts: number; + }; + +type PropsType = { + i18n: LocalizerType; + onClose: () => void; +} & PropsDataType; + +export const AddGroupMemberErrorDialog: FunctionComponent = props => { + const { i18n, onClose } = props; + + let title: string; + let body: ReactNode; + switch (props.mode) { + case AddGroupMemberErrorDialogMode.CantAddContact: { + const { contact } = props; + title = i18n('chooseGroupMembers__cant-add-member__title'); + body = ( + , + ]} + /> + ); + break; + } + case AddGroupMemberErrorDialogMode.MaximumGroupSize: { + const { maximumNumberOfContacts } = props; + title = i18n('chooseGroupMembers__maximum-group-size__title'); + body = i18n('chooseGroupMembers__maximum-group-size__body', [ + maximumNumberOfContacts.toString(), + ]); + break; + } + case AddGroupMemberErrorDialogMode.RecommendedMaximumGroupSize: { + const { recommendedMaximumNumberOfContacts } = props; + title = i18n('chooseGroupMembers__maximum-recommended-group-size__title'); + body = i18n('chooseGroupMembers__maximum-recommended-group-size__body', [ + recommendedMaximumNumberOfContacts.toString(), + ]); + break; + } + default: + throw missingCaseError(props); + } + + return ; +}; diff --git a/ts/components/Alert.stories.tsx b/ts/components/Alert.stories.tsx new file mode 100644 index 0000000000..db7b6594c9 --- /dev/null +++ b/ts/components/Alert.stories.tsx @@ -0,0 +1,41 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import 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 { Alert } from './Alert'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf('Components/Alert', module); + +const defaultProps = { + i18n, + onClose: action('onClose'), +}; + +story.add('Title and body are strings', () => ( + +)); + +story.add('Body is a ReactNode', () => ( + + Hello{' '} + world! + + } + /> +)); diff --git a/ts/components/Alert.tsx b/ts/components/Alert.tsx new file mode 100644 index 0000000000..183276277e --- /dev/null +++ b/ts/components/Alert.tsx @@ -0,0 +1,32 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { FunctionComponent, ReactNode } from 'react'; + +import { LocalizerType } from '../types/Util'; +import { Button } from './Button'; +import { ModalHost } from './ModalHost'; + +type PropsType = { + title?: string; + body: ReactNode; + i18n: LocalizerType; + onClose: () => void; +}; + +export const Alert: FunctionComponent = ({ + body, + i18n, + onClose, + title, +}) => ( + +
+ {title &&

{title}

} +

{body}

+
+ +
+
+
+); diff --git a/ts/components/AvatarInput.stories.tsx b/ts/components/AvatarInput.stories.tsx new file mode 100644 index 0000000000..14949c22ca --- /dev/null +++ b/ts/components/AvatarInput.stories.tsx @@ -0,0 +1,74 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useState, useEffect } from 'react'; +import { v4 as uuid } from 'uuid'; +import { chunk, noop } from 'lodash'; + +import { storiesOf } from '@storybook/react'; +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +import { AvatarInput, AvatarInputVariant } from './AvatarInput'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf('Components/AvatarInput', module); + +const TEST_IMAGE = new Uint8Array( + chunk( + '89504e470d0a1a0a0000000d4948445200000008000000080103000000fec12cc800000006504c5445ff00ff00ff000c82e9800000001849444154085b633061a8638863a867f8c720c760c12000001a4302f4d81dd9870000000049454e44ae426082', + 2 + ).map(bytePair => parseInt(bytePair.join(''), 16)) +).buffer; + +const Wrapper = ({ + startValue, + variant, +}: { + startValue: undefined | ArrayBuffer; + variant?: AvatarInputVariant; +}) => { + const [value, setValue] = useState(startValue); + const [objectUrl, setObjectUrl] = useState(); + + useEffect(() => { + if (!value) { + setObjectUrl(undefined); + return noop; + } + const url = URL.createObjectURL(new Blob([value])); + setObjectUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + }, [value]); + + return ( + <> + +
+
Processed image (if it exists)
+ {objectUrl && } +
+ + ); +}; + +story.add('No start state', () => { + return ; +}); + +story.add('Starting with a value', () => { + return ; +}); + +story.add('Dark variant', () => { + return ; +}); diff --git a/ts/components/AvatarInput.tsx b/ts/components/AvatarInput.tsx new file mode 100644 index 0000000000..9944540ad6 --- /dev/null +++ b/ts/components/AvatarInput.tsx @@ -0,0 +1,213 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { + useRef, + useState, + useEffect, + ChangeEventHandler, + MouseEventHandler, + FunctionComponent, +} from 'react'; +import classNames from 'classnames'; +import { ContextMenu, MenuItem, ContextMenuTrigger } from 'react-contextmenu'; +import loadImage, { LoadImageOptions } from 'blueimp-load-image'; +import { noop } from 'lodash'; + +import { LocalizerType } from '../types/Util'; +import { Spinner } from './Spinner'; +import { canvasToArrayBuffer } from '../util/canvasToArrayBuffer'; + +type PropsType = { + // This ID needs to be globally unique across the app. + contextMenuId: string; + disabled?: boolean; + i18n: LocalizerType; + onChange: (value: undefined | ArrayBuffer) => unknown; + value: undefined | ArrayBuffer; + variant?: AvatarInputVariant; +}; + +enum ImageStatus { + Nothing = 'nothing', + Loading = 'loading', + HasImage = 'has-image', +} + +export enum AvatarInputVariant { + Light = 'light', + Dark = 'dark', +} + +export const AvatarInput: FunctionComponent = ({ + contextMenuId, + disabled, + i18n, + onChange, + value, + variant = AvatarInputVariant.Light, +}) => { + const fileInputRef = useRef(null); + // Comes from a third-party dependency + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const menuTriggerRef = useRef(null); + + const [objectUrl, setObjectUrl] = useState(); + useEffect(() => { + if (!value) { + setObjectUrl(undefined); + return noop; + } + const url = URL.createObjectURL(new Blob([value])); + setObjectUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + }, [value]); + + const [processingFile, setProcessingFile] = useState( + undefined + ); + useEffect(() => { + if (!processingFile) { + return noop; + } + + let shouldCancel = false; + + (async () => { + let newValue: ArrayBuffer; + try { + newValue = await processFile(processingFile); + } catch (err) { + // Processing errors should be rare; if they do, we silently fail. In an ideal + // world, we may want to show a toast instead. + return; + } + if (shouldCancel) { + return; + } + setProcessingFile(undefined); + onChange(newValue); + })(); + + return () => { + shouldCancel = true; + }; + }, [processingFile, onChange]); + + const buttonLabel = value + ? i18n('AvatarInput--change-photo-label') + : i18n('AvatarInput--no-photo-label--group'); + + const startUpload = () => { + const fileInput = fileInputRef.current; + if (fileInput) { + fileInput.click(); + } + }; + + const clear = () => { + onChange(undefined); + }; + + const onClick: MouseEventHandler = value + ? event => { + const menuTrigger = menuTriggerRef.current; + if (!menuTrigger) { + return; + } + menuTrigger.handleContextClick(event); + } + : startUpload; + + const onInputChange: ChangeEventHandler = event => { + const file = event.target.files && event.target.files[0]; + if (file) { + setProcessingFile(file); + } + }; + + let imageStatus: ImageStatus; + if (processingFile || (value && !objectUrl)) { + imageStatus = ImageStatus.Loading; + } else if (objectUrl) { + imageStatus = ImageStatus.HasImage; + } else { + imageStatus = ImageStatus.Nothing; + } + + const isLoading = imageStatus === ImageStatus.Loading; + + return ( + <> + + + + + + {i18n('AvatarInput--upload-photo-choice')} + + + {i18n('AvatarInput--remove-photo-choice')} + + + + + ); +}; + +async function processFile(file: File): Promise { + const { image } = await loadImage(file, { + canvas: true, + cover: true, + crop: true, + imageSmoothingQuality: 'medium', + maxHeight: 512, + maxWidth: 512, + minHeight: 2, + minWidth: 2, + // `imageSmoothingQuality` is not present in `loadImage`'s types, but it is + // documented and supported. Updating DefinitelyTyped is the long-term solution + // here. + } as LoadImageOptions); + + // NOTE: The types for `loadImage` say this can never be a canvas, but it will be if + // `canvas: true`, at least in our case. Again, updating DefinitelyTyped should + // address this. + if (!(image instanceof HTMLCanvasElement)) { + throw new Error('Loaded image was not a canvas'); + } + + return canvasToArrayBuffer(image); +} diff --git a/ts/components/Button.stories.tsx b/ts/components/Button.stories.tsx new file mode 100644 index 0000000000..b9efb228da --- /dev/null +++ b/ts/components/Button.stories.tsx @@ -0,0 +1,59 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { Button, ButtonVariant } from './Button'; + +const story = storiesOf('Components/Button', module); + +story.add('Kitchen sink', () => ( + <> +

+ +

+

+ +

+ +

+ +

+

+ +

+ +

+ +

+

+ +

+ +)); diff --git a/ts/components/Button.tsx b/ts/components/Button.tsx new file mode 100644 index 0000000000..229886996d --- /dev/null +++ b/ts/components/Button.tsx @@ -0,0 +1,71 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { MouseEventHandler, ReactNode } from 'react'; +import classNames from 'classnames'; + +import { assert } from '../util/assert'; + +export enum ButtonVariant { + Primary, + Secondary, + Destructive, +} + +type PropsType = { + children: ReactNode; + className?: string; + disabled?: boolean; + variant?: ButtonVariant; +} & ( + | { + onClick: MouseEventHandler; + } + | { + type: 'submit'; + } +); + +const VARIANT_CLASS_NAMES = new Map([ + [ButtonVariant.Primary, 'module-Button--primary'], + [ButtonVariant.Secondary, 'module-Button--secondary'], + [ButtonVariant.Destructive, 'module-Button--destructive'], +]); + +export const Button = React.forwardRef( + (props, ref) => { + const { + children, + className, + disabled = false, + variant = ButtonVariant.Primary, + } = props; + + let onClick: undefined | MouseEventHandler; + let type: 'button' | 'submit'; + if ('onClick' in props) { + ({ onClick } = props); + type = 'button'; + } else { + onClick = undefined; + ({ type } = props); + } + + const variantClassName = VARIANT_CLASS_NAMES.get(variant); + assert(variantClassName, ' + ); + } +); diff --git a/ts/components/CallingPip.tsx b/ts/components/CallingPip.tsx index c6a6f1e719..e8b54e0b25 100644 --- a/ts/components/CallingPip.tsx +++ b/ts/components/CallingPip.tsx @@ -86,7 +86,7 @@ export const CallingPip = ({ const [windowHeight, setWindowHeight] = React.useState(window.innerHeight); const [positionState, setPositionState] = React.useState({ mode: PositionMode.SnapToRight, - offsetY: 0, + offsetY: PIP_TOP_MARGIN, }); React.useEffect(() => { @@ -202,7 +202,7 @@ export const CallingPip = ({ return [ PIP_PADDING, Math.min( - PIP_TOP_MARGIN + positionState.offsetY, + positionState.offsetY, windowHeight - PIP_PADDING - PIP_HEIGHT ), ]; @@ -210,7 +210,7 @@ export const CallingPip = ({ return [ windowWidth - PIP_PADDING - PIP_WIDTH, Math.min( - PIP_TOP_MARGIN + positionState.offsetY, + positionState.offsetY, windowHeight - PIP_PADDING - PIP_HEIGHT ), ]; diff --git a/ts/components/CallingPipRemoteVideo.tsx b/ts/components/CallingPipRemoteVideo.tsx index cd1e819207..495954a2b9 100644 --- a/ts/components/CallingPipRemoteVideo.tsx +++ b/ts/components/CallingPipRemoteVideo.tsx @@ -18,6 +18,7 @@ import { import { SetRendererCanvasType } from '../state/ducks/calling'; import { useGetCallingFrameBuffer } from '../calling/useGetCallingFrameBuffer'; import { usePageVisibility } from '../util/hooks'; +import { missingCaseError } from '../util/missingCaseError'; import { nonRenderedRemoteParticipant } from '../util/ringrtc/nonRenderedRemoteParticipant'; // This value should be kept in sync with the hard-coded CSS height. @@ -131,42 +132,39 @@ export const CallingPipRemoteVideo = ({ setGroupCallVideoRequest, ]); - if (activeCall.callMode === CallMode.Direct) { - const { hasRemoteVideo } = activeCall.remoteParticipants[0]; - - if (!hasRemoteVideo) { - return ; + switch (activeCall.callMode) { + case CallMode.Direct: { + const { hasRemoteVideo } = activeCall.remoteParticipants[0]; + if (!hasRemoteVideo) { + return ; + } + return ( +
+ +
+ ); } - - return ( -
- -
- ); + case CallMode.Group: + if (!activeGroupCallSpeaker) { + return ; + } + return ( +
+ +
+ ); + default: + throw missingCaseError(activeCall); } - - if (activeCall.callMode === CallMode.Group) { - if (!activeGroupCallSpeaker) { - return ; - } - - return ( -
- -
- ); - } - - throw new Error('CallingRemoteVideo: Unknown Call Mode'); }; diff --git a/ts/components/ContactPill.tsx b/ts/components/ContactPill.tsx new file mode 100644 index 0000000000..beb53d9599 --- /dev/null +++ b/ts/components/ContactPill.tsx @@ -0,0 +1,74 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { FunctionComponent } from 'react'; + +import { ColorType } from '../types/Colors'; +import { LocalizerType } from '../types/Util'; +import { ContactName } from './conversation/ContactName'; +import { Avatar, AvatarSize } from './Avatar'; + +export type PropsType = { + avatarPath?: string; + color?: ColorType; + firstName?: string; + i18n: LocalizerType; + id: string; + isMe?: boolean; + name?: string; + onClickRemove: (id: string) => void; + phoneNumber?: string; + profileName?: string; + title: string; +}; + +export const ContactPill: FunctionComponent = ({ + avatarPath, + color, + firstName, + i18n, + id, + name, + phoneNumber, + profileName, + title, + onClickRemove, +}) => { + const removeLabel = i18n('ContactPill--remove'); + + return ( +
+ + +
+ ); +}; diff --git a/ts/components/ContactPills.stories.tsx b/ts/components/ContactPills.stories.tsx new file mode 100644 index 0000000000..c486ef8f03 --- /dev/null +++ b/ts/components/ContactPills.stories.tsx @@ -0,0 +1,87 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { times } from 'lodash'; + +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 { ContactPills } from './ContactPills'; +import { ContactPill, PropsType as ContactPillPropsType } from './ContactPill'; +import { gifUrl } from '../storybook/Fixtures'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf('Components/Contact Pills', module); + +type ContactType = Omit; + +const contacts: Array = times(50, index => ({ + color: 'red', + id: `contact-${index}`, + isMe: false, + name: `Contact ${index}`, + phoneNumber: '(202) 555-0001', + profileName: `C${index}`, + title: `Contact ${index}`, +})); + +const contactPillProps = ( + overrideProps?: ContactType +): ContactPillPropsType => ({ + ...(overrideProps || { + avatarPath: gifUrl, + color: 'red', + firstName: 'John', + id: 'abc123', + isMe: false, + name: 'John Bon Bon Jovi', + phoneNumber: '(202) 555-0001', + profileName: 'JohnB', + title: 'John Bon Bon Jovi', + }), + i18n, + onClickRemove: action('onClickRemove'), +}); + +story.add('Empty list', () => ); + +story.add('One contact', () => ( + + + +)); + +story.add('Three contacts', () => ( + + + + + +)); + +story.add('Four contacts, one with a long name', () => ( + + + + + + +)); + +story.add('Fifty contacts', () => ( + + {contacts.map(contact => ( + + ))} + +)); diff --git a/ts/components/ContactPills.tsx b/ts/components/ContactPills.tsx new file mode 100644 index 0000000000..47577abd0c --- /dev/null +++ b/ts/components/ContactPills.tsx @@ -0,0 +1,38 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { + useRef, + useEffect, + Children, + FunctionComponent, + ReactNode, +} from 'react'; + +import { usePrevious } from '../util/hooks'; +import { scrollToBottom } from '../util/scrollToBottom'; + +type PropsType = { + children?: ReactNode; +}; + +export const ContactPills: FunctionComponent = ({ children }) => { + const elRef = useRef(null); + + const childCount = Children.count(children); + const previousChildCount = usePrevious(0, childCount); + + useEffect(() => { + const hasAddedNewChild = childCount > previousChildCount; + const el = elRef.current; + if (hasAddedNewChild && el) { + scrollToBottom(el); + } + }, [childCount, previousChildCount]); + + return ( +
+ {children} +
+ ); +}; diff --git a/ts/components/ConversationList.stories.tsx b/ts/components/ConversationList.stories.tsx new file mode 100644 index 0000000000..b523f836fc --- /dev/null +++ b/ts/components/ConversationList.stories.tsx @@ -0,0 +1,554 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { times, omit } from 'lodash'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { boolean, date, select, text } from '@storybook/addon-knobs'; + +import { ConversationList, PropsType, RowType, Row } from './ConversationList'; +import { MessageSearchResult } from './conversationList/MessageSearchResult'; +import { + PropsData as ConversationListItemPropsType, + MessageStatuses, +} from './conversationList/ConversationListItem'; +import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox'; +import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf('Components/ConversationList', module); + +const defaultConversations: Array = [ + { + id: 'fred-convo', + isSelected: false, + lastUpdated: Date.now(), + markedUnread: false, + title: 'Fred Willard', + type: 'direct', + }, + { + id: 'marc-convo', + isSelected: true, + lastUpdated: Date.now(), + markedUnread: false, + unreadCount: 12, + title: 'Marc Barraca', + type: 'direct', + }, + { + id: 'long-name-convo', + isSelected: false, + lastUpdated: Date.now(), + markedUnread: false, + title: + 'Pablo Diego José Francisco de Paula Juan Nepomuceno María de los Remedios Cipriano de la Santísima Trinidad Ruiz y Picasso', + type: 'direct', + }, + getDefaultConversation(), +]; + +const createProps = (rows: ReadonlyArray): PropsType => ({ + dimensions: { + width: 300, + height: 350, + }, + rowCount: rows.length, + getRow: (index: number) => rows[index], + shouldRecomputeRowHeights: false, + i18n, + onSelectConversation: action('onSelectConversation'), + onClickArchiveButton: action('onClickArchiveButton'), + onClickContactCheckbox: action('onClickContactCheckbox'), + renderMessageSearchResult: (id: string, style: React.CSSProperties) => ( + + ), + showChooseGroupMembers: action('showChooseGroupMembers'), + startNewConversationFromPhoneNumber: action( + 'startNewConversationFromPhoneNumber' + ), +}); + +story.add('Archive button', () => ( + +)); + +story.add('Contact: note to self', () => ( + +)); + +story.add('Contact: direct', () => ( + +)); + +story.add('Contact: direct with short about', () => ( + +)); + +story.add('Contact: direct with long about', () => ( + +)); + +story.add('Contact: group', () => ( + +)); + +story.add('Contact checkboxes', () => ( + +)); + +story.add('Contact checkboxes: disabled', () => ( + +)); + +{ + const createConversation = ( + overrideProps: Partial = {} + ): ConversationListItemPropsType => ({ + ...overrideProps, + acceptedMessageRequest: boolean( + 'acceptedMessageRequest', + overrideProps.acceptedMessageRequest !== undefined + ? overrideProps.acceptedMessageRequest + : true + ), + isMe: boolean('isMe', overrideProps.isMe || false), + avatarPath: text('avatarPath', overrideProps.avatarPath || ''), + id: overrideProps.id || '', + isSelected: boolean('isSelected', overrideProps.isSelected || false), + title: text('title', overrideProps.title || 'Some Person'), + name: overrideProps.name || 'Some Person', + type: overrideProps.type || 'direct', + markedUnread: boolean('markedUnread', overrideProps.markedUnread || false), + lastMessage: overrideProps.lastMessage || { + text: text('lastMessage.text', 'Hi there!'), + status: select( + 'status', + MessageStatuses.reduce((m, s) => ({ ...m, [s]: s }), {}), + 'read' + ), + }, + lastUpdated: date( + 'lastUpdated', + new Date(overrideProps.lastUpdated || Date.now() - 5 * 60 * 1000) + ), + }); + + const renderConversation = ( + overrideProps: Partial = {} + ) => ( + + ); + + story.add('Conversation: name', () => renderConversation()); + + story.add('Conversation: name and avatar', () => + renderConversation({ + avatarPath: '/fixtures/kitten-1-64-64.jpg', + }) + ); + + story.add('Conversation: with yourself', () => + renderConversation({ + lastMessage: { + text: 'Just a second', + status: 'read', + }, + name: 'Myself', + title: 'Myself', + isMe: true, + }) + ); + + story.add('Conversations: Message Statuses', () => ( + ({ + type: RowType.Conversation, + conversation: createConversation({ + lastMessage: { text: status, status }, + }), + })) + )} + /> + )); + + story.add('Conversation: Typing Status', () => + renderConversation({ + typingContact: { + name: 'Someone Here', + }, + }) + ); + + story.add('Conversation: With draft', () => + renderConversation({ + shouldShowDraft: true, + draftPreview: "I'm in the middle of typing this...", + }) + ); + + story.add('Conversation: Deleted for everyone', () => + renderConversation({ + lastMessage: { + status: 'sent', + text: 'You should not see this!', + deletedForEveryone: true, + }, + }) + ); + + story.add('Conversation: Message Request', () => + renderConversation({ + acceptedMessageRequest: false, + lastMessage: { + text: 'A Message', + status: 'delivered', + }, + }) + ); + + story.add('Conversations: unread count', () => ( + ({ + type: RowType.Conversation, + conversation: createConversation({ + lastMessage: { text: 'Hey there!', status: 'delivered' }, + unreadCount, + }), + })) + )} + /> + )); + + story.add('Conversation: marked unread', () => + renderConversation({ markedUnread: true }) + ); + + story.add('Conversation: Selected', () => + renderConversation({ + lastMessage: { + text: 'Hey there!', + status: 'read', + }, + isSelected: true, + }) + ); + + story.add('Conversation: Emoji in Message', () => + renderConversation({ + lastMessage: { + text: '🔥', + status: 'read', + }, + }) + ); + + story.add('Conversation: Link in Message', () => + renderConversation({ + lastMessage: { + text: 'Download at http://signal.org', + status: 'read', + }, + }) + ); + + story.add('Conversation: long name', () => { + const name = + 'Long contact name. Esquire. The third. And stuff. And more! And more!'; + + return renderConversation({ + name, + title: name, + }); + }); + + story.add('Conversation: Long Message', () => { + const messages = [ + "Long line. This is a really really really long line. Really really long. Because that's just how it is", + `Many lines. This is a many-line message. +Line 2 is really exciting but it shouldn't be seen. +Line three is even better. +Line 4, well.`, + ]; + + return ( + ({ + type: RowType.Conversation, + conversation: createConversation({ + lastMessage: { + text: messageText, + status: 'read', + }, + }), + })) + )} + /> + ); + }); + + story.add('Conversations: Various Times', () => { + const pairs: Array<[number, string]> = [ + [Date.now() - 5 * 60 * 60 * 1000, 'Five hours ago'], + [Date.now() - 24 * 60 * 60 * 1000, 'One day ago'], + [Date.now() - 7 * 24 * 60 * 60 * 1000, 'One week ago'], + [Date.now() - 365 * 24 * 60 * 60 * 1000, 'One year ago'], + ]; + + return ( + ({ + type: RowType.Conversation, + conversation: createConversation({ + lastUpdated, + lastMessage: { + text: messageText, + status: 'read', + }, + }), + })) + )} + /> + ); + }); + + story.add('Conversation: Missing Date', () => { + const row = { + type: RowType.Conversation as const, + conversation: omit(createConversation(), 'lastUpdated'), + }; + + return ; + }); + + story.add('Conversation: Missing Message', () => { + const row = { + type: RowType.Conversation as const, + conversation: omit(createConversation(), 'lastMessage'), + }; + + return ; + }); + + story.add('Conversation: Missing Text', () => + renderConversation({ + lastMessage: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + text: undefined as any, + status: 'sent', + }, + }) + ); + + story.add('Conversation: Muted Conversation', () => + renderConversation({ + muteExpiresAt: Date.now() + 1000 * 60 * 60, + }) + ); + + story.add('Conversation: At Mention', () => + renderConversation({ + title: 'The Rebellion', + type: 'group', + lastMessage: { + text: '@Leia Organa I know', + status: 'read', + }, + }) + ); +} + +story.add('Headers', () => ( + +)); + +story.add('Start new conversation', () => ( + +)); + +story.add('Search results loading skeleton', () => ( + ({ + type: RowType.SearchResultsLoadingFakeRow as const, + })), + ])} + /> +)); + +story.add('Kitchen sink', () => ( + +)); diff --git a/ts/components/ConversationList.tsx b/ts/components/ConversationList.tsx new file mode 100644 index 0000000000..09acd8f14b --- /dev/null +++ b/ts/components/ConversationList.tsx @@ -0,0 +1,327 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useRef, useEffect, useCallback, CSSProperties } from 'react'; +import { List, ListRowRenderer } from 'react-virtualized'; +import classNames from 'classnames'; + +import { missingCaseError } from '../util/missingCaseError'; +import { assert } from '../util/assert'; +import { LocalizerType, ScrollBehavior } from '../types/Util'; + +import { + ConversationListItem, + PropsData as ConversationListItemPropsType, +} from './conversationList/ConversationListItem'; +import { + ContactListItem, + PropsDataType as ContactListItemPropsType, +} from './conversationList/ContactListItem'; +import { + ContactCheckbox as ContactCheckboxComponent, + ContactCheckboxDisabledReason, +} from './conversationList/ContactCheckbox'; +import { CreateNewGroupButton } from './conversationList/CreateNewGroupButton'; +import { StartNewConversation as StartNewConversationComponent } from './conversationList/StartNewConversation'; +import { SearchResultsLoadingFakeHeader as SearchResultsLoadingFakeHeaderComponent } from './conversationList/SearchResultsLoadingFakeHeader'; +import { SearchResultsLoadingFakeRow as SearchResultsLoadingFakeRowComponent } from './conversationList/SearchResultsLoadingFakeRow'; + +export enum RowType { + ArchiveButton, + Blank, + Contact, + ContactCheckbox, + Conversation, + CreateNewGroup, + Header, + MessageSearchResult, + SearchResultsLoadingFakeHeader, + SearchResultsLoadingFakeRow, + StartNewConversation, +} + +type ArchiveButtonRowType = { + type: RowType.ArchiveButton; + archivedConversationsCount: number; +}; + +type BlankRowType = { type: RowType.Blank }; + +type ContactRowType = { + type: RowType.Contact; + contact: ContactListItemPropsType; + isClickable?: boolean; +}; + +type ContactCheckboxRowType = { + type: RowType.ContactCheckbox; + contact: ContactListItemPropsType; + isChecked: boolean; + disabledReason?: ContactCheckboxDisabledReason; +}; + +type ConversationRowType = { + type: RowType.Conversation; + conversation: ConversationListItemPropsType; +}; + +type CreateNewGroupRowType = { + type: RowType.CreateNewGroup; +}; + +type MessageRowType = { + type: RowType.MessageSearchResult; + messageId: string; +}; + +type HeaderRowType = { + type: RowType.Header; + i18nKey: string; +}; + +type SearchResultsLoadingFakeHeaderType = { + type: RowType.SearchResultsLoadingFakeHeader; +}; + +type SearchResultsLoadingFakeRowType = { + type: RowType.SearchResultsLoadingFakeRow; +}; + +type StartNewConversationRowType = { + type: RowType.StartNewConversation; + phoneNumber: string; +}; + +export type Row = + | ArchiveButtonRowType + | BlankRowType + | ContactRowType + | ContactCheckboxRowType + | ConversationRowType + | CreateNewGroupRowType + | MessageRowType + | HeaderRowType + | SearchResultsLoadingFakeHeaderType + | SearchResultsLoadingFakeRowType + | StartNewConversationRowType; + +export type PropsType = { + dimensions?: { + width: number; + height: number; + }; + rowCount: number; + // If `getRow` is called with an invalid index, it should return `undefined`. However, + // this should only happen if there is a bug somewhere. For example, an inaccurate + // `rowCount`. + getRow: (index: number) => undefined | Row; + scrollBehavior?: ScrollBehavior; + scrollToRowIndex?: number; + shouldRecomputeRowHeights: boolean; + scrollable?: boolean; + + i18n: LocalizerType; + + onClickArchiveButton: () => void; + onClickContactCheckbox: ( + conversationId: string, + disabledReason: undefined | ContactCheckboxDisabledReason + ) => void; + onSelectConversation: (conversationId: string, messageId?: string) => void; + renderMessageSearchResult: (id: string, style: CSSProperties) => JSX.Element; + showChooseGroupMembers: () => void; + startNewConversationFromPhoneNumber: (e164: string) => void; +}; + +const NORMAL_ROW_HEIGHT = 68; +const HEADER_ROW_HEIGHT = 40; + +export const ConversationList: React.FC = ({ + dimensions, + getRow, + i18n, + onClickArchiveButton, + onClickContactCheckbox, + onSelectConversation, + renderMessageSearchResult, + rowCount, + scrollBehavior = ScrollBehavior.Default, + scrollToRowIndex, + scrollable = true, + shouldRecomputeRowHeights, + showChooseGroupMembers, + startNewConversationFromPhoneNumber, +}) => { + const listRef = useRef(null); + + useEffect(() => { + const list = listRef.current; + if (shouldRecomputeRowHeights && list) { + list.recomputeRowHeights(); + } + }, [shouldRecomputeRowHeights]); + + const calculateRowHeight = useCallback( + ({ index }: { index: number }): number => { + const row = getRow(index); + if (!row) { + assert(false, `Expected a row at index ${index}`); + return NORMAL_ROW_HEIGHT; + } + switch (row.type) { + case RowType.Header: + case RowType.SearchResultsLoadingFakeHeader: + return HEADER_ROW_HEIGHT; + default: + return NORMAL_ROW_HEIGHT; + } + }, + [getRow] + ); + + const renderRow: ListRowRenderer = useCallback( + ({ key, index, style }) => { + const row = getRow(index); + if (!row) { + assert(false, `Expected a row at index ${index}`); + return
; + } + + switch (row.type) { + case RowType.ArchiveButton: + return ( + + ); + case RowType.Blank: + return
; + case RowType.Contact: { + const { isClickable = true } = row; + return ( + + ); + } + case RowType.ContactCheckbox: + return ( + + ); + case RowType.Conversation: + return ( + + ); + case RowType.CreateNewGroup: + return ( + + ); + case RowType.Header: + return ( +
+ {i18n(row.i18nKey)} +
+ ); + case RowType.MessageSearchResult: + return ( + + {renderMessageSearchResult(row.messageId, style)} + + ); + case RowType.SearchResultsLoadingFakeHeader: + return ( + + ); + case RowType.SearchResultsLoadingFakeRow: + return ( + + ); + case RowType.StartNewConversation: + return ( + { + startNewConversationFromPhoneNumber(row.phoneNumber); + }} + style={style} + /> + ); + default: + throw missingCaseError(row); + } + }, + [ + getRow, + i18n, + onClickArchiveButton, + onClickContactCheckbox, + onSelectConversation, + renderMessageSearchResult, + showChooseGroupMembers, + startNewConversationFromPhoneNumber, + ] + ); + + // Though `width` and `height` are required properties, we want to be careful in case + // the caller sends bogus data. Notably, react-measure's types seem to be inaccurate. + const { width = 0, height = 0 } = dimensions || {}; + if (!width || !height) { + return null; + } + + return ( + + ); +}; diff --git a/ts/components/ConversationListItem.stories.tsx b/ts/components/ConversationListItem.stories.tsx deleted file mode 100644 index cf98aa045a..0000000000 --- a/ts/components/ConversationListItem.stories.tsx +++ /dev/null @@ -1,304 +0,0 @@ -// 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, date, select, text } from '@storybook/addon-knobs'; - -import { - ConversationListItem, - MessageStatuses, - Props, -} from './ConversationListItem'; -import { setup as setupI18n } from '../../js/modules/i18n'; -import enMessages from '../../_locales/en/messages.json'; - -const i18n = setupI18n('en', enMessages); - -const story = storiesOf('Components/ConversationListItem', module); - -story.addDecorator(storyFn => ( -
{storyFn()}
-)); - -const createProps = (overrideProps: Partial = {}): Props => ({ - ...overrideProps, - i18n, - acceptedMessageRequest: boolean( - 'acceptedMessageRequest', - overrideProps.acceptedMessageRequest !== undefined - ? overrideProps.acceptedMessageRequest - : true - ), - isMe: boolean('isMe', overrideProps.isMe || false), - avatarPath: text('avatarPath', overrideProps.avatarPath || ''), - id: overrideProps.id || '', - isSelected: boolean('isSelected', overrideProps.isSelected || false), - title: text('title', overrideProps.title || 'Some Person'), - name: overrideProps.name || 'Some Person', - type: overrideProps.type || 'direct', - onClick: action('onClick'), - markedUnread: boolean('markedUnread', overrideProps.markedUnread || false), - lastMessage: overrideProps.lastMessage || { - text: text('lastMessage.text', 'Hi there!'), - status: select( - 'status', - MessageStatuses.reduce((m, s) => ({ ...m, [s]: s }), {}), - 'read' - ), - }, - lastUpdated: date( - 'lastUpdated', - new Date(overrideProps.lastUpdated || Date.now() - 5 * 60 * 1000) - ), -}); - -story.add('Name', () => { - const props = createProps(); - - return ; -}); - -story.add('Name and Avatar', () => { - const props = createProps({ - avatarPath: '/fixtures/kitten-1-64-64.jpg', - }); - - return ; -}); - -story.add('Conversation with Yourself', () => { - const props = createProps({ - lastMessage: { - text: 'Just a second', - status: 'read', - }, - name: 'Myself', - title: 'Myself', - isMe: true, - }); - - return ; -}); - -story.add('Message Statuses', () => { - return MessageStatuses.map(status => { - const props = createProps({ - lastMessage: { - text: status, - status, - }, - }); - - return ; - }); -}); - -story.add('Typing Status', () => { - const props = createProps({ - typingContact: { - name: 'Someone Here', - }, - }); - - return ; -}); - -story.add('With draft', () => { - const props = createProps({ - shouldShowDraft: true, - draftPreview: "I'm in the middle of typing this...", - }); - - return ; -}); - -story.add('Deleted for everyone', () => { - const props = createProps({ - lastMessage: { - status: 'sent', - text: 'You should not see this!', - deletedForEveryone: true, - }, - }); - - return ; -}); - -story.add('Message Request', () => { - const props = createProps({ - acceptedMessageRequest: false, - lastMessage: { - text: 'A Message', - status: 'delivered', - }, - }); - - return ; -}); - -story.add('Unread', () => { - const counts = [4, 10, 250]; - const defaultProps = createProps({ - lastMessage: { - text: 'Hey there!', - status: 'delivered', - }, - }); - - const items = counts.map(unreadCount => { - const props = { - ...defaultProps, - unreadCount, - }; - - return ; - }); - - const markedUnreadProps = { - ...defaultProps, - markedUnread: true, - }; - - const markedUnreadItem = [ - , - ]; - - return [...items, ...markedUnreadItem]; -}); - -story.add('Selected', () => { - const props = createProps({ - lastMessage: { - text: 'Hey there!', - status: 'read', - }, - isSelected: true, - }); - - return ; -}); - -story.add('Emoji in Message', () => { - const props = createProps({ - lastMessage: { - text: '🔥', - status: 'read', - }, - }); - - return ; -}); - -story.add('Link in Message', () => { - const props = createProps({ - lastMessage: { - text: 'Download at http://signal.org', - status: 'read', - }, - }); - - return ; -}); - -story.add('Long Name', () => { - const name = - 'Long contact name. Esquire. The third. And stuff. And more! And more!'; - - const props = createProps({ - name, - title: name, - }); - - return ; -}); - -story.add('Long Message', () => { - const messages = [ - "Long line. This is a really really really long line. Really really long. Because that's just how it is", - `Many lines. This is a many-line message. -Line 2 is really exciting but it shouldn't be seen. -Line three is even better. -Line 4, well.`, - ]; - - return messages.map(message => { - const props = createProps({ - lastMessage: { - text: message, - status: 'read', - }, - }); - - return ; - }); -}); - -story.add('Various Times', () => { - const times: Array<[number, string]> = [ - [Date.now() - 5 * 60 * 60 * 1000, 'Five hours ago'], - [Date.now() - 24 * 60 * 60 * 1000, 'One day ago'], - [Date.now() - 7 * 24 * 60 * 60 * 1000, 'One week ago'], - [Date.now() - 365 * 24 * 60 * 60 * 1000, 'One year ago'], - ]; - - return times.map(([lastUpdated, messageText]) => { - const props = createProps({ - lastUpdated, - lastMessage: { - text: messageText, - status: 'read', - }, - }); - - return ; - }); -}); - -story.add('Missing Date', () => { - const props = createProps(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return ; -}); - -story.add('Missing Message', () => { - const props = createProps(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return ; -}); - -story.add('Missing Text', () => { - const props = createProps(); - - return ( - - ); -}); - -story.add('Muted Conversation', () => { - const props = createProps(); - const muteExpiresAt = Date.now() + 1000 * 60 * 60; - - return ; -}); - -story.add('At Mention', () => { - const props = createProps({ - title: 'The Rebellion', - type: 'group', - lastMessage: { - text: '@Leia Organa I know', - status: 'read', - }, - }); - - return ; -}); diff --git a/ts/components/ConversationListItem.tsx b/ts/components/ConversationListItem.tsx deleted file mode 100644 index 99368e7ecf..0000000000 --- a/ts/components/ConversationListItem.tsx +++ /dev/null @@ -1,283 +0,0 @@ -// Copyright 2018-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React, { CSSProperties } from 'react'; -import classNames from 'classnames'; -import { isNumber } from 'lodash'; - -import { Avatar } from './Avatar'; -import { MessageBody } from './conversation/MessageBody'; -import { Timestamp } from './conversation/Timestamp'; -import { ContactName } from './conversation/ContactName'; -import { TypingAnimation } from './conversation/TypingAnimation'; -import { cleanId } from './_util'; - -import { LocalizerType } from '../types/Util'; -import { ColorType } from '../types/Colors'; - -export const MessageStatuses = [ - 'sending', - 'sent', - 'delivered', - 'read', - 'error', - 'partial-sent', -] as const; - -export type MessageStatusType = typeof MessageStatuses[number]; - -export type PropsData = { - id: string; - phoneNumber?: string; - color?: ColorType; - profileName?: string; - title: string; - name?: string; - type: 'group' | 'direct'; - avatarPath?: string; - isMe?: boolean; - muteExpiresAt?: number; - - lastUpdated?: number; - unreadCount?: number; - markedUnread?: boolean; - isSelected?: boolean; - - acceptedMessageRequest?: boolean; - draftPreview?: string; - shouldShowDraft?: boolean; - - typingContact?: unknown; - lastMessage?: { - status: MessageStatusType; - text: string; - deletedForEveryone?: boolean; - }; - isPinned?: boolean; -}; - -type PropsHousekeeping = { - i18n: LocalizerType; - style?: CSSProperties; - onClick?: (id: string) => void; -}; - -export type Props = PropsData & PropsHousekeeping; - -export class ConversationListItem extends React.PureComponent { - public renderAvatar(): JSX.Element { - const { - avatarPath, - color, - type, - i18n, - isMe, - name, - phoneNumber, - profileName, - title, - } = this.props; - - return ( -
- - {this.renderUnread()} -
- ); - } - - isUnread(): boolean { - const { markedUnread, unreadCount } = this.props; - - return Boolean((isNumber(unreadCount) && unreadCount > 0) || markedUnread); - } - - public renderUnread(): JSX.Element | null { - const { unreadCount } = this.props; - - if (this.isUnread()) { - return ( -
- {unreadCount || ''} -
- ); - } - - return null; - } - - public renderHeader(): JSX.Element { - const { - i18n, - isMe, - lastUpdated, - name, - phoneNumber, - profileName, - title, - } = this.props; - - return ( -
-
- {isMe ? ( - i18n('noteToSelf') - ) : ( - - )} -
-
- -
-
- ); - } - - public renderMessage(): JSX.Element | null { - const { - draftPreview, - i18n, - acceptedMessageRequest, - lastMessage, - muteExpiresAt, - shouldShowDraft, - typingContact, - } = this.props; - if (!lastMessage && !typingContact) { - return null; - } - - const messageBody = lastMessage ? lastMessage.text : ''; - const showingDraft = shouldShowDraft && draftPreview; - const deletedForEveryone = Boolean( - lastMessage && lastMessage.deletedForEveryone - ); - - /* eslint-disable no-nested-ternary */ - return ( -
-
- {muteExpiresAt && Date.now() < muteExpiresAt && ( - - )} - {!acceptedMessageRequest ? ( - - {i18n('ConversationListItem--message-request')} - - ) : typingContact ? ( - - ) : ( - <> - {showingDraft ? ( - <> - - {i18n('ConversationListItem--draft-prefix')} - - - - ) : deletedForEveryone ? ( - - {i18n('message--deletedForEveryone')} - - ) : ( - - )} - - )} -
- {!showingDraft && lastMessage && lastMessage.status ? ( -
- ) : null} -
- ); - } - /* eslint-enable no-nested-ternary */ - - public render(): JSX.Element { - const { id, isSelected, onClick, style } = this.props; - - return ( - - ); - } -} diff --git a/ts/components/GlobalAudioContext.tsx b/ts/components/GlobalAudioContext.tsx new file mode 100644 index 0000000000..efe4ab1e7a --- /dev/null +++ b/ts/components/GlobalAudioContext.tsx @@ -0,0 +1,57 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import LRU from 'lru-cache'; + +import { WaveformCache } from '../types/Audio'; + +const MAX_WAVEFORM_COUNT = 1000; + +type Contents = { + audio: HTMLAudioElement; + audioContext: AudioContext; + waveformCache: WaveformCache; +}; + +// This context's value is effectively global. This is not ideal but is necessary because +// the app has multiple React roots. In the future, we should use a single React root +// and instantiate these inside of `GlobalAudioProvider`. (We may wish to keep +// `audioContext` global, however, as the browser limits the number that can be +// created.) +const globalContents: Contents = { + audio: new Audio(), + audioContext: new AudioContext(), + waveformCache: new LRU({ + max: MAX_WAVEFORM_COUNT, + }), +}; + +export const GlobalAudioContext = React.createContext(globalContents); + +export type GlobalAudioProps = { + conversationId: string; + children?: React.ReactNode | React.ReactChildren; +}; + +/** + * A global context that holds Audio, AudioContext, LRU instances that are used + * inside the conversation by ts/components/conversation/MessageAudio.tsx + */ +export const GlobalAudioProvider: React.FC = ({ + conversationId, + children, +}) => { + // When moving between conversations - stop audio + React.useEffect(() => { + return () => { + globalContents.audio.pause(); + }; + }, [conversationId]); + + return ( + + {children} + + ); +}; diff --git a/ts/components/GroupDialog.tsx b/ts/components/GroupDialog.tsx new file mode 100644 index 0000000000..f31e92adec --- /dev/null +++ b/ts/components/GroupDialog.tsx @@ -0,0 +1,120 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { ReactChild, ReactNode } from 'react'; + +import { LocalizerType } from '../types/Util'; +import { ConversationType } from '../state/ducks/conversations'; +import { ModalHost } from './ModalHost'; +import { Button, ButtonVariant } from './Button'; +import { Avatar, AvatarSize } from './Avatar'; +import { ContactName } from './conversation/ContactName'; + +type PropsType = { + children: ReactNode; + i18n: LocalizerType; + onClickPrimaryButton: () => void; + onClose: () => void; + primaryButtonText: string; + title: string; +} & ( + | // We use this empty type for an "all or nothing" setup. + // eslint-disable-next-line @typescript-eslint/ban-types + {} + | { + onClickSecondaryButton: () => void; + secondaryButtonText: string; + } +); + +export function GroupDialog(props: Readonly): JSX.Element { + const { + children, + i18n, + onClickPrimaryButton, + onClose, + primaryButtonText, + title, + } = props; + + let secondaryButton: undefined | ReactChild; + if ('secondaryButtonText' in props) { + const { onClickSecondaryButton, secondaryButtonText } = props; + secondaryButton = ( + + ); + } + + return ( + +
+ +
+
+ + ); +} + +type ParagraphPropsType = { + children: ReactNode; +}; + +GroupDialog.Paragraph = ({ + children, +}: Readonly): JSX.Element => ( +

{children}

+); + +type ContactsPropsType = { + contacts: Array; + i18n: LocalizerType; +}; + +GroupDialog.Contacts = ({ contacts, i18n }: Readonly) => ( +
    + {contacts.map(contact => ( +
  • + + +
  • + ))} +
+); + +function focusRef(el: HTMLElement | null) { + if (el) { + el.focus(); + } +} diff --git a/ts/components/GroupTitleInput.stories.tsx b/ts/components/GroupTitleInput.stories.tsx new file mode 100644 index 0000000000..5ee5cbe66d --- /dev/null +++ b/ts/components/GroupTitleInput.stories.tsx @@ -0,0 +1,44 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useState } from 'react'; + +import { storiesOf } from '@storybook/react'; + +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +import { GroupTitleInput } from './GroupTitleInput'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf('Components/GroupTitleInput', module); + +const Wrapper = ({ + disabled, + startingValue = '', +}: { + disabled?: boolean; + startingValue?: string; +}) => { + const [value, setValue] = useState(startingValue); + + return ( + + ); +}; + +story.add('Default', () => ); + +story.add('Disabled', () => ( + <> + +
+ + +)); diff --git a/ts/components/GroupTitleInput.tsx b/ts/components/GroupTitleInput.tsx new file mode 100644 index 0000000000..d4df24fcc6 --- /dev/null +++ b/ts/components/GroupTitleInput.tsx @@ -0,0 +1,100 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { forwardRef, useRef, ClipboardEvent } from 'react'; + +import { LocalizerType } from '../types/Util'; +import { multiRef } from '../util/multiRef'; +import * as grapheme from '../util/grapheme'; + +const MAX_GRAPHEME_COUNT = 32; + +type PropsType = { + disabled?: boolean; + i18n: LocalizerType; + onChangeValue: (value: string) => void; + value: string; +}; + +/** + * Group titles must have fewer than MAX_GRAPHEME_COUNT glyphs. Ideally, we'd use the + * `maxLength` property on inputs, but that doesn't account for glyphs that are more than + * one UTF-16 code units. For example: `'💩💩'.length === 4`. + * + * This component effectively implements a "max grapheme length" on an input. + * + * At a high level, this component handles two methods of input: + * + * - `onChange`. *Before* the value is changed (in `onKeyDown`), we save the value and the + * cursor position. Then, in `onChange`, we see if the new value is too long. If it is, + * we revert the value and selection. Otherwise, we fire `onChangeValue`. + * + * - `onPaste`. If you're pasting something that will fit, we fall back to normal browser + * behavior, which calls `onChange`. If you're pasting something that won't fit, it's a + * noop. + */ +export const GroupTitleInput = forwardRef( + ({ i18n, disabled = false, onChangeValue, value }, ref) => { + const innerRef = useRef(null); + const valueOnKeydownRef = useRef(value); + const selectionStartOnKeydownRef = useRef(value.length); + + return ( + { + const inputEl = innerRef.current; + if (!inputEl) { + return; + } + + valueOnKeydownRef.current = inputEl.value; + selectionStartOnKeydownRef.current = inputEl.selectionStart || 0; + }} + onChange={() => { + const inputEl = innerRef.current; + if (!inputEl) { + return; + } + + const newValue = inputEl.value; + if (grapheme.count(newValue) <= MAX_GRAPHEME_COUNT) { + onChangeValue(newValue); + } else { + inputEl.value = valueOnKeydownRef.current; + inputEl.selectionStart = selectionStartOnKeydownRef.current; + inputEl.selectionEnd = selectionStartOnKeydownRef.current; + } + }} + onPaste={(event: ClipboardEvent) => { + const inputEl = innerRef.current; + if (!inputEl) { + return; + } + + const selectionStart = inputEl.selectionStart || 0; + const selectionEnd = + inputEl.selectionEnd || inputEl.selectionStart || 0; + const textBeforeSelection = value.slice(0, selectionStart); + const textAfterSelection = value.slice(selectionEnd); + + const pastedText = event.clipboardData.getData('Text'); + + const newGraphemeCount = + grapheme.count(textBeforeSelection) + + grapheme.count(pastedText) + + grapheme.count(textAfterSelection); + + if (newGraphemeCount > MAX_GRAPHEME_COUNT) { + event.preventDefault(); + } + }} + placeholder={i18n('setGroupMetadata__group-name-placeholder')} + ref={multiRef(ref, innerRef)} + type="text" + value={value} + /> + ); + } +); diff --git a/ts/components/GroupV1MigrationDialog.tsx b/ts/components/GroupV1MigrationDialog.tsx index 6fd3aed1da..68f43336fd 100644 --- a/ts/components/GroupV1MigrationDialog.tsx +++ b/ts/components/GroupV1MigrationDialog.tsx @@ -2,10 +2,9 @@ // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; -import classNames from 'classnames'; import { LocalizerType } from '../types/Util'; import { ConversationType } from '../state/ducks/conversations'; -import { Avatar } from './Avatar'; +import { GroupDialog } from './GroupDialog'; import { sortByTitle } from '../util/sortByTitle'; type CallbackType = () => unknown; @@ -25,61 +24,64 @@ export type HousekeepingPropsType = { export type PropsType = DataPropsType & HousekeepingPropsType; -function focusRef(el: HTMLElement | null) { - if (el) { - el.focus(); - } -} +export const GroupV1MigrationDialog: React.FunctionComponent = React.memo( + (props: PropsType) => { + const { + areWeInvited, + droppedMembers, + hasMigrated, + i18n, + invitedMembers, + migrate, + onClose, + } = props; -export const GroupV1MigrationDialog = React.memo((props: PropsType) => { - const { - areWeInvited, - droppedMembers, - hasMigrated, - i18n, - invitedMembers, - migrate, - onClose, - } = props; + const title = hasMigrated + ? i18n('GroupV1--Migration--info--title') + : i18n('GroupV1--Migration--migrate--title'); + const keepHistory = hasMigrated + ? i18n('GroupV1--Migration--info--keep-history') + : i18n('GroupV1--Migration--migrate--keep-history'); + const migrationKey = hasMigrated ? 'after' : 'before'; + const droppedMembersKey = `GroupV1--Migration--info--removed--${migrationKey}`; - const title = hasMigrated - ? i18n('GroupV1--Migration--info--title') - : i18n('GroupV1--Migration--migrate--title'); - const keepHistory = hasMigrated - ? i18n('GroupV1--Migration--info--keep-history') - : i18n('GroupV1--Migration--migrate--keep-history'); - const migrationKey = hasMigrated ? 'after' : 'before'; - const droppedMembersKey = `GroupV1--Migration--info--removed--${migrationKey}`; + let primaryButtonText: string; + let onClickPrimaryButton: () => void; + let secondaryButtonProps: + | undefined + | { + secondaryButtonText: string; + onClickSecondaryButton: () => void; + }; + if (hasMigrated) { + primaryButtonText = i18n('Confirmation--confirm'); + onClickPrimaryButton = onClose; + } else { + primaryButtonText = i18n('GroupV1--Migration--migrate'); + onClickPrimaryButton = migrate; + secondaryButtonProps = { + secondaryButtonText: i18n('cancel'), + onClickSecondaryButton: onClose, + }; + } - return ( -
- -
+ ); } - - return ( -
- - -
- ); -} +); function renderMembers( members: Array, prefix: string, i18n: LocalizerType -): React.ReactElement | null { +): React.ReactNode { if (!members.length) { return null; } @@ -159,27 +110,9 @@ function renderMembers( const key = `${prefix}${postfix}`; return ( -
-
-
-
{i18n(key)}
- {sortByTitle(members).map(member => ( -
- {' '} - - {member.title} - -
- ))} -
-
+ <> + {i18n(key)} + + ); } diff --git a/ts/components/GroupV2JoinDialog.tsx b/ts/components/GroupV2JoinDialog.tsx index d89aa7ff48..8199e0c181 100644 --- a/ts/components/GroupV2JoinDialog.tsx +++ b/ts/components/GroupV2JoinDialog.tsx @@ -6,6 +6,7 @@ import classNames from 'classnames'; import { LocalizerType } from '../types/Util'; import { Avatar } from './Avatar'; import { Spinner } from './Spinner'; +import { Button, ButtonVariant } from './Button'; import { PreJoinConversationType } from '../state/ducks/conversations'; @@ -90,30 +91,30 @@ export const GroupV2JoinDialog = React.memo((props: PropsType) => {
{promptString}
- - +
); diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index 791a0b452d..1eaf1084a2 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -1,14 +1,14 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; import { action } from '@storybook/addon-actions'; -import { boolean, text } from '@storybook/addon-knobs'; import { storiesOf } from '@storybook/react'; -import { LeftPane, PropsType } from './LeftPane'; -import { PropsData } from './ConversationListItem'; +import { LeftPane, LeftPaneMode, PropsType } from './LeftPane'; +import { PropsData as ConversationListItemPropsType } from './conversationList/ConversationListItem'; +import { MessageSearchResult } from './conversationList/MessageSearchResult'; import { setup as setupI18n } from '../../js/modules/i18n'; import enMessages from '../../_locales/en/messages.json'; @@ -16,7 +16,7 @@ const i18n = setupI18n('en', enMessages); const story = storiesOf('Components/LeftPane', module); -const defaultConversations: Array = [ +const defaultConversations: Array = [ { id: 'fred-convo', isSelected: false, @@ -35,7 +35,7 @@ const defaultConversations: Array = [ }, ]; -const defaultArchivedConversations: Array = [ +const defaultArchivedConversations: Array = [ { id: 'michelle-archive-convo', isSelected: false, @@ -46,7 +46,7 @@ const defaultArchivedConversations: Array = [ }, ]; -const pinnedConversations: Array = [ +const pinnedConversations: Array = [ { id: 'philly-convo', isPinned: true, @@ -67,107 +67,326 @@ const pinnedConversations: Array = [ }, ]; +const defaultModeSpecificProps = { + mode: LeftPaneMode.Inbox as const, + pinnedConversations, + conversations: defaultConversations, + archivedConversations: defaultArchivedConversations, +}; + +const emptySearchResultsGroup = { isLoading: false, results: [] }; + const createProps = (overrideProps: Partial = {}): PropsType => ({ - archivedConversations: - overrideProps.archivedConversations || defaultArchivedConversations, - conversations: overrideProps.conversations || defaultConversations, + cantAddContactToGroup: action('cantAddContactToGroup'), + clearGroupCreationError: action('clearGroupCreationError'), + closeCantAddContactToGroupModal: action('closeCantAddContactToGroupModal'), + closeMaximumGroupSizeModal: action('closeMaximumGroupSizeModal'), + closeRecommendedGroupSizeModal: action('closeRecommendedGroupSizeModal'), + createGroup: action('createGroup'), i18n, + modeSpecificProps: defaultModeSpecificProps, openConversationInternal: action('openConversationInternal'), - pinnedConversations: overrideProps.pinnedConversations || [], + regionCode: 'US', renderExpiredBuildDialog: () =>
, renderMainHeader: () =>
, - renderMessageSearchResult: () =>
, + renderMessageSearchResult: (id: string, style: React.CSSProperties) => ( + + ), renderNetworkStatus: () =>
, renderRelinkDialog: () =>
, renderUpdateDialog: () =>
, - searchResults: overrideProps.searchResults, - selectedConversationId: text( - 'selectedConversationId', - overrideProps.selectedConversationId || null - ), - showArchived: boolean('showArchived', overrideProps.showArchived || false), + selectedConversationId: undefined, + selectedMessageId: undefined, + setComposeSearchTerm: action('setComposeSearchTerm'), + setComposeGroupAvatar: action('setComposeGroupAvatar'), + setComposeGroupName: action('setComposeGroupName'), showArchivedConversations: action('showArchivedConversations'), showInbox: action('showInbox'), - startNewConversation: action('startNewConversation'), + startComposing: action('startComposing'), + showChooseGroupMembers: action('showChooseGroupMembers'), + startNewConversationFromPhoneNumber: action( + 'startNewConversationFromPhoneNumber' + ), + startSettingGroupMetadata: action('startSettingGroupMetadata'), + toggleConversationInChooseMembers: action( + 'toggleConversationInChooseMembers' + ), + + ...overrideProps, }); -story.add('Conversation States (Active, Selected, Archived)', () => { - const props = createProps(); +// Inbox stories - return ; -}); +story.add('Inbox: no conversations', () => ( + +)); -story.add('Pinned and Non-pinned Conversations', () => { - const props = createProps({ - pinnedConversations, - }); +story.add('Inbox: only pinned conversations', () => ( + +)); - return ; -}); +story.add('Inbox: only non-pinned conversations', () => ( + +)); -story.add('Only Pinned Conversations', () => { - const props = createProps({ - archivedConversations: [], - conversations: [], - pinnedConversations, - }); +story.add('Inbox: only archived conversations', () => ( + +)); - return ; -}); +story.add('Inbox: pinned and archived conversations', () => ( + +)); -story.add('Archived Conversations Shown', () => { - const props = createProps({ - showArchived: true, - }); - return ; -}); +story.add('Inbox: non-pinned and archived conversations', () => ( + +)); -story.add('Search Results', () => { - const props = createProps({ - searchResults: { - discussionsLoading: false, - items: [ - { - type: 'conversations-header', - data: undefined, +story.add('Inbox: pinned and non-pinned conversations', () => ( + +)); + +story.add('Inbox: pinned, non-pinned, and archived conversations', () => ( + +)); + +// Search stories + +story.add('Search: no results when searching everywhere', () => ( + +)); + +story.add('Search: no results when searching in a conversation', () => ( + +)); + +story.add('Search: all results loading', () => ( + +)); + +story.add('Search: some results loading', () => ( + +)); - return ; -}); +story.add('Search: has conversations and contacts, but not messages', () => ( + +)); + +story.add('Search: all results', () => ( + +)); + +// Archived stories + +story.add('Archive: no archived conversations', () => ( + +)); + +story.add('Archive: archived conversations', () => ( + +)); + +// Compose stories + +story.add('Compose: no contacts', () => ( + +)); + +story.add('Compose: some contacts, no search term', () => ( + +)); + +story.add('Compose: some contacts with a search term', () => ( + +)); diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index f7b3d4d008..bc210d5416 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -1,649 +1,478 @@ -// Copyright 2019-2020 Signal Messenger, LLC +// Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import Measure, { BoundingRect, MeasuredComponentProps } from 'react-measure'; -import React, { CSSProperties } from 'react'; -import { List } from 'react-virtualized'; -import { debounce, get } from 'lodash'; +import React, { useEffect, useMemo, CSSProperties } from 'react'; +import Measure, { MeasuredComponentProps } from 'react-measure'; +import { isNumber } from 'lodash'; import { - ConversationListItem, - PropsData as ConversationListItemPropsType, -} from './ConversationListItem'; + LeftPaneHelper, + FindDirection, + ToFindType, +} from './leftPane/LeftPaneHelper'; import { - PropsDataType as SearchResultsProps, - SearchResults, -} from './SearchResults'; -import { LocalizerType } from '../types/Util'; -import { cleanId } from './_util'; + LeftPaneInboxHelper, + LeftPaneInboxPropsType, +} from './leftPane/LeftPaneInboxHelper'; +import { + LeftPaneSearchHelper, + LeftPaneSearchPropsType, +} from './leftPane/LeftPaneSearchHelper'; +import { + LeftPaneArchiveHelper, + LeftPaneArchivePropsType, +} from './leftPane/LeftPaneArchiveHelper'; +import { + LeftPaneComposeHelper, + LeftPaneComposePropsType, +} from './leftPane/LeftPaneComposeHelper'; +import { + LeftPaneChooseGroupMembersHelper, + LeftPaneChooseGroupMembersPropsType, +} from './leftPane/LeftPaneChooseGroupMembersHelper'; +import { + LeftPaneSetGroupMetadataHelper, + LeftPaneSetGroupMetadataPropsType, +} from './leftPane/LeftPaneSetGroupMetadataHelper'; + +import * as OS from '../OS'; +import { LocalizerType, ScrollBehavior } from '../types/Util'; +import { usePrevious } from '../util/hooks'; +import { missingCaseError } from '../util/missingCaseError'; + +import { ConversationList } from './ConversationList'; +import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox'; + +export enum LeftPaneMode { + Inbox, + Search, + Archive, + Compose, + ChooseGroupMembers, + SetGroupMetadata, +} export type PropsType = { - conversations?: Array; - archivedConversations?: Array; - pinnedConversations?: Array; - selectedConversationId?: string; - searchResults?: SearchResultsProps; - showArchived?: boolean; - + // These help prevent invalid states. For example, we don't need the list of pinned + // conversations if we're trying to start a new conversation. Ideally these would be + // at the top level, but this is not supported by react-redux + TypeScript. + modeSpecificProps: + | ({ + mode: LeftPaneMode.Inbox; + } & LeftPaneInboxPropsType) + | ({ + mode: LeftPaneMode.Search; + } & LeftPaneSearchPropsType) + | ({ + mode: LeftPaneMode.Archive; + } & LeftPaneArchivePropsType) + | ({ + mode: LeftPaneMode.Compose; + } & LeftPaneComposePropsType) + | ({ + mode: LeftPaneMode.ChooseGroupMembers; + } & LeftPaneChooseGroupMembersPropsType) + | ({ + mode: LeftPaneMode.SetGroupMetadata; + } & LeftPaneSetGroupMetadataPropsType); i18n: LocalizerType; + selectedConversationId: undefined | string; + selectedMessageId: undefined | string; + regionCode: string; // Action Creators - startNewConversation: ( - query: string, - options: { regionCode: string } - ) => void; - openConversationInternal: (id: string, messageId?: string) => void; + cantAddContactToGroup: (conversationId: string) => void; + clearGroupCreationError: () => void; + closeCantAddContactToGroupModal: () => void; + closeMaximumGroupSizeModal: () => void; + closeRecommendedGroupSizeModal: () => void; + createGroup: () => void; + startNewConversationFromPhoneNumber: (e164: string) => void; + openConversationInternal: (_: { + conversationId: string; + messageId?: string; + switchToAssociatedView?: boolean; + }) => void; + setComposeSearchTerm: (composeSearchTerm: string) => void; + setComposeGroupAvatar: (_: undefined | ArrayBuffer) => void; + setComposeGroupName: (_: string) => void; showArchivedConversations: () => void; showInbox: () => void; + startComposing: () => void; + showChooseGroupMembers: () => void; + startSettingGroupMetadata: () => void; + toggleConversationInChooseMembers: (conversationId: string) => void; // Render Props renderExpiredBuildDialog: () => JSX.Element; renderMainHeader: () => JSX.Element; - renderMessageSearchResult: (id: string) => JSX.Element; + renderMessageSearchResult: (id: string, style: CSSProperties) => JSX.Element; renderNetworkStatus: () => JSX.Element; renderRelinkDialog: () => JSX.Element; renderUpdateDialog: () => JSX.Element; }; -// from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5 -type RowRendererParamsType = { - index: number; - isScrolling: boolean; - isVisible: boolean; - key: string; - parent: Record; - style: CSSProperties; -}; - -export enum RowType { - ArchiveButton, - ArchivedConversation, - Conversation, - Header, - PinnedConversation, - Undefined, -} - -export enum HeaderType { - Pinned, - Chats, -} - -type ArchiveButtonRow = { - type: RowType.ArchiveButton; -}; - -type ConversationRow = { - index: number; - type: - | RowType.ArchivedConversation - | RowType.Conversation - | RowType.PinnedConversation; -}; - -type HeaderRow = { - headerType: HeaderType; - type: RowType.Header; -}; - -type UndefinedRow = { - type: RowType.Undefined; -}; - -type Row = ArchiveButtonRow | ConversationRow | HeaderRow | UndefinedRow; - -export class LeftPane extends React.Component { - public listRef = React.createRef(); - - public containerRef = React.createRef(); - - public setFocusToFirstNeeded = false; - - public setFocusToLastNeeded = false; - - public calculateRowHeight = ({ index }: { index: number }): number => { - const { type } = this.getRowFromIndex(index); - return type === RowType.Header ? 40 : 68; - }; - - public getRowFromIndex = (index: number): Row => { - const { - archivedConversations, - conversations, - pinnedConversations, - showArchived, - } = this.props; - - if (!conversations || !pinnedConversations || !archivedConversations) { - return { - type: RowType.Undefined, - }; - } - - if (showArchived) { - return { - index, - type: RowType.ArchivedConversation, - }; - } - - let conversationIndex = index; - - if (pinnedConversations.length) { - if (conversations.length) { - if (index === 0) { - return { - headerType: HeaderType.Pinned, - type: RowType.Header, - }; - } - - if (index <= pinnedConversations.length) { - return { - index: index - 1, - type: RowType.PinnedConversation, - }; - } - - if (index === pinnedConversations.length + 1) { - return { - headerType: HeaderType.Chats, - type: RowType.Header, - }; - } - - conversationIndex -= pinnedConversations.length + 2; - } else if (index < pinnedConversations.length) { - return { - index, - type: RowType.PinnedConversation, - }; - } else { - conversationIndex = 0; - } - } - - if (conversationIndex === conversations.length) { - return { - type: RowType.ArchiveButton, - }; - } - - return { - index: conversationIndex, - type: RowType.Conversation, - }; - }; - - public renderConversationRow( - conversation: ConversationListItemPropsType, - key: string, - style: CSSProperties - ): JSX.Element { - const { i18n, openConversationInternal } = this.props; - - return ( -
- -
- ); - } - - public renderHeaderRow = ( - index: number, - key: string, - style: CSSProperties - ): JSX.Element => { - const { i18n } = this.props; - - switch (index) { - case HeaderType.Pinned: { - return ( -
- {i18n('LeftPane--pinned')} -
- ); - } - case HeaderType.Chats: { - return ( -
- {i18n('LeftPane--chats')} -
- ); - } - default: { - window.log.warn('LeftPane: invalid HeaderRowIndex received'); - return <>; - } - } - }; - - public renderRow = ({ - index, - key, - style, - }: RowRendererParamsType): JSX.Element => { - const { - archivedConversations, - conversations, - pinnedConversations, - } = this.props; - - if (!conversations || !pinnedConversations || !archivedConversations) { - throw new Error( - 'renderRow: Tried to render without conversations or pinnedConversations or archivedConversations' - ); - } - - const row = this.getRowFromIndex(index); - - switch (row.type) { - case RowType.ArchiveButton: { - return this.renderArchivedButton(key, style); - } - case RowType.ArchivedConversation: { - return this.renderConversationRow( - archivedConversations[row.index], - key, - style - ); - } - case RowType.Conversation: { - return this.renderConversationRow(conversations[row.index], key, style); - } - case RowType.Header: { - return this.renderHeaderRow(row.headerType, key, style); - } - case RowType.PinnedConversation: { - return this.renderConversationRow( - pinnedConversations[row.index], - key, - style - ); - } - default: - window.log.warn('LeftPane: unknown RowType received'); - return <>; - } - }; - - public renderArchivedButton = ( - key: string, - style: CSSProperties - ): JSX.Element => { - const { - archivedConversations, - i18n, - showArchivedConversations, - } = this.props; - - if (!archivedConversations || !archivedConversations.length) { - throw new Error( - 'renderArchivedButton: Tried to render without archivedConversations' - ); - } - - return ( - - ); - }; - - public handleKeyDown = (event: React.KeyboardEvent): void => { - const commandKey = get(window, 'platform') === 'darwin' && event.metaKey; - const controlKey = get(window, 'platform') !== 'darwin' && event.ctrlKey; - const commandOrCtrl = commandKey || controlKey; - - if (commandOrCtrl && !event.shiftKey && event.key === 'ArrowUp') { - this.scrollToRow(0); - this.setFocusToFirstNeeded = true; - - event.preventDefault(); - event.stopPropagation(); - - return; - } - - if (commandOrCtrl && !event.shiftKey && event.key === 'ArrowDown') { - const length = this.getLength(); - this.scrollToRow(length - 1); - this.setFocusToLastNeeded = true; - - event.preventDefault(); - event.stopPropagation(); - } - }; - - public handleFocus = (): void => { - const { selectedConversationId } = this.props; - const { current: container } = this.containerRef; - - if (!container) { - return; - } - - if (document.activeElement === container) { - const scrollingContainer = this.getScrollContainer(); - if (selectedConversationId && scrollingContainer) { - const escapedId = cleanId(selectedConversationId).replace( - /["\\]/g, - '\\$&' - ); - const target: HTMLElement | null = scrollingContainer.querySelector( - `.module-conversation-list-item[data-id="${escapedId}"]` - ); - - if (target && target.focus) { - target.focus(); - - return; - } - } - - this.setFocusToFirst(); - } - }; - - public scrollToRow = (row: number): void => { - if (!this.listRef || !this.listRef.current) { - return; - } - - this.listRef.current.scrollToRow(row); - }; - - public recomputeRowHeights = (): void => { - if (!this.listRef || !this.listRef.current) { - return; - } - - this.listRef.current.recomputeRowHeights(); - }; - - public getScrollContainer = (): HTMLDivElement | null => { - if (!this.listRef || !this.listRef.current) { - return null; - } - - const list = this.listRef.current; - - // TODO: DESKTOP-689 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const grid: any = list.Grid; - if (!grid || !grid._scrollingContainer) { - return null; - } - - return grid._scrollingContainer as HTMLDivElement; - }; - - public setFocusToFirst = (): void => { - const scrollContainer = this.getScrollContainer(); - if (!scrollContainer) { - return; - } - - const item: HTMLElement | null = scrollContainer.querySelector( - '.module-conversation-list-item' - ); - if (item && item.focus) { - item.focus(); - } - }; - - public onScroll = debounce( - (): void => { - if (this.setFocusToFirstNeeded) { - this.setFocusToFirstNeeded = false; - this.setFocusToFirst(); - } - if (this.setFocusToLastNeeded) { - this.setFocusToLastNeeded = false; - - const scrollContainer = this.getScrollContainer(); - if (!scrollContainer) { - return; - } - - const button: HTMLElement | null = scrollContainer.querySelector( - '.module-left-pane__archived-button' - ); - if (button && button.focus) { - button.focus(); - - return; - } - const items: NodeListOf = scrollContainer.querySelectorAll( - '.module-conversation-list-item' - ); - if (items && items.length > 0) { - const last = items[items.length - 1]; - - if (last && last.focus) { - last.focus(); - } - } - } - }, - 100, - { maxWait: 100 } +export const LeftPane: React.FC = ({ + cantAddContactToGroup, + clearGroupCreationError, + closeCantAddContactToGroupModal, + closeMaximumGroupSizeModal, + closeRecommendedGroupSizeModal, + createGroup, + i18n, + modeSpecificProps, + openConversationInternal, + renderExpiredBuildDialog, + renderMainHeader, + renderMessageSearchResult, + renderNetworkStatus, + renderRelinkDialog, + renderUpdateDialog, + selectedConversationId, + selectedMessageId, + setComposeSearchTerm, + setComposeGroupAvatar, + setComposeGroupName, + showArchivedConversations, + showInbox, + startComposing, + showChooseGroupMembers, + startNewConversationFromPhoneNumber, + startSettingGroupMetadata, + toggleConversationInChooseMembers, +}) => { + const previousModeSpecificProps = usePrevious( + modeSpecificProps, + modeSpecificProps ); - public getLength = (): number => { - const { - archivedConversations, - conversations, - pinnedConversations, - showArchived, - } = this.props; - - if (!conversations || !archivedConversations || !pinnedConversations) { - return 0; + // The left pane can be in various modes: the inbox, the archive, the composer, etc. + // Ideally, this would render subcomponents such as `` or + // `` (and if there's a way to do that cleanly, we should refactor + // this). + // + // But doing that presents two problems: + // + // 1. Different components render the same logical inputs (the main header's search), + // but React doesn't know that they're the same, so you can lose focus as you change + // modes. + // 2. These components render virtualized lists, which are somewhat slow to initialize. + // Switching between modes can cause noticable hiccups. + // + // To get around those problems, we use "helpers" which all correspond to the same + // interface. + // + // Unfortunately, there's a little bit of repetition here because TypeScript isn't quite + // smart enough. + let helper: LeftPaneHelper; + let shouldRecomputeRowHeights: boolean; + switch (modeSpecificProps.mode) { + case LeftPaneMode.Inbox: { + const inboxHelper = new LeftPaneInboxHelper(modeSpecificProps); + shouldRecomputeRowHeights = + previousModeSpecificProps.mode === modeSpecificProps.mode + ? inboxHelper.shouldRecomputeRowHeights(previousModeSpecificProps) + : true; + helper = inboxHelper; + break; } - - if (showArchived) { - return archivedConversations.length; + case LeftPaneMode.Search: { + const searchHelper = new LeftPaneSearchHelper(modeSpecificProps); + shouldRecomputeRowHeights = + previousModeSpecificProps.mode === modeSpecificProps.mode + ? searchHelper.shouldRecomputeRowHeights(previousModeSpecificProps) + : true; + helper = searchHelper; + break; } + case LeftPaneMode.Archive: { + const archiveHelper = new LeftPaneArchiveHelper(modeSpecificProps); + shouldRecomputeRowHeights = + previousModeSpecificProps.mode === modeSpecificProps.mode + ? archiveHelper.shouldRecomputeRowHeights(previousModeSpecificProps) + : true; + helper = archiveHelper; + break; + } + case LeftPaneMode.Compose: { + const composeHelper = new LeftPaneComposeHelper(modeSpecificProps); + shouldRecomputeRowHeights = + previousModeSpecificProps.mode === modeSpecificProps.mode + ? composeHelper.shouldRecomputeRowHeights(previousModeSpecificProps) + : true; + helper = composeHelper; + break; + } + case LeftPaneMode.ChooseGroupMembers: { + const chooseGroupMembersHelper = new LeftPaneChooseGroupMembersHelper( + modeSpecificProps + ); + shouldRecomputeRowHeights = + previousModeSpecificProps.mode === modeSpecificProps.mode + ? chooseGroupMembersHelper.shouldRecomputeRowHeights( + previousModeSpecificProps + ) + : true; + helper = chooseGroupMembersHelper; + break; + } + case LeftPaneMode.SetGroupMetadata: { + const setGroupMetadataHelper = new LeftPaneSetGroupMetadataHelper( + modeSpecificProps + ); + shouldRecomputeRowHeights = + previousModeSpecificProps.mode === modeSpecificProps.mode + ? setGroupMetadataHelper.shouldRecomputeRowHeights( + previousModeSpecificProps + ) + : true; + helper = setGroupMetadataHelper; + break; + } + default: + throw missingCaseError(modeSpecificProps); + } - let { length } = conversations; + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const { ctrlKey, shiftKey, altKey, metaKey, key } = event; + const commandOrCtrl = OS.isMacOS() ? metaKey : ctrlKey; - if (pinnedConversations.length) { - if (length) { - // includes two additional rows for pinned/chats headers - length += 2; + if ( + commandOrCtrl && + !shiftKey && + !altKey && + (key === 'n' || key === 'N') + ) { + startComposing(); + + event.preventDefault(); + event.stopPropagation(); + return; } - length += pinnedConversations.length; - } - // includes one additional row for 'archived conversations' button - if (archivedConversations.length) { - length += 1; - } + let conversationToOpen: + | undefined + | { + conversationId: string; + messageId?: string; + }; - return length; - }; + const numericIndex = keyboardKeyToNumericIndex(event.key); + if (commandOrCtrl && isNumber(numericIndex)) { + conversationToOpen = helper.getConversationAndMessageAtIndex( + numericIndex + ); + } else { + let toFind: undefined | ToFindType; + if ( + (altKey && !shiftKey && key === 'ArrowUp') || + (commandOrCtrl && shiftKey && key === '[') || + (ctrlKey && shiftKey && key === 'Tab') + ) { + toFind = { direction: FindDirection.Up, unreadOnly: false }; + } else if ( + (altKey && !shiftKey && key === 'ArrowDown') || + (commandOrCtrl && shiftKey && key === ']') || + (ctrlKey && key === 'Tab') + ) { + toFind = { direction: FindDirection.Down, unreadOnly: false }; + } else if (altKey && shiftKey && key === 'ArrowUp') { + toFind = { direction: FindDirection.Up, unreadOnly: true }; + } else if (altKey && shiftKey && key === 'ArrowDown') { + toFind = { direction: FindDirection.Down, unreadOnly: true }; + } + if (toFind) { + conversationToOpen = helper.getConversationAndMessageInDirection( + toFind, + selectedConversationId, + selectedMessageId + ); + } + } - public renderList = ({ - height, - width, - }: BoundingRect): JSX.Element | Array => { - const { - archivedConversations, - i18n, - conversations, - openConversationInternal, - pinnedConversations, - renderMessageSearchResult, - startNewConversation, - searchResults, - showArchived, - } = this.props; + if (conversationToOpen) { + const { conversationId, messageId } = conversationToOpen; + openConversationInternal({ conversationId, messageId }); + event.preventDefault(); + event.stopPropagation(); + } + }; - if (searchResults) { - return ( - - ); - } + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + }; + }, [ + helper, + openConversationInternal, + selectedConversationId, + selectedMessageId, + startComposing, + ]); - if (!conversations || !archivedConversations || !pinnedConversations) { - throw new Error( - 'render: must provided conversations and archivedConverstions if no search results are provided' - ); - } + const preRowsNode = helper.getPreRowsNode({ + clearGroupCreationError, + closeCantAddContactToGroupModal, + closeMaximumGroupSizeModal, + closeRecommendedGroupSizeModal, + createGroup, + i18n, + setComposeGroupAvatar, + setComposeGroupName, + onChangeComposeSearchTerm: event => { + setComposeSearchTerm(event.target.value); + }, + removeSelectedContact: toggleConversationInChooseMembers, + }); + const footerContents = helper.getFooterContents({ + createGroup, + i18n, + startSettingGroupMetadata, + }); - const length = this.getLength(); + const getRow = useMemo(() => helper.getRow.bind(helper), [helper]); - // We ensure that the listKey differs between inbox and archive views, which ensures - // that AutoSizer properly detects the new size of its slot in the flexbox. The - // archive explainer text at the top of the archive view causes problems otherwise. - // It also ensures that we scroll to the top when switching views. - const listKey = showArchived ? 1 : 0; + const previousSelectedConversationId = usePrevious( + selectedConversationId, + selectedConversationId + ); - // Note: conversations is not a known prop for List, but it is required to ensure that - // it re-renders when our conversation data changes. Otherwise it would just render - // on startup and scroll. - return ( -
- + const isScrollable = helper.isScrollable(); + + let rowIndexToScrollTo: undefined | number; + let scrollBehavior: ScrollBehavior; + if (isScrollable) { + rowIndexToScrollTo = + previousSelectedConversationId === selectedConversationId + ? undefined + : helper.getRowIndexToScrollTo(selectedConversationId); + scrollBehavior = ScrollBehavior.Default; + } else { + rowIndexToScrollTo = 0; + scrollBehavior = ScrollBehavior.Hard; + } + + // We ensure that the listKey differs between some modes (e.g. inbox/archived), ensuring + // that AutoSizer properly detects the new size of its slot in the flexbox. The + // archive explainer text at the top of the archive view causes problems otherwise. + // It also ensures that we scroll to the top when switching views. + const listKey = preRowsNode ? 1 : 0; + + // We disable this lint rule because we're trying to capture bubbled events. See [the + // lint rule's docs][0]. + // + // [0]: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/645900a0e296ca7053dbf6cd9e12cc85849de2d5/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events + /* eslint-disable jsx-a11y/no-static-element-interactions */ + return ( +
{ + if (event.key === 'Escape') { + const backAction = helper.getBackAction({ + showInbox, + startComposing, + showChooseGroupMembers, + }); + if (backAction) { + event.preventDefault(); + event.stopPropagation(); + backAction(); + } + } + }} + > + {/* eslint-enable jsx-a11y/no-static-element-interactions */} +
+ {helper.getHeaderContents({ + i18n, + showInbox, + startComposing, + showChooseGroupMembers, + }) || renderMainHeader()}
- ); - }; - - public renderArchivedHeader = (): JSX.Element => { - const { i18n, showInbox } = this.props; - - return ( -
-
- ); - }; - - public render(): JSX.Element { - const { - i18n, - renderExpiredBuildDialog, - renderMainHeader, - renderNetworkStatus, - renderRelinkDialog, - renderUpdateDialog, - showArchived, - } = this.props; - - // Relying on 3rd party code for contentRect.bounds - /* eslint-disable @typescript-eslint/no-non-null-assertion */ - return ( -
-
- {showArchived ? this.renderArchivedHeader() : renderMainHeader()} -
- {renderExpiredBuildDialog()} - {renderRelinkDialog()} - {renderNetworkStatus()} - {renderUpdateDialog()} - {showArchived && ( -
- {i18n('archiveHelperText')} -
- )} - - {({ contentRect, measureRef }: MeasuredComponentProps) => ( -
-
- {this.renderList(contentRect.bounds!)} + {renderExpiredBuildDialog()} + {renderRelinkDialog()} + {helper.shouldRenderNetworkStatusAndUpdateDialog() && ( + <> + {renderNetworkStatus()} + {renderUpdateDialog()} + + )} + {preRowsNode && {preRowsNode}} + + {({ contentRect, measureRef }: MeasuredComponentProps) => ( +
+
+
+ { + switch (disabledReason) { + case undefined: + toggleConversationInChooseMembers(conversationId); + break; + case ContactCheckboxDisabledReason.AlreadyAdded: + case ContactCheckboxDisabledReason.MaximumContactsSelected: + // These are no-ops. + break; + case ContactCheckboxDisabledReason.NotCapable: + cantAddContactToGroup(conversationId); + break; + default: + throw missingCaseError(disabledReason); + } + }} + onSelectConversation={( + conversationId: string, + messageId?: string + ) => { + openConversationInternal({ + conversationId, + messageId, + switchToAssociatedView: true, + }); + }} + renderMessageSearchResult={renderMessageSearchResult} + rowCount={helper.getRowCount()} + scrollBehavior={scrollBehavior} + scrollToRowIndex={rowIndexToScrollTo} + scrollable={isScrollable} + shouldRecomputeRowHeights={shouldRecomputeRowHeights} + showChooseGroupMembers={showChooseGroupMembers} + startNewConversationFromPhoneNumber={ + startNewConversationFromPhoneNumber + } + />
- )} - -
- ); - } - - componentDidUpdate(oldProps: PropsType): void { - const { - conversations: oldConversations = [], - pinnedConversations: oldPinnedConversations = [], - archivedConversations: oldArchivedConversations = [], - showArchived: oldShowArchived, - } = oldProps; - const { - conversations: newConversations = [], - pinnedConversations: newPinnedConversations = [], - archivedConversations: newArchivedConversations = [], - showArchived: newShowArchived, - } = this.props; - - const oldHasArchivedConversations = Boolean( - oldArchivedConversations.length - ); - const newHasArchivedConversations = Boolean( - newArchivedConversations.length - ); - - // This could probably be optimized further, but we want to be extra-careful that our - // heights are correct. - if ( - oldConversations.length !== newConversations.length || - oldPinnedConversations.length !== newPinnedConversations.length || - oldHasArchivedConversations !== newHasArchivedConversations || - oldShowArchived !== newShowArchived - ) { - this.recomputeRowHeights(); - } +
+ )} + + {footerContents && ( +
{footerContents}
+ )} +
+ ); +}; + +function keyboardKeyToNumericIndex(key: string): undefined | number { + if (key.length !== 1) { + return undefined; } + const result = parseInt(key, 10) - 1; + const isValidIndex = Number.isInteger(result) && result >= 0 && result <= 8; + return isValidIndex ? result : undefined; } diff --git a/ts/components/LightboxGallery.stories.tsx b/ts/components/LightboxGallery.stories.tsx index 6fad0185a0..568a844618 100644 --- a/ts/components/LightboxGallery.stories.tsx +++ b/ts/components/LightboxGallery.stories.tsx @@ -40,7 +40,8 @@ story.add('Image and Video', () => { message: { attachments: [], id: 'image-msg', - received_at: Date.now(), + received_at: 1, + received_at_ms: Date.now(), }, objectURL: '/fixtures/tina-rolf-269345-unsplash.jpg', }, @@ -55,7 +56,8 @@ story.add('Image and Video', () => { message: { attachments: [], id: 'video-msg', - received_at: Date.now(), + received_at: 2, + received_at_ms: Date.now(), }, objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4', }, @@ -79,7 +81,8 @@ story.add('Missing Media', () => { message: { attachments: [], id: 'image-msg', - received_at: Date.now(), + received_at: 3, + received_at_ms: Date.now(), }, objectURL: undefined, }, diff --git a/ts/components/MainHeader.stories.tsx b/ts/components/MainHeader.stories.tsx index e54303e408..22f8d5d0d0 100644 --- a/ts/components/MainHeader.stories.tsx +++ b/ts/components/MainHeader.stories.tsx @@ -33,6 +33,7 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ 'searchConversationId', overrideProps.searchConversationId ), + selectedConversation: undefined, startSearchCounter: 0, ourConversationId: '', @@ -46,14 +47,17 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ avatarPath: optionalText('avatarPath', overrideProps.avatarPath), i18n, + updateSearchTerm: action('updateSearchTerm'), searchMessages: action('searchMessages'), searchDiscussions: action('searchDiscussions'), - + startSearch: action('startSearch'), + searchInConversation: action('searchInConversation'), clearConversationSearch: action('clearConversationSearch'), clearSearch: action('clearSearch'), showArchivedConversations: action('showArchivedConversations'), + startComposing: action('startComposing'), }); story.add('Basic', () => { diff --git a/ts/components/MainHeader.tsx b/ts/components/MainHeader.tsx index fdb57856ad..b1a5a4f57c 100644 --- a/ts/components/MainHeader.tsx +++ b/ts/components/MainHeader.tsx @@ -12,12 +12,14 @@ import { Avatar } from './Avatar'; import { AvatarPopup } from './AvatarPopup'; import { LocalizerType } from '../types/Util'; import { ColorType } from '../types/Colors'; +import { ConversationType } from '../state/ducks/conversations'; export type PropsType = { searchTerm: string; searchConversationName?: string; searchConversationId?: string; startSearchCounter: number; + selectedConversation: undefined | ConversationType; // To be used as an ID ourConversationId: string; @@ -36,7 +38,10 @@ export type PropsType = { avatarPath?: string; i18n: LocalizerType; + updateSearchTerm: (searchTerm: string) => void; + startSearch: () => void; + searchInConversation: (id: string, name: string) => void; searchMessages: ( query: string, options: { @@ -53,11 +58,11 @@ export type PropsType = { noteToSelf: string; } ) => void; - clearConversationSearch: () => void; clearSearch: () => void; showArchivedConversations: () => void; + startComposing: () => void; }; type StateType = { @@ -107,12 +112,6 @@ export class MainHeader extends React.Component { } }; - public handleOutsideKeyDown = (event: KeyboardEvent): void => { - if (event.key === 'Escape') { - this.hideAvatarPopup(); - } - }; - public showAvatarPopup = (): void => { const popperRoot = document.createElement('div'); document.body.appendChild(popperRoot); @@ -122,14 +121,12 @@ export class MainHeader extends React.Component { popperRoot, }); document.addEventListener('click', this.handleOutsideClick); - document.addEventListener('keydown', this.handleOutsideKeyDown); }; public hideAvatarPopup = (): void => { const { popperRoot } = this.state; document.removeEventListener('click', this.handleOutsideClick); - document.removeEventListener('keydown', this.handleOutsideKeyDown); this.setState({ showingAvatarPopup: false, @@ -141,11 +138,15 @@ export class MainHeader extends React.Component { } }; + public componentDidMount(): void { + document.addEventListener('keydown', this.handleGlobalKeyDown); + } + public componentWillUnmount(): void { const { popperRoot } = this.state; document.removeEventListener('click', this.handleOutsideClick); - document.removeEventListener('keydown', this.handleOutsideKeyDown); + document.removeEventListener('keydown', this.handleGlobalKeyDown); if (popperRoot && document.body.contains(popperRoot)) { document.body.removeChild(popperRoot); @@ -225,7 +226,7 @@ export class MainHeader extends React.Component { this.setFocus(); }; - public handleKeyDown = ( + public handleInputKeyDown = ( event: React.KeyboardEvent ): void => { const { @@ -262,6 +263,50 @@ export class MainHeader extends React.Component { event.stopPropagation(); }; + public handleGlobalKeyDown = (event: KeyboardEvent): void => { + const { showingAvatarPopup } = this.state; + const { + i18n, + selectedConversation, + startSearch, + searchInConversation, + } = this.props; + + const { ctrlKey, metaKey, shiftKey, key } = event; + const commandKey = get(window, 'platform') === 'darwin' && metaKey; + const controlKey = get(window, 'platform') !== 'darwin' && ctrlKey; + const commandOrCtrl = commandKey || controlKey; + const commandAndCtrl = commandKey && ctrlKey; + + if (showingAvatarPopup && key === 'Escape') { + this.hideAvatarPopup(); + } else if ( + commandOrCtrl && + !commandAndCtrl && + !shiftKey && + (key === 'f' || key === 'F') + ) { + startSearch(); + + event.preventDefault(); + event.stopPropagation(); + } else if ( + selectedConversation && + commandOrCtrl && + !commandAndCtrl && + shiftKey && + (key === 'f' || key === 'F') + ) { + const name = selectedConversation.isMe + ? i18n('noteToSelf') + : selectedConversation.title; + searchInConversation(selectedConversation.id, name); + + event.preventDefault(); + event.stopPropagation(); + } + }; + public handleXButton = (): void => { const { searchConversationId, @@ -296,6 +341,7 @@ export class MainHeader extends React.Component { color, i18n, name, + startComposing, phoneNumber, profileName, title, @@ -310,6 +356,10 @@ export class MainHeader extends React.Component { ? i18n('searchIn', [searchConversationName]) : i18n('search'); + const isSearching = Boolean( + searchConversationId || searchTerm.trim().length + ); + return (
@@ -317,6 +367,7 @@ export class MainHeader extends React.Component { {({ ref }) => ( { )} placeholder={placeholder} dir="auto" - onKeyDown={this.handleKeyDown} + onKeyDown={this.handleInputKeyDown} value={searchTerm} onChange={this.updateSearch} /> @@ -412,6 +463,15 @@ export class MainHeader extends React.Component { /> ) : null}
+ {!isSearching && ( +
); } diff --git a/ts/components/MessageBodyHighlight.tsx b/ts/components/MessageBodyHighlight.tsx deleted file mode 100644 index 14a59c1467..0000000000 --- a/ts/components/MessageBodyHighlight.tsx +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright 2019-2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React from 'react'; - -import { MessageBody } from './conversation/MessageBody'; -import { Emojify } from './conversation/Emojify'; -import { AddNewLines } from './conversation/AddNewLines'; - -import { SizeClassType } from './emoji/lib'; - -import { LocalizerType, RenderTextCallbackType } from '../types/Util'; - -export type Props = { - text: string; - i18n: LocalizerType; -}; - -const renderNewLines: RenderTextCallbackType = ({ text, key }) => ( - -); - -const renderEmoji = ({ - text, - key, - sizeClass, - renderNonEmoji, -}: { - i18n: LocalizerType; - text: string; - key: number; - sizeClass?: SizeClassType; - renderNonEmoji: RenderTextCallbackType; -}) => ( - -); - -export class MessageBodyHighlight extends React.Component { - public render(): JSX.Element | Array { - const { text, i18n } = this.props; - const results: Array = []; - const FIND_BEGIN_END = /<>(.+?)<>/g; - - let match = FIND_BEGIN_END.exec(text); - let last = 0; - let count = 1; - - if (!match) { - return ( - - ); - } - - const sizeClass = ''; - - while (match) { - if (last < match.index) { - const beforeText = text.slice(last, match.index); - count += 1; - results.push( - renderEmoji({ - text: beforeText, - sizeClass, - key: count, - i18n, - renderNonEmoji: renderNewLines, - }) - ); - } - - const [, toHighlight] = match; - count += 2; - results.push( - - {renderEmoji({ - text: toHighlight, - sizeClass, - key: count, - i18n, - renderNonEmoji: renderNewLines, - })} - - ); - - last = FIND_BEGIN_END.lastIndex; - match = FIND_BEGIN_END.exec(text); - } - - if (last < text.length) { - count += 1; - results.push( - renderEmoji({ - text: text.slice(last), - sizeClass, - key: count, - i18n, - renderNonEmoji: renderNewLines, - }) - ); - } - - return results; - } -} diff --git a/ts/components/MessageSearchResult.stories.tsx b/ts/components/MessageSearchResult.stories.tsx deleted file mode 100644 index c7a6923cad..0000000000 --- a/ts/components/MessageSearchResult.stories.tsx +++ /dev/null @@ -1,142 +0,0 @@ -// 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, withKnobs } from '@storybook/addon-knobs'; - -import { setup as setupI18n } from '../../js/modules/i18n'; -import enMessages from '../../_locales/en/messages.json'; -import { MessageSearchResult, PropsType } from './MessageSearchResult'; - -const i18n = setupI18n('en', enMessages); -const story = storiesOf('Components/MessageSearchResult', module); - -// Storybook types are incorrect -// eslint-disable-next-line @typescript-eslint/no-explicit-any -story.addDecorator((withKnobs as any)({ escapeHTML: false })); - -const someone = { - title: 'Some Person', - name: 'Some Person', - phoneNumber: '(202) 555-0011', -}; - -const me = { - title: 'Me', - name: 'Me', - isMe: true, -}; - -const group = { - title: 'Group Chat', - name: 'Group Chat', -}; - -const createProps = (overrideProps: Partial = {}): PropsType => ({ - i18n, - id: '', - conversationId: '', - sentAt: Date.now() - 24 * 60 * 1000, - snippet: text( - 'snippet', - overrideProps.snippet || "What's <>going<> on?" - ), - from: overrideProps.from as PropsType['from'], - to: overrideProps.to as PropsType['to'], - isSelected: boolean('isSelected', overrideProps.isSelected || false), - openConversationInternal: action('openConversationInternal'), - isSearchingInConversation: boolean( - 'isSearchingInConversation', - overrideProps.isSearchingInConversation || false - ), -}); - -story.add('Default', () => { - const props = createProps({ - from: someone, - to: me, - }); - - return ; -}); - -story.add('Selected', () => { - const props = createProps({ - from: someone, - to: me, - isSelected: true, - }); - - return ; -}); - -story.add('From You', () => { - const props = createProps({ - from: me, - to: someone, - }); - - return ; -}); - -story.add('Searching in Conversation', () => { - const props = createProps({ - from: me, - to: someone, - isSearchingInConversation: true, - }); - - return ; -}); - -story.add('From You to Yourself', () => { - const props = createProps({ - from: me, - to: me, - }); - - return ; -}); - -story.add('From You to Group', () => { - const props = createProps({ - from: me, - to: group, - }); - - return ; -}); - -story.add('From Someone to Group', () => { - const props = createProps({ - from: someone, - to: group, - }); - - return ; -}); - -story.add('Long Search Result', () => { - const snippets = [ - 'This is a really <>detail<>ed long line which will wrap and only be cut off after it gets to three lines. So maybe this will make it in as well?', - "Okay, here are the <>detail<>s:\n\n1355 Ridge Way\nCode: 234\n\nI'm excited!", - ]; - - return snippets.map(snippet => { - const props = createProps({ - from: someone, - to: me, - snippet, - }); - - return ; - }); -}); - -story.add('Empty', () => { - const props = createProps(); - - return ; -}); diff --git a/ts/components/MessageSearchResult.tsx b/ts/components/MessageSearchResult.tsx deleted file mode 100644 index 800dc2196b..0000000000 --- a/ts/components/MessageSearchResult.tsx +++ /dev/null @@ -1,182 +0,0 @@ -// Copyright 2019-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React from 'react'; -import classNames from 'classnames'; - -import { Avatar } from './Avatar'; -import { MessageBodyHighlight } from './MessageBodyHighlight'; -import { Timestamp } from './conversation/Timestamp'; -import { ContactName } from './conversation/ContactName'; - -import { LocalizerType } from '../types/Util'; -import { ColorType } from '../types/Colors'; - -export type PropsDataType = { - isSelected?: boolean; - isSearchingInConversation?: boolean; - - id: string; - conversationId: string; - sentAt?: number; - - snippet: string; - - from: { - phoneNumber?: string; - title: string; - isMe?: boolean; - name?: string; - color?: ColorType; - profileName?: string; - avatarPath?: string; - }; - - to: { - groupName?: string; - phoneNumber?: string; - title: string; - isMe?: boolean; - name?: string; - profileName?: string; - }; -}; - -type PropsHousekeepingType = { - i18n: LocalizerType; - openConversationInternal: ( - conversationId: string, - messageId?: string - ) => void; -}; - -export type PropsType = PropsDataType & PropsHousekeepingType; - -export class MessageSearchResult extends React.PureComponent { - public renderFromName(): JSX.Element { - const { from, i18n, to } = this.props; - - if (from.isMe && to.isMe) { - return ( - - {i18n('noteToSelf')} - - ); - } - if (from.isMe) { - return ( - - {i18n('you')} - - ); - } - - return ( - - ); - } - - public renderFrom(): JSX.Element { - const { i18n, to, isSearchingInConversation } = this.props; - const fromName = this.renderFromName(); - - if (!to.isMe && !isSearchingInConversation) { - return ( -
- {fromName} {i18n('toJoiner')}{' '} - - - -
- ); - } - - return ( -
- {fromName} -
- ); - } - - public renderAvatar(): JSX.Element { - const { from, i18n, to } = this.props; - const isNoteToSelf = from.isMe && to.isMe; - - return ( - - ); - } - - public render(): JSX.Element | null { - const { - from, - i18n, - id, - isSelected, - conversationId, - openConversationInternal, - sentAt, - snippet, - to, - } = this.props; - - if (!from || !to) { - return null; - } - - return ( - - ); - } -} diff --git a/ts/components/NewlyCreatedGroupInvitedContactsDialog.stories.tsx b/ts/components/NewlyCreatedGroupInvitedContactsDialog.stories.tsx new file mode 100644 index 0000000000..69815f8241 --- /dev/null +++ b/ts/components/NewlyCreatedGroupInvitedContactsDialog.stories.tsx @@ -0,0 +1,54 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { NewlyCreatedGroupInvitedContactsDialog } from './NewlyCreatedGroupInvitedContactsDialog'; +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; +import { ConversationType } from '../state/ducks/conversations'; + +const i18n = setupI18n('en', enMessages); + +const conversations: Array = [ + { + id: 'fred-convo', + isSelected: false, + lastUpdated: Date.now(), + markedUnread: false, + title: 'Fred Willard', + type: 'direct', + }, + { + id: 'marc-convo', + isSelected: true, + lastUpdated: Date.now(), + markedUnread: false, + title: 'Marc Barraca', + type: 'direct', + }, +]; + +const story = storiesOf( + 'Components/NewlyCreatedGroupInvitedContactsDialog', + module +); + +story.add('One contact', () => ( + +)); + +story.add('Two contacts', () => ( + +)); diff --git a/ts/components/NewlyCreatedGroupInvitedContactsDialog.tsx b/ts/components/NewlyCreatedGroupInvitedContactsDialog.tsx new file mode 100644 index 0000000000..1a9800ae0a --- /dev/null +++ b/ts/components/NewlyCreatedGroupInvitedContactsDialog.tsx @@ -0,0 +1,80 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { FunctionComponent, ReactNode } from 'react'; + +import { LocalizerType } from '../types/Util'; +import { ConversationType } from '../state/ducks/conversations'; +import { Intl } from './Intl'; +import { ContactName } from './conversation/ContactName'; +import { GroupDialog } from './GroupDialog'; + +type PropsType = { + contacts: Array; + i18n: LocalizerType; + onClose: () => void; +}; + +export const NewlyCreatedGroupInvitedContactsDialog: FunctionComponent = ({ + contacts, + i18n, + onClose, +}) => { + let title: string; + let body: ReactNode; + if (contacts.length === 1) { + const contact = contacts[0]; + + title = i18n('NewlyCreatedGroupInvitedContactsDialog--title--one'); + body = ( + <> + + ]} + /> + + + {i18n('NewlyCreatedGroupInvitedContactsDialog--body--info-paragraph')} + + + ); + } else { + title = i18n('NewlyCreatedGroupInvitedContactsDialog--title--many', [ + contacts.length.toString(), + ]); + body = ( + <> + + {i18n( + 'NewlyCreatedGroupInvitedContactsDialog--body--user-paragraph--many' + )} + + + {i18n('NewlyCreatedGroupInvitedContactsDialog--body--info-paragraph')} + + + + ); + } + + return ( + { + window.location.href = + 'https://support.signal.org/hc/articles/360007319331-Group-chats'; + }} + onClose={onClose} + title={title} + > + {body} + + ); +}; diff --git a/ts/components/SafetyNumberViewer.tsx b/ts/components/SafetyNumberViewer.tsx index 3945112dbd..7c804f2fb3 100644 --- a/ts/components/SafetyNumberViewer.tsx +++ b/ts/components/SafetyNumberViewer.tsx @@ -4,7 +4,6 @@ import React from 'react'; import { ConversationType } from '../state/ducks/conversations'; import { LocalizerType } from '../types/Util'; -import { getPlaceholder } from '../util/safetyNumber'; import { Intl } from './Intl'; export type PropsType = { @@ -112,3 +111,9 @@ export const SafetyNumberViewer = ({
); }; + +function getPlaceholder(): string { + return Array.from(Array(12)) + .map(() => 'XXXXX') + .join(' '); +} diff --git a/ts/components/SearchResults.stories.tsx b/ts/components/SearchResults.stories.tsx deleted file mode 100644 index cfb9b972a0..0000000000 --- a/ts/components/SearchResults.stories.tsx +++ /dev/null @@ -1,423 +0,0 @@ -// 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 { SearchResults } from './SearchResults'; -import { - MessageSearchResult, - PropsDataType as MessageSearchResultPropsType, -} from './MessageSearchResult'; -import { setup as setupI18n } from '../../js/modules/i18n'; -import enMessages from '../../_locales/en/messages.json'; -import { - gifUrl, - landscapeGreenUrl, - landscapePurpleUrl, - pngUrl, -} from '../storybook/Fixtures'; - -const i18n = setupI18n('en', enMessages); - -const messageLookup: Map = new Map(); - -const CONTACT = 'contact' as const; -const CONTACTS_HEADER = 'contacts-header' as const; -const CONVERSATION = 'conversation' as const; -const CONVERSATIONS_HEADER = 'conversations-header' as const; -const DIRECT = 'direct' as const; -const GROUP = 'group' as const; -const MESSAGE = 'message' as const; -const MESSAGES_HEADER = 'messages-header' as const; -const SENT = 'sent' as const; -const START_NEW_CONVERSATION = 'start-new-conversation' as const; -const SMS_MMS_NOT_SUPPORTED = 'sms-mms-not-supported-text' as const; - -messageLookup.set('1-guid-guid-guid-guid-guid', { - id: '1-guid-guid-guid-guid-guid', - conversationId: '(202) 555-0015', - sentAt: Date.now() - 5 * 60 * 1000, - snippet: '<>Everyone<>! Get in!', - - from: { - phoneNumber: '(202) 555-0020', - title: '(202) 555-0020', - isMe: true, - color: 'blue', - avatarPath: gifUrl, - }, - to: { - phoneNumber: '(202) 555-0015', - title: 'Mr. Fire 🔥', - name: 'Mr. Fire 🔥', - }, -}); - -messageLookup.set('2-guid-guid-guid-guid-guid', { - id: '2-guid-guid-guid-guid-guid', - conversationId: '(202) 555-0016', - sentAt: Date.now() - 20 * 60 * 1000, - snippet: 'Why is <>everyone<> so frustrated?', - from: { - phoneNumber: '(202) 555-0016', - name: 'Jon ❄️', - title: 'Jon ❄️', - color: 'green', - }, - to: { - phoneNumber: '(202) 555-0020', - title: '(202) 555-0020', - isMe: true, - }, -}); - -messageLookup.set('3-guid-guid-guid-guid-guid', { - id: '3-guid-guid-guid-guid-guid', - conversationId: 'EveryoneGroupID', - sentAt: Date.now() - 24 * 60 * 1000, - snippet: 'Hello, <>everyone<>! Woohooo!', - from: { - phoneNumber: '(202) 555-0011', - name: 'Someone', - title: 'Someone', - color: 'green', - avatarPath: pngUrl, - }, - to: { - phoneNumber: '(202) 555-0016', - name: "Y'all 🌆", - title: "Y'all 🌆", - }, -}); - -messageLookup.set('4-guid-guid-guid-guid-guid', { - id: '4-guid-guid-guid-guid-guid', - conversationId: 'EveryoneGroupID', - sentAt: Date.now() - 24 * 60 * 1000, - snippet: 'Well, <>everyone<>, happy new year!', - from: { - phoneNumber: '(202) 555-0020', - title: '(202) 555-0020', - isMe: true, - color: 'light_green', - avatarPath: gifUrl, - }, - to: { - phoneNumber: '(202) 555-0016', - name: "Y'all 🌆", - title: "Y'all 🌆", - }, -}); - -const defaultProps = { - discussionsLoading: false, - height: 700, - items: [], - i18n, - messagesLoading: false, - noResults: false, - openConversationInternal: action('open-conversation-internal'), - regionCode: 'US', - renderMessageSearchResult(id: string): JSX.Element { - const messageProps = messageLookup.get(id) as MessageSearchResultPropsType; - - return ( - - ); - }, - searchConversationName: undefined, - searchTerm: '1234567890', - selectedConversationId: undefined, - selectedMessageId: undefined, - startNewConversation: action('start-new-conversation'), - width: 320, -}; - -const conversations = [ - { - type: CONVERSATION, - data: { - id: '+12025550011', - phoneNumber: '(202) 555-0011', - name: 'Everyone 🌆', - title: 'Everyone 🌆', - type: GROUP, - color: 'signal-blue' as const, - avatarPath: landscapeGreenUrl, - isMe: false, - lastUpdated: Date.now() - 5 * 60 * 1000, - unreadCount: 0, - isSelected: false, - lastMessage: { - text: 'The rabbit hopped silently in the night.', - status: SENT, - }, - markedUnread: false, - }, - }, - { - type: CONVERSATION, - data: { - id: '+12025550012', - phoneNumber: '(202) 555-0012', - name: 'Everyone Else 🔥', - title: 'Everyone Else 🔥', - color: 'pink' as const, - type: DIRECT, - avatarPath: landscapePurpleUrl, - isMe: false, - lastUpdated: Date.now() - 5 * 60 * 1000, - unreadCount: 0, - isSelected: false, - lastMessage: { - text: "What's going on?", - status: SENT, - }, - markedUnread: false, - }, - }, -]; - -const contacts = [ - { - type: CONTACT, - data: { - id: '+12025550013', - phoneNumber: '(202) 555-0013', - name: 'The one Everyone', - title: 'The one Everyone', - color: 'blue' as const, - type: DIRECT, - avatarPath: gifUrl, - isMe: false, - lastUpdated: Date.now() - 10 * 60 * 1000, - unreadCount: 0, - isSelected: false, - markedUnread: false, - }, - }, - { - type: CONTACT, - data: { - id: '+12025550014', - phoneNumber: '(202) 555-0014', - name: 'No likey everyone', - title: 'No likey everyone', - type: DIRECT, - color: 'red' as const, - isMe: false, - lastUpdated: Date.now() - 11 * 60 * 1000, - unreadCount: 0, - isSelected: false, - markedUnread: false, - }, - }, -]; - -const messages = [ - { - type: MESSAGE, - data: '1-guid-guid-guid-guid-guid', - }, - { - type: MESSAGE, - data: '2-guid-guid-guid-guid-guid', - }, - { - type: MESSAGE, - data: '3-guid-guid-guid-guid-guid', - }, - { - type: MESSAGE, - data: '4-guid-guid-guid-guid-guid', - }, -]; - -const messagesMany = Array.from(Array(100), (_, i) => messages[i % 4]); - -const permutations = [ - { - title: 'SMS/MMS Not Supported Text', - props: { - items: [ - { - type: START_NEW_CONVERSATION, - data: undefined, - }, - { - type: SMS_MMS_NOT_SUPPORTED, - data: undefined, - }, - ], - }, - }, - { - title: 'All Result Types', - props: { - items: [ - { - type: CONVERSATIONS_HEADER, - data: undefined, - }, - ...conversations, - { - type: CONTACTS_HEADER, - data: undefined, - }, - ...contacts, - { - type: MESSAGES_HEADER, - data: undefined, - }, - ...messages, - ], - }, - }, - { - title: 'Start new Conversation', - props: { - items: [ - { - type: START_NEW_CONVERSATION, - data: undefined, - }, - { - type: CONVERSATIONS_HEADER, - data: undefined, - }, - ...conversations, - { - type: CONTACTS_HEADER, - data: undefined, - }, - ...contacts, - { - type: MESSAGES_HEADER, - data: undefined, - }, - ...messages, - ], - }, - }, - { - title: 'No Conversations', - props: { - items: [ - { - type: CONTACTS_HEADER, - data: undefined, - }, - ...contacts, - { - type: MESSAGES_HEADER, - data: undefined, - }, - ...messages, - ], - }, - }, - { - title: 'No Contacts', - props: { - items: [ - { - type: CONVERSATIONS_HEADER, - data: undefined, - }, - ...conversations, - { - type: MESSAGES_HEADER, - data: undefined, - }, - ...messages, - ], - }, - }, - { - title: 'No Messages', - props: { - items: [ - { - type: CONVERSATIONS_HEADER, - data: undefined, - }, - ...conversations, - { - type: CONTACTS_HEADER, - data: undefined, - }, - ...contacts, - ], - }, - }, - { - title: 'No Results', - props: { - noResults: true, - }, - }, - { - title: 'No Results, Searching in Conversation', - props: { - noResults: true, - searchInConversationName: 'Everyone 🔥', - searchTerm: 'something', - }, - }, - { - title: 'Searching in Conversation no search term', - props: { - noResults: true, - searchInConversationName: 'Everyone 🔥', - searchTerm: '', - }, - }, - { - title: 'Lots of results', - props: { - items: [ - { - type: CONVERSATIONS_HEADER, - data: undefined, - }, - ...conversations, - { - type: CONTACTS_HEADER, - data: undefined, - }, - ...contacts, - { - type: MESSAGES_HEADER, - data: undefined, - }, - ...messagesMany, - ], - }, - }, - { - title: 'Messages, no header', - props: { - items: messages, - }, - }, -]; - -storiesOf('Components/SearchResults', module).add('Iterations', () => { - return permutations.map(({ props, title }) => ( - <> -

{title}

-
- -
-
- - )); -}); diff --git a/ts/components/SearchResults.tsx b/ts/components/SearchResults.tsx deleted file mode 100644 index 135e7a2c0b..0000000000 --- a/ts/components/SearchResults.tsx +++ /dev/null @@ -1,611 +0,0 @@ -// Copyright 2019-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React, { CSSProperties } from 'react'; -import { CellMeasurer, CellMeasurerCache, List } from 'react-virtualized'; -import { debounce, get, isNumber } from 'lodash'; - -import { Intl } from './Intl'; -import { Emojify } from './conversation/Emojify'; -import { Spinner } from './Spinner'; -import { - ConversationListItem, - PropsData as ConversationListItemPropsType, -} from './ConversationListItem'; -import { StartNewConversation } from './StartNewConversation'; -import { cleanId } from './_util'; - -import { LocalizerType } from '../types/Util'; - -export type PropsDataType = { - discussionsLoading: boolean; - items: Array; - messagesLoading: boolean; - noResults: boolean; - regionCode: string; - searchConversationName?: string; - searchTerm: string; - selectedConversationId?: string; - selectedMessageId?: string; -}; - -type StartNewConversationType = { - type: 'start-new-conversation'; - data: undefined; -}; -type NotSupportedSMS = { - type: 'sms-mms-not-supported-text'; - data: undefined; -}; -type ConversationHeaderType = { - type: 'conversations-header'; - data: undefined; -}; -type ContactsHeaderType = { - type: 'contacts-header'; - data: undefined; -}; -type MessagesHeaderType = { - type: 'messages-header'; - data: undefined; -}; -type ConversationType = { - type: 'conversation'; - data: ConversationListItemPropsType; -}; -type ContactsType = { - type: 'contact'; - data: ConversationListItemPropsType; -}; -type MessageType = { - type: 'message'; - data: string; -}; -type SpinnerType = { - type: 'spinner'; - data: undefined; -}; - -export type SearchResultRowType = - | StartNewConversationType - | NotSupportedSMS - | ConversationHeaderType - | ContactsHeaderType - | MessagesHeaderType - | ConversationType - | ContactsType - | MessageType - | SpinnerType; - -type PropsHousekeepingType = { - i18n: LocalizerType; - openConversationInternal: (id: string, messageId?: string) => void; - startNewConversation: ( - query: string, - options: { regionCode: string } - ) => void; - height: number; - width: number; - - renderMessageSearchResult: (id: string) => JSX.Element; -}; - -type PropsType = PropsDataType & PropsHousekeepingType; -type StateType = { - scrollToIndex?: number; -}; - -// from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5 -type RowRendererParamsType = { - index: number; - isScrolling: boolean; - isVisible: boolean; - key: string; - parent: Record; - style: CSSProperties; -}; -type OnScrollParamsType = { - scrollTop: number; - clientHeight: number; - scrollHeight: number; - - clientWidth: number; - scrollWidth?: number; - scrollLeft?: number; - scrollToColumn?: number; - _hasScrolledToColumnTarget?: boolean; - scrollToRow?: number; - _hasScrolledToRowTarget?: boolean; -}; - -export class SearchResults extends React.Component { - public setFocusToFirstNeeded = false; - - public setFocusToLastNeeded = false; - - public cellSizeCache = new CellMeasurerCache({ - defaultHeight: 80, - fixedWidth: true, - }); - - public listRef = React.createRef(); - - public containerRef = React.createRef(); - - constructor(props: PropsType) { - super(props); - this.state = { - scrollToIndex: undefined, - }; - } - - public handleStartNewConversation = (): void => { - const { regionCode, searchTerm, startNewConversation } = this.props; - - startNewConversation(searchTerm, { regionCode }); - }; - - public handleKeyDown = (event: React.KeyboardEvent): void => { - const { items } = this.props; - const commandKey = get(window, 'platform') === 'darwin' && event.metaKey; - const controlKey = get(window, 'platform') !== 'darwin' && event.ctrlKey; - const commandOrCtrl = commandKey || controlKey; - - if (!items || items.length < 1) { - return; - } - - if (commandOrCtrl && !event.shiftKey && event.key === 'ArrowUp') { - this.setState({ scrollToIndex: 0 }); - this.setFocusToFirstNeeded = true; - - event.preventDefault(); - event.stopPropagation(); - - return; - } - - if (commandOrCtrl && !event.shiftKey && event.key === 'ArrowDown') { - const lastIndex = items.length - 1; - this.setState({ scrollToIndex: lastIndex }); - this.setFocusToLastNeeded = true; - - event.preventDefault(); - event.stopPropagation(); - } - }; - - public handleFocus = (): void => { - const { selectedConversationId, selectedMessageId } = this.props; - const { current: container } = this.containerRef; - - if (!container) { - return; - } - - if (document.activeElement === container) { - const scrollingContainer = this.getScrollContainer(); - - // First we try to scroll to the selected message - if (selectedMessageId && scrollingContainer) { - const target: HTMLElement | null = scrollingContainer.querySelector( - `.module-message-search-result[data-id="${selectedMessageId}"]` - ); - - if (target && target.focus) { - target.focus(); - - return; - } - } - - // Then we try for the selected conversation - if (selectedConversationId && scrollingContainer) { - const escapedId = cleanId(selectedConversationId).replace( - /["\\]/g, - '\\$&' - ); - const target: HTMLElement | null = scrollingContainer.querySelector( - `.module-conversation-list-item[data-id="${escapedId}"]` - ); - - if (target && target.focus) { - target.focus(); - - return; - } - } - - // Otherwise we set focus to the first non-header item - this.setFocusToFirst(); - } - }; - - public setFocusToFirst = (): void => { - const { current: container } = this.containerRef; - - if (container) { - const noResultsItem: HTMLElement | null = container.querySelector( - '.module-search-results__no-results' - ); - if (noResultsItem && noResultsItem.focus) { - noResultsItem.focus(); - - return; - } - } - - const scrollContainer = this.getScrollContainer(); - if (!scrollContainer) { - return; - } - - const startItem: HTMLElement | null = scrollContainer.querySelector( - '.module-start-new-conversation' - ); - if (startItem && startItem.focus) { - startItem.focus(); - - return; - } - - const conversationItem: HTMLElement | null = scrollContainer.querySelector( - '.module-conversation-list-item' - ); - if (conversationItem && conversationItem.focus) { - conversationItem.focus(); - - return; - } - - const messageItem: HTMLElement | null = scrollContainer.querySelector( - '.module-message-search-result' - ); - if (messageItem && messageItem.focus) { - messageItem.focus(); - } - }; - - public getScrollContainer = (): HTMLDivElement | null => { - if (!this.listRef || !this.listRef.current) { - return null; - } - - const list = this.listRef.current; - - // We're using an internal variable (_scrollingContainer)) here, - // so cannot rely on the public type. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const grid: any = list.Grid; - if (!grid || !grid._scrollingContainer) { - return null; - } - - return grid._scrollingContainer as HTMLDivElement; - }; - - public onScroll = debounce( - (data: OnScrollParamsType) => { - // Ignore scroll events generated as react-virtualized recursively scrolls and - // re-measures to get us where we want to go. - if ( - isNumber(data.scrollToRow) && - data.scrollToRow >= 0 && - !data._hasScrolledToRowTarget - ) { - return; - } - - this.setState({ scrollToIndex: undefined }); - - if (this.setFocusToFirstNeeded) { - this.setFocusToFirstNeeded = false; - this.setFocusToFirst(); - } - - if (this.setFocusToLastNeeded) { - this.setFocusToLastNeeded = false; - - const scrollContainer = this.getScrollContainer(); - if (!scrollContainer) { - return; - } - - const messageItems: NodeListOf = scrollContainer.querySelectorAll( - '.module-message-search-result' - ); - if (messageItems && messageItems.length > 0) { - const last = messageItems[messageItems.length - 1]; - - if (last && last.focus) { - last.focus(); - - return; - } - } - - const contactItems: NodeListOf = scrollContainer.querySelectorAll( - '.module-conversation-list-item' - ); - if (contactItems && contactItems.length > 0) { - const last = contactItems[contactItems.length - 1]; - - if (last && last.focus) { - last.focus(); - - return; - } - } - - const startItem = scrollContainer.querySelectorAll( - '.module-start-new-conversation' - ) as NodeListOf; - if (startItem && startItem.length > 0) { - const last = startItem[startItem.length - 1]; - - if (last && last.focus) { - last.focus(); - } - } - } - }, - 100, - { maxWait: 100 } - ); - - public renderRowContents(row: SearchResultRowType): JSX.Element { - const { - searchTerm, - i18n, - openConversationInternal, - renderMessageSearchResult, - } = this.props; - - if (row.type === 'start-new-conversation') { - return ( - - ); - } - if (row.type === 'sms-mms-not-supported-text') { - return ( -
- {i18n('notSupportedSMS')} -
- ); - } - if (row.type === 'conversations-header') { - return ( -
- {i18n('conversationsHeader')} -
- ); - } - if (row.type === 'conversation') { - const { data } = row; - - return ( - - ); - } - if (row.type === 'contacts-header') { - return ( -
- {i18n('contactsHeader')} -
- ); - } - if (row.type === 'contact') { - const { data } = row; - - return ( - - ); - } - if (row.type === 'messages-header') { - return ( -
- {i18n('messagesHeader')} -
- ); - } - if (row.type === 'message') { - const { data } = row; - - return renderMessageSearchResult(data); - } - if (row.type === 'spinner') { - return ( -
- -
- ); - } - throw new Error( - 'SearchResults.renderRowContents: Encountered unknown row type' - ); - } - - public renderRow = ({ - index, - key, - parent, - style, - }: RowRendererParamsType): JSX.Element => { - const { items, width } = this.props; - - const row = items[index]; - - return ( -
- - {this.renderRowContents(row)} - -
- ); - }; - - public componentDidUpdate(prevProps: PropsType): void { - const { - items, - searchTerm, - discussionsLoading, - messagesLoading, - } = this.props; - - if (searchTerm !== prevProps.searchTerm) { - this.resizeAll(); - } else if ( - discussionsLoading !== prevProps.discussionsLoading || - messagesLoading !== prevProps.messagesLoading - ) { - this.resizeAll(); - } else if ( - items && - prevProps.items && - prevProps.items.length !== items.length - ) { - this.resizeAll(); - } - } - - public getList = (): List | null => { - if (!this.listRef) { - return null; - } - - const { current } = this.listRef; - - return current; - }; - - public recomputeRowHeights = (row?: number): void => { - const list = this.getList(); - if (!list) { - return; - } - - list.recomputeRowHeights(row); - }; - - public resizeAll = (): void => { - this.cellSizeCache.clearAll(); - this.recomputeRowHeights(0); - }; - - public getRowCount(): number { - const { items } = this.props; - - return items ? items.length : 0; - } - - public render(): JSX.Element { - const { - height, - i18n, - items, - noResults, - searchConversationName, - searchTerm, - width, - } = this.props; - const { scrollToIndex } = this.state; - - if (noResults) { - return ( -
- {!searchConversationName || searchTerm ? ( -
- {searchConversationName ? ( - - ), - }} - /> - ) : ( - i18n('noSearchResults', [searchTerm]) - )} -
- ) : null} -
- ); - } - - return ( -
- -
- ); - } -} diff --git a/ts/components/ShortcutGuide.tsx b/ts/components/ShortcutGuide.tsx index b1810ece09..4adf76dbff 100644 --- a/ts/components/ShortcutGuide.tsx +++ b/ts/components/ShortcutGuide.tsx @@ -1,4 +1,4 @@ -// Copyright 2019-2020 Signal Messenger, LLC +// Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -32,6 +32,7 @@ type KeyType = | 'J' | 'L' | 'M' + | 'N' | 'P' | 'R' | 'S' @@ -84,6 +85,10 @@ const NAVIGATION_SHORTCUTS: Array = [ description: 'Keyboard--open-conversation-menu', keys: [['commandOrCtrl', 'shift', 'L']], }, + { + description: 'Keyboard--new-conversation', + keys: [['commandOrCtrl', 'N']], + }, { description: 'Keyboard--search', keys: [['commandOrCtrl', 'F']], diff --git a/ts/components/StartNewConversation.stories.tsx b/ts/components/StartNewConversation.stories.tsx deleted file mode 100644 index d6bc78fda5..0000000000 --- a/ts/components/StartNewConversation.stories.tsx +++ /dev/null @@ -1,38 +0,0 @@ -// 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 { text } from '@storybook/addon-knobs'; -import { Props, StartNewConversation } from './StartNewConversation'; - -import { setup as setupI18n } from '../../js/modules/i18n'; -import enMessages from '../../_locales/en/messages.json'; - -const i18n = setupI18n('en', enMessages); - -const createProps = (overrideProps: Partial = {}): Props => ({ - i18n, - onClick: action('onClick'), - phoneNumber: text('phoneNumber', overrideProps.phoneNumber || ''), -}); - -const stories = storiesOf('Components/StartNewConversation', module); - -stories.add('Full Phone Number', () => { - const props = createProps({ - phoneNumber: '(202) 555-0011', - }); - - return ; -}); - -stories.add('Partial Phone Number', () => { - const props = createProps({ - phoneNumber: '202', - }); - - return ; -}); diff --git a/ts/components/StartNewConversation.tsx b/ts/components/StartNewConversation.tsx deleted file mode 100644 index 34cc5947a4..0000000000 --- a/ts/components/StartNewConversation.tsx +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright 2019-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React from 'react'; - -import { Avatar } from './Avatar'; - -import { LocalizerType } from '../types/Util'; - -export type Props = { - phoneNumber: string; - i18n: LocalizerType; - onClick: () => void; -}; - -export class StartNewConversation extends React.PureComponent { - public render(): JSX.Element { - const { phoneNumber, i18n, onClick } = this.props; - - return ( - - ); - } -} diff --git a/ts/components/Tooltip.tsx b/ts/components/Tooltip.tsx index 6c3f67c569..d3e67b112c 100644 --- a/ts/components/Tooltip.tsx +++ b/ts/components/Tooltip.tsx @@ -6,6 +6,7 @@ import classNames from 'classnames'; import { noop } from 'lodash'; import { Manager, Reference, Popper } from 'react-popper'; import { Theme, themeClassName } from '../util/theme'; +import { multiRef } from '../util/multiRef'; type EventWrapperPropsType = { children: React.ReactNode; @@ -22,6 +23,14 @@ const TooltipEventWrapper = React.forwardRef< >(({ onHoverChanged, children }, ref) => { const wrapperRef = React.useRef(null); + const on = React.useCallback(() => { + onHoverChanged(true); + }, [onHoverChanged]); + + const off = React.useCallback(() => { + onHoverChanged(false); + }, [onHoverChanged]); + React.useEffect(() => { const wrapperEl = wrapperRef.current; @@ -29,44 +38,20 @@ const TooltipEventWrapper = React.forwardRef< return noop; } - const on = () => { - onHoverChanged(true); - }; - const off = () => { - onHoverChanged(false); - }; - - wrapperEl.addEventListener('focus', on); - wrapperEl.addEventListener('blur', off); wrapperEl.addEventListener('mouseenter', on); wrapperEl.addEventListener('mouseleave', off); return () => { - wrapperEl.removeEventListener('focus', on); - wrapperEl.removeEventListener('blur', off); wrapperEl.removeEventListener('mouseenter', on); wrapperEl.removeEventListener('mouseleave', off); }; - }, [onHoverChanged]); + }, [on, off]); return ( { - wrapperRef.current = el; - - // This is a simplified version of [what React does][0] to set a ref. - // [0]: https://github.com/facebook/react/blob/29b7b775f2ecf878eaf605be959d959030598b07/packages/react-reconciler/src/ReactFiberCommitWork.js#L661-L677 - if (typeof ref === 'function') { - ref(el); - } else if (ref) { - // I believe the types for `ref` are wrong in this case, as `ref.current` should - // not be `readonly`. That's why we do this cast. See [the React source][1]. - // [1]: https://github.com/facebook/react/blob/29b7b775f2ecf878eaf605be959d959030598b07/packages/shared/ReactTypes.js#L78-L80 - // eslint-disable-next-line no-param-reassign - (ref as React.MutableRefObject).current = el; - } - }} + onFocus={on} + onBlur={off} + ref={multiRef(ref, wrapperRef)} > {children} diff --git a/ts/components/conversation/About.tsx b/ts/components/conversation/About.tsx index 779cded2ea..fb3ee855a7 100644 --- a/ts/components/conversation/About.tsx +++ b/ts/components/conversation/About.tsx @@ -1,4 +1,4 @@ -// Copyright 2018-2020 Signal Messenger, LLC +// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; @@ -6,16 +6,20 @@ import React from 'react'; import { Emojify } from './Emojify'; export type PropsType = { + className?: string; text?: string; }; -export const About = ({ text }: PropsType): JSX.Element | null => { +export const About = ({ + className = 'module-about__text', + text, +}: PropsType): JSX.Element | null => { if (!text) { return null; } return ( - + ); diff --git a/ts/components/conversation/AtMentionify.tsx b/ts/components/conversation/AtMentionify.tsx index b6af418671..3c3726bbb4 100644 --- a/ts/components/conversation/AtMentionify.tsx +++ b/ts/components/conversation/AtMentionify.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; +import { sortBy } from 'lodash'; import { Emojify } from './Emojify'; import { BodyRangesType } from '../../types/Util'; @@ -102,7 +103,8 @@ AtMentionify.preprocessMentions = ( return text; } - return bodyRanges.reduce((str, range) => { + // Sorting by the start index to ensure that we always replace last -> first. + return sortBy(bodyRanges, 'start').reduceRight((str, range) => { const textBegin = str.substr(0, range.start); const encodedMention = `\uFFFC@${range.start}`; const textEnd = str.substr(range.start + range.length, str.length); diff --git a/ts/components/conversation/CallingNotification.tsx b/ts/components/conversation/CallingNotification.tsx index e4ad7eb166..565503afe9 100644 --- a/ts/components/conversation/CallingNotification.tsx +++ b/ts/components/conversation/CallingNotification.tsx @@ -1,7 +1,7 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import Measure from 'react-measure'; import { Timestamp } from './Timestamp'; @@ -11,6 +11,7 @@ import { CallingNotificationType, getCallingNotificationText, } from '../../util/callingNotification'; +import { usePrevious } from '../../util/hooks'; import { missingCaseError } from '../../util/missingCaseError'; import { Tooltip, TooltipPlacement } from '../Tooltip'; @@ -34,23 +35,18 @@ type PropsType = CallingNotificationType & PropsActionsType & PropsHousekeeping; export const CallingNotification: React.FC = React.memo(props => { const { conversationId, i18n, messageId, messageSizeChanged } = props; - const previousHeightRef = useRef(null); const [height, setHeight] = useState(null); + const previousHeight = usePrevious(null, height); useEffect(() => { if (height === null) { return; } - if ( - previousHeightRef.current !== null && - height !== previousHeightRef.current - ) { + if (previousHeight !== null && height !== previousHeight) { messageSizeChanged(messageId, conversationId); } - - previousHeightRef.current = height; - }, [height, conversationId, messageId, messageSizeChanged]); + }, [height, previousHeight, conversationId, messageId, messageSizeChanged]); let timestamp: number; let callType: 'audio' | 'video'; diff --git a/ts/components/conversation/ChatSessionRefreshedDialog.stories.tsx b/ts/components/conversation/ChatSessionRefreshedDialog.stories.tsx new file mode 100644 index 0000000000..b08bad13b8 --- /dev/null +++ b/ts/components/conversation/ChatSessionRefreshedDialog.stories.tsx @@ -0,0 +1,25 @@ +// Copyright 2021 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 { ChatSessionRefreshedDialog } from './ChatSessionRefreshedDialog'; + +const i18n = setupI18n('en', enMessages); + +storiesOf('Components/Conversation/ChatSessionRefreshedDialog', module).add( + 'Default', + () => { + return ( + + ); + } +); diff --git a/ts/components/conversation/ChatSessionRefreshedDialog.tsx b/ts/components/conversation/ChatSessionRefreshedDialog.tsx new file mode 100644 index 0000000000..cb73ab4cdf --- /dev/null +++ b/ts/components/conversation/ChatSessionRefreshedDialog.tsx @@ -0,0 +1,57 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import classNames from 'classnames'; + +import { LocalizerType } from '../../types/Util'; + +export type PropsType = { + i18n: LocalizerType; + contactSupport: () => unknown; + onClose: () => unknown; +}; + +export function ChatSessionRefreshedDialog( + props: PropsType +): React.ReactElement { + const { i18n, contactSupport, onClose } = props; + + return ( +
+
+ +
+
+ {i18n('ChatRefresh--notification')} +
+
+ {i18n('ChatRefresh--summary')} +
+
+ + +
+
+ ); +} diff --git a/ts/components/conversation/ChatSessionRefreshedNotification.stories.tsx b/ts/components/conversation/ChatSessionRefreshedNotification.stories.tsx new file mode 100644 index 0000000000..80c14cb5eb --- /dev/null +++ b/ts/components/conversation/ChatSessionRefreshedNotification.stories.tsx @@ -0,0 +1,24 @@ +// Copyright 2021 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 { ChatSessionRefreshedNotification } from './ChatSessionRefreshedNotification'; + +const i18n = setupI18n('en', enMessages); + +storiesOf( + 'Components/Conversation/ChatSessionRefreshedNotification', + module +).add('Default', () => { + return ( + + ); +}); diff --git a/ts/components/conversation/ChatSessionRefreshedNotification.tsx b/ts/components/conversation/ChatSessionRefreshedNotification.tsx new file mode 100644 index 0000000000..cf5e316744 --- /dev/null +++ b/ts/components/conversation/ChatSessionRefreshedNotification.tsx @@ -0,0 +1,63 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useCallback, useState, ReactElement } from 'react'; + +import { LocalizerType } from '../../types/Util'; + +import { ModalHost } from '../ModalHost'; +import { ChatSessionRefreshedDialog } from './ChatSessionRefreshedDialog'; + +type PropsHousekeepingType = { + i18n: LocalizerType; +}; + +export type PropsActionsType = { + contactSupport: () => unknown; +}; + +export type PropsType = PropsHousekeepingType & PropsActionsType; + +export function ChatSessionRefreshedNotification( + props: PropsType +): ReactElement { + const { contactSupport, i18n } = props; + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const openDialog = useCallback(() => { + setIsDialogOpen(true); + }, [setIsDialogOpen]); + const closeDialog = useCallback(() => { + setIsDialogOpen(false); + }, [setIsDialogOpen]); + + const wrappedContactSupport = useCallback(() => { + setIsDialogOpen(false); + contactSupport(); + }, [contactSupport, setIsDialogOpen]); + + return ( +
+
+ + {i18n('ChatRefresh--notification')} +
+ + {isDialogOpen ? ( + + + + ) : null} +
+ ); +} diff --git a/ts/components/conversation/ContactName.tsx b/ts/components/conversation/ContactName.tsx index 5ae4b80c0f..e4b132769f 100644 --- a/ts/components/conversation/ContactName.tsx +++ b/ts/components/conversation/ContactName.tsx @@ -7,20 +7,34 @@ import { LocalizerType } from '../../types/Util'; import { Emojify } from './Emojify'; export type PropsType = { + firstName?: string; i18n: LocalizerType; - title: string; module?: string; name?: string; phoneNumber?: string; + preferFirstName?: boolean; profileName?: string; + title: string; }; -export const ContactName = ({ module, title }: PropsType): JSX.Element => { +export const ContactName = ({ + firstName, + module, + preferFirstName, + title, +}: PropsType): JSX.Element => { const prefix = module || 'module-contact-name'; + let text: string; + if (preferFirstName) { + text = firstName || title || ''; + } else { + text = title || ''; + } + return ( - + ); }; diff --git a/ts/components/conversation/ConversationHeader.stories.tsx b/ts/components/conversation/ConversationHeader.stories.tsx index 5f005ac1fe..c7d34124cc 100644 --- a/ts/components/conversation/ConversationHeader.stories.tsx +++ b/ts/components/conversation/ConversationHeader.stories.tsx @@ -159,6 +159,20 @@ const stories: Array = [ acceptedMessageRequest: true, }, }, + { + title: 'Disappearing messages + verified', + props: { + ...commonProps, + color: 'indigo', + title: '(202) 555-0005', + phoneNumber: '(202) 555-0005', + type: 'direct', + id: '5', + expireTimer: 60, + acceptedMessageRequest: true, + isVerified: true, + }, + }, { title: 'Muting Conversation', props: { diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index c515539d04..a63b968b52 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -1,7 +1,8 @@ -// Copyright 2018-2020 Signal Messenger, LLC +// Copyright 2018-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React from 'react'; +import React, { ReactNode } from 'react'; +import Measure from 'react-measure'; import moment from 'moment'; import classNames from 'classnames'; import { @@ -17,7 +18,7 @@ import { InContactsIcon } from '../InContactsIcon'; import { LocalizerType } from '../../types/Util'; import { ColorType } from '../../types/Colors'; -import { getMuteOptions } from '../../util/getMuteOptions'; +import { MuteOption, getMuteOptions } from '../../util/getMuteOptions'; import { ExpirationTimerOptions, TimerOption, @@ -92,27 +93,33 @@ export type PropsType = PropsDataType & PropsActionsType & PropsHousekeepingType; -export class ConversationHeader extends React.Component { - public showMenuBound: (event: React.MouseEvent) => void; +type StateType = { + isNarrow: boolean; +}; + +export class ConversationHeader extends React.Component { + private showMenuBound: (event: React.MouseEvent) => void; // Comes from a third-party dependency // eslint-disable-next-line @typescript-eslint/no-explicit-any - public menuTriggerRef: React.RefObject; + private menuTriggerRef: React.RefObject; public constructor(props: PropsType) { super(props); + this.state = { isNarrow: false }; + this.menuTriggerRef = React.createRef(); this.showMenuBound = this.showMenu.bind(this); } - public showMenu(event: React.MouseEvent): void { + private showMenu(event: React.MouseEvent): void { if (this.menuTriggerRef.current) { this.menuTriggerRef.current.handleContextClick(event); } } - public renderBackButton(): JSX.Element { + private renderBackButton(): ReactNode { const { i18n, onGoBack, showBackButton } = this.props; return ( @@ -120,8 +127,8 @@ export class ConversationHeader extends React.Component { type="button" onClick={onGoBack} className={classNames( - 'module-conversation-header__back-icon', - showBackButton ? 'module-conversation-header__back-icon--show' : null + 'module-ConversationHeader__back-icon', + showBackButton ? 'module-ConversationHeader__back-icon--show' : null )} disabled={!showBackButton} aria-label={i18n('goBack')} @@ -129,51 +136,49 @@ export class ConversationHeader extends React.Component { ); } - public renderTitle(): JSX.Element | null { - const { - name, - phoneNumber, - title, - type, - i18n, - isMe, - profileName, - isVerified, - } = this.props; + private renderHeaderInfoTitle(): ReactNode { + const { name, title, type, i18n, isMe } = this.props; if (isMe) { return ( -
+
{i18n('noteToSelf')}
); } const shouldShowIcon = Boolean(name && type === 'direct'); - const shouldShowNumber = Boolean(phoneNumber && (name || profileName)); return ( -
+
{shouldShowIcon ? ( - - {' '} - - - ) : null} - {shouldShowNumber ? ` · ${phoneNumber}` : null} - {isVerified ? ( - - {' · '} - - {i18n('verified')} - + ) : null}
); } - public renderAvatar(): JSX.Element { + private renderHeaderInfoSubtitle(): ReactNode { + const expirationNode = this.renderExpirationLength(); + const verifiedNode = this.renderVerifiedIcon(); + + if (expirationNode || verifiedNode) { + return ( +
+ {expirationNode} + {verifiedNode} +
+ ); + } + + return null; + } + + private renderAvatar(): ReactNode { const { avatarPath, color, @@ -187,7 +192,7 @@ export class ConversationHeader extends React.Component { } = this.props; return ( - + { ); } - public renderExpirationLength(): JSX.Element | null { - const { i18n, expireTimer, showBackButton } = this.props; + private renderExpirationLength(): ReactNode { + const { i18n, expireTimer } = this.props; const expirationSettingName = expireTimer - ? ExpirationTimerOptions.getName(i18n, expireTimer) + ? ExpirationTimerOptions.getAbbreviated(i18n, expireTimer) : undefined; if (!expirationSettingName) { return null; } return ( -
-
-
- {expirationSettingName} -
+
+ {expirationSettingName}
); } - public renderMoreButton(triggerId: string): JSX.Element { + private renderVerifiedIcon(): ReactNode { + const { i18n, isVerified } = this.props; + + if (!isVerified) { + return null; + } + + return ( +
+ {i18n('verified')} +
+ ); + } + + private renderMoreButton(triggerId: string): ReactNode { const { i18n, showBackButton } = this.props; return ( @@ -240,10 +249,9 @@ export class ConversationHeader extends React.Component { type="button" onClick={this.showMenuBound} className={classNames( - 'module-conversation-header__more-button', - showBackButton - ? null - : 'module-conversation-header__more-button--show' + 'module-ConversationHeader__button', + 'module-ConversationHeader__button--more', + showBackButton ? null : 'module-ConversationHeader__button--show' )} disabled={showBackButton} aria-label={i18n('moreInfo')} @@ -252,7 +260,7 @@ export class ConversationHeader extends React.Component { ); } - public renderSearchButton(): JSX.Element { + private renderSearchButton(): ReactNode { const { i18n, onSearchInConversation, showBackButton } = this.props; return ( @@ -260,10 +268,9 @@ export class ConversationHeader extends React.Component { type="button" onClick={onSearchInConversation} className={classNames( - 'module-conversation-header__search-button', - showBackButton - ? null - : 'module-conversation-header__search-button--show' + 'module-ConversationHeader__button', + 'module-ConversationHeader__button--search', + showBackButton ? null : 'module-ConversationHeader__button--show' )} disabled={showBackButton} aria-label={i18n('search')} @@ -271,7 +278,7 @@ export class ConversationHeader extends React.Component { ); } - private renderOutgoingCallButtons(): JSX.Element | null { + private renderOutgoingCallButtons(): ReactNode { const { i18n, onOutgoingAudioCallInConversation, @@ -279,17 +286,16 @@ export class ConversationHeader extends React.Component { outgoingCallButtonStyle, showBackButton, } = this.props; + const { isNarrow } = this.state; const videoButton = ( ); default: @@ -342,7 +347,7 @@ export class ConversationHeader extends React.Component { } } - public renderMenu(triggerId: string): JSX.Element { + private renderMenu(triggerId: string): ReactNode { const { i18n, acceptedMessageRequest, @@ -370,7 +375,7 @@ export class ConversationHeader extends React.Component { onMoveToInbox, } = this.props; - const muteOptions = []; + const muteOptions: Array = []; if (isMuted(muteExpiresAt)) { const expires = moment(muteExpiresAt); const muteExpirationLabel = moment().isSame(expires, 'day') @@ -406,10 +411,7 @@ export class ConversationHeader extends React.Component { isMissingMandatoryProfileSharing ); - const hasGV2AdminEnabled = - isGroup && - groupVersion === 2 && - window.Signal.RemoteConfig.isEnabled('desktop.gv2Admin'); + const hasGV2AdminEnabled = isGroup && groupVersion === 2; return ( @@ -484,7 +486,7 @@ export class ConversationHeader extends React.Component { ); } - private renderHeader(): JSX.Element { + private renderHeader(): ReactNode { const { conversationTitle, groupVersion, @@ -497,92 +499,94 @@ export class ConversationHeader extends React.Component { if (conversationTitle !== undefined) { return ( -
-
- {conversationTitle} +
+
+
+ {conversationTitle} +
); } - const hasGV2AdminEnabled = - groupVersion === 2 && - window.Signal.RemoteConfig.isEnabled('desktop.gv2Admin'); - - if (type === 'group' && hasGV2AdminEnabled) { - const onHeaderClick = () => onShowConversationDetails(); - const onKeyDown = (e: React.KeyboardEvent): void => { - if (e.key === 'Enter' || e.key === ' ') { - e.stopPropagation(); - e.preventDefault(); - - onShowConversationDetails(); - } - }; - - return ( -
- {this.renderAvatar()} - {this.renderTitle()} -
- ); - } - - if (type === 'group' || isMe) { - return ( -
- {this.renderAvatar()} - {this.renderTitle()} -
- ); - } - - const onContactClick = () => onShowContactModal(id); - const onKeyDown = (e: React.KeyboardEvent): void => { - if (e.key === 'Enter' || e.key === ' ') { - e.stopPropagation(); - e.preventDefault(); - - onShowContactModal(id); + let onClick: undefined | (() => void); + switch (type) { + case 'direct': + onClick = isMe + ? undefined + : () => { + onShowContactModal(id); + }; + break; + case 'group': { + const hasGV2AdminEnabled = groupVersion === 2; + onClick = hasGV2AdminEnabled + ? () => { + onShowConversationDetails(); + } + : undefined; + break; } - }; + default: + throw missingCaseError(type); + } - return ( -
+ const contents = ( + <> {this.renderAvatar()} - {this.renderTitle()} -
+
+ {this.renderHeaderInfoTitle()} + {this.renderHeaderInfoSubtitle()} +
+ ); + + if (onClick) { + return ( + + ); + } + + return
{contents}
; } - public render(): JSX.Element { + public render(): ReactNode { const { id } = this.props; + const { isNarrow } = this.state; const triggerId = `conversation-${id}`; return ( -
- {this.renderBackButton()} -
- {this.renderHeader()} -
- {this.renderExpirationLength()} - {this.renderOutgoingCallButtons()} - {this.renderSearchButton()} - {this.renderMoreButton(triggerId)} - {this.renderMenu(triggerId)} -
+ { + if (!bounds || !bounds.width) { + return; + } + this.setState({ isNarrow: bounds.width < 500 }); + }} + > + {({ measureRef }) => ( +
+ {this.renderBackButton()} + {this.renderHeader()} + {this.renderOutgoingCallButtons()} + {this.renderSearchButton()} + {this.renderMoreButton(triggerId)} + {this.renderMenu(triggerId)} +
+ )} +
); } } diff --git a/ts/components/conversation/ConversationHero.tsx b/ts/components/conversation/ConversationHero.tsx index a4e3b446f0..72a3fd2b79 100644 --- a/ts/components/conversation/ConversationHero.tsx +++ b/ts/components/conversation/ConversationHero.tsx @@ -23,22 +23,26 @@ export type Props = { const renderMembershipRow = ({ i18n, - sharedGroupNames, + phoneNumber, + sharedGroupNames = [], conversationType, isMe, -}: Pick) => { +}: Pick< + Props, + 'i18n' | 'phoneNumber' | 'sharedGroupNames' | 'conversationType' | 'isMe' +>) => { const className = 'module-conversation-hero__membership'; const nameClassName = `${className}__name`; + if (conversationType !== 'direct') { + return null; + } + if (isMe) { return
{i18n('noteToSelfHero')}
; } - if ( - conversationType === 'direct' && - sharedGroupNames && - sharedGroupNames.length > 0 - ) { + if (sharedGroupNames.length > 0) { const firstThreeGroups = take(sharedGroupNames, 3).map((group, i) => ( // We cannot guarantee uniqueness of group names // eslint-disable-next-line react/no-array-index-key @@ -108,6 +112,10 @@ const renderMembershipRow = ({ } } + if (!phoneNumber) { + return
{i18n('no-groups-in-common')}
; + } + return null; }; @@ -207,7 +215,13 @@ export const ConversationHero = ({ : phoneNumber}
) : null} - {renderMembershipRow({ isMe, sharedGroupNames, conversationType, i18n })} + {renderMembershipRow({ + conversationType, + i18n, + isMe, + phoneNumber, + sharedGroupNames, + })}
); /* eslint-enable no-nested-ternary */ diff --git a/ts/components/conversation/GroupV1Migration.tsx b/ts/components/conversation/GroupV1Migration.tsx index 3bd068253f..08c0c4ddd2 100644 --- a/ts/components/conversation/GroupV1Migration.tsx +++ b/ts/components/conversation/GroupV1Migration.tsx @@ -7,7 +7,6 @@ import { LocalizerType } from '../../types/Util'; import { ConversationType } from '../../state/ducks/conversations'; import { Intl } from '../Intl'; import { ContactName } from './ContactName'; -import { ModalHost } from '../ModalHost'; import { GroupV1MigrationDialog } from '../GroupV1MigrationDialog'; export type PropsDataType = { @@ -58,19 +57,17 @@ export function GroupV1Migration(props: PropsType): React.ReactElement { {i18n('GroupV1--Migration--learn-more')} {showingDialog ? ( - - - window.log.warn('GroupV1Migration: Modal called migrate()') - } - onClose={dismissDialog} - /> - + + window.log.warn('GroupV1Migration: Modal called migrate()') + } + onClose={dismissDialog} + /> ) : null}
); diff --git a/ts/components/conversation/Image.stories.tsx b/ts/components/conversation/Image.stories.tsx index a5e3f8fe1e..ce31b1e468 100644 --- a/ts/components/conversation/Image.stories.tsx +++ b/ts/components/conversation/Image.stories.tsx @@ -186,8 +186,6 @@ story.add('Blurhash', () => { const props = { ...defaultProps, blurHash: 'thisisafakeblurhashthatwasmadeup', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - url: undefined as any, }; return ; @@ -198,8 +196,6 @@ story.add('undefined blurHash (light)', () => { const props = { ...defaultProps, blurHash: undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - url: undefined as any, theme: ThemeType.light, }; @@ -211,8 +207,6 @@ story.add('undefined blurHash (dark)', () => { const props = { ...defaultProps, blurHash: undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - url: undefined as any, theme: ThemeType.dark, }; @@ -225,8 +219,6 @@ story.add('Missing Image', () => { ...defaultProps, // eslint-disable-next-line @typescript-eslint/no-explicit-any attachment: undefined as any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - url: undefined as any, }; return ; diff --git a/ts/components/conversation/Image.tsx b/ts/components/conversation/Image.tsx index 874a7e1969..e196acd230 100644 --- a/ts/components/conversation/Image.tsx +++ b/ts/components/conversation/Image.tsx @@ -12,7 +12,7 @@ import { AttachmentType, hasNotDownloaded } from '../../types/Attachment'; export type Props = { alt: string; attachment: AttachmentType; - url: string; + url?: string; height?: number; width?: number; diff --git a/ts/components/conversation/Message.stories.tsx b/ts/components/conversation/Message.stories.tsx index c9afc3b521..4b39cbffd9 100644 --- a/ts/components/conversation/Message.stories.tsx +++ b/ts/components/conversation/Message.stories.tsx @@ -3,14 +3,16 @@ import * as React from 'react'; import { isBoolean } from 'lodash'; +import LRU from 'lru-cache'; import { action } from '@storybook/addon-actions'; -import { boolean, number, text } from '@storybook/addon-knobs'; +import { boolean, number, text, select } from '@storybook/addon-knobs'; import { storiesOf } from '@storybook/react'; import { Colors } from '../../types/Colors'; +import { WaveformCache } from '../../types/Audio'; import { EmojiPicker } from '../emoji/EmojiPicker'; -import { Message, Props } from './Message'; +import { Message, Props, AudioAttachmentProps } from './Message'; import { AUDIO_MP3, IMAGE_JPEG, @@ -19,6 +21,7 @@ import { MIMEType, VIDEO_MP4, } from '../../types/MIME'; +import { MessageAudio } from './MessageAudio'; import { setup as setupI18n } from '../../../js/modules/i18n'; import enMessages from '../../../_locales/en/messages.json'; import { pngUrl } from '../../storybook/Fixtures'; @@ -42,10 +45,35 @@ const renderEmojiPicker: Props['renderEmojiPicker'] = ({ /> ); +const MessageAudioContainer: React.FC = props => { + const [activeAudioID, setActiveAudioID] = React.useState( + undefined + ); + const audio = React.useMemo(() => new Audio(), []); + const audioContext = React.useMemo(() => new AudioContext(), []); + const waveformCache: WaveformCache = React.useMemo(() => new LRU(), []); + + return ( + + ); +}; + +const renderAudioAttachment: Props['renderAudioAttachment'] = props => ( + +); + const createProps = (overrideProps: Partial = {}): Props => ({ attachments: overrideProps.attachments, authorId: overrideProps.authorId || 'some-id', - authorColor: overrideProps.authorColor || 'blue', + authorColor: select('authorColor', Colors, Colors[0]), authorAvatarPath: overrideProps.authorAvatarPath, authorTitle: text('authorTitle', overrideProps.authorTitle || ''), bodyRanges: overrideProps.bodyRanges, @@ -73,6 +101,9 @@ const createProps = (overrideProps: Partial = {}): Props => ({ i18n, id: text('id', overrideProps.id || ''), interactionMode: overrideProps.interactionMode || 'keyboard', + isSticker: isBoolean(overrideProps.isSticker) + ? overrideProps.isSticker + : false, isBlocked: isBoolean(overrideProps.isBlocked) ? overrideProps.isBlocked : false, @@ -83,12 +114,14 @@ const createProps = (overrideProps: Partial = {}): Props => ({ isTapToViewError: overrideProps.isTapToViewError, isTapToViewExpired: overrideProps.isTapToViewExpired, kickOffAttachmentDownload: action('kickOffAttachmentDownload'), + markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), openConversation: action('openConversation'), openLink: action('openLink'), previews: overrideProps.previews || [], reactions: overrideProps.reactions, reactToMessage: action('reactToMessage'), renderEmojiPicker, + renderAudioAttachment, replyToMessage: action('replyToMessage'), retrySend: action('retrySend'), scrollToQuotedMessage: action('scrollToQuotedMessage'), @@ -724,6 +757,35 @@ story.add('Audio with Caption', () => { return renderBothDirections(props); }); +story.add('Audio with Not Downloaded Attachment', () => { + const props = createProps({ + attachments: [ + { + contentType: AUDIO_MP3, + fileName: 'incompetech-com-Agnus-Dei-X.mp3', + }, + ], + status: 'sent', + }); + + return renderBothDirections(props); +}); + +story.add('Audio with Pending Attachment', () => { + const props = createProps({ + attachments: [ + { + contentType: AUDIO_MP3, + fileName: 'incompetech-com-Agnus-Dei-X.mp3', + pending: true, + }, + ], + status: 'sent', + }); + + return renderBothDirections(props); +}); + story.add('Other File Type', () => { const props = createProps({ attachments: [ @@ -755,6 +817,23 @@ story.add('Other File Type with Caption', () => { return renderBothDirections(props); }); +story.add('Other File Type with Long Filename', () => { + const props = createProps({ + attachments: [ + { + contentType: 'text/plain' as MIMEType, + fileName: + 'INSERT-APP-NAME_INSERT-APP-APPLE-ID_AppStore_AppsGamesWatch.psd.zip', + url: 'a2/a2334324darewer4234', + }, + ], + status: 'sent', + text: 'This is what I have done.', + }); + + return renderBothDirections(props); +}); + story.add('TapToView Image', () => { const props = createProps({ attachments: [ diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 241f9d349a..6a3ee4bdbc 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -79,6 +79,20 @@ export type DirectionType = typeof Directions[number]; export const ConversationTypes = ['direct', 'group'] as const; export type ConversationTypesType = typeof ConversationTypes[number]; +export type AudioAttachmentProps = { + id: string; + i18n: LocalizerType; + buttonRef: React.RefObject; + direction: DirectionType; + theme: ThemeType | undefined; + attachment: AttachmentType; + withContentAbove: boolean; + withContentBelow: boolean; + + kickOffAttachmentDownload(): void; + onCorrupted(): void; +}; + export type PropsData = { id: string; conversationId: string; @@ -87,7 +101,6 @@ export type PropsData = { isSticker?: boolean; isSelected?: boolean; isSelectedCounter?: number; - interactionMode: InteractionModeType; direction: DirectionType; timestamp: number; status?: MessageStatusType; @@ -102,7 +115,7 @@ export type PropsData = { attachments?: Array; quote?: { text: string; - attachment?: QuotedAttachmentType; + rawAttachment?: QuotedAttachmentType; isFromMe: boolean; sentAt: number; authorId: string; @@ -140,10 +153,12 @@ export type PropsData = { export type PropsHousekeeping = { i18n: LocalizerType; + interactionMode: InteractionModeType; theme?: ThemeType; disableMenu?: boolean; disableScroll?: boolean; collapseMetadata?: boolean; + renderAudioAttachment: (props: AudioAttachmentProps) => JSX.Element; }; export type PropsActions = { @@ -170,6 +185,10 @@ export type PropsActions = { attachment: AttachmentType; messageId: string; }) => void; + markAttachmentAsCorrupted: (options: { + attachment: AttachmentType; + messageId: string; + }) => void; showVisualAttachment: (options: { attachment: AttachmentType; messageId: string; @@ -219,10 +238,10 @@ const EXPIRED_DELAY = 600; export class Message extends React.PureComponent { public menuTriggerRef: Trigger | undefined; - public audioRef: React.RefObject = React.createRef(); - public focusRef: React.RefObject = React.createRef(); + public audioButtonRef: React.RefObject = React.createRef(); + public reactionsContainerRef: React.RefObject< HTMLDivElement > = React.createRef(); @@ -671,11 +690,14 @@ export class Message extends React.PureComponent { i18n, id, kickOffAttachmentDownload, + markAttachmentAsCorrupted, quote, showVisualAttachment, isSticker, text, theme, + + renderAudioAttachment, } = this.props; const { imageBroken } = this.state; @@ -739,25 +761,30 @@ export class Message extends React.PureComponent {
); } - if (!firstAttachment.pending && isAudio(attachments)) { - return ( - - ); + if (isAudio(attachments)) { + return renderAudioAttachment({ + i18n, + buttonRef: this.audioButtonRef, + id, + direction, + theme, + attachment: firstAttachment, + withContentAbove, + withContentBelow, + + kickOffAttachmentDownload() { + kickOffAttachmentDownload({ + attachment: firstAttachment, + messageId: id, + }); + }, + onCorrupted() { + markAttachmentAsCorrupted({ + attachment: firstAttachment, + messageId: id, + }); + }, + }); } const { pending, fileName, fileSize, contentType } = firstAttachment; const extension = getExtensionForDisplay({ contentType, fileName }); @@ -780,20 +807,7 @@ export class Message extends React.PureComponent { )} // There's only ever one of these, so we don't want users to tab into it tabIndex={-1} - onClick={(event: React.MouseEvent) => { - event.stopPropagation(); - event.preventDefault(); - - if (hasNotDownloaded(firstAttachment)) { - kickOffAttachmentDownload({ - attachment: firstAttachment, - messageId: id, - }); - return; - } - - this.openGenericAttachment(); - }} + onClick={this.openGenericAttachment} > {pending ? (
@@ -839,6 +853,7 @@ export class Message extends React.PureComponent { public renderPreview(): JSX.Element | null { const { + id, attachments, conversationType, direction, @@ -847,6 +862,7 @@ export class Message extends React.PureComponent { previews, quote, theme, + kickOffAttachmentDownload, } = this.props; // Attachments take precedence over Link Previews @@ -882,6 +898,14 @@ export class Message extends React.PureComponent { 'module-message__link-preview--nonclickable': !isClickable, } ); + const onPreviewImageClick = () => { + if (first.image && hasNotDownloaded(first.image)) { + kickOffAttachmentDownload({ + attachment: first.image, + messageId: id, + }); + } + }; const contents = ( <> {first.image && previewHasImage && isFullSizeImage ? ( @@ -892,6 +916,7 @@ export class Message extends React.PureComponent { onError={this.handleImageError} i18n={i18n} theme={theme} + onClick={onPreviewImageClick} /> ) : null}
@@ -909,6 +934,7 @@ export class Message extends React.PureComponent { attachment={first.image} onError={this.handleImageError} i18n={i18n} + onClick={onPreviewImageClick} />
) : null} @@ -943,8 +969,9 @@ export class Message extends React.PureComponent { ); return isClickable ? ( - +
) : (
{contents}
); @@ -1003,7 +1030,7 @@ export class Message extends React.PureComponent { i18n={i18n} onClick={clickHandler} text={quote.text} - attachment={quote.attachment} + rawAttachment={quote.rawAttachment} isIncoming={direction === 'incoming'} authorPhoneNumber={quote.authorPhoneNumber} authorProfileName={quote.authorProfileName} @@ -2043,17 +2070,13 @@ export class Message extends React.PureComponent { if ( !isAttachmentPending && isAudio(attachments) && - this.audioRef && - this.audioRef.current + this.audioButtonRef && + this.audioButtonRef.current ) { event.preventDefault(); event.stopPropagation(); - if (this.audioRef.current.paused) { - this.audioRef.current.play(); - } else { - this.audioRef.current.pause(); - } + this.audioButtonRef.current.click(); } if (contact && contact.signalAccount) { @@ -2072,7 +2095,13 @@ export class Message extends React.PureComponent { }; public openGenericAttachment = (event?: React.MouseEvent): void => { - const { attachments, downloadAttachment, timestamp } = this.props; + const { + id, + attachments, + downloadAttachment, + timestamp, + kickOffAttachmentDownload, + } = this.props; if (event) { event.preventDefault(); @@ -2084,6 +2113,14 @@ export class Message extends React.PureComponent { } const attachment = attachments[0]; + if (hasNotDownloaded(attachment)) { + kickOffAttachmentDownload({ + attachment, + messageId: id, + }); + return; + } + const { fileName } = attachment; const isDangerous = isFileDangerous(fileName || ''); @@ -2193,7 +2230,7 @@ export class Message extends React.PureComponent { public render(): JSX.Element | null { const { - authorPhoneNumber, + authorId, attachments, direction, id, @@ -2204,7 +2241,7 @@ export class Message extends React.PureComponent { // This id is what connects our triple-dot click with our associated pop-up menu. // It needs to be unique. - const triggerId = String(id || `${authorPhoneNumber}-${timestamp}`); + const triggerId = String(id || `${authorId}-${timestamp}`); if (expired) { return null; diff --git a/ts/components/conversation/MessageAudio.tsx b/ts/components/conversation/MessageAudio.tsx new file mode 100644 index 0000000000..d5301e5cff --- /dev/null +++ b/ts/components/conversation/MessageAudio.tsx @@ -0,0 +1,563 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useRef, useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { noop } from 'lodash'; + +import { assert } from '../../util/assert'; +import { LocalizerType } from '../../types/Util'; +import { WaveformCache } from '../../types/Audio'; +import { hasNotDownloaded, AttachmentType } from '../../types/Attachment'; + +export type Props = { + direction?: 'incoming' | 'outgoing'; + id: string; + i18n: LocalizerType; + attachment: AttachmentType; + withContentAbove: boolean; + withContentBelow: boolean; + + // See: GlobalAudioContext.tsx + audio: HTMLAudioElement; + audioContext: AudioContext; + waveformCache: WaveformCache; + + buttonRef: React.RefObject; + kickOffAttachmentDownload(): void; + onCorrupted(): void; + + activeAudioID: string | undefined; + setActiveAudioID: (id: string | undefined) => void; +}; + +type ButtonProps = { + i18n: LocalizerType; + buttonRef: React.RefObject; + + mod: string; + label: string; + onClick: () => void; +}; + +type LoadAudioOptions = { + audioContext: AudioContext; + waveformCache: WaveformCache; + url: string; +}; + +type LoadAudioResult = { + duration: number; + peaks: ReadonlyArray; +}; + +enum State { + NotDownloaded = 'NotDownloaded', + Pending = 'Pending', + Normal = 'Normal', +} + +// Constants + +const CSS_BASE = 'module-message__audio-attachment'; +const BAR_COUNT = 47; +const BAR_NOT_DOWNLOADED_HEIGHT = 2; +const BAR_MIN_HEIGHT = 4; +const BAR_MAX_HEIGHT = 20; + +const REWIND_BAR_COUNT = 2; + +// Increments for keyboard audio seek (in seconds) +const SMALL_INCREMENT = 1; +const BIG_INCREMENT = 5; + +// Utils + +const timeToText = (time: number): string => { + const hours = Math.floor(time / 3600); + let minutes = Math.floor((time % 3600) / 60).toString(); + let seconds = Math.floor(time % 60).toString(); + + if (hours !== 0 && minutes.length < 2) { + minutes = `0${minutes}`; + } + + if (seconds.length < 2) { + seconds = `0${seconds}`; + } + + return hours ? `${hours}:${minutes}:${seconds}` : `${minutes}:${seconds}`; +}; + +/** + * Load audio from `url`, decode PCM data, and compute RMS peaks for displaying + * the waveform. + * + * The results are cached in the `waveformCache` which is shared across + * messages in the conversation and provided by GlobalAudioContext. + */ +// TODO(indutny): move this to GlobalAudioContext and limit the concurrency. +// see DESKTOP-1267 +async function loadAudio(options: LoadAudioOptions): Promise { + const { audioContext, waveformCache, url } = options; + + const existing = waveformCache.get(url); + if (existing) { + window.log.info('MessageAudio: waveform cache hit', url); + return Promise.resolve(existing); + } + + window.log.info('MessageAudio: waveform cache miss', url); + + // Load and decode `url` into a raw PCM + const response = await fetch(url); + const raw = await response.arrayBuffer(); + + const data = await audioContext.decodeAudioData(raw); + + // Compute RMS peaks + const peaks = new Array(BAR_COUNT).fill(0); + const norms = new Array(BAR_COUNT).fill(0); + + const samplesPerPeak = data.length / peaks.length; + for ( + let channelNum = 0; + channelNum < data.numberOfChannels; + channelNum += 1 + ) { + const channel = data.getChannelData(channelNum); + + for (let sample = 0; sample < channel.length; sample += 1) { + const i = Math.floor(sample / samplesPerPeak); + peaks[i] += channel[sample] ** 2; + norms[i] += 1; + } + } + + // Average + let max = 1e-23; + for (let i = 0; i < peaks.length; i += 1) { + peaks[i] = Math.sqrt(peaks[i] / Math.max(1, norms[i])); + max = Math.max(max, peaks[i]); + } + + // Normalize + for (let i = 0; i < peaks.length; i += 1) { + peaks[i] /= max; + } + + const result = { peaks, duration: data.duration }; + waveformCache.set(url, result); + return result; +} + +const Button: React.FC = props => { + const { i18n, buttonRef, mod, label, onClick } = props; + // Clicking button toggle playback + const onButtonClick = (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + + onClick(); + }; + + // Keyboard playback toggle + const onButtonKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== 'Space') { + return; + } + event.stopPropagation(); + event.preventDefault(); + + onClick(); + }; + + return ( +