diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c093921d6..90a41150f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,6 +91,8 @@ jobs: - run: yarn prepare-beta-build - run: yarn test-node - run: yarn test-electron + env: + ARTIFACTS_DIR: artifacts/macos timeout-minutes: 5 - run: yarn build env: @@ -102,6 +104,12 @@ jobs: NODE_ENV: production - run: yarn test-eslint + - name: Upload artifacts on failure + if: failure() + uses: actions/upload-artifact@v3 + with: + path: artifacts + linux: needs: lint runs-on: ubuntu-latest @@ -150,12 +158,19 @@ jobs: - run: xvfb-run --auto-servernum yarn test-electron timeout-minutes: 5 env: + ARTIFACTS_DIR: artifacts/linux LANG: en_US LANGUAGE: en_US - run: xvfb-run --auto-servernum yarn test-release env: NODE_ENV: production + - name: Upload artifacts on failure + if: failure() + uses: actions/upload-artifact@v3 + with: + path: artifacts + windows: needs: lint runs-on: windows-latest @@ -205,11 +220,19 @@ jobs: DISABLE_INSPECT_FUSE: on - run: yarn test-electron + env: + ARTIFACTS_DIR: artifacts/windows timeout-minutes: 5 - run: yarn test-release env: SIGNAL_ENV: production + - name: Upload artifacts on failure + if: failure() + uses: actions/upload-artifact@v3 + with: + path: artifacts + mock-tests: needs: lint runs-on: ubuntu-latest diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html index fa1e1c6e1..ce8fe08d0 100644 --- a/.storybook/preview-head.html +++ b/.storybook/preview-head.html @@ -49,6 +49,9 @@ OS: { hasCustomTitleBar: () => false, }, + usernames: { + hash: x => x, + }, config: {}, }; diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 8bdde1ea1..a1c8e6897 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -415,6 +415,10 @@ Signal Desktop makes use of the following open source projects. OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## changedpi + + License: MIT + ## cirbuf MIT License diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 013652a71..df7b79e0c 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -5255,6 +5255,18 @@ "messageformat": "Username", "description": "Default text for username field" }, + "icu:ProfileEditor__username-link": { + "messageformat": "QR code or link", + "description": "Label of a profile editor row that navigates to username link and qr code modal" + }, + "icu:ProfileEditor__username-link__tooltip__title": { + "messageformat": "Share your username", + "description": "Title of tooltip displayed under 'QR code or link' button for getting username link" + }, + "icu:ProfileEditor__username-link__tooltip__body": { + "messageformat": "Let others start a chat with you by sharing your unique QR code or link.", + "description": "Body of tooltip displayed under 'QR code or link' button for getting username link" + }, "icu:ProfileEditor--username--title": { "messageformat": "Choose your username", "description": "Title text for username modal" @@ -5313,6 +5325,10 @@ }, "icu:ProfileEditor--username--confirm-delete-body": { "messageformat": "This will remove your username, allowing other users to claim it. Are you sure?", + "description": "(deleted 07/10/2023) Shown in dialog body if user has saved an empty string to delete their username" + }, + "icu:ProfileEditor--username--confirm-delete-body-2": { + "messageformat": "This will remove your username and disable your QR code and link. “{username}” will be available for others to claim. Are you sure?", "description": "Shown in dialog body if user has saved an empty string to delete their username" }, "icu:ProfileEditor--username--confirm-delete-button": { @@ -6491,6 +6507,42 @@ "messageformat": "These digits help keep your username private so you can avoid unwanted messages. Share your username with only the people and groups you’d like to chat with. If you change usernames you’ll get a new set of digits.", "description": "Body of the popup with information about discriminator in username" }, + "icu:EditUsernameModalBody__change-confirmation": { + "messageformat": "Changing your username will reset your existing QR code and link. Are you sure?", + "description": "Body of the confirmation dialog displayed when user is about to change their username" + }, + "icu:EditUsernameModalBody__change-confirmation__continue": { + "messageformat": "Continue", + "description": "Text of the primary button on username change confirmation modal" + }, + "icu:UsernameLinkModalBody__save": { + "messageformat": "Save", + "description": "Name of the button for saving username link QR code to disk in the username link modal" + }, + "icu:UsernameLinkModalBody__color": { + "messageformat": "Color", + "description": "Name of the button for changing the username link QR code color in the username link modal" + }, + "icu:UsernameLinkModalBody__copy": { + "messageformat": "Copy to Clipboard", + "description": "ARIA label of the button for copying the username link to clipboard in the username link modal" + }, + "icu:UsernameLinkModalBody__help": { + "messageformat": "Only share your QR code and link with people you trust. When shared others will be able to view your username and start a chat with you.", + "description": "Text of disclaimer at the bottom of the username link modal" + }, + "icu:UsernameLinkModalBody__reset": { + "messageformat": "Reset", + "description": "Text of button at the bottom of the username link modal" + }, + "icu:UsernameLinkModalBody__color__radio": { + "messageformat": "Username link color, {index, number} of {total, number}", + "description": "ARIA label of button for selecting username link color" + }, + "icu:UsernameLinkModalBody__reset__confirm": { + "messageformat": "If you reset your QR code, your existing QR code and link will no longer work.", + "description": "Text of confirmation modal when resetting the username link" + }, "icu:UsernameOnboardingModalBody__title": { "messageformat": "Set up your Signal username", "description": "Title of username onboarding modal" @@ -6500,7 +6552,7 @@ "description": "Content of the first row of username onboarding modal" }, "icu:UsernameOnboardingModalBody__row__link": { - "messageformat": "Each username has a unique link you can share with your friends to start a chat with you", + "messageformat": "Each username has a unique QR code and link you can share with friends to start a chat with you", "description": "Content of the second row of username onboarding modal" }, "icu:UsernameOnboardingModalBody__row__lock": { diff --git a/fixtures/username-link-blue-darwin.png b/fixtures/username-link-blue-darwin.png new file mode 100644 index 000000000..c5d4c252f Binary files /dev/null and b/fixtures/username-link-blue-darwin.png differ diff --git a/fixtures/username-link-blue-linux.png b/fixtures/username-link-blue-linux.png new file mode 100644 index 000000000..161b03348 Binary files /dev/null and b/fixtures/username-link-blue-linux.png differ diff --git a/fixtures/username-link-blue-win32.png b/fixtures/username-link-blue-win32.png new file mode 100644 index 000000000..59d0eaf40 Binary files /dev/null and b/fixtures/username-link-blue-win32.png differ diff --git a/fixtures/username-link-white-darwin.png b/fixtures/username-link-white-darwin.png new file mode 100644 index 000000000..4baab8cb1 Binary files /dev/null and b/fixtures/username-link-white-darwin.png differ diff --git a/fixtures/username-link-white-linux.png b/fixtures/username-link-white-linux.png new file mode 100644 index 000000000..87e552cd3 Binary files /dev/null and b/fixtures/username-link-white-linux.png differ diff --git a/fixtures/username-link-white-win32.png b/fixtures/username-link-white-win32.png new file mode 100644 index 000000000..186f01193 Binary files /dev/null and b/fixtures/username-link-white-win32.png differ diff --git a/images/icons/v2/link_color_32.svg b/images/icons/v2/link_color_32.svg deleted file mode 100644 index 408d30141..000000000 --- a/images/icons/v2/link_color_32.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/images/icons/v3/qr_code/qr_code.svg b/images/icons/v3/qr_code/qr_code.svg new file mode 100644 index 000000000..54f9910e5 --- /dev/null +++ b/images/icons/v3/qr_code/qr_code.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/images/icons/v3/share/share.svg b/images/icons/v3/share/share.svg new file mode 100644 index 000000000..4c936e507 --- /dev/null +++ b/images/icons/v3/share/share.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/qr-and-link.svg b/images/qr-and-link.svg new file mode 100644 index 000000000..8ee17d066 --- /dev/null +++ b/images/qr-and-link.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/signal-qr-logo.svg b/images/signal-qr-logo.svg new file mode 100644 index 000000000..51ba45423 --- /dev/null +++ b/images/signal-qr-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/package.json b/package.json index 4ed6d1dca..dffd5f931 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "@popperjs/core": "2.11.6", "@react-spring/web": "9.5.5", "@signalapp/better-sqlite3": "8.4.3", - "@signalapp/libsignal-client": "0.27.0", + "@signalapp/libsignal-client": "0.28.0", "@signalapp/ringrtc": "2.29.1", "@types/fabric": "4.5.3", "backbone": "1.4.0", @@ -97,6 +97,7 @@ "blueimp-load-image": "5.14.0", "blurhash": "1.1.3", "buffer": "6.0.3", + "changedpi": "1.0.4", "cirbuf": "1.0.1", "classnames": "2.2.5", "config": "1.28.1", @@ -189,7 +190,7 @@ "@electron/fuses": "1.5.0", "@formatjs/intl": "2.6.7", "@mixer/parallel-prettier": "2.0.3", - "@signalapp/mock-server": "3.1.0", + "@signalapp/mock-server": "3.2.0", "@storybook/addon-a11y": "6.5.6", "@storybook/addon-actions": "6.5.6", "@storybook/addon-controls": "6.5.6", diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 9b870c80b..e96e2cd8b 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -147,6 +147,24 @@ message AccountRecord { } } + message UsernameLink { + enum Color { + UNKNOWN = 0; + BLUE = 1; + WHITE = 2; + GREY = 3; + OLIVE = 4; + GREEN = 5; + ORANGE = 6; + PINK = 7; + PURPLE = 8; + } + + optional bytes entropy = 1; // 32 bytes of entropy used for encryption + optional bytes serverId = 2; // 16 bytes of encoded UUID provided by the server + optional Color color = 3; // color of the QR code itself + } + optional bytes profileKey = 1; optional string givenName = 2; optional string familyName = 3; @@ -179,6 +197,7 @@ message AccountRecord { reserved 32; // hasSeenGroupStoryEducationSheet optional string username = 33; optional bool hasCompletedUsernameOnboarding = 34; + optional UsernameLink usernameLink = 35; } message StoryDistributionListRecord { diff --git a/stylesheets/components/ProfileEditor.scss b/stylesheets/components/ProfileEditor.scss index cf0ba5614..87e980c79 100644 --- a/stylesheets/components/ProfileEditor.scss +++ b/stylesheets/components/ProfileEditor.scss @@ -58,6 +58,24 @@ } } + &--username-link { + &::after { + @include light-theme { + @include color-svg( + '../images/icons/v3/qr_code/qr_code.svg', + $color-gray-75 + ); + } + + @include dark-theme { + @include color-svg( + '../images/icons/v3/qr_code/qr_code.svg', + $color-gray-15 + ); + } + } + } + &--bio { &::after { @include light-theme { @@ -159,4 +177,61 @@ } } } + + &__username-link { + &__tooltip { + padding: 12px; + + &__container { + display: flex; + flex-direction: row; + } + + &__icon { + width: 24px; + height: 24px; + margin-block-start: 4px; + margin-inline: 4px 12px; + + @include dark-theme { + @include color-svg( + '../images/icons/v3/share/share.svg', + $color-white + ); + } + @include light-theme { + @include color-svg( + '../images/icons/v3/share/share.svg', + $color-black + ); + } + } + + &__content { + text-align: start; + + h3 { + @include font-body-2-bold; + margin: 0; + } + + p { + max-width: 240px; + margin: 0; + } + } + + &__close { + @include button-reset; + @include button-focus-outline; + + width: 20px; + height: 20px; + padding: 0; + margin: 0; + + @include color-svg('../images/icons/v3/x/x.svg', $color-gray-45); + } + } + } } diff --git a/stylesheets/components/UsernameLinkModalBody.scss b/stylesheets/components/UsernameLinkModalBody.scss new file mode 100644 index 000000000..d1b4de080 --- /dev/null +++ b/stylesheets/components/UsernameLinkModalBody.scss @@ -0,0 +1,299 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.UsernameLinkModalBody { + display: flex; + flex-direction: column; + align-items: center; + user-select: none; + max-width: 295px; + width: 100%; + + &__container { + display: flex; + align-items: center; + justify-content: center; + } + + &__card { + --bg-color: #506ecd; + --fg-color: #2449c0; + --text-color: #ffffff; + + padding-block: 22px; + padding-inline: 28px; + background: var(--bg-color); + border-radius: 18px; + max-width: 204px; + + &--shadow { + box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.08); + } + + &__qr { + position: relative; + + display: flex; + align-items: center; + justify-content: center; + + padding: 10px; + background-color: $color-white; + border-radius: 8px; + width: 148px; + height: 148px; + + .UsernameLinkModalBody__card--shadow & { + outline: 2px solid $color-gray-05; + } + + &__spinner__arc { + background-color: var(--fg-color); + } + + &__blotches { + width: 100%; + } + + &__logo { + --size: 25px; + position: absolute; + top: calc(50% - var(--size) / 2); + inset-inline-start: calc(50% - var(--size) / 2); + width: var(--size); + height: var(--size); + @include color-svg('../images/signal-qr-logo.svg', var(--fg-color)); + } + } + + &__username { + display: flex; + flex-direction: row; + gap: 4px; + justify-content: center; + margin-block: 12px 2px; + + &__text { + color: var(--text-color); + font-size: 16px; + font-weight: 600; + line-height: normal; + letter-spacing: -0.252px; + text-align: center; + } + + &__copy { + @include button-reset; + @include button-focus-outline; + + flex-shrink: 0; + margin-top: 2px; + display: inline-block; + width: 16px; + height: 16px; + + @include color-svg( + '../images/icons/v3/copy/copy.svg', + var(--text-color) + ); + } + } + } + + &__actions { + display: flex; + flex-direction: row; + gap: 12px; + align-items: center; + justify-content: center; + margin-block-start: 16px; + + &__save, + &__color { + @include button-reset; + @include button-focus-outline; + @include font-caption; + + display: flex; + flex-direction: column; + gap: 4px; + align-items: center; + justify-content: center; + + min-width: 68px; + border-radius: 8px; + padding: 5px; + + @include light-theme() { + background-color: $color-gray-05; + color: $color-black; + } + + @include dark-theme() { + background-color: $color-gray-75; + color: $color-gray-02; + } + + i { + display: block; + width: 20px; + height: 20px; + margin-block-start: 2px; + } + } + + &__save i { + @include light-theme() { + @include color-svg('../images/icons/v3/save/save.svg', $color-black); + } + + @include dark-theme() { + @include color-svg('../images/icons/v3/save/save.svg', $color-gray-02); + } + } + + &__color i { + @include light-theme() { + @include color-svg('../images/icons/v3/color/color.svg', $color-black); + } + + @include dark-theme() { + @include color-svg( + '../images/icons/v3/color/color.svg', + $color-gray-02 + ); + } + } + } + + &__link { + display: flex; + flex-direction: row; + gap: 12px; + align-items: center; + + padding-block: 12px; + padding-inline: 16px; + border-radius: 12px; + margin-block-start: 20px; + max-width: 296px; + width: 100%; + @include light-theme() { + border: 2px solid $color-gray-05; + } + @include dark-theme() { + border: 2px solid $color-gray-75; + } + + &__icon { + @include button-reset; + @include button-focus-outline; + border-radius: 2px; + + &:after { + content: ''; + + display: block; + width: 20px; + height: 20px; + flex-shrink: 0; + + @include light-theme() { + @include color-svg('../images/icons/v3/copy/copy.svg', $color-black); + } + + @include dark-theme() { + @include color-svg( + '../images/icons/v3/copy/copy.svg', + $color-gray-02 + ); + } + } + } + + &__text { + word-break: break-all; + user-select: text; + } + } + + &__help { + @include font-subtitle; + + margin-block-start: 16px; + text-align: center; + + @include light-theme() { + color: $color-gray-60; + } + @include dark-theme() { + color: $color-gray-25; + } + } + + &__reset { + @include button-reset; + @include button-focus-outline; + @include font-body-1-bold; + + margin-block: 12px 16px; + + @include light-theme() { + color: $color-ultramarine; + } + + @include dark-theme() { + color: $color-ultramarine-light; + } + } + + &__colors { + &__grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + grid-template-rows: 1fr 1fr; + gap: 18px 20px; + + margin-block: 24px 30px; + } + + &__radio { + @include button-reset; + @include button-focus-outline; + + display: flex; + width: 48px; + height: 48px; + border-radius: 24px; + + &[aria-pressed='true'] { + padding: 3px; + @include light-theme() { + border: 2px solid $color-black; + } + @include dark-theme() { + border: 2px solid $color-ultramarine; + } + } + + i { + width: 100%; + height: 100%; + border-radius: 24px; + border: 2px solid var(--fg-color); + background: var(--bg-color); + } + + &--white-bg { + i { + @include light-theme() { + border-color: $color-gray-15; + } + @include dark-theme() { + border-color: $color-gray-60; + } + } + } + } + } +} diff --git a/stylesheets/components/UsernameOnboardingModalBody.scss b/stylesheets/components/UsernameOnboardingModalBody.scss index 671e7bd75..068f80b9a 100644 --- a/stylesheets/components/UsernameOnboardingModalBody.scss +++ b/stylesheets/components/UsernameOnboardingModalBody.scss @@ -64,7 +64,9 @@ } &--link { - background: url(../images/icons/v2/link_color_32.svg); + width: 32px; + height: 34px; + background: url(../images/qr-and-link.svg); } &--lock { diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 97d5ac9ce..76c23c4c3 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -151,5 +151,6 @@ @import './components/Toast.scss'; @import './components/Waveform.scss'; @import './components/WaveformScrubber.scss'; +@import './components/UsernameLinkModalBody.scss'; @import './components/UsernameOnboardingModalBody.scss'; @import './components/WhatsNew.scss'; diff --git a/test/setup-test-node.js b/test/setup-test-node.js index 727ed051b..64b2820d6 100644 --- a/test/setup-test-node.js +++ b/test/setup-test-node.js @@ -5,6 +5,7 @@ const chai = require('chai'); const chaiAsPromised = require('chai-as-promised'); +const { usernames } = require('@signalapp/libsignal-client'); const { Crypto } = require('../ts/context/Crypto'); const { setEnvironment, Environment } = require('../ts/environment'); @@ -21,6 +22,7 @@ global.window = { performance, SignalContext: { crypto: new Crypto(), + usernames, log: { info: (...args) => console.log(...args), warn: (...args) => console.warn(...args), diff --git a/ts/changedpi.d.ts b/ts/changedpi.d.ts new file mode 100644 index 000000000..aed0e02e3 --- /dev/null +++ b/ts/changedpi.d.ts @@ -0,0 +1,7 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +declare module 'changedpi' { + function changeDpiBlob(blob: Blob, dpi: number): Promise; + function changeDpiDataUrl(url: string, dpi: number): string; +} diff --git a/ts/components/EditUsernameModalBody.tsx b/ts/components/EditUsernameModalBody.tsx index 4baaa90bd..8f4d86805 100644 --- a/ts/components/EditUsernameModalBody.tsx +++ b/ts/components/EditUsernameModalBody.tsx @@ -73,6 +73,7 @@ export function EditUsernameModalBody({ const [hasEverChanged, setHasEverChanged] = useState(false); const [nickname, setNickname] = useState(currentNickname); const [isLearnMoreVisible, setIsLearnMoreVisible] = useState(false); + const [isConfirmingSave, setIsConfirmingSave] = useState(false); useEffect(() => { if (state === UsernameReservationState.Closed) { @@ -144,6 +145,18 @@ export function EditUsernameModalBody({ }, []); const onSave = useCallback(() => { + if (!currentUsername) { + confirmUsername(); + } else { + setIsConfirmingSave(true); + } + }, [confirmUsername, currentUsername]); + + const onCancelSave = useCallback(() => { + setIsConfirmingSave(false); + }, []); + + const onConfirmUsername = useCallback(() => { confirmUsername(); }, [confirmUsername]); @@ -285,6 +298,26 @@ export function EditUsernameModalBody({ })} )} + + {isConfirmingSave && ( + + {i18n('icu:EditUsernameModalBody__change-confirmation')} + + )} ); } diff --git a/ts/components/ProfileEditor.stories.tsx b/ts/components/ProfileEditor.stories.tsx index 935765c61..7eace186b 100644 --- a/ts/components/ProfileEditor.stories.tsx +++ b/ts/components/ProfileEditor.stories.tsx @@ -12,6 +12,7 @@ import { ProfileEditor } from './ProfileEditor'; import { EditUsernameModalBody } from './EditUsernameModalBody'; import { UsernameEditState, + UsernameLinkState, UsernameReservationState, } from '../state/ducks/usernameEnums'; import { UUID } from '../types/UUID'; @@ -49,6 +50,12 @@ export default { i18n: { defaultValue: i18n, }, + usernameLink: { + defaultValue: 'https://signal.me/#eu/testtest', + }, + usernameLinkFgColor: { + defaultValue: '', + }, isUsernameFlagEnabled: { control: { type: 'checkbox' }, defaultValue: false, @@ -62,16 +69,25 @@ export default { Deleting: UsernameEditState.Deleting, }, }, + usernameLinkState: { + control: { type: 'select' }, + defaultValue: UsernameLinkState.Ready, + options: [UsernameLinkState.Ready, UsernameLinkState.Updating], + }, onEditStateChanged: { action: true }, onProfileChanged: { action: true }, onSetSkinTone: { action: true }, + saveAttachment: { action: true }, + setUsernameLinkColor: { action: true }, showToast: { action: true }, recentEmojis: { defaultValue: [], }, replaceAvatar: { action: true }, + resetUsernameLink: { action: true }, saveAvatarToDisk: { action: true }, markCompletedUsernameOnboarding: { action: true }, + markCompletedUsernameLinkOnboarding: { action: true }, openUsernameReservationModal: { action: true }, setUsernameEditState: { action: true }, deleteUsername: { action: true }, diff --git a/ts/components/ProfileEditor.tsx b/ts/components/ProfileEditor.tsx index a192f9e58..72e4a8f46 100644 --- a/ts/components/ProfileEditor.tsx +++ b/ts/components/ProfileEditor.tsx @@ -25,8 +25,12 @@ import { Intl } from './Intl'; import type { LocalizerType } from '../types/Util'; import { Modal } from './Modal'; import { PanelRow } from './conversation/conversation-details/PanelRow'; -import type { ProfileDataType } from '../state/ducks/conversations'; +import type { + ProfileDataType, + SaveAttachmentActionCreatorType, +} from '../state/ducks/conversations'; import { UsernameEditState } from '../state/ducks/usernameEnums'; +import type { UsernameLinkState } from '../state/ducks/usernameEnums'; import { ToastType } from '../types/Toast'; import type { ShowToastAction } from '../state/ducks/toast'; import { getEmojiData, unifiedToEmoji } from './emoji/lib'; @@ -34,14 +38,15 @@ import { assertDev } from '../util/assert'; import { missingCaseError } from '../util/missingCaseError'; import { ConfirmationDialog } from './ConfirmationDialog'; import { ContextMenu } from './ContextMenu'; +import { UsernameLinkModalBody } from './UsernameLinkModalBody'; import { UsernameOnboardingModalBody } from './UsernameOnboardingModalBody'; import { ConversationDetailsIcon, IconType, } from './conversation/conversation-details/ConversationDetailsIcon'; import { isWhitespace, trim } from '../util/whitespaceStringUtil'; -import { generateUsernameLink } from '../util/sgnlHref'; import { UserText } from './UserText'; +import { Tooltip, TooltipPlacement } from './Tooltip'; export enum EditState { None = 'None', @@ -50,6 +55,7 @@ export enum EditState { Bio = 'Bio', Username = 'Username', UsernameOnboarding = 'UsernameOnboarding', + UsernameLink = 'UsernameLink', } type PropsExternalType = { @@ -70,20 +76,28 @@ export type PropsDataType = { familyName?: string; firstName: string; hasCompletedUsernameOnboarding: boolean; + hasCompletedUsernameLinkOnboarding: boolean; i18n: LocalizerType; isUsernameFlagEnabled: boolean; userAvatarData: ReadonlyArray; username?: string; usernameEditState: UsernameEditState; - markCompletedUsernameOnboarding: () => void; + usernameLinkState: UsernameLinkState; + usernameLinkColor?: number; + usernameLink?: string; } & Pick; type PropsActionType = { deleteAvatarFromDisk: DeleteAvatarFromDiskActionType; + markCompletedUsernameOnboarding: () => void; + markCompletedUsernameLinkOnboarding: () => void; onSetSkinTone: (tone: number) => unknown; replaceAvatar: ReplaceAvatarActionType; + saveAttachment: SaveAttachmentActionCreatorType; saveAvatarToDisk: SaveAvatarToDiskActionType; setUsernameEditState: (editState: UsernameEditState) => void; + setUsernameLinkColor: (color: number) => void; + resetUsernameLink: () => void; deleteUsername: () => void; showToast: ShowToastAction; openUsernameReservationModal: () => void; @@ -131,9 +145,11 @@ export function ProfileEditor({ familyName, firstName, hasCompletedUsernameOnboarding, + hasCompletedUsernameLinkOnboarding, i18n, isUsernameFlagEnabled, markCompletedUsernameOnboarding, + markCompletedUsernameLinkOnboarding, onEditStateChanged, onProfileChanged, onSetSkinTone, @@ -142,13 +158,19 @@ export function ProfileEditor({ recentEmojis, renderEditUsernameModalBody, replaceAvatar, + resetUsernameLink, + saveAttachment, saveAvatarToDisk, setUsernameEditState, + setUsernameLinkColor, showToast, skinTone, userAvatarData, username, usernameEditState, + usernameLinkState, + usernameLinkColor, + usernameLink, }: PropsType): JSX.Element { const focusInputRef = useRef(null); const [editState, setEditState] = useState(EditState.None); @@ -499,8 +521,22 @@ export function ProfileEditor({ }} /> ); + } else if (editState === EditState.UsernameLink) { + content = ( + + ); } else if (editState === EditState.None) { - let maybeUsernameRow: JSX.Element | undefined; + let maybeUsernameRows: JSX.Element | undefined; if (isUsernameFlagEnabled) { let actions: JSX.Element | undefined; @@ -528,21 +564,6 @@ export function ProfileEditor({ showToast({ toastType: ToastType.CopiedUsername }); }, }, - { - group: 'copy', - icon: 'ProfileEditor__username-menu__copy-link-icon', - label: i18n('icu:ProfileEditor--username--copy-link'), - onClick: () => { - assertDev( - username !== undefined, - 'Should not be visible without username' - ); - void window.navigator.clipboard.writeText( - generateUsernameLink(username) - ); - showToast({ toastType: ToastType.CopiedUsernameLink }); - }, - }, { // Different group to display a divider above it group: 'delete', @@ -568,24 +589,74 @@ export function ProfileEditor({ } } - maybeUsernameRow = ( - - } - label={username || i18n('icu:ProfileEditor--username')} - info={username && generateUsernameLink(username, { short: true })} - onClick={() => { - openUsernameReservationModal(); - if (username || hasCompletedUsernameOnboarding) { - setEditState(EditState.Username); - } else { - setEditState(EditState.UsernameOnboarding); + let maybeUsernameLinkRow: JSX.Element | undefined; + if (username) { + maybeUsernameLinkRow = ( + } - }} - actions={actions} - /> + label={i18n('icu:ProfileEditor__username-link')} + onClick={() => { + setEditState(EditState.UsernameLink); + }} + /> + ); + + if (!hasCompletedUsernameLinkOnboarding) { + const tooltip = ( +
+
+ +
+

+ {i18n('icu:ProfileEditor__username-link__tooltip__title')} +

+

{i18n('icu:ProfileEditor__username-link__tooltip__body')}

+
+ +
+ ); + maybeUsernameLinkRow = ( + + {maybeUsernameLinkRow} + + ); + } + } + + maybeUsernameRows = ( + <> + + } + label={username || i18n('icu:ProfileEditor--username')} + onClick={() => { + openUsernameReservationModal(); + if (username || hasCompletedUsernameOnboarding) { + setEditState(EditState.Username); + } else { + setEditState(EditState.UsernameOnboarding); + } + }} + actions={actions} + /> + {maybeUsernameLinkRow} + ); } @@ -618,7 +689,7 @@ export function ProfileEditor({ setEditState(EditState.ProfileName); }} /> - {maybeUsernameRow} + {maybeUsernameRows} - {i18n('icu:ProfileEditor--username--confirm-delete-body')} + {i18n('icu:ProfileEditor--username--confirm-delete-body-2', { + username, + })} )} diff --git a/ts/components/ProfileEditorModal.tsx b/ts/components/ProfileEditorModal.tsx index b650a06df..a9f3bbc08 100644 --- a/ts/components/ProfileEditorModal.tsx +++ b/ts/components/ProfileEditorModal.tsx @@ -39,6 +39,7 @@ export function ProfileEditorModal({ [EditState.ProfileName]: i18n('icu:ProfileEditorModal--name'), [EditState.UsernameOnboarding]: undefined, [EditState.Username]: i18n('icu:ProfileEditorModal--username'), + [EditState.UsernameLink]: undefined, }; const [modalTitle, setModalTitle] = useState( diff --git a/ts/components/UsernameLinkModalBody.stories.tsx b/ts/components/UsernameLinkModalBody.stories.tsx new file mode 100644 index 000000000..d02d32bf3 --- /dev/null +++ b/ts/components/UsernameLinkModalBody.stories.tsx @@ -0,0 +1,104 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useCallback, useState } from 'react'; +import type { Meta, Story } from '@storybook/react'; + +import enMessages from '../../_locales/en/messages.json'; +import { UsernameLinkState } from '../state/ducks/usernameEnums'; +import { setupI18n } from '../util/setupI18n'; +import { SignalService as Proto } from '../protobuf'; + +import type { PropsType } from './UsernameLinkModalBody'; +import { UsernameLinkModalBody } from './UsernameLinkModalBody'; +import { Modal } from './Modal'; + +const ColorEnum = Proto.AccountRecord.UsernameLink.Color; + +const i18n = setupI18n('en', enMessages); + +export default { + component: UsernameLinkModalBody, + title: 'Components/UsernameLinkModalBody', + argTypes: { + i18n: { + defaultValue: i18n, + }, + link: { + control: { type: 'text' }, + defaultValue: + 'https://signal.me#eu/n-AJkmmykrFB7j6UODGndSycxcMdp_v6ppRp9rFu5Ad39q_9Ngi_k9-TARWfT43t', + }, + username: { + control: { type: 'text' }, + defaultValue: 'alice.12', + }, + usernameLinkState: { + control: { type: 'select' }, + defaultValue: UsernameLinkState.Ready, + options: [UsernameLinkState.Ready, UsernameLinkState.Updating], + }, + colorId: { + control: { type: 'select' }, + defaultValue: ColorEnum.BLUE, + mapping: { + blue: ColorEnum.BLUE, + white: ColorEnum.WHITE, + grey: ColorEnum.GREY, + olive: ColorEnum.OLIVE, + green: ColorEnum.GREEN, + orange: ColorEnum.ORANGE, + pink: ColorEnum.PINK, + purple: ColorEnum.PURPLE, + }, + }, + showToast: { action: true }, + resetUsernameLink: { action: true }, + setUsernameLinkColor: { action: true }, + }, +} as Meta; + +type ArgsType = PropsType; + +// eslint-disable-next-line react/function-component-definition +const Template: Story = args => { + const [attachment, setAttachment] = useState(); + const saveAttachment = useCallback(({ data }: { data?: Uint8Array }) => { + if (!data) { + setAttachment(undefined); + return; + } + + const blob = new Blob([data], { + type: 'image/png', + }); + + setAttachment(oldURL => { + if (oldURL) { + URL.revokeObjectURL(oldURL); + } + return URL.createObjectURL(blob); + }); + }, []); + + return ( + <> + + + + {attachment && printable qr code} + + ); +}; + +export const Normal = Template.bind({}); +Normal.args = {}; +Normal.story = { + name: 'normal', +}; + +export const NoLink = Template.bind({}); +NoLink.args = { link: '' }; +NoLink.story = { + name: 'normal', +}; diff --git a/ts/components/UsernameLinkModalBody.tsx b/ts/components/UsernameLinkModalBody.tsx new file mode 100644 index 000000000..6dabd10e8 --- /dev/null +++ b/ts/components/UsernameLinkModalBody.tsx @@ -0,0 +1,739 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useCallback, useState, useEffect } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import classnames from 'classnames'; +import QR from 'qrcode-generator'; +import { changeDpiBlob } from 'changedpi'; + +import { SignalService as Proto } from '../protobuf'; +import type { SaveAttachmentActionCreatorType } from '../state/ducks/conversations'; +import { UsernameLinkState } from '../state/ducks/usernameEnums'; +import { ToastType } from '../types/Toast'; +import type { ShowToastAction } from '../state/ducks/toast'; +import type { LocalizerType } from '../types/Util'; +import { IMAGE_PNG } from '../types/MIME'; +import { strictAssert } from '../util/assert'; +import { drop } from '../util/drop'; +import { Button, ButtonVariant } from './Button'; +import { Modal } from './Modal'; +import { ConfirmationDialog } from './ConfirmationDialog'; +import { Spinner } from './Spinner'; + +export type PropsType = Readonly<{ + i18n: LocalizerType; + link?: string; + username: string; + colorId?: number; + usernameLinkState: UsernameLinkState; + + setUsernameLinkColor: (colorId: number) => void; + resetUsernameLink: () => void; + saveAttachment: SaveAttachmentActionCreatorType; + showToast: ShowToastAction; +}>; + +export type ColorMapEntryType = Readonly<{ + fg: string; + bg: string; +}>; + +const ColorEnum = Proto.AccountRecord.UsernameLink.Color; + +const DEFAULT_PRESET: ColorMapEntryType = { fg: '#2449c0', bg: '#506ecd' }; + +export const COLOR_MAP: ReadonlyMap = new Map([ + [ColorEnum.BLUE, DEFAULT_PRESET], + [ColorEnum.WHITE, { fg: '#000000', bg: '#ffffff' }], + [ColorEnum.GREY, { fg: '#464852', bg: '#6a6c74' }], + [ColorEnum.OLIVE, { fg: '#73694f', bg: '#a89d7f' }], + [ColorEnum.GREEN, { fg: '#55733f', bg: '#829a6e' }], + [ColorEnum.ORANGE, { fg: '#d96b2d', bg: '#de7134' }], + [ColorEnum.PINK, { fg: '#bb617b', bg: '#e67899' }], + [ColorEnum.PURPLE, { fg: '#7651c5', bg: '#9c84cf' }], +]); + +const CLASS = 'UsernameLinkModalBody'; +const AUTODETECT_TYPE_NUMBER = 0; +const ERROR_CORRECTION_LEVEL = 'H'; +const CENTER_CUTAWAY_PERCENTAGE = 32 / 184; + +const PRINT_WIDTH = 296; +const DEFAULT_PRINT_HEIGHT = 324; +const PRINT_SHADOW_BLUR = 4; +const PRINT_CARD_RADIUS = 24; +const PRINT_MAX_USERNAME_WIDTH = 222; +const PRINT_USERNAME_LINE_HEIGHT = 25; +const PRINT_USERNAME_Y = 269; +const PRINT_QR_SIZE = 184; +const PRINT_QR_Y = 48; +const PRINT_QR_PADDING = 16; +const PRINT_QR_PADDING_RADIUS = 12; +const PRINT_DPI = 224; +const PRINT_LOGO_SIZE = 36; + +type BlotchesPropsType = Readonly<{ + className?: string; + link: string; + color: string; +}>; + +function Blotches({ className, link, color }: BlotchesPropsType): JSX.Element { + const qr = QR(AUTODETECT_TYPE_NUMBER, ERROR_CORRECTION_LEVEL); + qr.addData(link); + qr.make(); + + const size = qr.getModuleCount(); + const center = size / 2; + const radius = CENTER_CUTAWAY_PERCENTAGE * size; + + function hasPixel(x: number, y: number): boolean { + if (x < 0 || y < 0 || x >= size || y >= size) { + return false; + } + + const distanceFromCenter = Math.sqrt( + (x - center + 0.5) ** 2 + (y - center + 0.5) ** 2 + ); + + // Center and 1 dot away should remain clear for the logo placement. + if (Math.ceil(distanceFromCenter) <= radius + 2) { + return false; + } + + return qr.isDark(x, y); + } + + const path = []; + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + if (!hasPixel(x, y)) { + continue; + } + + const onTop = hasPixel(x, y - 1); + const onBottom = hasPixel(x, y + 1); + const onLeft = hasPixel(x - 1, y); + const onRight = hasPixel(x + 1, y); + + const roundTL = !onLeft && !onTop; + const roundTR = !onTop && !onRight; + const roundBR = !onRight && !onBottom; + const roundBL = !onBottom && !onLeft; + + path.push( + `M${2 * x} ${2 * y + 1}`, + roundTL ? 'a1 1 0 0 1 1 -1' : 'v-1h1', + roundTR ? 'a1 1 0 0 1 1 1' : 'h1v1', + roundBR ? 'a1 1 0 0 1 -1 1' : 'v1h-1', + roundBL ? 'a1 1 0 0 1 -1 -1' : 'h-1v-1', + 'z' + ); + } + } + + return ( + + + + + ); +} + +type CreateCanvasAndContextOptionsType = Readonly<{ + width: number; + height: number; + devicePixelRatio?: number; +}>; + +function createCanvasAndContext({ + width, + height, + devicePixelRatio = window.devicePixelRatio, +}: CreateCanvasAndContextOptionsType): [ + OffscreenCanvas, + OffscreenCanvasRenderingContext2D +] { + const canvas = new OffscreenCanvas( + devicePixelRatio * width, + devicePixelRatio * height + ); + + const context = canvas.getContext('2d'); + strictAssert(context, 'Failed to get 2d context'); + + // Retina support + context.scale(devicePixelRatio, devicePixelRatio); + + // Font config + context.font = `600 20px/${PRINT_USERNAME_LINE_HEIGHT}px Inter`; + context.textAlign = 'center'; + context.textBaseline = 'top'; + + // Experimental Chrome APIs + ( + context as unknown as { + letterSpacing: number; + } + ).letterSpacing = -0.34; + ( + context as unknown as { + textRendering: string; + } + ).textRendering = 'optimizeLegibility'; + + context.imageSmoothingEnabled = false; + + return [canvas, context]; +} + +type GetLogoCanvasOptionsType = Readonly<{ + fgColor: string; + imageUrl?: string; + devicePixelRatio?: number; +}>; + +async function getLogoCanvas({ + fgColor, + imageUrl = 'images/signal-qr-logo.svg', + devicePixelRatio, +}: GetLogoCanvasOptionsType): Promise { + const img = new Image(); + await new Promise((resolve, reject) => { + img.addEventListener('load', resolve); + img.addEventListener('error', () => + reject(new Error('Failed to load image')) + ); + img.src = imageUrl; + }); + + const [canvas, context] = createCanvasAndContext({ + width: PRINT_LOGO_SIZE, + height: PRINT_LOGO_SIZE, + devicePixelRatio, + }); + + context.fillStyle = fgColor; + context.fillRect(0, 0, PRINT_LOGO_SIZE, PRINT_LOGO_SIZE); + context.globalCompositeOperation = 'destination-in'; + context.drawImage(img, 0, 0, PRINT_LOGO_SIZE, PRINT_LOGO_SIZE); + + return canvas; +} + +function splitUsername(username: string): Array { + const result = new Array(); + + const [, context] = createCanvasAndContext({ width: 1, height: 1 }); + + // Compute number of lines and height of username + for (let i = 0, last = 0; i < username.length; i += 1) { + const part = username.slice(last, i); + if (context.measureText(part).width > PRINT_MAX_USERNAME_WIDTH) { + result.push(username.slice(last, i - 1)); + last = i - 1; + } else if (i === username.length - 1) { + result.push(username.slice(last)); + } + } + + return result; +} + +type GenerateImageURLOptionsType = Readonly<{ + link: string; + username: string; + colorId: number; + bgColor: string; + fgColor: string; + + // For testing + logoUrl?: string; + devicePixelRatio?: number; +}>; + +// Exported for testing +export async function _generateImageBlob({ + link, + username, + colorId, + bgColor, + fgColor, + logoUrl, + devicePixelRatio, +}: GenerateImageURLOptionsType): Promise { + const usernameLines = splitUsername(username); + const usernameHeight = PRINT_USERNAME_LINE_HEIGHT * usernameLines.length; + + const isWhiteBackground = colorId === ColorEnum.WHITE; + + const padding = isWhiteBackground ? PRINT_SHADOW_BLUR : 0; + + const totalHeight = + DEFAULT_PRINT_HEIGHT - PRINT_USERNAME_LINE_HEIGHT + usernameHeight; + const [canvas, context] = createCanvasAndContext({ + width: PRINT_WIDTH + 2 * padding, + height: totalHeight + 2 * padding, + devicePixelRatio, + }); + + // Draw card + context.save(); + if (isWhiteBackground) { + context.shadowColor = 'rgba(0, 0, 0, 0.08)'; + context.shadowBlur = PRINT_SHADOW_BLUR; + } + context.fillStyle = bgColor; + context.beginPath(); + context.roundRect( + padding, + padding, + PRINT_WIDTH, + totalHeight, + PRINT_CARD_RADIUS + ); + context.fill(); + context.restore(); + + // Draw padding around QR code + context.save(); + context.fillStyle = '#fff'; + const sizeWithPadding = PRINT_QR_SIZE + 2 * PRINT_QR_PADDING; + context.beginPath(); + context.roundRect( + padding + (PRINT_WIDTH - sizeWithPadding) / 2, + padding + PRINT_QR_Y - PRINT_QR_PADDING, + sizeWithPadding, + sizeWithPadding, + PRINT_QR_PADDING_RADIUS + ); + context.fill(); + if (isWhiteBackground) { + context.lineWidth = 2; + context.strokeStyle = '#e9e9e9'; + context.stroke(); + } + context.restore(); + + // Draw username + context.fillStyle = isWhiteBackground ? '#000' : '#fff'; + for (const [i, line] of usernameLines.entries()) { + context.fillText( + line, + padding + PRINT_WIDTH / 2, + PRINT_USERNAME_Y + i * PRINT_USERNAME_LINE_HEIGHT + ); + } + + // Draw logo + context.drawImage( + await getLogoCanvas({ fgColor, imageUrl: logoUrl, devicePixelRatio }), + padding + (PRINT_WIDTH - PRINT_LOGO_SIZE) / 2, + padding + PRINT_QR_Y + (PRINT_QR_SIZE - PRINT_LOGO_SIZE) / 2, + PRINT_LOGO_SIZE, + PRINT_LOGO_SIZE + ); + + // Draw QR code + const svg = renderToStaticMarkup(Blotches({ link, color: fgColor })); + const svgURL = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; + + const img = new Image(); + await new Promise((resolve, reject) => { + img.addEventListener('load', resolve); + img.addEventListener('error', () => + reject(new Error('Failed to load image')) + ); + img.src = svgURL; + }); + + context.drawImage( + img, + padding + (PRINT_WIDTH - PRINT_QR_SIZE) / 2, + PRINT_QR_Y + padding, + PRINT_QR_SIZE, + PRINT_QR_SIZE + ); + + const blob = await canvas.convertToBlob({ type: 'image/png' }); + return changeDpiBlob(blob, PRINT_DPI); +} + +type UsernameLinkColorRadioPropsType = Readonly<{ + i18n: LocalizerType; + index: number; + colorId: number; + fgColor: string; + bgColor: string; + isSelected: boolean; + onSelect: (colorId: number) => void; +}>; + +function UsernameLinkColorRadio({ + i18n, + index, + colorId, + fgColor, + bgColor, + isSelected, + onSelect, +}: UsernameLinkColorRadioPropsType): JSX.Element { + const className = `${CLASS}__colors__radio`; + + const onClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + onSelect(colorId); + }, + [colorId, onSelect] + ); + + const onRef = useCallback( + (elem: HTMLButtonElement | null): void => { + if (elem) { + // Note that these cannot be set through html attributes + elem.style.setProperty('--bg-color', bgColor); + elem.style.setProperty('--fg-color', fgColor); + } + }, + [fgColor, bgColor] + ); + + const isWhiteBackground = colorId === ColorEnum.WHITE; + + return ( + + ); +} + +type UsernameLinkColorsPropsType = Readonly<{ + i18n: LocalizerType; + value: number; + onChange: (colorId: number) => void; + onSave: () => void; + onCancel: () => void; +}>; + +function UsernameLinkColors({ + i18n, + value, + onChange, + onSave, + onCancel, +}: UsernameLinkColorsPropsType): JSX.Element { + const className = `${CLASS}__colors`; + + const normalizedValue = value === ColorEnum.UNKNOWN ? ColorEnum.BLUE : value; + + return ( +
+
+ {[...COLOR_MAP.entries()].map(([colorId, { fg, bg }], index) => { + return ( + + ); + })} +
+ + + + +
+ ); +} + +export function UsernameLinkModalBody({ + i18n, + link, + username, + usernameLinkState, + colorId: initialColorId = ColorEnum.UNKNOWN, + + setUsernameLinkColor, + resetUsernameLink, + saveAttachment, + showToast, +}: PropsType): JSX.Element { + const [pngData, setPngData] = useState(); + const [showColors, setShowColors] = useState(false); + const [confirmReset, setConfirmReset] = useState(false); + const [colorId, setColorId] = useState(initialColorId); + + const { fg: fgColor, bg: bgColor } = COLOR_MAP.get(colorId) ?? DEFAULT_PRESET; + + const isWhiteBackground = colorId === ColorEnum.WHITE; + const onCardRef = useCallback( + (elem: HTMLDivElement | null): void => { + if (elem) { + // Note that these cannot be set through html attributes + elem.style.setProperty('--bg-color', bgColor); + elem.style.setProperty('--fg-color', fgColor); + elem.style.setProperty( + '--text-color', + isWhiteBackground ? '#000' : '#fff' + ); + } + }, + [bgColor, fgColor, isWhiteBackground] + ); + + useEffect(() => { + let isAborted = false; + async function run() { + if (!link) { + return; + } + + const blob = await _generateImageBlob({ + link, + username, + colorId, + bgColor, + fgColor, + }); + const arrayBuffer = await blob.arrayBuffer(); + if (isAborted) { + return; + } + setPngData(new Uint8Array(arrayBuffer)); + } + + drop(run()); + + return () => { + isAborted = true; + }; + }, [link, username, colorId, bgColor, fgColor]); + + const onSave = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + if (!pngData) { + return; + } + + saveAttachment({ + data: pngData, + fileName: 'signal-username-qr-code.png', + contentType: IMAGE_PNG, + size: pngData.length, + }); + }, + [saveAttachment, pngData] + ); + + const onStartColorChange = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + + setShowColors(true); + }, []); + + const onCopyLink = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + if (link) { + drop(window.navigator.clipboard.writeText(link)); + showToast({ toastType: ToastType.CopiedUsernameLink }); + } + }, + [link, showToast] + ); + + const onCopyUsername = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + drop(window.navigator.clipboard.writeText(username)); + showToast({ toastType: ToastType.CopiedUsername }); + }, + [username, showToast] + ); + + // Color change sub modal + + const onUsernameLinkColorChange = useCallback((newColor: number) => { + setColorId(newColor); + }, []); + + const onUsernameLinkColorSave = useCallback(() => { + setUsernameLinkColor(colorId); + setShowColors(false); + }, [setUsernameLinkColor, colorId]); + + const onUsernameLinkColorCancel = useCallback(() => { + setShowColors(false); + setColorId(initialColorId); + }, [initialColorId]); + + // Reset sub modal + + const onClickReset = useCallback(() => { + setConfirmReset(true); + }, []); + + const onCancelReset = useCallback(() => { + setConfirmReset(false); + }, []); + + const onConfirmReset = useCallback(() => { + setConfirmReset(false); + resetUsernameLink(); + }, [resetUsernameLink]); + + const info = ( + <> +
+ + + +
+ +
+
+ +
+ {i18n('icu:UsernameLinkModalBody__help')} +
+ + + + ); + + return ( +
+
+
+
+ {usernameLinkState === UsernameLinkState.Ready && link ? ( + <> + +
+ + ) : ( + + )} +
+
+ {!showColors && ( +
+
+ + {confirmReset && ( + + {i18n('icu:UsernameLinkModalBody__reset__confirm')} + + )} + + {showColors ? ( + + ) : ( + info + )} +
+
+ ); +} diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index b330b20db..e306ebade 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -419,6 +419,20 @@ export function toAccountRecord( accountRecord.storyViewReceiptsEnabled = Proto.OptionalBool.UNSET; } + // Username link + { + const color = window.storage.get('usernameLinkColor'); + const linkData = window.storage.get('usernameLink'); + + if (linkData?.entropy.length && linkData?.serverId.length) { + accountRecord.usernameLink = { + color, + entropy: linkData.entropy, + serverId: linkData.serverId, + }; + } + } + applyUnknownFields(accountRecord, conversation); return accountRecord; @@ -1171,6 +1185,7 @@ export async function mergeAccountRecord( storiesDisabled, storyViewReceiptsEnabled, username, + usernameLink, } = accountRecord; const updatedConversations = new Array(); @@ -1425,6 +1440,22 @@ export async function mergeAccountRecord( break; } + if (usernameLink?.entropy?.length && usernameLink?.serverId?.length) { + await Promise.all([ + usernameLink.color && + window.storage.put('usernameLinkColor', usernameLink.color), + window.storage.put('usernameLink', { + entropy: usernameLink.entropy, + serverId: usernameLink.serverId, + }), + ]); + } else { + await Promise.all([ + window.storage.remove('usernameLinkColor'), + window.storage.remove('usernameLink'), + ]); + } + const ourID = window.ConversationController.getOurConversationId(); if (!ourID) { diff --git a/ts/services/username.ts b/ts/services/username.ts index 08fcadaa2..3576b28b9 100644 --- a/ts/services/username.ts +++ b/ts/services/username.ts @@ -11,6 +11,8 @@ import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue'; import { strictAssert } from '../util/assert'; import { sleep } from '../util/sleep'; import { getMinNickname, getMaxNickname } from '../util/Username'; +import { bytesToUuid } from '../Crypto'; +import { uuidToBytes } from '../util/uuidToBytes'; import type { UsernameReservationType } from '../types/Username'; import { ReserveUsernameError, ConfirmUsernameResult } from '../types/Username'; import * as Errors from '../types/errors'; @@ -18,6 +20,8 @@ import * as log from '../logging/log'; import MessageSender from '../textsecure/SendMessage'; import { HTTPError } from '../textsecure/Errors'; import { findRetryAfterTimeFromError } from '../jobs/helpers/findRetryAfterTimeFromError'; +import * as Bytes from '../Bytes'; +import { storageServiceUploadJob } from './storage'; export type WriteUsernameOptionsType = Readonly< | { @@ -186,6 +190,9 @@ export async function confirmUsername( }); await updateUsernameAndSyncProfile(username); + + // TODO: DESKTOP-5687 + await resetLink(username); } catch (error) { if (error instanceof HTTPError) { if (error.code === 413 || error.code === 429) { @@ -221,6 +228,67 @@ export async function deleteUsername( throw new Error('Username has changed on another device'); } + await window.storage.remove('usernameLink'); await server.deleteUsername(abortSignal); await updateUsernameAndSyncProfile(undefined); } + +export async function resetLink(username: string): Promise { + const { server } = window.textsecure; + if (!server) { + throw new Error('server interface is not available!'); + } + + const me = window.ConversationController.getOurConversationOrThrow(); + + if (me.get('username') !== username) { + throw new Error('Username has changed on another device'); + } + + const link = usernames.createUsernameLink(username); + + await window.storage.remove('usernameLink'); + + const { usernameLinkHandle: serverIdString } = + await server.replaceUsernameLink({ + encryptedUsername: link.encryptedUsername, + }); + + await window.storage.put('usernameLink', { + entropy: link.entropy, + serverId: uuidToBytes(serverIdString), + }); + + me.captureChange('usernameLink'); + storageServiceUploadJob(); +} + +const USERNAME_LINK_ENTROPY_SIZE = 32; + +export async function resolveUsernameByLinkBase64( + base64: string +): Promise { + const { server } = window.textsecure; + if (!server) { + throw new Error('server interface is not available!'); + } + + const content = Bytes.fromBase64(base64); + const entropy = content.slice(0, USERNAME_LINK_ENTROPY_SIZE); + const serverIdBytes = content.slice(USERNAME_LINK_ENTROPY_SIZE); + + const serverId = bytesToUuid(serverIdBytes); + strictAssert(serverId, 'Failed to re-encode server id as uuid'); + + strictAssert(window.textsecure.server, 'WebAPI must be available'); + const { usernameLinkEncryptedValue } = await server.resolveUsernameLink( + serverId + ); + + const link = new usernames.UsernameLink( + Buffer.from(entropy), + Buffer.from(usernameLinkEncryptedValue) + ); + + return link.decryptUsername(); +} diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 9c8e346c9..f1f8988c0 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -379,6 +379,13 @@ const ITEM_SPECS: Partial> = { senderCertificate: ['value.serialized'], senderCertificateNoE164: ['value.serialized'], subscriberId: ['value'], + usernameLink: { + key: 'value', + valueSpec: { + isMap: true, + valueSpec: ['entropy', 'serverId'], + }, + }, }; async function createOrUpdateItem( data: ItemType diff --git a/ts/state/ducks/items.ts b/ts/state/ducks/items.ts index 52089b57b..b2d0cdbdf 100644 --- a/ts/state/ducks/items.ts +++ b/ts/state/ducks/items.ts @@ -13,8 +13,6 @@ import { drop } from '../../util/drop'; import type { ConversationColorType, CustomColorType, - CustomColorsItemType, - DefaultConversationColorType, } from '../../types/Colors'; import { ConversationColors } from '../../types/Colors'; import { reloadSelectedConversation } from '../../shims/reloadSelectedConversation'; @@ -24,25 +22,26 @@ import type { ConfigMapType as RemoteConfigType } from '../../RemoteConfig'; // State -export type ItemsStateType = ReadonlyDeep<{ - universalExpireTimer?: number; +export type ItemsStateType = ReadonlyDeep< + { + [key: string]: unknown; - [key: string]: unknown; - - remoteConfig?: RemoteConfigType; - serverTimeSkew?: number; - - // This property should always be set and this is ensured in background.ts - defaultConversationColor?: DefaultConversationColorType; - - customColors?: CustomColorsItemType; - - preferredLeftPaneWidth?: number; - - preferredReactionEmoji?: Array; - - areWeASubscriber?: boolean; -}>; + remoteConfig?: RemoteConfigType; + serverTimeSkew?: number; + } & Partial< + Pick< + StorageAccessType, + | 'universalExpireTimer' + | 'defaultConversationColor' + | 'customColors' + | 'preferredLeftPaneWidth' + | 'preferredReactionEmoji' + | 'areWeASubscriber' + | 'usernameLinkColor' + | 'usernameLink' + > + > +>; // Actions diff --git a/ts/state/ducks/username.ts b/ts/state/ducks/username.ts index 7d20f0b27..99f32c5e0 100644 --- a/ts/state/ducks/username.ts +++ b/ts/state/ducks/username.ts @@ -10,6 +10,7 @@ import { ConfirmUsernameResult, } from '../../types/Username'; import * as usernameServices from '../../services/username'; +import { storageServiceUploadJob } from '../../services/storage'; import type { ReserveUsernameResultType } from '../../services/username'; import { missingCaseError } from '../../util/missingCaseError'; import { sleep } from '../../util/sleep'; @@ -19,6 +20,7 @@ import type { PromiseAction } from '../util'; import { getMe } from '../selectors/conversations'; import { UsernameEditState, + UsernameLinkState, UsernameReservationState, UsernameReservationError, } from './usernameEnums'; @@ -37,6 +39,9 @@ export type UsernameStateType = ReadonlyDeep<{ // ProfileEditor editState: UsernameEditState; + // UsernameLinkModalBody + linkState: UsernameLinkState; + // EditUsernameModalBody usernameReservation: UsernameReservationStateType; }>; @@ -50,6 +55,7 @@ const SET_USERNAME_RESERVATION_ERROR = 'username/SET_RESERVATION_ERROR'; const RESERVE_USERNAME = 'username/RESERVE_USERNAME'; const CONFIRM_USERNAME = 'username/CONFIRM_USERNAME'; const DELETE_USERNAME = 'username/DELETE_USERNAME'; +const RESET_USERNAME_LINK = 'username/RESET_USERNAME_LINK'; type SetUsernameEditStateActionType = ReadonlyDeep<{ type: typeof SET_USERNAME_EDIT_STATE; @@ -86,6 +92,9 @@ type ConfirmUsernameActionType = ReadonlyDeep< type DeleteUsernameActionType = ReadonlyDeep< PromiseAction >; +type ResetUsernameLinkActionType = ReadonlyDeep< + PromiseAction +>; export type UsernameActionType = ReadonlyDeep< | SetUsernameEditStateActionType @@ -95,6 +104,7 @@ export type UsernameActionType = ReadonlyDeep< | ReserveUsernameActionType | ConfirmUsernameActionType | DeleteUsernameActionType + | ResetUsernameLinkActionType >; export const actions = { @@ -105,6 +115,10 @@ export const actions = { reserveUsername, confirmUsername, deleteUsername, + markCompletedUsernameOnboarding, + resetUsernameLink, + setUsernameLinkColor, + markCompletedUsernameLinkOnboarding, }; export function setUsernameEditState( @@ -255,11 +269,72 @@ export function deleteUsername({ }; } +export type ResetUsernameLinkOptionsType = ReadonlyDeep<{ + doResetLink?: typeof usernameServices.resetLink; +}>; + +export function resetUsernameLink({ + doResetLink = usernameServices.resetLink, +}: ResetUsernameLinkOptionsType = {}): ThunkAction< + void, + RootStateType, + unknown, + ResetUsernameLinkActionType +> { + return dispatch => { + const me = window.ConversationController.getOurConversationOrThrow(); + const username = me.get('username'); + assertDev(username, 'Username is required for resetting link'); + + dispatch({ + type: RESET_USERNAME_LINK, + payload: doResetLink(username), + }); + }; +} + +function markCompletedUsernameOnboarding(): ThunkAction< + void, + RootStateType, + unknown, + never +> { + return async () => { + await window.storage.put('hasCompletedUsernameOnboarding', true); + const me = window.ConversationController.getOurConversationOrThrow(); + me.captureChange('usernameOnboarding'); + storageServiceUploadJob(); + }; +} + +function markCompletedUsernameLinkOnboarding(): ThunkAction< + void, + RootStateType, + unknown, + never +> { + return async () => { + await window.storage.put('hasCompletedUsernameLinkOnboarding', true); + }; +} + +function setUsernameLinkColor( + color: number +): ThunkAction { + return async () => { + await window.storage.put('usernameLinkColor', color); + const me = window.ConversationController.getOurConversationOrThrow(); + me.captureChange('usernameLinkColor'); + storageServiceUploadJob(); + }; +} + // Reducers export function getEmptyState(): UsernameStateType { return { editState: UsernameEditState.Editing, + linkState: UsernameLinkState.Ready, usernameReservation: { state: UsernameReservationState.Closed, }, @@ -476,5 +551,26 @@ export function reducer( return state; } + if (action.type === 'username/RESET_USERNAME_LINK_PENDING') { + return { + ...state, + linkState: UsernameLinkState.Updating, + }; + } + + if (action.type === 'username/RESET_USERNAME_LINK_FULFILLED') { + return { + ...state, + linkState: UsernameLinkState.Ready, + }; + } + + if (action.type === 'username/RESET_USERNAME_LINK_REJECTED') { + return { + ...state, + linkState: UsernameLinkState.Ready, + }; + } + return state; } diff --git a/ts/state/ducks/usernameEnums.ts b/ts/state/ducks/usernameEnums.ts index bc86ac318..c4f289d87 100644 --- a/ts/state/ducks/usernameEnums.ts +++ b/ts/state/ducks/usernameEnums.ts @@ -11,6 +11,15 @@ export enum UsernameEditState { Deleting = 'Deleting', } +// +// UsernameLinkModalBody +// + +export enum UsernameLinkState { + Ready = 'Ready', + Updating = 'Updating', +} + // // EditUsernameModalBody // diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts index 66a8d767b..75ebfb353 100644 --- a/ts/state/selectors/items.ts +++ b/ts/state/selectors/items.ts @@ -19,6 +19,8 @@ import { DEFAULT_CONVERSATION_COLOR } from '../../types/Colors'; import { getPreferredReactionEmoji as getPreferredReactionEmojiFromStoredValue } from '../../reactions/preferredReactionEmoji'; import { isBeta } from '../../util/version'; import { DurationInSeconds } from '../../util/durations'; +import { generateUsernameLink } from '../../util/sgnlHref'; +import * as Bytes from '../../Bytes'; import { getUserNumber, getUserACI } from './user'; const DEFAULT_PREFERRED_LEFT_PANE_WIDTH = 320; @@ -86,12 +88,41 @@ export const getHasCompletedUsernameOnboarding = createSelector( Boolean(state.hasCompletedUsernameOnboarding) ); +export const getHasCompletedUsernameLinkOnboarding = createSelector( + getItems, + (state: ItemsStateType): boolean => + Boolean(state.hasCompletedUsernameLinkOnboarding) +); + export const getHasCompletedSafetyNumberOnboarding = createSelector( getItems, (state: ItemsStateType): boolean => Boolean(state.hasCompletedSafetyNumberOnboarding) ); +export const getUsernameLinkColor = createSelector( + getItems, + (state: ItemsStateType): number | undefined => state.usernameLinkColor +); + +export const getUsernameLink = createSelector( + getItems, + ({ usernameLink }: ItemsStateType): string | undefined => { + if (!usernameLink) { + return undefined; + } + const { entropy, serverId } = usernameLink; + + if (!entropy.length || !serverId.length) { + return undefined; + } + + const content = Bytes.concatenate([entropy, serverId]); + + return generateUsernameLink(Bytes.toBase64(content)); + } +); + export const isInternalUser = createSelector( getRemoteConfig, (remoteConfig: ConfigMapType): boolean => { diff --git a/ts/state/selectors/username.ts b/ts/state/selectors/username.ts index dc968f05c..6a5bc4dd2 100644 --- a/ts/state/selectors/username.ts +++ b/ts/state/selectors/username.ts @@ -11,6 +11,7 @@ import type { } from '../ducks/username'; import type { UsernameEditState, + UsernameLinkState, UsernameReservationState, UsernameReservationError, } from '../ducks/usernameEnums'; @@ -23,6 +24,11 @@ export const getUsernameEditState = createSelector( (state: UsernameStateType): UsernameEditState => state.editState ); +export const getUsernameLinkState = createSelector( + getUsernameState, + (state: UsernameStateType): UsernameLinkState => state.linkState +); + export const getUsernameReservation = createSelector( getUsernameState, (state: UsernameStateType): UsernameReservationStateType => diff --git a/ts/state/smart/ProfileEditorModal.tsx b/ts/state/smart/ProfileEditorModal.tsx index ed832c655..ced8485db 100644 --- a/ts/state/smart/ProfileEditorModal.tsx +++ b/ts/state/smart/ProfileEditorModal.tsx @@ -8,7 +8,6 @@ import { mapDispatchToProps } from '../actions'; import type { PropsDataType as ProfileEditorModalPropsType } from '../../components/ProfileEditorModal'; import { ProfileEditorModal } from '../../components/ProfileEditorModal'; import type { PropsDataType } from '../../components/ProfileEditor'; -import { storageServiceUploadJob } from '../../services/storage'; import { SmartEditUsernameModalBody } from './EditUsernameModalBody'; import type { StateType } from '../reducer'; import { getIntl } from '../selectors/user'; @@ -16,10 +15,16 @@ import { getEmojiSkinTone, getUsernamesEnabled, getHasCompletedUsernameOnboarding, + getHasCompletedUsernameLinkOnboarding, + getUsernameLinkColor, + getUsernameLink, } from '../selectors/items'; import { getMe } from '../selectors/conversations'; import { selectRecentEmojis } from '../selectors/emojis'; -import { getUsernameEditState } from '../selectors/username'; +import { + getUsernameEditState, + getUsernameLinkState, +} from '../selectors/username'; function renderEditUsernameModalBody(props: { onClose: () => void; @@ -27,12 +32,6 @@ function renderEditUsernameModalBody(props: { return ; } -async function markCompletedUsernameOnboarding(): Promise { - await window.storage.put('hasCompletedUsernameOnboarding', true); - - storageServiceUploadJob(); -} - function mapStateToProps( state: StateType ): Omit & @@ -53,7 +52,12 @@ function mapStateToProps( const isUsernameFlagEnabled = getUsernamesEnabled(state); const hasCompletedUsernameOnboarding = getHasCompletedUsernameOnboarding(state); + const hasCompletedUsernameLinkOnboarding = + getHasCompletedUsernameLinkOnboarding(state); const usernameEditState = getUsernameEditState(state); + const usernameLinkState = getUsernameLinkState(state); + const usernameLinkColor = getUsernameLinkColor(state); + const usernameLink = getUsernameLink(state); return { aboutEmoji, @@ -64,15 +68,18 @@ function mapStateToProps( familyName, firstName: String(firstName), hasCompletedUsernameOnboarding, + hasCompletedUsernameLinkOnboarding, hasError: state.globalModals.profileEditorHasError, i18n: getIntl(state), isUsernameFlagEnabled, - markCompletedUsernameOnboarding, recentEmojis, skinTone, userAvatarData, username, usernameEditState, + usernameLinkState, + usernameLinkColor, + usernameLink, renderEditUsernameModalBody, }; diff --git a/ts/test-electron/components/UsernameLinkModalBody_test.ts b/ts/test-electron/components/UsernameLinkModalBody_test.ts new file mode 100644 index 000000000..1a8f5b9d1 --- /dev/null +++ b/ts/test-electron/components/UsernameLinkModalBody_test.ts @@ -0,0 +1,136 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import path from 'path'; +import { mkdir } from 'fs/promises'; +import { pathExists, writeFile, readFile } from 'fs-extra'; + +import { + _generateImageBlob, + COLOR_MAP, +} from '../../components/UsernameLinkModalBody'; +import { SignalService as Proto } from '../../protobuf'; + +const ColorEnum = Proto.AccountRecord.UsernameLink.Color; + +async function getImageData(blob: Blob): Promise { + const url = URL.createObjectURL(blob); + + try { + const img = new Image(); + await new Promise(resolve => { + img.addEventListener('load', resolve); + img.src = url; + }); + + const canvas = new OffscreenCanvas(img.width, img.height); + + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Failed to get 2d context'); + } + + context.drawImage(img, 0, 0); + return context.getImageData(0, 0, img.width, img.height); + } finally { + URL.revokeObjectURL(url); + } +} + +const TEST_COLORS: ReadonlyArray<[string, number]> = [ + ['white', ColorEnum.WHITE], + ['blue', ColorEnum.BLUE], +]; + +describe('', () => { + before(async () => { + // We need to load the font first, otherwise the first test render will use + // default font (not Inter) + const f = new FontFace( + 'Inter', + 'url(../fonts/inter-v3.19/Inter-SemiBold.woff2)', + { + weight: '600', + } + ); + + await f.load(); + + document.fonts.add(f); + }); + + for (const [colorName, colorId] of TEST_COLORS) { + it(`should generate correct ${colorName} QR code image`, async () => { + const scheme = COLOR_MAP.get(colorId); + if (!scheme) { + throw new Error(`Missing color scheme for: ${colorId}`); + } + + const { bg: bgColor, fg: fgColor } = scheme; + + const generatedBlob = await _generateImageBlob({ + link: + 'https://signal.me#eu/' + + 'E7wk7FTMz_UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4_Efe', + username: 'signal.12', + colorId, + bgColor, + fgColor, + + // Just because we run from `test/` folder, and not `/` + logoUrl: '../images/signal-qr-logo.svg', + + // Force pixel ratio since test runner might not be on Retina + devicePixelRatio: 2, + }); + + // Create fixture if not present + const fileName = `username-link-${colorName}-${process.platform}.png`; + const fixture = path.join( + __dirname, + '..', + '..', + '..', + 'fixtures', + fileName + ); + if (!(await pathExists(fixture))) { + await writeFile( + fixture, + Buffer.from(await generatedBlob.arrayBuffer()) + ); + return; + } + + // Otherwise compare against existing fixture + const expectedData = new Blob([await readFile(fixture)], { + type: 'image/png', + }); + + const expected = await getImageData(expectedData); + const actual = await getImageData(generatedBlob); + + try { + assert.strictEqual(actual.width, expected.width, 'Wrong image width'); + assert.strictEqual( + actual.height, + expected.height, + 'Wrong image height' + ); + assert.deepEqual(actual.data, expected.data, 'Wrong image data'); + } catch (error) { + const { ARTIFACTS_DIR } = process.env; + if (ARTIFACTS_DIR) { + await mkdir(ARTIFACTS_DIR, { recursive: true }); + await writeFile( + path.join(ARTIFACTS_DIR, fileName), + Buffer.from(await generatedBlob.arrayBuffer()) + ); + } + + throw error; + } + }); + } +}); diff --git a/ts/test-mock/helpers.ts b/ts/test-mock/helpers.ts new file mode 100644 index 000000000..9747647ae --- /dev/null +++ b/ts/test-mock/helpers.ts @@ -0,0 +1,14 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export function bufferToUuid(buffer: Buffer): string { + const hex = buffer.toString('hex'); + + return [ + hex.substring(0, 8), + hex.substring(8, 12), + hex.substring(12, 16), + hex.substring(16, 20), + hex.substring(20), + ].join('-'); +} diff --git a/ts/test-mock/pnp/username_test.ts b/ts/test-mock/pnp/username_test.ts index 37a9aa341..e7f5b2f87 100644 --- a/ts/test-mock/pnp/username_test.ts +++ b/ts/test-mock/pnp/username_test.ts @@ -4,13 +4,16 @@ import { assert } from 'chai'; import { Proto, StorageState } from '@signalapp/mock-server'; import type { PrimaryDevice } from '@signalapp/mock-server'; +import { usernames } from '@signalapp/libsignal-client'; import createDebug from 'debug'; import * as durations from '../../util/durations'; import { uuidToBytes } from '../../util/uuidToBytes'; +import { generateUsernameLink } from '../../util/sgnlHref'; import { MY_STORY_ID } from '../../types/Stories'; import { Bootstrap } from '../bootstrap'; import type { App } from '../bootstrap'; +import { bufferToUuid } from '../helpers'; export const debug = createDebug('mock:test:username'); @@ -206,6 +209,32 @@ describe('pnp/username', function needsName() { assert.strictEqual(removed.length, 1, 'only one record must be removed'); assert.strictEqual(added[0]?.account?.username, username); + const usernameLink = added[0]?.account?.usernameLink; + if (!usernameLink) { + throw new Error('No username link in AccountRecord'); + } + if (!usernameLink.entropy) { + throw new Error('No username link entropy in AccountRecord'); + } + if (!usernameLink.serverId) { + throw new Error('No username link serverId in AccountRecord'); + } + + const linkUuid = bufferToUuid(Buffer.from(usernameLink.serverId)); + + const encryptedLinkBase64 = await server.lookupByUsernameLink(linkUuid); + if (!encryptedLinkBase64) { + throw new Error('Could not find link on the sever'); + } + + const encryptedLink = Buffer.from(encryptedLinkBase64, 'base64'); + + const link = new usernames.UsernameLink( + Buffer.from(usernameLink.entropy), + encryptedLink + ); + const linkUsername = link.decryptUsername(); + assert.strictEqual(linkUsername, username); state = newState; } @@ -233,7 +262,17 @@ describe('pnp/username', function needsName() { assert.strictEqual(added.length, 1, 'only one record must be added'); assert.strictEqual(removed.length, 1, 'only one record must be removed'); - assert.strictEqual(added[0]?.account?.username, ''); + assert.strictEqual(added[0]?.account?.username, '', 'clears username'); + assert.strictEqual( + added[0]?.account?.usernameLink?.entropy?.length ?? 0, + 0, + 'clears usernameLink.entropy' + ); + assert.strictEqual( + added[0]?.account?.usernameLink?.serverId?.length ?? 0, + 0, + 'clears usernameLink.serverId' + ); state = newState; } @@ -272,4 +311,56 @@ describe('pnp/username', function needsName() { assert.strictEqual(source, desktop); } }); + + it('looks up contacts by username link', async () => { + const { desktop, phone, server } = bootstrap; + + debug('creating a contact with username link'); + const carl = await server.createPrimaryDevice({ + profileName: 'Devin', + }); + + await server.setUsername(carl.device.uuid, CARL_USERNAME); + const { entropy, serverId } = await server.setUsernameLink( + carl.device.uuid, + CARL_USERNAME + ); + + const linkUrl = generateUsernameLink( + Buffer.concat([entropy, uuidToBytes(serverId)]).toString('base64') + ); + + debug('sending link to Note to Self'); + await phone.sendText(desktop, linkUrl, { + withProfileKey: true, + }); + + const window = await app.getWindow(); + + debug('opening note to self'); + const leftPane = window.locator('.left-pane-wrapper'); + await leftPane.locator(`[data-testid="${desktop.uuid}"]`).click(); + + debug('clicking link'); + await window.locator('.module-message__text a').click({ + noWaitAfter: true, + }); + + debug('waiting for conversation to open'); + await window + .locator(`.module-conversation-hero >> "${CARL_USERNAME}"`) + .waitFor(); + + debug('sending a message'); + { + const compositionInput = await app.waitForEnabledComposer(); + + await compositionInput.type('Hello Carl'); + await compositionInput.press('Enter'); + + const { body, source } = await carl.waitForMessage(); + assert.strictEqual(body, 'Hello Carl'); + assert.strictEqual(source, desktop); + } + }); }); diff --git a/ts/test-node/util/sgnlHref_test.ts b/ts/test-node/util/sgnlHref_test.ts index 75544aa63..af97b4954 100644 --- a/ts/test-node/util/sgnlHref_test.ts +++ b/ts/test-node/util/sgnlHref_test.ts @@ -12,7 +12,7 @@ import { parseSgnlHref, parseCaptchaHref, parseE164FromSignalDotMeHash, - parseUsernameFromSignalDotMeHash, + parseUsernameBase64FromSignalDotMeHash, parseSignalHttpsLink, generateUsernameLink, rewriteSignalHrefsIfNecessary, @@ -375,21 +375,19 @@ describe('sgnlHref', () => { }); }); - describe('parseUsernameFromSignalDotMeHash', () => { + describe('parseUsernameBase64FromSignalDotMeHash', () => { it('returns undefined for invalid inputs', () => { - ['', ' u/+18885551234', 'z/18885551234'].forEach(hash => { - assert.isUndefined(parseUsernameFromSignalDotMeHash(hash)); + ['', ' eu/+18885551234', 'z/18885551234'].forEach(hash => { + assert.isUndefined(parseUsernameBase64FromSignalDotMeHash(hash)); }); }); it('returns the username for valid inputs', () => { assert.strictEqual( - parseUsernameFromSignalDotMeHash('u/signal.03'), - 'signal.03' - ); - assert.strictEqual( - parseUsernameFromSignalDotMeHash('u/signal%2F03'), - 'signal/03' + parseUsernameBase64FromSignalDotMeHash( + 'eu/E7wk7FTMz_UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4_Efe' + ), + 'E7wk7FTMz/UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4/Efe' ); }); }); @@ -397,22 +395,20 @@ describe('sgnlHref', () => { describe('generateUsernameLink', () => { it('generates regular link', () => { assert.strictEqual( - generateUsernameLink('signal.03'), - 'https://signal.me/#u/signal.03' - ); - }); - - it('generates encoded link', () => { - assert.strictEqual( - generateUsernameLink('signal/03'), - 'https://signal.me/#u/signal%2F03' + generateUsernameLink( + 'E7wk7FTMz/UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4/Efe' + ), + 'https://signal.me#eu/E7wk7FTMz_UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4_Efe' ); }); it('generates short link', () => { assert.strictEqual( - generateUsernameLink('signal/03', { short: true }), - 'signal.me/#u/signal%2F03' + generateUsernameLink( + 'E7wk7FTMz/UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4/Efe', + { short: true } + ), + 'signal.me#eu/E7wk7FTMz_UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4_Efe' ); }); }); diff --git a/ts/textsecure/Storage.ts b/ts/textsecure/Storage.ts index 1908dc089..7e10f6d13 100644 --- a/ts/textsecure/Storage.ts +++ b/ts/textsecure/Storage.ts @@ -56,10 +56,10 @@ export class Storage implements StorageInterface { defaultValue: V ): V; - public get( + public get( key: K, - defaultValue?: V - ): V | undefined { + defaultValue?: Access[K] + ): Access[K] | undefined { if (!this.ready) { log.warn('Called storage.get before storage is ready. key:', key); } @@ -69,7 +69,7 @@ export class Storage implements StorageInterface { return defaultValue; } - return item as V; + return item; } public async put( diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 2d98b37d3..a4b2b575e 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -520,6 +520,7 @@ const URL_CALLS = { username: 'v1/accounts/username_hash', reserveUsername: 'v1/accounts/username_hash/reserve', confirmUsername: 'v1/accounts/username_hash/confirm', + usernameLink: 'v1/accounts/username_link', whoami: 'v1/accounts/whoami', }; @@ -823,6 +824,10 @@ export type ReserveUsernameOptionsType = Readonly<{ abortSignal?: AbortSignal; }>; +export type ReplaceUsernameLinkOptionsType = Readonly<{ + encryptedUsername: Uint8Array; +}>; + export type ConfirmUsernameOptionsType = Readonly<{ hash: Uint8Array; proof: Uint8Array; @@ -838,6 +843,20 @@ export type ReserveUsernameResultType = z.infer< typeof reserveUsernameResultZod >; +const replaceUsernameLinkResultZod = z.object({ + usernameLinkHandle: z.string(), +}); +export type ReplaceUsernameLinkResultType = z.infer< + typeof replaceUsernameLinkResultZod +>; + +const resolveUsernameLinkResultZod = z.object({ + usernameLinkEncryptedValue: z.string().transform(x => Bytes.fromBase64(x)), +}); +export type ResolveUsernameLinkResultType = z.infer< + typeof resolveUsernameLinkResultZod +>; + export type ConfirmCodeOptionsType = Readonly<{ number: string; code: string; @@ -1002,6 +1021,13 @@ export type WebAPIType = { options: ReserveUsernameOptionsType ) => Promise; confirmUsername(options: ConfirmUsernameOptionsType): Promise; + replaceUsernameLink: ( + options: ReplaceUsernameLinkOptionsType + ) => Promise; + deleteUsernameLink: () => Promise; + resolveUsernameLink: ( + serverId: string + ) => Promise; registerCapabilities: (capabilities: CapabilitiesUploadType) => Promise; registerKeys: (genKeys: UploadKeysType, uuidKind: UUIDKind) => Promise; registerSupportForUnauthenticatedDelivery: () => Promise; @@ -1284,6 +1310,7 @@ export function initialize({ confirmUsername, createGroup, deleteUsername, + deleteUsernameLink, downloadOnboardingStories, fetchLinkPreviewImage, fetchLinkPreviewMetadata, @@ -1336,6 +1363,8 @@ export function initialize({ registerKeys, registerRequestHandler, registerSupportForUnauthenticatedDelivery, + resolveUsernameLink, + replaceUsernameLink, reportMessage, requestVerificationSMS, requestVerificationVoice, @@ -1900,6 +1929,43 @@ export function initialize({ }); } + async function replaceUsernameLink({ + encryptedUsername, + }: ReplaceUsernameLinkOptionsType): Promise { + return replaceUsernameLinkResultZod.parse( + await _ajax({ + call: 'usernameLink', + httpType: 'PUT', + responseType: 'json', + jsonData: { + usernameLinkEncryptedValue: Bytes.toBase64(encryptedUsername), + }, + }) + ); + } + + async function deleteUsernameLink(): Promise { + await _ajax({ + call: 'usernameLink', + httpType: 'DELETE', + }); + } + + async function resolveUsernameLink( + serverId: string + ): Promise { + return resolveUsernameLinkResultZod.parse( + await _ajax({ + httpType: 'GET', + call: 'usernameLink', + urlParameters: `/${encodeURIComponent(serverId)}`, + responseType: 'json', + unauthenticated: true, + accessKey: undefined, + }) + ); + } + async function reportMessage({ senderUuid, serverGuid, diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 2990dc58d..847b9eabb 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -74,6 +74,7 @@ export type StorageAccessType = { hasRegisterSupportForUnauthenticatedDelivery: boolean; hasSetMyStoriesPrivacy: boolean; hasCompletedUsernameOnboarding: boolean; + hasCompletedUsernameLinkOnboarding: boolean; hasCompletedSafetyNumberOnboarding: boolean; hasViewedOnboardingStory: boolean; hasStoriesDisabled: boolean; @@ -158,6 +159,11 @@ export type StorageAccessType = { subscriberCurrencyCode: string; displayBadgesOnProfile: boolean; keepMutedChatsArchived: boolean; + usernameLinkColor: number; + usernameLink: { + entropy: Uint8Array; + serverId: Uint8Array; + }; // Deprecated 'challenge:retry-message-ids': never; diff --git a/ts/types/Username.ts b/ts/types/Username.ts index 51366f522..265603a56 100644 --- a/ts/types/Username.ts +++ b/ts/types/Username.ts @@ -1,8 +1,6 @@ // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { usernames } from '@signalapp/libsignal-client'; - export type UsernameReservationType = Readonly<{ username: string; previousUsername: string | undefined; @@ -27,7 +25,7 @@ export enum ConfirmUsernameResult { export function getUsernameFromSearch(searchTerm: string): string | undefined { try { - usernames.hash(searchTerm); + window.SignalContext.usernames.hash(searchTerm); return searchTerm; } catch { return undefined; diff --git a/ts/util/createIPCEvents.ts b/ts/util/createIPCEvents.ts index 53d11c653..4819d81b1 100644 --- a/ts/util/createIPCEvents.ts +++ b/ts/util/createIPCEvents.ts @@ -21,6 +21,7 @@ import { parseSystemTraySetting } from '../types/SystemTraySetting'; import type { ConversationType } from '../state/ducks/conversations'; import type { AuthorizeArtCreatorDataType } from '../state/ducks/globalModals'; import { calling } from '../services/calling'; +import { resolveUsernameByLinkBase64 } from '../services/username'; import { getConversationsWithCustomColorSelector } from '../state/selectors/conversations'; import { getCustomColors } from '../state/selectors/items'; import { themeChanged } from '../shims/themeChanged'; @@ -36,7 +37,7 @@ import { isPhoneNumberSharingEnabled } from './isPhoneNumberSharingEnabled'; import * as Registration from './registration'; import { parseE164FromSignalDotMeHash, - parseUsernameFromSignalDotMeHash, + parseUsernameBase64FromSignalDotMeHash, } from './sgnlHref'; import { lookupConversationWithoutUuid } from './lookupConversationWithoutUuid'; import * as log from '../logging/log'; @@ -538,11 +539,13 @@ export function createIPCEvents( return; } - const maybeUsername = parseUsernameFromSignalDotMeHash(hash); - if (maybeUsername) { + const maybeUsernameBase64 = parseUsernameBase64FromSignalDotMeHash(hash); + if (maybeUsernameBase64) { + const username = await resolveUsernameByLinkBase64(maybeUsernameBase64); + const convoId = await lookupConversationWithoutUuid({ type: 'username', - username: maybeUsername, + username, showUserNotFoundModal, setIsFetchingUUID: noop, }); diff --git a/ts/util/sgnlHref.ts b/ts/util/sgnlHref.ts index 7d0c8fe64..d6b890284 100644 --- a/ts/util/sgnlHref.ts +++ b/ts/util/sgnlHref.ts @@ -4,10 +4,10 @@ import type { LoggerType } from '../types/Logging'; import { maybeParseUrl } from './url'; import { isValidE164 } from './isValidE164'; +import { fromWebSafeBase64, toWebSafeBase64 } from './webSafeBase64'; const SIGNAL_HOSTS = new Set(['signal.group', 'signal.art', 'signal.me']); const SIGNAL_DOT_ME_E164_PREFIX = 'p/'; -const SIGNAL_DOT_ME_USERNAME_PREFIX = 'u/'; function parseUrl(value: string | URL, logger: LoggerType): undefined | URL { if (value instanceof URL) { @@ -147,14 +147,15 @@ export function parseE164FromSignalDotMeHash(hash: string): undefined | string { return isValidE164(maybeE164, true) ? maybeE164 : undefined; } -export function parseUsernameFromSignalDotMeHash( +export function parseUsernameBase64FromSignalDotMeHash( hash: string ): undefined | string { - if (!hash.startsWith(SIGNAL_DOT_ME_USERNAME_PREFIX)) { + const match = hash.match(/^eu\/([a-zA-Z0-9_-]{64})$/); + if (!match) { return; } - return decodeURIComponent(hash.slice(SIGNAL_DOT_ME_USERNAME_PREFIX.length)); + return fromWebSafeBase64(match[1]); } /** @@ -184,10 +185,10 @@ export type GenerateUsernameLinkOptionsType = Readonly<{ }>; export function generateUsernameLink( - username: string, + base64: string, { short = false }: GenerateUsernameLinkOptionsType = {} ): string { - const shortVersion = `signal.me/#u/${encodeURIComponent(username)}`; + const shortVersion = `signal.me#eu/${toWebSafeBase64(base64)}`; if (short) { return shortVersion; } diff --git a/ts/windows/context.ts b/ts/windows/context.ts index 9e963764b..0b32d815b 100644 --- a/ts/windows/context.ts +++ b/ts/windows/context.ts @@ -3,6 +3,7 @@ import { ipcRenderer } from 'electron'; import type { MenuItemConstructorOptions } from 'electron'; +import { usernames } from '@signalapp/libsignal-client'; import type { MenuOptionsType, MenuActionType } from '../types/menu'; import type { IPCEventsValuesType } from '../util/createIPCEvents'; @@ -61,6 +62,7 @@ export type MinimalSignalContextType = { export type SignalContextType = { bytes: Bytes; crypto: Crypto; + usernames: typeof usernames; i18n: LocalizerType; log: LoggerType; renderWindow?: () => void; @@ -72,6 +74,7 @@ export const SignalContext: SignalContextType = { ...MinimalSignalContext, bytes: new Bytes(), crypto: new Crypto(), + usernames, i18n, log: window.SignalContext.log, setIsCallActive(isCallActive: boolean): void { diff --git a/yarn.lock b/yarn.lock index d618df0ac..f1c91c6fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2276,20 +2276,20 @@ bindings "^1.5.0" tar "^6.1.0" -"@signalapp/libsignal-client@0.27.0", "@signalapp/libsignal-client@^0.27.0": - version "0.27.0" - resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.27.0.tgz#012e254d42e4dcd752979419c048af65a3f1eed1" - integrity sha512-XinrJ9R2veJM/u3CAaL/YN5Yid+ASfsSceQiL/Qr1vKCsMori0bWG6AzOBnDUx/Bnm6dcDBc15t8w31WkXOTVw== +"@signalapp/libsignal-client@0.28.0", "@signalapp/libsignal-client@^0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.28.0.tgz#b1553a4b56fc01afe5e9b2785abd5c680f46ebc4" + integrity sha512-Vl3vt9hBdPW2/cwuf8+ZMwxmlAlnuBSgsKebRPfDOboLWDRlQRq+tstlwfBFU0e/2ixgY95Wulu46I1cl6H40g== dependencies: node-gyp-build "^4.2.3" uuid "^8.3.0" -"@signalapp/mock-server@3.1.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-3.1.0.tgz#6f499cf1a396626901760b93e888bb5020983075" - integrity sha512-u6zz9PWV7NLP+RIz2hg4zl0UY34Ufj2pjQsPmIY//oVnu3PfIiyuKMIzPU7jPMSYq29uFbUQPypLJYOYP2dOiA== +"@signalapp/mock-server@3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-3.2.0.tgz#9371dc2002a1a8aa25ac815944a443cdc0a1b7c5" + integrity sha512-4rpAAH5tV8eoIikb6FozMmrCKr+pqaP9JNyfZQ5YLPanAiTE9iER7WJex0HJ9PhscgswbzWIPFuRyhm/qPocVQ== dependencies: - "@signalapp/libsignal-client" "^0.27.0" + "@signalapp/libsignal-client" "^0.28.0" debug "^4.3.2" long "^4.0.0" micro "^9.3.4" @@ -6594,6 +6594,11 @@ chalk@^3.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +changedpi@1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/changedpi/-/changedpi-1.0.4.tgz#91b915c7e3fbbcd75559249f553902b96f3e4c7e" + integrity sha512-9r6MNQrbg+cFURvEy10wo9Q35PD5GVj2GvXCbUYv8mU0Uf/NbkR7KlzMrjT4Ycd8a2nxApFJXQX2lTOPRFyG2g== + character-entities-legacy@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.1.tgz#f40779df1a101872bb510a3d295e1fccf147202f"