From f9aaf30a32c1bcdfd9591478ac72826f83149876 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Mon, 13 Feb 2023 10:51:41 -0800 Subject: [PATCH] Username onboarding --- _locales/en/messages.json | 24 ++++ images/icons/v2/link_color_32.svg | 1 + images/icons/v2/lock_color_32.svg | 1 + images/icons/v2/number_color_32.svg | 1 + protos/SignalStorage.proto | 1 + .../UsernameOnboardingModalBody.scss | 107 ++++++++++++++++++ stylesheets/manifest.scss | 1 + ts/components/ProfileEditor.stories.tsx | 1 + ts/components/ProfileEditor.tsx | 22 +++- ts/components/ProfileEditorModal.tsx | 3 +- .../UsernameOnboardingModalBody.stories.tsx | 37 ++++++ ts/components/UsernameOnboardingModalBody.tsx | 68 +++++++++++ ts/services/storageRecordOps.ts | 18 +++ ts/state/selectors/items.ts | 6 + ts/state/smart/ProfileEditorModal.tsx | 17 ++- ts/test-mock/pnp/username_test.ts | 3 + ts/types/Storage.d.ts | 1 + 17 files changed, 309 insertions(+), 3 deletions(-) create mode 100644 images/icons/v2/link_color_32.svg create mode 100644 images/icons/v2/lock_color_32.svg create mode 100644 images/icons/v2/number_color_32.svg create mode 100644 stylesheets/components/UsernameOnboardingModalBody.scss create mode 100644 ts/components/UsernameOnboardingModalBody.stories.tsx create mode 100644 ts/components/UsernameOnboardingModalBody.tsx diff --git a/_locales/en/messages.json b/_locales/en/messages.json index fe22e5d97b61..e6ce8bfdfefa 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -6383,6 +6383,30 @@ "message": "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:UsernameOnboardingModalBody__title": { + "messageformat": "Set up your Signal username", + "description": "Title of username onboarding modal" + }, + "icu:UsernameOnboardingModalBody__row__number": { + "messageformat": "Usernames are paired with a set of digits and aren’t shared on your profile", + "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", + "description": "Content of the second row of username onboarding modal" + }, + "icu:UsernameOnboardingModalBody__row__lock": { + "messageformat": "Turn off phone number discovery under Settings > Phone Number > Who can find my number, to use your username as the primary way others can contact you.", + "description": "Content of the third row of username onboarding modal" + }, + "icu:UsernameOnboardingModalBody__learn-more": { + "messageformat": "Learn More", + "description": "Text that open a popup with information about username onboarding" + }, + "icu:UsernameOnboardingModalBody__continue": { + "messageformat": "Continue", + "description": "Text of the primary button on username onboarding modal" + }, "icu:UnsupportedOSWarningDialog__body": { "messageformat": "Signal desktop will no longer support your computer’s version of {OS} soon. To keep using Signal, update your computer’s operating system by {expirationDate}. Learn more", "description": "Body of a dialog displayed on unsupported operating systems" diff --git a/images/icons/v2/link_color_32.svg b/images/icons/v2/link_color_32.svg new file mode 100644 index 000000000000..408d30141b32 --- /dev/null +++ b/images/icons/v2/link_color_32.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/lock_color_32.svg b/images/icons/v2/lock_color_32.svg new file mode 100644 index 000000000000..e76d0b2bdd53 --- /dev/null +++ b/images/icons/v2/lock_color_32.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/number_color_32.svg b/images/icons/v2/number_color_32.svg new file mode 100644 index 000000000000..6772dd224460 --- /dev/null +++ b/images/icons/v2/number_color_32.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 1f4a02980e98..fe3d15fec281 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -176,6 +176,7 @@ message AccountRecord { reserved 31; // hasReadOnboardingStory reserved 32; // hasSeenGroupStoryEducationSheet optional string username = 33; + optional bool hasCompletedUsernameOnboarding = 34; } message StoryDistributionListRecord { diff --git a/stylesheets/components/UsernameOnboardingModalBody.scss b/stylesheets/components/UsernameOnboardingModalBody.scss new file mode 100644 index 000000000000..7d29df78a73f --- /dev/null +++ b/stylesheets/components/UsernameOnboardingModalBody.scss @@ -0,0 +1,107 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.UsernameOnboardingModalBody { + display: flex; + flex-direction: column; + align-items: center; + user-select: none; + + &__large-at { + display: flex; + align-items: center; + justify-content: center; + + width: 48px; + height: 48px; + border-radius: 24px; + + margin-bottom: 12px; + + @include light-theme { + background-color: $color-gray-04; + } + + @include dark-theme { + background-color: $color-gray-65; + } + + &::after { + display: block; + width: 28px; + height: 28px; + -webkit-mask-size: 100%; + content: ''; + + @include light-theme { + background-color: $color-gray-75; + } + + @include dark-theme { + background-color: $color-gray-15; + } + + -webkit-mask: url(../images/icons/v2/at-24.svg) no-repeat center; + } + } + + &__title { + @include font-title-2; + margin-bottom: 20px; + max-width: 240px; + text-align: center; + } + + &__row { + display: flex; + gap: 16px; + margin-bottom: 24px; + + &__icon { + flex-shrink: 0; + width: 32px; + height: 32px; + + &--number { + background: url(../images/icons/v2/number_color_32.svg); + } + + &--link { + background: url(../images/icons/v2/link_color_32.svg); + } + + &--lock { + background: url(../images/icons/v2/lock_color_32.svg); + } + } + + &__body { + @include font-body-2; + + @include light-theme { + color: $color-gray-60; + } + + @include dark-theme { + color: $color-gray-25; + } + + max-width: 248px; + } + + &--center { + justify-content: center; + } + } + + &__learn-more { + text-decoration: none; + font-weight: 600; + } + + &__submit { + width: 100%; + max-width: 296px; + margin-bottom: 16px; + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index eb59b5c7deb5..a96d1a8fd44f 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -134,4 +134,5 @@ @import './components/TimelineWarnings.scss'; @import './components/TitleBarContainer.scss'; @import './components/Toast.scss'; +@import './components/UsernameOnboardingModalBody.scss'; @import './components/WhatsNew.scss'; diff --git a/ts/components/ProfileEditor.stories.tsx b/ts/components/ProfileEditor.stories.tsx index 0071ba3daca9..935765c6134b 100644 --- a/ts/components/ProfileEditor.stories.tsx +++ b/ts/components/ProfileEditor.stories.tsx @@ -71,6 +71,7 @@ export default { }, replaceAvatar: { action: true }, saveAvatarToDisk: { action: true }, + markCompletedUsernameOnboarding: { action: true }, openUsernameReservationModal: { action: true }, setUsernameEditState: { action: true }, deleteUsername: { action: true }, diff --git a/ts/components/ProfileEditor.tsx b/ts/components/ProfileEditor.tsx index ad111b457b98..d6e5b0a60890 100644 --- a/ts/components/ProfileEditor.tsx +++ b/ts/components/ProfileEditor.tsx @@ -34,6 +34,7 @@ import { assertDev } from '../util/assert'; import { missingCaseError } from '../util/missingCaseError'; import { ConfirmationDialog } from './ConfirmationDialog'; import { ContextMenu } from './ContextMenu'; +import { UsernameOnboardingModalBody } from './UsernameOnboardingModalBody'; import { ConversationDetailsIcon, IconType, @@ -48,6 +49,7 @@ export enum EditState { ProfileName = 'ProfileName', Bio = 'Bio', Username = 'Username', + UsernameOnboarding = 'UsernameOnboarding', } type PropsExternalType = { @@ -67,11 +69,13 @@ export type PropsDataType = { conversationId: string; familyName?: string; firstName: string; + hasCompletedUsernameOnboarding: boolean; i18n: LocalizerType; isUsernameFlagEnabled: boolean; userAvatarData: ReadonlyArray; username?: string; usernameEditState: UsernameEditState; + markCompletedUsernameOnboarding: () => void; } & Pick; type PropsActionType = { @@ -124,8 +128,10 @@ export function ProfileEditor({ deleteUsername, familyName, firstName, + hasCompletedUsernameOnboarding, i18n, isUsernameFlagEnabled, + markCompletedUsernameOnboarding, onEditStateChanged, onProfileChanged, onSetSkinTone, @@ -481,6 +487,16 @@ export function ProfileEditor({ content = renderEditUsernameModalBody({ onClose: () => setEditState(EditState.None), }); + } else if (editState === EditState.UsernameOnboarding) { + content = ( + { + markCompletedUsernameOnboarding(); + setEditState(EditState.Username); + }} + /> + ); } else if (editState === EditState.None) { let maybeUsernameRow: JSX.Element | undefined; if (isUsernameFlagEnabled) { @@ -560,7 +576,11 @@ export function ProfileEditor({ info={username && generateUsernameLink(username, { short: true })} onClick={() => { openUsernameReservationModal(); - setEditState(EditState.Username); + if (username || hasCompletedUsernameOnboarding) { + setEditState(EditState.Username); + } else { + setEditState(EditState.UsernameOnboarding); + } }} actions={actions} /> diff --git a/ts/components/ProfileEditorModal.tsx b/ts/components/ProfileEditorModal.tsx index d0d64cc8099d..66c3f7fb6ea5 100644 --- a/ts/components/ProfileEditorModal.tsx +++ b/ts/components/ProfileEditorModal.tsx @@ -32,11 +32,12 @@ export function ProfileEditorModal({ toggleProfileEditorHasError, ...restProps }: PropsType): JSX.Element { - const MODAL_TITLES_BY_EDIT_STATE: Record = { + const MODAL_TITLES_BY_EDIT_STATE: Record = { [EditState.BetterAvatar]: i18n('ProfileEditorModal--avatar'), [EditState.Bio]: i18n('ProfileEditorModal--about'), [EditState.None]: i18n('ProfileEditorModal--profile'), [EditState.ProfileName]: i18n('ProfileEditorModal--name'), + [EditState.UsernameOnboarding]: undefined, [EditState.Username]: i18n('ProfileEditorModal--username'), }; diff --git a/ts/components/UsernameOnboardingModalBody.stories.tsx b/ts/components/UsernameOnboardingModalBody.stories.tsx new file mode 100644 index 000000000000..4c6b346e1e46 --- /dev/null +++ b/ts/components/UsernameOnboardingModalBody.stories.tsx @@ -0,0 +1,37 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import type { Meta, Story } from '@storybook/react'; + +import enMessages from '../../_locales/en/messages.json'; +import { setupI18n } from '../util/setupI18n'; + +import type { PropsType } from './UsernameOnboardingModalBody'; +import { UsernameOnboardingModalBody } from './UsernameOnboardingModalBody'; + +const i18n = setupI18n('en', enMessages); + +export default { + component: UsernameOnboardingModalBody, + title: 'Components/UsernameOnboardingModalBody', + argTypes: { + i18n: { + defaultValue: i18n, + }, + onNext: { action: true }, + }, +} as Meta; + +type ArgsType = PropsType; + +// eslint-disable-next-line react/function-component-definition +const Template: Story = args => { + return ; +}; + +export const Normal = Template.bind({}); +Normal.args = {}; +Normal.story = { + name: 'normal', +}; diff --git a/ts/components/UsernameOnboardingModalBody.tsx b/ts/components/UsernameOnboardingModalBody.tsx new file mode 100644 index 000000000000..a7a555fdb27a --- /dev/null +++ b/ts/components/UsernameOnboardingModalBody.tsx @@ -0,0 +1,68 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import type { LocalizerType } from '../types/Util'; +import { Button } from './Button'; + +export type PropsType = Readonly<{ + i18n: LocalizerType; + onNext: () => void; +}>; + +const CLASS = 'UsernameOnboardingModalBody'; + +const SUPPORT_URL = 'https://support.signal.org/hc/articles/5389476324250'; + +export function UsernameOnboardingModalBody({ + i18n, + onNext, +}: PropsType): JSX.Element { + return ( +
+
+ +
{i18n(`icu:${CLASS}__title`)}
+ +
+
+ +
+ {i18n(`icu:${CLASS}__row__number`)} +
+
+ +
+
+ +
+ {i18n(`icu:${CLASS}__row__link`)} +
+
+ +
+
+ +
+ {i18n(`icu:${CLASS}__row__lock`)} +
+
+ + + + +
+ ); +} diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index 2b6f2af631be..17206cb3a841 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -395,6 +395,14 @@ export function toAccountRecord( accountRecord.hasViewedOnboardingStory = hasViewedOnboardingStory; } + const hasCompletedUsernameOnboarding = window.storage.get( + 'hasCompletedUsernameOnboarding' + ); + if (hasCompletedUsernameOnboarding !== undefined) { + accountRecord.hasCompletedUsernameOnboarding = + hasCompletedUsernameOnboarding; + } + const hasStoriesDisabled = window.storage.get('hasStoriesDisabled'); accountRecord.storiesDisabled = hasStoriesDisabled === true; @@ -1137,6 +1145,7 @@ export async function mergeAccountRecord( subscriberCurrencyCode, displayBadgesOnProfile, keepMutedChatsArchived, + hasCompletedUsernameOnboarding, hasSetMyStoriesPrivacy, hasViewedOnboardingStory, storiesDisabled, @@ -1368,6 +1377,15 @@ export async function mergeAccountRecord( void findAndDeleteOnboardingStoryIfExists(); } } + { + const hasCompletedUsernameOnboardingBool = Boolean( + hasCompletedUsernameOnboarding + ); + await window.storage.put( + 'hasCompletedUsernameOnboarding', + hasCompletedUsernameOnboardingBool + ); + } { const hasStoriesDisabled = Boolean(storiesDisabled); await window.storage.put('hasStoriesDisabled', hasStoriesDisabled); diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts index 56578922faa3..453b7095c3d7 100644 --- a/ts/state/selectors/items.ts +++ b/ts/state/selectors/items.ts @@ -74,6 +74,12 @@ export const getUsernamesEnabled = createSelector( isRemoteConfigFlagEnabled(remoteConfig, 'desktop.usernames') ); +export const getHasCompletedUsernameOnboarding = createSelector( + getItems, + (state: ItemsStateType): boolean => + Boolean(state.hasCompletedUsernameOnboarding) +); + export const isInternalUser = createSelector( getRemoteConfig, (remoteConfig: ConfigMapType): boolean => { diff --git a/ts/state/smart/ProfileEditorModal.tsx b/ts/state/smart/ProfileEditorModal.tsx index e260dd4ac63e..ed832c655f9b 100644 --- a/ts/state/smart/ProfileEditorModal.tsx +++ b/ts/state/smart/ProfileEditorModal.tsx @@ -8,10 +8,15 @@ 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'; -import { getEmojiSkinTone, getUsernamesEnabled } from '../selectors/items'; +import { + getEmojiSkinTone, + getUsernamesEnabled, + getHasCompletedUsernameOnboarding, +} from '../selectors/items'; import { getMe } from '../selectors/conversations'; import { selectRecentEmojis } from '../selectors/emojis'; import { getUsernameEditState } from '../selectors/username'; @@ -22,6 +27,12 @@ function renderEditUsernameModalBody(props: { return ; } +async function markCompletedUsernameOnboarding(): Promise { + await window.storage.put('hasCompletedUsernameOnboarding', true); + + storageServiceUploadJob(); +} + function mapStateToProps( state: StateType ): Omit & @@ -40,6 +51,8 @@ function mapStateToProps( const recentEmojis = selectRecentEmojis(state); const skinTone = getEmojiSkinTone(state); const isUsernameFlagEnabled = getUsernamesEnabled(state); + const hasCompletedUsernameOnboarding = + getHasCompletedUsernameOnboarding(state); const usernameEditState = getUsernameEditState(state); return { @@ -50,9 +63,11 @@ function mapStateToProps( conversationId, familyName, firstName: String(firstName), + hasCompletedUsernameOnboarding, hasError: state.globalModals.profileEditorHasError, i18n: getIntl(state), isUsernameFlagEnabled, + markCompletedUsernameOnboarding, recentEmojis, skinTone, userAvatarData, diff --git a/ts/test-mock/pnp/username_test.ts b/ts/test-mock/pnp/username_test.ts index 9e9efbe1621f..c25aca309345 100644 --- a/ts/test-mock/pnp/username_test.ts +++ b/ts/test-mock/pnp/username_test.ts @@ -154,6 +154,9 @@ describe('pnp/username', function needsName() { const profileEditor = window.locator('.ProfileEditor'); await profileEditor.locator('.ProfileEditor__row >> "Username"').click(); + debug('skipping onboarding'); + await profileEditor.locator('.module-Button >> "Continue"').click(); + debug('entering new username'); const usernameField = profileEditor.locator('.Input__input'); await usernameField.type(NICKNAME); diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index d08619019ad4..5c6a2c4db6d3 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -71,6 +71,7 @@ export type StorageAccessType = { existingOnboardingStoryMessageIds: ReadonlyArray | undefined; hasRegisterSupportForUnauthenticatedDelivery: boolean; hasSetMyStoriesPrivacy: boolean; + hasCompletedUsernameOnboarding: boolean; hasViewedOnboardingStory: boolean; hasStoriesDisabled: boolean; storyViewReceiptsEnabled: boolean;