A few updates for the standalone registration flow

This commit is contained in:
Scott Nonnenberg
2026-04-21 03:00:57 +10:00
committed by GitHub
parent 0122ae3c9a
commit f4cdf08bbc
6 changed files with 283 additions and 147 deletions
+5 -2
View File
@@ -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;
}
}
+7 -42
View File
@@ -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<string>;
registerSingleDevice: (
number: string,
code: string,
sessionId: string
) => Promise<void>;
uploadProfile: (opts: {
firstName: string;
lastName: string;
}) => Promise<void>;
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 = (
<StandaloneRegistration
onComplete={onComplete}
getCaptchaToken={getCaptchaToken}
readyForUpdates={readyForUpdates}
requestVerification={requestVerification}
registerSingleDevice={registerSingleDevice}
uploadProfile={uploadProfile}
/>
);
contents = renderStandaloneRegistration();
} else if (state.appView === AppViewType.Inbox) {
contents = renderInbox();
} else if (state.appView === AppViewType.Blank) {
@@ -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<string>,
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<void>,
uploadProfile: fn(async () => {
uploadInitialProfile: fn(async (...params) => {
// oxlint-disable-next-line no-console
console.log('uploadInitialProfile', params);
await sleep(SECOND);
}) as () => Promise<void>,
onComplete: fn() as () => void,
readyForUpdates: fn() as () => void,
onComplete: action('onComplete'),
readyForUpdates: action('readyForUpdates'),
},
} satisfies Meta<PropsType & { daysAgo?: number }>;
+135 -36
View File
@@ -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<HTMLInputElement>) => {
if (elemRef.current) {
validateNumber(elemRef.current.value);
}
(event: ChangeEvent<HTMLInputElement>) => {
validateNumber(event.target.value);
},
[validateNumber]
);
const onKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
// 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 (
<div className="phone-input">
@@ -96,7 +104,8 @@ function PhoneInput({
type="tel"
ref={onRef}
onChange={onChange}
onKeyDown={onKeyDown}
onBlur={validate}
onKeyUp={validate}
placeholder="Phone Number"
/>
</div>
@@ -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<ArrayBuffer>;
}) => Promise<void>;
onNext: () => void;
userAvatarData: ReadonlyArray<AvatarDataType>;
}): React.JSX.Element {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [error, setError] = useState<string | undefined>(undefined);
const [isEditingAvatar, setIsEditingAvatar] = useState(false);
const [avatarData, setAvatarData] = useState<
Uint8Array<ArrayBuffer> | undefined
>(undefined);
const onChangeFirstName = useCallback(
(event: ChangeEvent<HTMLInputElement>) => setFirstName(event.target.value),
@@ -367,24 +394,78 @@ function ProfileNameStage({
const onNextClick = useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => {
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 (
<div className="step-body">
<div className="banner-image module-splash-screen__logo module-img--128" />
<div className="header">Set up profile avatar</div>
<AvatarEditor
avatarColor={AvatarColors[0]}
avatarUrl={undefined}
avatarValue={avatarData}
conversationId={conversationId}
conversationTitle={fullName}
deleteAvatarFromDisk={deleteAvatarFromDisk}
i18n={i18n}
onCancel={() => {
setIsEditingAvatar(false);
}}
onSave={(avatar: Uint8Array<ArrayBuffer> | undefined) => {
setAvatarData(avatar);
setIsEditingAvatar(false);
}}
userAvatarData={userAvatarData}
replaceAvatar={replaceAvatar}
saveAvatarToDisk={saveAvatarToDisk}
/>
</div>
);
}
return (
<>
<div className="step-body">
<div className="banner-image module-splash-screen__logo module-img--128" />
<div className="header">Select Profile Name</div>
<div className="header">Set up profile</div>
<AvatarPreview
avatarColor={AvatarColors[0]}
avatarUrl={undefined}
avatarValue={avatarData}
conversationTitle={fullName}
i18n={i18n}
onAvatarLoaded={avatar => {
setAvatarData(avatar);
}}
onClick={() => {
setIsEditingAvatar(true);
}}
style={{
height: 80,
width: 80,
}}
/>
<input
className={`form-control ${firstName ? 'valid' : 'invalid'}`}
type="text"
@@ -396,6 +477,7 @@ function ProfileNameStage({
value={firstName}
onChange={onChangeFirstName}
/>
&nbsp;
<input
className="form-control"
type="text"
@@ -407,15 +489,13 @@ function ProfileNameStage({
value={lastName}
onChange={onChangeLastName}
/>
{/* TODO(indutny): highlight error */}
<div>{error}</div>
<div className="StandaloneRegistration__error">{error}</div>
</div>
<div className="nav">
<button
type="button"
className="button"
disabled={Boolean(normalizeProfileName(firstName))}
disabled={!normalizeProfileName(firstName) || !avatarData}
onClick={onNextClick}
>
Finish
@@ -426,8 +506,12 @@ function ProfileNameStage({
}
export type PropsType = Readonly<{
onComplete: () => void;
conversationId?: string;
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
i18n: LocalizerType;
getCaptchaToken: () => Promise<string>;
onComplete: () => void;
readyForUpdates: () => void;
requestVerification: (
number: string,
captcha: string,
@@ -438,20 +522,29 @@ export type PropsType = Readonly<{
code: string,
sessionId: string
) => Promise<void>;
uploadProfile: (opts: {
replaceAvatar: ReplaceAvatarActionType;
saveAvatarToDisk: SaveAvatarToDiskActionType;
uploadInitialProfile: (opts: {
firstName: string;
lastName: string;
avatarData: Uint8Array<ArrayBuffer>;
}) => Promise<void>;
readyForUpdates: () => void;
userAvatarData: ReadonlyArray<AvatarDataType>;
}>;
export function StandaloneRegistration({
onComplete,
conversationId,
deleteAvatarFromDisk,
getCaptchaToken,
requestVerification,
registerSingleDevice,
uploadProfile,
i18n,
onComplete,
readyForUpdates,
registerSingleDevice,
replaceAvatar,
requestVerification,
saveAvatarToDisk,
uploadInitialProfile,
userAvatarData,
}: PropsType): React.JSX.Element {
useEffect(() => {
readyForUpdates();
@@ -515,8 +608,14 @@ export function StandaloneRegistration({
body = (
<ProfileNameStage
{...stageData}
uploadProfile={uploadProfile}
conversationId={conversationId}
deleteAvatarFromDisk={deleteAvatarFromDisk}
i18n={i18n}
onNext={onComplete}
replaceAvatar={replaceAvatar}
saveAvatarToDisk={saveAvatarToDisk}
uploadInitialProfile={uploadInitialProfile}
userAvatarData={userAvatarData}
/>
);
} else {
+10 -61
View File
@@ -2,16 +2,8 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo } from 'react';
import { useSelector } from 'react-redux';
import { requestVerification as doRequestVerification } from '../../textsecure/WebAPI.preload.ts';
import { accountManager } from '../../textsecure/AccountManager.preload.ts';
import type { VerificationTransport } from '../../types/VerificationTransport.std.ts';
import { DataWriter } from '../../sql/Client.preload.ts';
import { App } from '../../components/App.dom.tsx';
import OS from '../../util/os/osMain.node.ts';
import { getConversation } from '../../util/getConversation.preload.ts';
import { getChallengeURL } from '../../challenge.dom.ts';
import { writeProfile } from '../../services/writeProfile.preload.ts';
import { challengeHandler } from '../../services/challengeHandler.preload.ts';
import { SmartCallManager } from './CallManager.preload.tsx';
import { SmartGlobalModalContainer } from './GlobalModalContainer.preload.tsx';
import { SmartLightbox } from './Lightbox.preload.tsx';
@@ -22,7 +14,6 @@ import {
getTheme,
} from '../selectors/user.std.ts';
import { hasSelectedStoryData as getHasSelectedStoryData } from '../selectors/stories.preload.ts';
import { useAppActions } from '../ducks/app.preload.ts';
import { useConversationsActions } from '../ducks/conversations.preload.ts';
import { useStoriesActions } from '../ducks/stories.preload.ts';
import { ErrorBoundary } from '../../components/ErrorBoundary.dom.tsx';
@@ -31,6 +22,7 @@ import { SmartInbox } from './Inbox.preload.tsx';
import { SmartInstallScreen } from './InstallScreen.preload.tsx';
import { getApp } from '../selectors/app.std.ts';
import { SmartFunProvider } from './FunProvider.preload.tsx';
import { SmartStandaloneRegistration } from './StandaloneRegistration.preload.tsx';
function renderInbox(): React.JSX.Element {
return <SmartInbox />;
@@ -56,6 +48,14 @@ function renderLightbox(): React.JSX.Element {
return <SmartLightbox />;
}
function renderStandaloneRegistration(): React.JSX.Element {
return (
<ErrorBoundary name="App/renderStandaloneRegistration">
<SmartStandaloneRegistration />
</ErrorBoundary>
);
}
function renderStoryViewer(closeView: () => unknown): React.JSX.Element {
return (
<ErrorBoundary name="App/renderStoryViewer" closeView={closeView}>
@@ -64,51 +64,6 @@ function renderStoryViewer(closeView: () => unknown): React.JSX.Element {
);
}
async function getCaptchaToken(): Promise<string> {
const url = getChallengeURL('registration');
document.location.href = url;
return challengeHandler.requestCaptcha({
reason: 'standalone registration',
});
}
function requestVerification(
number: string,
captcha: string,
transport: VerificationTransport
): Promise<{ sessionId: string }> {
return doRequestVerification(number, captcha, transport);
}
function registerSingleDevice(
number: string,
code: string,
sessionId: string
): Promise<void> {
return accountManager.registerSingleDevice(number, code, sessionId);
}
function readyForUpdates(): void {
window.IPC.readyForUpdates();
}
async function uploadProfile({
firstName,
lastName,
}: {
firstName: string;
lastName: string;
}): Promise<void> {
const us = window.ConversationController.getOurConversationOrThrow();
us.set({ profileName: firstName, profileFamilyName: lastName });
us.captureChange('standaloneProfile');
await DataWriter.updateConversation(us.attributes);
await writeProfile(getConversation(us), {
keepAvatar: true,
});
}
export const SmartApp = memo(function SmartApp() {
const state = useSelector(getApp);
const isMaximized = useSelector(getIsMainWindowMaximized);
@@ -116,7 +71,6 @@ export const SmartApp = memo(function SmartApp() {
const hasSelectedStoryData = useSelector(getHasSelectedStoryData);
const theme = useSelector(getTheme);
const { openInbox } = useAppActions();
const { scrollToMessage } = useConversationsActions();
const { viewStory } = useStoriesActions();
@@ -128,21 +82,16 @@ export const SmartApp = memo(function SmartApp() {
state={state}
isMaximized={isMaximized}
isFullScreen={isFullScreen}
getCaptchaToken={getCaptchaToken}
osClassName={osClassName}
renderCallManager={renderCallManager}
renderGlobalModalContainer={renderGlobalModalContainer}
renderInstallScreen={renderInstallScreen}
renderLightbox={renderLightbox}
renderStandaloneRegistration={renderStandaloneRegistration}
hasSelectedStoryData={hasSelectedStoryData}
readyForUpdates={readyForUpdates}
renderStoryViewer={renderStoryViewer}
renderInbox={renderInbox}
requestVerification={requestVerification}
registerSingleDevice={registerSingleDevice}
uploadProfile={uploadProfile}
theme={theme}
openInbox={openInbox}
scrollToMessage={scrollToMessage}
viewStory={viewStory}
/>
@@ -0,0 +1,106 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo } from 'react';
import { DataWriter } from '../../sql/Client.preload.ts';
import { getConversation } from '../../util/getConversation.preload.ts';
import { writeProfile } from '../../services/writeProfile.preload.ts';
import { useAppActions } from '../ducks/app.preload.ts';
import { requestVerification as doRequestVerification } from '../../textsecure/WebAPI.preload.ts';
import { accountManager } from '../../textsecure/AccountManager.preload.ts';
import { getChallengeURL } from '../../challenge.dom.ts';
import { challengeHandler } from '../../services/challengeHandler.preload.ts';
import { useSelector } from 'react-redux';
import { getIntl, getUserConversationId } from '../selectors/user.std.ts';
import { StandaloneRegistration } from '../../components/StandaloneRegistration.dom.tsx';
import type { VerificationTransport } from '../../types/VerificationTransport.std.ts';
import { useConversationsActions } from '../ducks/conversations.preload.ts';
import { getMe } from '../selectors/conversations.dom.ts';
export const SmartStandaloneRegistration = memo(
function SmartStandaloneRegistration() {
const { openInbox } = useAppActions();
const { deleteAvatarFromDisk, replaceAvatar, saveAvatarToDisk } =
useConversationsActions();
const i18n = useSelector(getIntl);
const conversationId = useSelector(getUserConversationId);
const me = useSelector(getMe);
const userAvatarData = me?.avatars ?? [];
const onComplete = () => {
window.IPC.removeSetupMenuItems();
openInbox();
};
return (
<StandaloneRegistration
conversationId={conversationId}
deleteAvatarFromDisk={deleteAvatarFromDisk}
getCaptchaToken={getCaptchaToken}
i18n={i18n}
onComplete={onComplete}
readyForUpdates={readyForUpdates}
registerSingleDevice={registerSingleDevice}
replaceAvatar={replaceAvatar}
requestVerification={requestVerification}
saveAvatarToDisk={saveAvatarToDisk}
uploadInitialProfile={uploadInitialProfile}
userAvatarData={userAvatarData}
/>
);
}
);
async function getCaptchaToken(): Promise<string> {
const url = getChallengeURL('registration');
document.location.href = url;
return challengeHandler.requestCaptcha({
reason: 'standalone registration',
});
}
function readyForUpdates(): void {
window.IPC.readyForUpdates();
}
function registerSingleDevice(
number: string,
code: string,
sessionId: string
): Promise<void> {
return accountManager.registerSingleDevice(number, code, sessionId);
}
function requestVerification(
number: string,
captcha: string,
transport: VerificationTransport
): Promise<{ sessionId: string }> {
return doRequestVerification(number, captcha, transport);
}
async function uploadInitialProfile({
firstName,
lastName,
avatarData,
}: {
firstName: string;
lastName: string;
avatarData: Uint8Array<ArrayBuffer>;
}): Promise<void> {
const us = window.ConversationController.getOurConversationOrThrow();
us.set({ profileName: firstName, profileFamilyName: lastName });
us.captureChange('standaloneProfile');
await DataWriter.updateConversation(us.attributes);
await writeProfile(getConversation(us), {
keepAvatar: false,
avatarUpdate: {
oldAvatar: undefined,
newAvatar: avatarData,
},
});
}