diff --git a/stylesheets/_global.scss b/stylesheets/_global.scss index 8395213bba..bc4b90e6f3 100644 --- a/stylesheets/_global.scss +++ b/stylesheets/_global.scss @@ -391,11 +391,13 @@ $loading-height: 16px; text-align: center; font-size: 10pt; - .number { + .number, + input.form-control { font-size: 12pt; border: 2px solid variables.$color-ultramarine; padding: 0.5em; text-align: center; + outline: none; } .phone-input .number { @@ -406,7 +408,8 @@ $loading-height: 16px; @media (min-height: 750px) and (min-width: 700px) { font-size: 14pt; - .number { + .number, + input.form-control { font-size: 16pt; } } diff --git a/ts/components/App.dom.tsx b/ts/components/App.dom.tsx index a3a403b8d7..16da9a7e49 100644 --- a/ts/components/App.dom.tsx +++ b/ts/components/App.dom.tsx @@ -4,41 +4,24 @@ import React, { useEffect } from 'react'; import classNames from 'classnames'; -import type { ViewStoryActionCreatorType } from '../state/ducks/stories.preload.ts'; -import type { AppStateType } from '../state/ducks/app.preload.ts'; -import type { VerificationTransport } from '../types/VerificationTransport.std.ts'; -import { ThemeType } from '../types/Util.std.ts'; import { AppViewType } from '../types/app.std.ts'; import { missingCaseError } from '../util/missingCaseError.std.ts'; -import { StandaloneRegistration } from './StandaloneRegistration.dom.tsx'; import { usePageVisibility } from '../hooks/usePageVisibility.dom.ts'; import { TitlebarDragArea } from './TitlebarDragArea.dom.tsx'; +import { ThemeType } from '../types/Util.std.ts'; + +import type { ViewStoryActionCreatorType } from '../state/ducks/stories.preload.ts'; +import type { AppStateType } from '../state/ducks/app.preload.ts'; type PropsType = { state: AppStateType; - openInbox: () => void; - getCaptchaToken: () => Promise; - registerSingleDevice: ( - number: string, - code: string, - sessionId: string - ) => Promise; - uploadProfile: (opts: { - firstName: string; - lastName: string; - }) => Promise; renderCallManager: () => React.JSX.Element; renderGlobalModalContainer: () => React.JSX.Element; hasSelectedStoryData: boolean; - readyForUpdates: () => void; + renderStandaloneRegistration: () => React.JSX.Element; renderStoryViewer: (closeView: () => unknown) => React.JSX.Element; renderInstallScreen: () => React.JSX.Element; renderLightbox: () => React.JSX.Element | null; - requestVerification: ( - number: string, - captcha: string, - transport: VerificationTransport - ) => Promise<{ sessionId: string }>; theme: ThemeType; isMaximized: boolean; isFullScreen: boolean; @@ -51,23 +34,18 @@ type PropsType = { export function App({ state, - getCaptchaToken, hasSelectedStoryData, isFullScreen, isMaximized, - openInbox, osClassName, - readyForUpdates, - registerSingleDevice, renderCallManager, renderGlobalModalContainer, renderInbox, renderInstallScreen, renderLightbox, + renderStandaloneRegistration, renderStoryViewer, - requestVerification, theme, - uploadProfile, viewStory, }: PropsType): React.JSX.Element { let contents; @@ -75,20 +53,7 @@ export function App({ if (state.appView === AppViewType.Installer) { contents = renderInstallScreen(); } else if (state.appView === AppViewType.Standalone) { - const onComplete = () => { - window.IPC.removeSetupMenuItems(); - openInbox(); - }; - contents = ( - - ); + contents = renderStandaloneRegistration(); } else if (state.appView === AppViewType.Inbox) { contents = renderInbox(); } else if (state.appView === AppViewType.Blank) { diff --git a/ts/components/StandaloneRegistration.dom.stories.tsx b/ts/components/StandaloneRegistration.dom.stories.tsx index 26b68a8c99..e87b43a587 100644 --- a/ts/components/StandaloneRegistration.dom.stories.tsx +++ b/ts/components/StandaloneRegistration.dom.stories.tsx @@ -4,31 +4,45 @@ import React from 'react'; import type { Meta, StoryFn } from '@storybook/react'; import { fn } from '@storybook/test'; +import { action } from '@storybook/addon-actions'; +import messages from '../../_locales/en/messages.json'; +import { setupI18n } from '../util/setupI18n.dom.tsx'; import { StandaloneRegistration } from './StandaloneRegistration.dom.tsx'; import type { PropsType } from './StandaloneRegistration.dom.tsx'; import { SECOND } from '../util/durations/index.std.ts'; import { sleep } from '../util/sleep.std.ts'; +const i18n = setupI18n('en', messages); + export default { title: 'Components/StandaloneRegistration', args: { - getCaptchaToken: fn(async () => { + i18n, + getCaptchaToken: fn(async (...params) => { + // oxlint-disable-next-line no-console + console.log('getCaptchaToken', params); await sleep(SECOND); return 'captcha-token'; }) as () => Promise, - requestVerification: fn(async () => { + requestVerification: fn(async (...params) => { + // oxlint-disable-next-line no-console + console.log('requestVerification', params); await sleep(SECOND); return { sessionId: 'fake-session-id' }; }) as () => Promise<{ sessionId: string }>, - registerSingleDevice: fn(async () => { + registerSingleDevice: fn(async (...params) => { + // oxlint-disable-next-line no-console + console.log('registerSingleDevice', params); await sleep(SECOND); }) as () => Promise, - uploadProfile: fn(async () => { + uploadInitialProfile: fn(async (...params) => { + // oxlint-disable-next-line no-console + console.log('uploadInitialProfile', params); await sleep(SECOND); }) as () => Promise, - onComplete: fn() as () => void, - readyForUpdates: fn() as () => void, + onComplete: action('onComplete'), + readyForUpdates: action('readyForUpdates'), }, } satisfies Meta; diff --git a/ts/components/StandaloneRegistration.dom.tsx b/ts/components/StandaloneRegistration.dom.tsx index 8cfff6a3eb..cb816837bf 100644 --- a/ts/components/StandaloneRegistration.dom.tsx +++ b/ts/components/StandaloneRegistration.dom.tsx @@ -1,17 +1,29 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { ChangeEvent } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react'; -import type { Iti } from 'intl-tel-input'; import intlTelInput from 'intl-tel-input'; +import type { ChangeEvent } from 'react'; +import type { Iti } from 'intl-tel-input'; + import { strictAssert } from '../util/assert.std.ts'; import { parseNumber } from '../util/libphonenumberUtil.std.ts'; import { missingCaseError } from '../util/missingCaseError.std.ts'; import { VerificationTransport } from '../types/VerificationTransport.std.ts'; import { normalizeProfileName } from '../util/normalizeProfileName.std.ts'; import { TitlebarDragArea } from './TitlebarDragArea.dom.tsx'; +import { AvatarPreview } from './AvatarPreview.dom.tsx'; +import { AvatarColors } from '../types/Colors.std.ts'; +import { AvatarEditor } from './AvatarEditor.dom.tsx'; + +import type { LocalizerType } from '../types/I18N.std.ts'; +import type { + AvatarDataType, + DeleteAvatarFromDiskActionType, + ReplaceAvatarActionType, + SaveAvatarToDiskActionType, +} from '../types/Avatar.std.ts'; function PhoneInput({ initialValue, @@ -41,7 +53,7 @@ function PhoneInput({ pluginRef.current?.destroy(); - const plugin = intlTelInput(elem); + const plugin = intlTelInput(elem, { formatAsYouType: true }); pluginRef.current = plugin; }, [initialValue] @@ -68,24 +80,20 @@ function PhoneInput({ [setIsValid, onNumberChange, onValidation] ); + // We don't always get change events when expected because of int-tel-input const onChange = useCallback( - (_: ChangeEvent) => { - if (elemRef.current) { - validateNumber(elemRef.current.value); - } + (event: ChangeEvent) => { + validateNumber(event.target.value); }, [validateNumber] ); - const onKeyDown = useCallback( - (event: React.KeyboardEvent) => { - // Pacify TypeScript and handle events bubbling up - if (event.target instanceof HTMLInputElement) { - validateNumber(event.target.value); - } - }, - [validateNumber] - ); + // We validate in more scenarios to make things more responsive + const validate = useCallback(() => { + if (elemRef.current) { + validateNumber(elemRef.current.value); + } + }, [validateNumber]); return (
@@ -96,7 +104,8 @@ function PhoneInput({ type="tel" ref={onRef} onChange={onChange} - onKeyDown={onKeyDown} + onBlur={validate} + onKeyUp={validate} placeholder="Phone Number" />
@@ -342,18 +351,36 @@ function VerificationCodeStage({ } function ProfileNameStage({ - uploadProfile, + conversationId, + deleteAvatarFromDisk, + i18n, onNext, + replaceAvatar, + saveAvatarToDisk, + uploadInitialProfile, + userAvatarData, }: { - uploadProfile: (opts: { + conversationId?: string; + deleteAvatarFromDisk: DeleteAvatarFromDiskActionType; + i18n: LocalizerType; + onNext: () => void; + replaceAvatar: ReplaceAvatarActionType; + saveAvatarToDisk: SaveAvatarToDiskActionType; + uploadInitialProfile: (opts: { firstName: string; lastName: string; + avatarData: Uint8Array; }) => Promise; - onNext: () => void; + userAvatarData: ReadonlyArray; }): React.JSX.Element { const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [error, setError] = useState(undefined); + const [isEditingAvatar, setIsEditingAvatar] = useState(false); + + const [avatarData, setAvatarData] = useState< + Uint8Array | undefined + >(undefined); const onChangeFirstName = useCallback( (event: ChangeEvent) => setFirstName(event.target.value), @@ -367,24 +394,78 @@ function ProfileNameStage({ const onNextClick = useCallback( async (event: React.MouseEvent) => { + if (!avatarData) { + return; + } + event.preventDefault(); event.stopPropagation(); try { - await uploadProfile({ firstName, lastName }); + await uploadInitialProfile({ + firstName, + lastName, + avatarData, + }); onNext(); } catch (err) { setError(err.message); } }, - [onNext, firstName, lastName, uploadProfile] + [onNext, firstName, lastName, avatarData, uploadInitialProfile] ); + const fullName = `${firstName} ${lastName}`; + + if (isEditingAvatar) { + return ( +
+
+
Set up profile avatar
+ { + setIsEditingAvatar(false); + }} + onSave={(avatar: Uint8Array | undefined) => { + setAvatarData(avatar); + setIsEditingAvatar(false); + }} + userAvatarData={userAvatarData} + replaceAvatar={replaceAvatar} + saveAvatarToDisk={saveAvatarToDisk} + /> +
+ ); + } + return ( <>
-
Select Profile Name
- +
Set up profile
+ { + setAvatarData(avatar); + }} + onClick={() => { + setIsEditingAvatar(true); + }} + style={{ + height: 80, + width: 80, + }} + /> +   - - {/* TODO(indutny): highlight error */} -
{error}
+
{error}