Username Education

Co-authored-by: Jamie Kyle <jamie@signal.org>
This commit is contained in:
Fedor Indutny 2024-01-29 12:09:54 -08:00 committed by GitHub
parent c6a7637513
commit 7dc11c1928
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
100 changed files with 1443 additions and 1269 deletions

View file

@ -5,15 +5,12 @@ import React, { useEffect } from 'react';
import { Globals } from '@react-spring/web';
import classNames from 'classnames';
import type { AnyToast } from '../types/Toast';
import type { ViewStoryActionCreatorType } from '../state/ducks/stories';
import type { LocalizerType } from '../types/Util';
import type { VerificationTransport } from '../types/VerificationTransport';
import { ThemeType } from '../types/Util';
import { AppViewType } from '../state/ducks/app';
import { SmartInstallScreen } from '../state/smart/InstallScreen';
import { StandaloneRegistration } from './StandaloneRegistration';
import { ToastManager } from './ToastManager';
import { usePageVisibility } from '../hooks/usePageVisibility';
import { useReducedMotion } from '../hooks/useReducedMotion';
@ -27,7 +24,6 @@ type PropsType = {
) => Promise<void>;
renderCallManager: () => JSX.Element;
renderGlobalModalContainer: () => JSX.Element;
i18n: LocalizerType;
hasSelectedStoryData: boolean;
renderStoryViewer: (closeView: () => unknown) => JSX.Element;
renderLightbox: () => JSX.Element | null;
@ -39,13 +35,8 @@ type PropsType = {
theme: ThemeType;
isMaximized: boolean;
isFullScreen: boolean;
onUndoArchive: (conversationId: string) => unknown;
openFileInFolder: (target: string) => unknown;
OS: string;
osClassName: string;
hideToast: () => unknown;
toast?: AnyToast;
scrollToMessage: (conversationId: string, messageId: string) => unknown;
viewStory: ViewStoryActionCreatorType;
renderInbox: () => JSX.Element;
@ -54,14 +45,9 @@ type PropsType = {
export function App({
appView,
hasSelectedStoryData,
hideToast,
i18n,
isFullScreen,
isMaximized,
onUndoArchive,
openFileInFolder,
openInbox,
OS,
osClassName,
registerSingleDevice,
renderCallManager,
@ -71,7 +57,6 @@ export function App({
renderStoryViewer,
requestVerification,
theme,
toast,
viewStory,
}: PropsType): JSX.Element {
let contents;
@ -139,14 +124,6 @@ export function App({
})}
>
{contents}
<ToastManager
OS={OS}
hideToast={hideToast}
i18n={i18n}
onUndoArchive={onUndoArchive}
openFileInFolder={openFileInFolder}
toast={toast}
/>
{renderGlobalModalContainer()}
{renderCallManager()}
{renderLightbox()}

View file

@ -17,6 +17,7 @@ import type { ActiveCallStateType } from '../state/ducks/calling';
import { ContextMenu } from './ContextMenu';
import { ConfirmationDialog } from './ConfirmationDialog';
import type { UnreadStats } from '../util/countUnreadStats';
import type { WidthBreakpoint } from './_util';
enum CallsTabSidebarView {
CallsListView,
@ -50,6 +51,9 @@ type CallsTabProps = Readonly<{
conversationId: string,
callHistoryGroup: CallHistoryGroup | null
) => JSX.Element;
renderToastManager: (_: {
containerWidthBreakpoint: WidthBreakpoint;
}) => JSX.Element;
regionCode: string | undefined;
savePreferredLeftPaneWidth: (preferredLeftPaneWidth: number) => void;
}>;
@ -73,6 +77,7 @@ export function CallsTab({
onOutgoingVideoCallInConversation,
preferredLeftPaneWidth,
renderConversationDetails,
renderToastManager,
regionCode,
savePreferredLeftPaneWidth,
}: CallsTabProps): JSX.Element {
@ -175,6 +180,7 @@ export function CallsTab({
requiresFullWidth
preferredLeftPaneWidth={preferredLeftPaneWidth}
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
renderToastManager={renderToastManager}
actions={
<>
{sidebarView === CallsTabSidebarView.CallsListView && (

View file

@ -563,6 +563,7 @@ export function CompositionArea({
conversationId={conversationId}
draftAttachments={draftAttachments}
i18n={i18n}
showToast={showToast}
startRecording={startRecording}
/>
</div>

View file

@ -51,6 +51,8 @@ export function Default(): JSX.Element {
errorRecording={_ => action('error')()}
addAttachment={action('addAttachment')}
completeRecording={action('completeRecording')}
showToast={action('showToast')}
hideToast={action('hideToast')}
/>
)}
</>

View file

@ -5,14 +5,16 @@ import { noop } from 'lodash';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useEscapeHandling } from '../hooks/useEscapeHandling';
import { usePrevious } from '../hooks/usePrevious';
import type { HideToastAction, ShowToastAction } from '../state/ducks/toast';
import type { InMemoryAttachmentDraftType } from '../types/Attachment';
import { ErrorDialogAudioRecorderType } from '../types/AudioRecorder';
import type { LocalizerType } from '../types/Util';
import type { AnyToast } from '../types/Toast';
import { ToastType } from '../types/Toast';
import { DurationInSeconds, SECOND } from '../util/durations';
import { durationToPlaybackText } from '../util/durationToPlaybackText';
import { ConfirmationDialog } from './ConfirmationDialog';
import { RecordingComposer } from './RecordingComposer';
import { ToastVoiceNoteLimit } from './ToastVoiceNoteLimit';
export type Props = {
i18n: LocalizerType;
@ -29,6 +31,8 @@ export type Props = {
conversationId: string,
onRecordingComplete: (rec: InMemoryAttachmentDraftType) => unknown
) => unknown;
showToast: ShowToastAction;
hideToast: HideToastAction;
};
export function CompositionRecording({
@ -40,11 +44,11 @@ export function CompositionRecording({
errorDialogAudioRecorderType,
addAttachment,
completeRecording,
showToast,
hideToast,
}: Props): JSX.Element {
useEscapeHandling(onCancel);
const [showVoiceNoteLimitToast, setShowVoiceNoteLimitToast] = useState(true);
// when interrupted (blur, switching convos)
// stop recording and save draft
const handleRecordingInterruption = useCallback(() => {
@ -69,15 +73,12 @@ export function CompositionRecording({
}
});
const handleCloseToast = useCallback(() => {
setShowVoiceNoteLimitToast(false);
}, []);
useEffect(() => {
return () => {
handleCloseToast();
};
}, [handleCloseToast]);
const toast: AnyToast = { toastType: ToastType.VoiceNoteLimit };
showToast(toast);
return () => hideToast(toast);
}, [showToast, hideToast]);
const startTime = useRef(Date.now());
const [duration, setDuration] = useState(0);
@ -148,9 +149,6 @@ export function CompositionRecording({
</div>
{confirmationDialog}
{showVoiceNoteLimitToast && (
<ToastVoiceNoteLimit i18n={i18n} onClose={handleCloseToast} />
)}
</RecordingComposer>
);
}

View file

@ -6,13 +6,15 @@ import React, { useEffect, useState } from 'react';
import copyText from 'copy-text-to-clipboard';
import type { LocalizerType } from '../types/Util';
import * as Errors from '../types/errors';
import type { AnyToast } from '../types/Toast';
import { ToastType } from '../types/Toast';
import * as log from '../logging/log';
import { Button, ButtonVariant } from './Button';
import { Spinner } from './Spinner';
import { ToastDebugLogError } from './ToastDebugLogError';
import { ToastLinkCopied } from './ToastLinkCopied';
import { ToastLoadingFullLogs } from './ToastLoadingFullLogs';
import { ToastManager } from './ToastManager';
import { WidthBreakpoint } from './_util';
import { createSupportUrl } from '../util/createSupportUrl';
import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled';
import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser';
import { useEscapeHandling } from '../hooks/useEscapeHandling';
@ -31,12 +33,6 @@ export type PropsType = {
uploadLogs: (logs: string) => Promise<string>;
};
enum ToastType {
Copied,
Error,
Loading,
}
export function DebugLogWindow({
closeWindow,
downloadLog,
@ -50,7 +46,7 @@ export function DebugLogWindow({
const [textAreaValue, setTextAreaValue] = useState<string>(
i18n('icu:loading')
);
const [toastType, setToastType] = useState<ToastType | undefined>();
const [toast, setToast] = useState<AnyToast | undefined>();
useEscapeHandling(closeWindow);
@ -66,7 +62,7 @@ export function DebugLogWindow({
return;
}
setToastType(ToastType.Loading);
setToast({ toastType: ToastType.LoadingFullLogs });
setLogText(fetchedLogText);
setLoadState(LoadState.Loaded);
@ -76,7 +72,7 @@ export function DebugLogWindow({
const value = fetchedLogText.split(/\n/g, linesToShow).join('\n');
setTextAreaValue(`${value}\n\n\n${i18n('icu:debugLogLogIsIncomplete')}`);
setToastType(undefined);
setToast(undefined);
}
void doFetchLogs();
@ -103,28 +99,19 @@ export function DebugLogWindow({
} catch (error) {
log.error('DebugLogWindow error:', Errors.toLogFormat(error));
setLoadState(LoadState.Loaded);
setToastType(ToastType.Error);
setToast({ toastType: ToastType.DebugLogError });
}
};
function closeToast() {
setToastType(undefined);
}
let toastElement: JSX.Element | undefined;
if (toastType === ToastType.Loading) {
toastElement = <ToastLoadingFullLogs i18n={i18n} onClose={closeToast} />;
} else if (toastType === ToastType.Copied) {
toastElement = <ToastLinkCopied i18n={i18n} onClose={closeToast} />;
} else if (toastType === ToastType.Error) {
toastElement = <ToastDebugLogError i18n={i18n} onClose={closeToast} />;
setToast(undefined);
}
if (publicLogURL) {
const copyLog = (ev: MouseEvent) => {
ev.preventDefault();
copyText(publicLogURL);
setToastType(ToastType.Copied);
setToast({ toastType: ToastType.LinkCopied });
};
const supportURL = createSupportUrl({
@ -162,7 +149,16 @@ export function DebugLogWindow({
</Button>
<Button onClick={copyLog}>{i18n('icu:debugLogCopy')}</Button>
</div>
{toastElement}
<ToastManager
OS="unused"
hideToast={closeToast}
i18n={i18n}
onShowDebugLog={shouldNeverBeCalled}
onUndoArchive={shouldNeverBeCalled}
openFileInFolder={shouldNeverBeCalled}
toast={toast}
containerWidthBreakpoint={WidthBreakpoint.Narrow}
/>
</div>
);
}
@ -209,7 +205,16 @@ export function DebugLogWindow({
{i18n('icu:submit')}
</Button>
</div>
{toastElement}
<ToastManager
OS="unused"
hideToast={closeToast}
i18n={i18n}
onShowDebugLog={shouldNeverBeCalled}
onUndoArchive={shouldNeverBeCalled}
openFileInFolder={shouldNeverBeCalled}
toast={toast}
containerWidthBreakpoint={WidthBreakpoint.Narrow}
/>
</div>
);
}

View file

@ -14,6 +14,7 @@ import type {
UserNotFoundModalStateType,
} from '../state/ducks/globalModals';
import type { LocalizerType, ThemeType } from '../types/Util';
import { UsernameOnboardingState } from '../types/globalModals';
import type { ExplodePromiseResultType } from '../util/explodePromise';
import { missingCaseError } from '../util/missingCaseError';
@ -90,6 +91,9 @@ export type PropsType = {
// WhatsNewModal
isWhatsNewVisible: boolean;
hideWhatsNewModal: () => unknown;
// UsernameOnboarding
usernameOnboardingState: UsernameOnboardingState;
renderUsernameOnboarding: () => JSX.Element;
// AuthArtCreatorModal
authArtCreatorData?: AuthorizeArtCreatorDataType;
isAuthorizingArtCreator?: boolean;
@ -151,6 +155,9 @@ export function GlobalModalContainer({
// WhatsNewModal
hideWhatsNewModal,
isWhatsNewVisible,
// UsernameOnboarding
usernameOnboardingState,
renderUsernameOnboarding,
// AuthArtCreatorModal
authArtCreatorData,
isAuthorizingArtCreator,
@ -253,6 +260,10 @@ export function GlobalModalContainer({
return <WhatsNewModal hideWhatsNewModal={hideWhatsNewModal} i18n={i18n} />;
}
if (usernameOnboardingState === UsernameOnboardingState.Open) {
return renderUsernameOnboarding();
}
if (safetyNumberModalContactId) {
return renderSafetyNumber();
}

View file

@ -8,6 +8,7 @@ import type { PropsType } from './LeftPane';
import { LeftPane, LeftPaneMode } from './LeftPane';
import { CaptchaDialog } from './CaptchaDialog';
import { CrashReportDialog } from './CrashReportDialog';
import { ToastManager } from './ToastManager';
import type { PropsType as DialogNetworkStatusPropsType } from './DialogNetworkStatus';
import { DialogExpiredBuild } from './DialogExpiredBuild';
import { DialogNetworkStatus } from './DialogNetworkStatus';
@ -251,6 +252,19 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
{...props}
/>
),
renderToastManager: ({ containerWidthBreakpoint }) => (
<ToastManager
OS="unused"
hideToast={action('hideToast')}
i18n={i18n}
onShowDebugLog={action('onShowDebugLog')}
onUndoArchive={action('onUndoArchive')}
openFileInFolder={action('openFileInFolder')}
toast={undefined}
megaphone={undefined}
containerWidthBreakpoint={containerWidthBreakpoint}
/>
),
selectedConversationId: undefined,
targetedMessageId: undefined,
savePreferredLeftPaneWidth: action('savePreferredLeftPaneWidth'),

View file

@ -158,6 +158,9 @@ export type PropsType = {
renderCaptchaDialog: (props: { onSkip(): void }) => JSX.Element;
renderCrashReportDialog: () => JSX.Element;
renderExpiredBuildDialog: (_: DialogExpiredBuildPropsType) => JSX.Element;
renderToastManager: (_: {
containerWidthBreakpoint: WidthBreakpoint;
}) => JSX.Element;
} & LookupConversationWithoutServiceIdActionsType;
export function LeftPane({
@ -200,6 +203,7 @@ export function LeftPane({
renderUnsupportedOSDialog,
renderRelinkDialog,
renderUpdateDialog,
renderToastManager,
savePreferredLeftPaneWidth,
searchInConversation,
selectedConversationId,
@ -597,6 +601,7 @@ export function LeftPane({
modeSpecificProps.isAboutToSearch
}
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
renderToastManager={renderToastManager}
actions={
<>
<NavSidebarActionButton

View file

@ -36,6 +36,7 @@ export default {
onForward: jest.fn(action('onForward')),
onSave: jest.fn(action('onSave')),
hasViewReceiptSetting: false,
renderToastManager: () => <i />,
queueStoryDownload: action('queueStoryDownload'),
retryMessageSend: action('retryMessageSend'),
viewStory: action('viewStory'),

View file

@ -20,6 +20,7 @@ import { Theme } from '../util/theme';
import { resolveStorySendStatus } from '../util/resolveStorySendStatus';
import { useRetryStorySend } from '../hooks/useRetryStorySend';
import { NavSidebar } from './NavSidebar';
import type { WidthBreakpoint } from './_util';
import type { UnreadStats } from '../util/countUnreadStats';
export type PropsType = {
@ -40,6 +41,9 @@ export type PropsType = {
viewStory: ViewStoryActionCreatorType;
hasViewReceiptSetting: boolean;
preferredLeftPaneWidth: number;
renderToastManager: (_: {
containerWidthBreakpoint: WidthBreakpoint;
}) => JSX.Element;
savePreferredLeftPaneWidth: (preferredLeftPaneWidth: number) => void;
theme: ThemeType;
};
@ -62,6 +66,7 @@ export function MyStories({
onMediaPlaybackStart,
onToggleNavTabsCollapse,
preferredLeftPaneWidth,
renderToastManager,
savePreferredLeftPaneWidth,
theme,
}: PropsType): JSX.Element {
@ -98,6 +103,7 @@ export function MyStories({
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
preferredLeftPaneWidth={preferredLeftPaneWidth}
requiresFullWidth
renderToastManager={renderToastManager}
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
>
<div className="Stories__pane__list">

View file

@ -55,6 +55,9 @@ export type NavSidebarProps = Readonly<{
savePreferredLeftPaneWidth: (width: number) => void;
title: string;
otherTabsUnreadStats: UnreadStats;
renderToastManager: (_: {
containerWidthBreakpoint: WidthBreakpoint;
}) => JSX.Element;
}>;
enum DragState {
@ -78,6 +81,7 @@ export function NavSidebar({
savePreferredLeftPaneWidth,
title,
otherTabsUnreadStats,
renderToastManager,
}: NavSidebarProps): JSX.Element {
const isRTL = i18n.getLocaleDirection() === 'rtl';
const [dragState, setDragState] = useState(DragState.INITIAL);
@ -218,6 +222,8 @@ export function NavSidebar({
tabIndex={0}
{...moveProps}
/>
{renderToastManager({ containerWidthBreakpoint: widthBreakpoint })}
</div>
);
}

View file

@ -39,6 +39,7 @@ import type {
import { Button, ButtonVariant } from './Button';
import { ChatColorPicker } from './ChatColorPicker';
import { Checkbox } from './Checkbox';
import { WidthBreakpoint } from './_util';
import {
CircleCheckbox,
Variant as CircleCheckboxVariant,
@ -1581,9 +1582,11 @@ export function Preferences({
OS="unused"
hideToast={() => setToast(undefined)}
i18n={i18n}
onShowDebugLog={shouldNeverBeCalled}
onUndoArchive={shouldNeverBeCalled}
openFileInFolder={shouldNeverBeCalled}
toast={toast}
containerWidthBreakpoint={WidthBreakpoint.Narrow}
/>
</>
);

View file

@ -79,7 +79,6 @@ export default {
replaceAvatar: action('replaceAvatar'),
resetUsernameLink: action('resetUsernameLink'),
saveAvatarToDisk: action('saveAvatarToDisk'),
markCompletedUsernameOnboarding: action('markCompletedUsernameOnboarding'),
markCompletedUsernameLinkOnboarding: action(
'markCompletedUsernameLinkOnboarding'
),

View file

@ -39,7 +39,6 @@ import { missingCaseError } from '../util/missingCaseError';
import { ConfirmationDialog } from './ConfirmationDialog';
import { ContextMenu } from './ContextMenu';
import { UsernameLinkModalBody } from './UsernameLinkModalBody';
import { UsernameOnboardingModalBody } from './UsernameOnboardingModalBody';
import {
ConversationDetailsIcon,
IconType,
@ -54,7 +53,6 @@ export enum EditState {
ProfileName = 'ProfileName',
Bio = 'Bio',
Username = 'Username',
UsernameOnboarding = 'UsernameOnboarding',
UsernameLink = 'UsernameLink',
}
@ -75,13 +73,13 @@ export type PropsDataType = {
conversationId: string;
familyName?: string;
firstName: string;
hasCompletedUsernameOnboarding: boolean;
hasCompletedUsernameLinkOnboarding: boolean;
i18n: LocalizerType;
isUsernameFlagEnabled: boolean;
phoneNumber?: string;
userAvatarData: ReadonlyArray<AvatarDataType>;
username?: string;
initialEditState?: EditState;
usernameCorrupted: boolean;
usernameEditState: UsernameEditState;
usernameLinkState: UsernameLinkState;
@ -92,7 +90,6 @@ export type PropsDataType = {
type PropsActionType = {
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
markCompletedUsernameOnboarding: () => void;
markCompletedUsernameLinkOnboarding: () => void;
onSetSkinTone: (tone: number) => unknown;
replaceAvatar: ReplaceAvatarActionType;
@ -147,11 +144,10 @@ export function ProfileEditor({
deleteUsername,
familyName,
firstName,
hasCompletedUsernameOnboarding,
hasCompletedUsernameLinkOnboarding,
i18n,
initialEditState = EditState.None,
isUsernameFlagEnabled,
markCompletedUsernameOnboarding,
markCompletedUsernameLinkOnboarding,
onEditStateChanged,
onProfileChanged,
@ -179,7 +175,7 @@ export function ProfileEditor({
usernameLinkCorrupted,
}: PropsType): JSX.Element {
const focusInputRef = useRef<HTMLInputElement | null>(null);
const [editState, setEditState] = useState<EditState>(EditState.None);
const [editState, setEditState] = useState<EditState>(initialEditState);
const [confirmDiscardAction, setConfirmDiscardAction] = useState<
(() => unknown) | undefined
>(undefined);
@ -518,16 +514,6 @@ export function ProfileEditor({
content = renderEditUsernameModalBody({
onClose: () => setEditState(EditState.None),
});
} else if (editState === EditState.UsernameOnboarding) {
content = (
<UsernameOnboardingModalBody
i18n={i18n}
onNext={() => {
markCompletedUsernameOnboarding();
setEditState(EditState.Username);
}}
/>
);
} else if (editState === EditState.UsernameLink) {
content = (
<UsernameLinkModalBody
@ -686,11 +672,7 @@ export function ProfileEditor({
}
openUsernameReservationModal();
if (username || hasCompletedUsernameOnboarding) {
setEditState(EditState.Username);
} else {
setEditState(EditState.UsernameOnboarding);
}
setEditState(EditState.Username);
}}
alwaysShowActions={alwaysShowActions}
actions={actions}

View file

@ -37,7 +37,6 @@ export function ProfileEditorModal({
[EditState.Bio]: i18n('icu:ProfileEditorModal--about'),
[EditState.None]: i18n('icu:ProfileEditorModal--profile'),
[EditState.ProfileName]: i18n('icu:ProfileEditorModal--name'),
[EditState.UsernameOnboarding]: undefined,
[EditState.Username]: i18n('icu:ProfileEditorModal--username'),
[EditState.UsernameLink]: undefined,
};

View file

@ -33,6 +33,7 @@ export default {
onSaveStory: action('onSaveStory'),
preferredWidthFromStorage: 380,
queueStoryDownload: action('queueStoryDownload'),
renderToastManager: () => <i />,
renderStoryCreator: () => <>StoryCreator</>,
retryMessageSend: action('retryMessageSend'),
showConversation: action('showConversation'),

View file

@ -24,6 +24,7 @@ import { StoriesPane } from './StoriesPane';
import { NavSidebar, NavSidebarActionButton } from './NavSidebar';
import { StoriesAddStoryButton } from './StoriesAddStoryButton';
import { ContextMenu } from './ContextMenu';
import type { WidthBreakpoint } from './_util';
import type { UnreadStats } from '../util/countUnreadStats';
export type PropsType = {
@ -50,6 +51,9 @@ export type PropsType = {
preferredWidthFromStorage: number;
queueStoryDownload: (storyId: string) => unknown;
renderStoryCreator: () => JSX.Element;
renderToastManager: (_: {
containerWidthBreakpoint: WidthBreakpoint;
}) => JSX.Element;
retryMessageSend: (messageId: string) => unknown;
savePreferredLeftPaneWidth: (preferredLeftPaneWidth: number) => void;
setAddStoryData: (data: AddStoryData) => unknown;
@ -84,6 +88,7 @@ export function StoriesTab({
preferredLeftPaneWidth,
queueStoryDownload,
renderStoryCreator,
renderToastManager,
retryMessageSend,
savePreferredLeftPaneWidth,
setAddStoryData,
@ -127,6 +132,7 @@ export function StoriesTab({
preferredLeftPaneWidth={preferredLeftPaneWidth}
queueStoryDownload={queueStoryDownload}
retryMessageSend={retryMessageSend}
renderToastManager={renderToastManager}
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
theme={theme}
viewStory={viewStory}
@ -143,6 +149,7 @@ export function StoriesTab({
requiresFullWidth
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
otherTabsUnreadStats={otherTabsUnreadStats}
renderToastManager={renderToastManager}
actions={
<>
<StoriesAddStoryButton

View file

@ -4,10 +4,8 @@
import type { KeyboardEvent, MouseEvent, ReactNode } from 'react';
import React, { memo, useEffect } from 'react';
import classNames from 'classnames';
import { createPortal } from 'react-dom';
import { useRestoreFocus } from '../hooks/useRestoreFocus';
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
import * as log from '../logging/log';
export type PropsType = {
autoDismissDisabled?: boolean;
@ -33,51 +31,10 @@ export const Toast = memo(function ToastInner({
timeout = 8000,
toastAction,
}: PropsType): JSX.Element | null {
const [root, setRoot] = React.useState<HTMLElement | null>(null);
const [focusRef] = useRestoreFocus();
const [align, setAlign] = React.useState<'left' | 'center'>('left');
useEffect(() => {
function updateAlign() {
const leftPane = document.querySelector('.module-left-pane');
const composer = document.querySelector(
'.ConversationView__composition-area'
);
if (
leftPane != null &&
composer != null &&
leftPane.classList.contains('module-left-pane--width-narrow')
) {
setAlign('center');
return;
}
setAlign('left');
}
updateAlign();
if (window.reduxStore == null) {
log.warn('Toast: No redux store');
return;
}
return window.reduxStore.subscribe(updateAlign);
}, []);
useEffect(() => {
const div = document.createElement('div');
document.body.appendChild(div);
setRoot(div);
return () => {
document.body.removeChild(div);
setRoot(null);
};
}, []);
useEffect(() => {
if (!root || autoDismissDisabled) {
if (autoDismissDisabled) {
return;
}
@ -86,56 +43,53 @@ export const Toast = memo(function ToastInner({
return () => {
clearTimeoutIfNecessary(timeoutId);
};
}, [autoDismissDisabled, onClose, root, timeout]);
}, [autoDismissDisabled, onClose, timeout]);
return root
? createPortal(
return (
<div
aria-live="assertive"
className={classNames('Toast', className)}
onClick={() => {
if (!disableCloseOnClick) {
onClose();
}
}}
onKeyDown={(ev: KeyboardEvent<HTMLDivElement>) => {
if (ev.key === 'Enter' || ev.key === ' ') {
if (!disableCloseOnClick) {
onClose();
}
}
}}
role="button"
tabIndex={0}
style={style}
>
<div className="Toast__content">{children}</div>
{toastAction && (
<div
aria-live="assertive"
className={classNames('Toast', `Toast--align-${align}`, className)}
onClick={() => {
if (!disableCloseOnClick) {
onClose();
}
className="Toast__button"
onClick={(ev: MouseEvent<HTMLDivElement>) => {
ev.stopPropagation();
ev.preventDefault();
toastAction.onClick();
onClose();
}}
onKeyDown={(ev: KeyboardEvent<HTMLDivElement>) => {
if (ev.key === 'Enter' || ev.key === ' ') {
if (!disableCloseOnClick) {
onClose();
}
ev.stopPropagation();
ev.preventDefault();
toastAction.onClick();
onClose();
}
}}
ref={focusRef}
role="button"
tabIndex={0}
style={style}
>
<div className="Toast__content">{children}</div>
{toastAction && (
<div
className="Toast__button"
onClick={(ev: MouseEvent<HTMLDivElement>) => {
ev.stopPropagation();
ev.preventDefault();
toastAction.onClick();
onClose();
}}
onKeyDown={(ev: KeyboardEvent<HTMLDivElement>) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.stopPropagation();
ev.preventDefault();
toastAction.onClick();
onClose();
}
}}
ref={focusRef}
role="button"
tabIndex={0}
>
{toastAction.label}
</div>
)}
</div>,
root
)
: null;
{toastAction.label}
</div>
)}
</div>
);
});

View file

@ -1,26 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { PropsType } from './ToastAlreadyRequestedToJoin';
import { ToastAlreadyRequestedToJoin } from './ToastAlreadyRequestedToJoin';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
i18n,
onClose: action('onClose'),
};
export default {
title: 'Components/ToastAlreadyRequestedToJoin',
} satisfies Meta<PropsType>;
export const _ToastAlreadyRequestedToJoin = (): JSX.Element => (
<ToastAlreadyRequestedToJoin {...defaultProps} />
);

View file

@ -1,22 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Toast } from './Toast';
export type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
};
export function ToastAlreadyRequestedToJoin({
i18n,
onClose,
}: PropsType): JSX.Element {
return (
<Toast onClose={onClose}>
{i18n('icu:GroupV2--join--already-awaiting-approval')}
</Toast>
);
}

View file

@ -1,25 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { PropsType } from './ToastCaptchaFailed';
import { ToastCaptchaFailed } from './ToastCaptchaFailed';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
i18n,
onClose: action('onClose'),
};
export default {
title: 'Components/ToastCaptchaFailed',
} satisfies Meta<PropsType>;
export const _ToastCaptchaFailed = (): JSX.Element => (
<ToastCaptchaFailed {...defaultProps} />
);

View file

@ -1,15 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Toast } from './Toast';
export type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
};
export function ToastCaptchaFailed({ i18n, onClose }: PropsType): JSX.Element {
return <Toast onClose={onClose}>{i18n('icu:verificationFailed')}</Toast>;
}

View file

@ -1,25 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { PropsType } from './ToastCaptchaSolved';
import { ToastCaptchaSolved } from './ToastCaptchaSolved';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
i18n,
onClose: action('onClose'),
};
export default {
title: 'Components/ToastCaptchaSolved',
} satisfies Meta<PropsType>;
export const _ToastCaptchaSolved = (): JSX.Element => (
<ToastCaptchaSolved {...defaultProps} />
);

View file

@ -1,15 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Toast } from './Toast';
export type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
};
export function ToastCaptchaSolved({ i18n, onClose }: PropsType): JSX.Element {
return <Toast onClose={onClose}>{i18n('icu:verificationComplete')}</Toast>;
}

View file

@ -1,25 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { PropsType } from './ToastDebugLogError';
import { ToastDebugLogError } from './ToastDebugLogError';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
i18n,
onClose: action('onClose'),
};
export default {
title: 'Components/ToastDebugLogError',
} satisfies Meta<PropsType>;
export const _ToastDebugLogError = (): JSX.Element => (
<ToastDebugLogError {...defaultProps} />
);

View file

@ -1,15 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Toast } from './Toast';
export type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
};
export function ToastDebugLogError({ i18n, onClose }: PropsType): JSX.Element {
return <Toast onClose={onClose}>{i18n('icu:debugLogError')}</Toast>;
}

View file

@ -1,22 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Toast } from './Toast';
type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
};
export function ToastFailedToFetchPhoneNumber({
i18n,
onClose,
}: PropsType): JSX.Element {
return (
<Toast onClose={onClose} style={{ maxWidth: '280px' }}>
{i18n('icu:Toast--failed-to-fetch-phone-number')}
</Toast>
);
}

View file

@ -1,22 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Toast } from './Toast';
type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
};
export function ToastFailedToFetchUsername({
i18n,
onClose,
}: PropsType): JSX.Element {
return (
<Toast onClose={onClose} style={{ maxWidth: '280px' }}>
{i18n('icu:Toast--failed-to-fetch-username')}
</Toast>
);
}

View file

@ -1,25 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { PropsType } from './ToastFileSize';
import { ToastFileSize } from './ToastFileSize';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
i18n,
onClose: action('onClose'),
};
export default {
title: 'Components/ToastFileSize',
} satisfies Meta<PropsType>;
export const _ToastFileSize = (): JSX.Element => (
<ToastFileSize {...defaultProps} limit={100} units="MB" />
);

View file

@ -1,29 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Toast } from './Toast';
export type ToastPropsType = {
limit: number;
units: string;
};
export type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
} & ToastPropsType;
export function ToastFileSize({
i18n,
limit,
onClose,
units,
}: PropsType): JSX.Element {
return (
<Toast onClose={onClose}>
{i18n('icu:fileSizeWarning', { limit, units })}
</Toast>
);
}

View file

@ -1,25 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { PropsType } from './ToastGroupLinkCopied';
import { ToastGroupLinkCopied } from './ToastGroupLinkCopied';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
i18n,
onClose: action('onClose'),
};
export default {
title: 'Components/ToastGroupLinkCopied',
} satisfies Meta<PropsType>;
export const _ToastGroupLinkCopied = (): JSX.Element => (
<ToastGroupLinkCopied {...defaultProps} />
);

View file

@ -1,22 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Toast } from './Toast';
export type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
};
export function ToastGroupLinkCopied({
i18n,
onClose,
}: PropsType): JSX.Element {
return (
<Toast onClose={onClose}>
{i18n('icu:GroupLinkManagement--clipboard')}
</Toast>
);
}

View file

@ -1,45 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { PropsType } from './ToastInternalError';
import {
ToastInternalError,
ToastInternalErrorKind,
} from './ToastInternalError';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
i18n,
onClose: action('onClose'),
onShowDebugLog: action('onShowDebugLog'),
};
export default {
title: 'Components/ToastInternalError',
} satisfies Meta<PropsType>;
export function ToastDecryptionError(): JSX.Element {
return (
<ToastInternalError
kind={ToastInternalErrorKind.DecryptionError}
deviceId={3}
name="Someone Somewhere"
{...defaultProps}
/>
);
}
export function ToastCDSMirroringError(): JSX.Element {
return (
<ToastInternalError
kind={ToastInternalErrorKind.CDSMirroringError}
{...defaultProps}
/>
);
}

View file

@ -1,63 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { missingCaseError } from '../util/missingCaseError';
import { Toast } from './Toast';
export enum ToastInternalErrorKind {
DecryptionError = 'DecryptionError',
CDSMirroringError = 'CDSMirroringError',
}
export type ToastPropsType = {
onShowDebugLog: () => unknown;
} & (
| {
kind: ToastInternalErrorKind.DecryptionError;
deviceId: number;
name: string;
}
| {
kind: ToastInternalErrorKind.CDSMirroringError;
}
);
export type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
} & ToastPropsType;
export function ToastInternalError(props: PropsType): JSX.Element {
const { kind, i18n, onClose, onShowDebugLog } = props;
let body: string;
if (kind === ToastInternalErrorKind.DecryptionError) {
const { deviceId, name } = props;
body = i18n('icu:decryptionErrorToast', {
name,
deviceId,
});
} else if (kind === ToastInternalErrorKind.CDSMirroringError) {
body = i18n('icu:cdsMirroringErrorToast');
} else {
throw missingCaseError(kind);
}
return (
<Toast
autoDismissDisabled
className="internal-error-toast"
onClose={onClose}
style={{ maxWidth: '500px' }}
toastAction={{
label: i18n('icu:decryptionErrorToastAction'),
onClick: onShowDebugLog,
}}
>
{body}
</Toast>
);
}

View file

@ -1,25 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { PropsType } from './ToastLinkCopied';
import { ToastLinkCopied } from './ToastLinkCopied';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
i18n,
onClose: action('onClose'),
};
export default {
title: 'Components/ToastLinkCopied',
} satisfies Meta<PropsType>;
export const _ToastLinkCopied = (): JSX.Element => (
<ToastLinkCopied {...defaultProps} />
);

View file

@ -1,15 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Toast } from './Toast';
export type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
};
export function ToastLinkCopied({ i18n, onClose }: PropsType): JSX.Element {
return <Toast onClose={onClose}>{i18n('icu:debugLogLinkCopied')}</Toast>;
}

View file

@ -1,25 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { PropsType } from './ToastLoadingFullLogs';
import { ToastLoadingFullLogs } from './ToastLoadingFullLogs';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
i18n,
onClose: action('onClose'),
};
export default {
title: 'Components/ToastLoadingFullLogs',
} satisfies Meta<PropsType>;
export const _ToastLoadingFullLogs = (): JSX.Element => (
<ToastLoadingFullLogs {...defaultProps} />
);

View file

@ -1,18 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Toast } from './Toast';
export type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
};
export function ToastLoadingFullLogs({
i18n,
onClose,
}: PropsType): JSX.Element {
return <Toast onClose={onClose}>{i18n('icu:loading')}</Toast>;
}

View file

@ -9,6 +9,8 @@ import enMessages from '../../_locales/en/messages.json';
import { ToastManager } from './ToastManager';
import type { AnyToast } from '../types/Toast';
import { ToastType } from '../types/Toast';
import type { AnyActionableMegaphone } from '../types/Megaphone';
import { MegaphoneType } from '../types/Megaphone';
import { setupI18n } from '../util/setupI18n';
import { missingCaseError } from '../util/missingCaseError';
import type { PropsType } from './ToastManager';
@ -41,6 +43,10 @@ function getToast(toastType: ToastType): AnyToast {
return { toastType: ToastType.CannotOpenGiftBadgeOutgoing };
case ToastType.CannotStartGroupCall:
return { toastType: ToastType.CannotStartGroupCall };
case ToastType.CaptchaFailed:
return { toastType: ToastType.CaptchaFailed };
case ToastType.CaptchaSolved:
return { toastType: ToastType.CaptchaSolved };
case ToastType.ConversationArchived:
return {
toastType: ToastType.ConversationArchived,
@ -61,6 +67,16 @@ function getToast(toastType: ToastType): AnyToast {
return { toastType: ToastType.CopiedUsernameLink };
case ToastType.DangerousFileType:
return { toastType: ToastType.DangerousFileType };
case ToastType.DebugLogError:
return { toastType: ToastType.DebugLogError };
case ToastType.DecryptionError:
return {
toastType: ToastType.DecryptionError,
parameters: {
deviceId: 2,
name: 'Alice',
},
};
case ToastType.DeleteForEveryoneFailed:
return { toastType: ToastType.DeleteForEveryoneFailed };
case ToastType.Error:
@ -69,6 +85,10 @@ function getToast(toastType: ToastType): AnyToast {
return { toastType: ToastType.Expired };
case ToastType.FailedToDeleteUsername:
return { toastType: ToastType.FailedToDeleteUsername };
case ToastType.FailedToFetchPhoneNumber:
return { toastType: ToastType.FailedToFetchPhoneNumber };
case ToastType.FailedToFetchUsername:
return { toastType: ToastType.FailedToFetchUsername };
case ToastType.FileSaved:
return {
toastType: ToastType.FileSaved,
@ -79,10 +99,16 @@ function getToast(toastType: ToastType): AnyToast {
toastType: ToastType.FileSize,
parameters: { limit: 100, units: 'MB' },
};
case ToastType.GroupLinkCopied:
return { toastType: ToastType.GroupLinkCopied };
case ToastType.InvalidConversation:
return { toastType: ToastType.InvalidConversation };
case ToastType.LeftGroup:
return { toastType: ToastType.LeftGroup };
case ToastType.LinkCopied:
return { toastType: ToastType.LinkCopied };
case ToastType.LoadingFullLogs:
return { toastType: ToastType.LoadingFullLogs };
case ToastType.MaxAttachments:
return { toastType: ToastType.MaxAttachments };
case ToastType.MessageBodyTooLong:
@ -95,6 +121,8 @@ function getToast(toastType: ToastType): AnyToast {
return { toastType: ToastType.ReactionFailed };
case ToastType.ReportedSpamAndBlocked:
return { toastType: ToastType.ReportedSpamAndBlocked };
case ToastType.StickerPackInstallFailed:
return { toastType: ToastType.StickerPackInstallFailed };
case ToastType.StoryMuted:
return { toastType: ToastType.StoryMuted };
case ToastType.StoryReact:
@ -130,6 +158,10 @@ function getToast(toastType: ToastType): AnyToast {
group: 'Hike Group 🏔',
},
};
case ToastType.VoiceNoteLimit:
return { toastType: ToastType.VoiceNoteLimit };
case ToastType.VoiceNoteMustBeTheOnlyAttachment:
return { toastType: ToastType.VoiceNoteMustBeTheOnlyAttachment };
case ToastType.WhoCanFindMeReadOnly:
return { toastType: ToastType.WhoCanFindMeReadOnly };
default:
@ -137,8 +169,22 @@ function getToast(toastType: ToastType): AnyToast {
}
}
type Args = Omit<PropsType, 'toast'> & {
function getMegaphone(megaphoneType: MegaphoneType): AnyActionableMegaphone {
switch (megaphoneType) {
case MegaphoneType.UsernameOnboarding:
return {
type: megaphoneType,
onLearnMore: action('onLearnMore'),
onDismiss: action('onDismiss'),
};
default:
throw missingCaseError(megaphoneType);
}
}
type Args = Omit<PropsType, 'toast' | 'megaphone'> & {
toastType: ToastType;
megaphoneType: MegaphoneType;
};
export default {
@ -149,24 +195,34 @@ export default {
options: ToastType,
control: { type: 'select' },
},
megaphoneType: {
options: MegaphoneType,
control: { type: 'select' },
},
},
args: {
hideToast: action('hideToast'),
openFileInFolder: action('openFileInFolder'),
onShowDebugLog: action('onShowDebugLog'),
onUndoArchive: action('onUndoArchive'),
i18n,
toastType: ToastType.AddingUserToGroup,
megaphoneType: MegaphoneType.UsernameOnboarding,
OS: 'macOS',
},
} satisfies Meta<Args>;
// eslint-disable-next-line react/function-component-definition
const Template: StoryFn<Args> = args => {
const { toastType, ...rest } = args;
const { toastType, megaphoneType, ...rest } = args;
return (
<>
<p>Select a toast type in controls</p>
<ToastManager toast={getToast(toastType)} {...rest} />
<ToastManager
toast={getToast(toastType)}
megaphone={getMegaphone(megaphoneType)}
{...rest}
/>
</>
);
};

View file

@ -1,29 +1,43 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import classNames from 'classnames';
import React from 'react';
import { createPortal } from 'react-dom';
import type { LocalizerType } from '../types/Util';
import { SECOND } from '../util/durations';
import { Toast } from './Toast';
import { WidthBreakpoint } from './_util';
import { UsernameMegaphone } from './UsernameMegaphone';
import { assertDev } from '../util/assert';
import { missingCaseError } from '../util/missingCaseError';
import type { AnyToast } from '../types/Toast';
import { ToastType } from '../types/Toast';
import type { AnyActionableMegaphone } from '../types/Megaphone';
import { MegaphoneType } from '../types/Megaphone';
export type PropsType = {
hideToast: () => unknown;
i18n: LocalizerType;
openFileInFolder: (target: string) => unknown;
OS: string;
onShowDebugLog: () => unknown;
onUndoArchive: (conversaetionId: string) => unknown;
toast?: AnyToast;
megaphone?: AnyActionableMegaphone;
centerToast?: boolean;
containerWidthBreakpoint: WidthBreakpoint;
isCompositionAreaVisible?: boolean;
};
const SHORT_TIMEOUT = 3 * SECOND;
export function ToastManager({
export function renderToast({
hideToast,
i18n,
openFileInFolder,
onShowDebugLog,
onUndoArchive,
OS,
toast,
@ -116,6 +130,16 @@ export function ToastManager({
);
}
if (toastType === ToastType.CaptchaFailed) {
return <Toast onClose={hideToast}>{i18n('icu:verificationFailed')}</Toast>;
}
if (toastType === ToastType.CaptchaSolved) {
return (
<Toast onClose={hideToast}>{i18n('icu:verificationComplete')}</Toast>
);
}
if (toastType === ToastType.CannotStartGroupCall) {
return (
<Toast onClose={hideToast}>
@ -184,6 +208,10 @@ export function ToastManager({
return <Toast onClose={hideToast}>{i18n('icu:dangerousFileType')}</Toast>;
}
if (toastType === ToastType.DebugLogError) {
return <Toast onClose={hideToast}>{i18n('icu:debugLogError')}</Toast>;
}
if (toastType === ToastType.DeleteForEveryoneFailed) {
return (
<Toast onClose={hideToast}>{i18n('icu:deleteForEveryoneFailed')}</Toast>
@ -217,6 +245,22 @@ export function ToastManager({
);
}
if (toastType === ToastType.FailedToFetchPhoneNumber) {
return (
<Toast onClose={hideToast} style={{ maxWidth: '280px' }}>
{i18n('icu:Toast--failed-to-fetch-phone-number')}
</Toast>
);
}
if (toastType === ToastType.FailedToFetchUsername) {
return (
<Toast onClose={hideToast} style={{ maxWidth: '280px' }}>
{i18n('icu:Toast--failed-to-fetch-username')}
</Toast>
);
}
if (toastType === ToastType.FileSaved) {
return (
<Toast
@ -244,6 +288,41 @@ export function ToastManager({
);
}
if (toastType === ToastType.GroupLinkCopied) {
return (
<Toast onClose={hideToast}>
{i18n('icu:GroupLinkManagement--clipboard')}
</Toast>
);
}
if (toastType === ToastType.DecryptionError) {
assertDev(
toast.toastType === ToastType.DecryptionError,
'Pacify typescript'
);
const { parameters } = toast;
const { deviceId, name } = parameters;
return (
<Toast
autoDismissDisabled
className="internal-error-toast"
onClose={hideToast}
style={{ maxWidth: '500px' }}
toastAction={{
label: i18n('icu:decryptionErrorToastAction'),
onClick: onShowDebugLog,
}}
>
{i18n('icu:decryptionErrorToast', {
name,
deviceId,
})}
</Toast>
);
}
if (toastType === ToastType.InvalidConversation) {
return <Toast onClose={hideToast}>{i18n('icu:invalidConversation')}</Toast>;
}
@ -252,6 +331,14 @@ export function ToastManager({
return <Toast onClose={hideToast}>{i18n('icu:youLeftTheGroup')}</Toast>;
}
if (toastType === ToastType.LinkCopied) {
return <Toast onClose={hideToast}>{i18n('icu:debugLogLinkCopied')}</Toast>;
}
if (toastType === ToastType.LoadingFullLogs) {
return <Toast onClose={hideToast}>{i18n('icu:loading')}</Toast>;
}
if (toastType === ToastType.MaxAttachments) {
return <Toast onClose={hideToast}>{i18n('icu:maximumAttachments')}</Toast>;
}
@ -284,6 +371,14 @@ export function ToastManager({
);
}
if (toastType === ToastType.StickerPackInstallFailed) {
return (
<Toast onClose={hideToast}>
{i18n('icu:stickers--toast--InstallFailed')}
</Toast>
);
}
if (toastType === ToastType.StoryMuted) {
return (
<Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
@ -392,6 +487,18 @@ export function ToastManager({
);
}
if (toastType === ToastType.VoiceNoteLimit) {
return <Toast onClose={hideToast}>{i18n('icu:voiceNoteLimit')}</Toast>;
}
if (toastType === ToastType.VoiceNoteMustBeTheOnlyAttachment) {
return (
<Toast onClose={hideToast}>
{i18n('icu:voiceNoteMustBeOnlyAttachment')}
</Toast>
);
}
if (toastType === ToastType.WhoCanFindMeReadOnly) {
return (
<Toast onClose={hideToast}>{i18n('icu:WhoCanFindMeReadOnlyToast')}</Toast>
@ -400,3 +507,43 @@ export function ToastManager({
throw missingCaseError(toastType);
}
export function renderMegaphone({
i18n,
megaphone,
}: PropsType): JSX.Element | null {
if (!megaphone) {
return null;
}
if (megaphone.type === MegaphoneType.UsernameOnboarding) {
return <UsernameMegaphone i18n={i18n} {...megaphone} />;
}
throw missingCaseError(megaphone.type);
}
export function ToastManager(props: PropsType): JSX.Element {
const { centerToast, containerWidthBreakpoint, isCompositionAreaVisible } =
props;
const toast = renderToast(props);
return (
<div
className={classNames('ToastManager', {
'ToastManager--narrow-sidebar':
containerWidthBreakpoint === WidthBreakpoint.Narrow,
'ToastManager--composition-area-visible': isCompositionAreaVisible,
})}
>
{centerToast
? createPortal(
<div className="ToastManager__root">{toast}</div>,
document.body
)
: toast}
{renderMegaphone(props)}
</div>
);
}

View file

@ -1,25 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { PropsType } from './ToastStickerPackInstallFailed';
import { ToastStickerPackInstallFailed } from './ToastStickerPackInstallFailed';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
i18n,
onClose: action('onClose'),
};
export default {
title: 'Components/ToastStickerPackInstallFailed',
} satisfies Meta<PropsType>;
export const _ToastStickerPackInstallFailed = (): JSX.Element => (
<ToastStickerPackInstallFailed {...defaultProps} />
);

View file

@ -1,22 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Toast } from './Toast';
export type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
};
export function ToastStickerPackInstallFailed({
i18n,
onClose,
}: PropsType): JSX.Element {
return (
<Toast onClose={onClose}>
{i18n('icu:stickers--toast--InstallFailed')}
</Toast>
);
}

View file

@ -1,15 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Toast } from './Toast';
type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
};
export function ToastVoiceNoteLimit({ i18n, onClose }: PropsType): JSX.Element {
return <Toast onClose={onClose}>{i18n('icu:voiceNoteError')}</Toast>;
}

View file

@ -1,25 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { PropsType } from './ToastVoiceNoteLimit';
import { ToastVoiceNoteLimit } from './ToastVoiceNoteLimit';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
i18n,
onClose: action('onClose'),
};
export default {
title: 'Components/ToastVoiceNoteLimit',
} satisfies Meta<PropsType>;
export const _ToastVoiceNoteLimit = (): JSX.Element => (
<ToastVoiceNoteLimit {...defaultProps} />
);

View file

@ -1,15 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Toast } from './Toast';
export type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
};
export function ToastVoiceNoteLimit({ i18n, onClose }: PropsType): JSX.Element {
return <Toast onClose={onClose}>{i18n('icu:voiceNoteLimit')}</Toast>;
}

View file

@ -1,25 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { PropsType } from './ToastVoiceNoteMustBeOnlyAttachment';
import { ToastVoiceNoteMustBeOnlyAttachment } from './ToastVoiceNoteMustBeOnlyAttachment';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
i18n,
onClose: action('onClose'),
};
export default {
title: 'Components/ToastVoiceNoteMustBeOnlyAttachment',
} satisfies Meta<PropsType>;
export const _ToastVoiceNoteMustBeOnlyAttachment = (): JSX.Element => (
<ToastVoiceNoteMustBeOnlyAttachment {...defaultProps} />
);

View file

@ -1,20 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Toast } from './Toast';
export type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
};
export function ToastVoiceNoteMustBeOnlyAttachment({
i18n,
onClose,
}: PropsType): JSX.Element {
return (
<Toast onClose={onClose}>{i18n('icu:voiceNoteMustBeOnlyAttachment')}</Toast>
);
}

View file

@ -0,0 +1,27 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { PropsType } from './UsernameMegaphone';
import { UsernameMegaphone } from './UsernameMegaphone';
import { type ComponentMeta } from '../storybook/types';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/UsernameMegaphone',
component: UsernameMegaphone,
argTypes: {},
args: {
i18n,
onLearnMore: action('onLearnMore'),
onDismiss: action('onDismiss'),
},
} satisfies ComponentMeta<PropsType>;
export function Defaults(args: PropsType): JSX.Element {
return <UsernameMegaphone {...args} />;
}

View file

@ -0,0 +1,50 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import type { UsernameOnboardingActionableMegaphoneType } from '../types/Megaphone';
import { Button, ButtonSize, ButtonVariant } from './Button';
export type PropsType = {
i18n: LocalizerType;
} & Omit<UsernameOnboardingActionableMegaphoneType, 'type'>;
export function UsernameMegaphone({
i18n,
onLearnMore,
onDismiss,
}: PropsType): JSX.Element {
return (
<div className="UsernameMegaphone">
<div className="UsernameMegaphone__row">
<i className="UsernameMegaphone__row__icon" />
<div className="UsernameMegaphone__row__text">
<h2>{i18n('icu:UsernameMegaphone__title')}</h2>
<p>{i18n('icu:UsernameMegaphone__body')}</p>
</div>
</div>
<div className="UsernameMegaphone__buttons">
<Button
className="UsernameMegaphone__buttons__button"
variant={ButtonVariant.SecondaryAffirmative}
size={ButtonSize.Small}
onClick={onDismiss}
>
{i18n('icu:UsernameMegaphone__dismiss')}
</Button>
<Button
className="UsernameMegaphone__buttons__button"
variant={ButtonVariant.SecondaryAffirmative}
size={ButtonSize.Small}
onClick={onLearnMore}
>
{i18n('icu:UsernameMegaphone__learn-more')}
</Button>
</div>
</div>
);
}

View file

@ -8,23 +8,25 @@ import { action } from '@storybook/addon-actions';
import enMessages from '../../_locales/en/messages.json';
import { setupI18n } from '../util/setupI18n';
import type { PropsType } from './UsernameOnboardingModalBody';
import { UsernameOnboardingModalBody } from './UsernameOnboardingModalBody';
import type { PropsType } from './UsernameOnboardingModal';
import { UsernameOnboardingModal } from './UsernameOnboardingModal';
const i18n = setupI18n('en', enMessages);
export default {
component: UsernameOnboardingModalBody,
title: 'Components/UsernameOnboardingModalBody',
component: UsernameOnboardingModal,
title: 'Components/UsernameOnboardingModal',
args: {
i18n,
onNext: action('onNext'),
onSkip: action('onSkip'),
onClose: action('onClose'),
},
} satisfies Meta<PropsType>;
// eslint-disable-next-line react/function-component-definition
const Template: StoryFn<PropsType> = args => {
return <UsernameOnboardingModalBody {...args} />;
return <UsernameOnboardingModal {...args} />;
};
export const Normal = Template.bind({});

View file

@ -0,0 +1,80 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Button, ButtonVariant } from './Button';
import { Modal } from './Modal';
export type PropsType = Readonly<{
i18n: LocalizerType;
onNext: () => void;
onSkip: () => void;
onClose: () => void;
}>;
export function UsernameOnboardingModal({
i18n,
onNext,
onSkip,
onClose,
}: PropsType): JSX.Element {
return (
<Modal
modalName="UsernameOnboardingModal"
hasXButton
i18n={i18n}
onClose={onClose}
>
<div className="UsernameOnboardingModal">
<div className="UsernameOnboardingModal__title">
{i18n('icu:UsernameOnboardingModalBody__title')}
</div>
<div className="UsernameOnboardingModal__row">
<div className="UsernameOnboardingModal__row__icon UsernameOnboardingModal__row__icon--number" />
<div className="UsernameOnboardingModal__row__body">
<h2>
{i18n('icu:UsernameOnboardingModalBody__row__number__title')}
</h2>
{i18n('icu:UsernameOnboardingModalBody__row__number__body')}
</div>
</div>
<div className="UsernameOnboardingModal__row">
<div className="UsernameOnboardingModal__row__icon UsernameOnboardingModal__row__icon--username" />
<div className="UsernameOnboardingModal__row__body">
<h2>
{i18n('icu:UsernameOnboardingModalBody__row__username__title')}
</h2>
{i18n('icu:UsernameOnboardingModalBody__row__username__body')}
</div>
</div>
<div className="UsernameOnboardingModal__row">
<div className="UsernameOnboardingModal__row__icon UsernameOnboardingModal__row__icon--qr" />
<div className="UsernameOnboardingModal__row__body">
<h2>{i18n('icu:UsernameOnboardingModalBody__row__qr__title')}</h2>
{i18n('icu:UsernameOnboardingModalBody__row__qr__body')}
</div>
</div>
<Button className="UsernameOnboardingModal__submit" onClick={onNext}>
{i18n('icu:UsernameOnboardingModalBody__continue')}
</Button>
<Button
className="UsernameOnboardingModal__skip"
variant={ButtonVariant.SecondaryAffirmative}
onClick={onSkip}
>
{i18n('icu:UsernameOnboardingModalBody__skip')}
</Button>
</div>
</Modal>
);
}

View file

@ -1,70 +0,0 @@
// 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 (
<div className={CLASS}>
<div className={`${CLASS}__large-at`} />
<div className={`${CLASS}__title`}>
{i18n('icu:UsernameOnboardingModalBody__title')}
</div>
<div className={`${CLASS}__row`}>
<div className={`${CLASS}__row__icon ${CLASS}__row__icon--number`} />
<div className={`${CLASS}__row__body`}>
{i18n('icu:UsernameOnboardingModalBody__row__number')}
</div>
</div>
<div className={`${CLASS}__row`}>
<div className={`${CLASS}__row__icon ${CLASS}__row__icon--link`} />
<div className={`${CLASS}__row__body`}>
{i18n('icu:UsernameOnboardingModalBody__row__link')}
</div>
</div>
<div className={`${CLASS}__row`}>
<div className={`${CLASS}__row__icon ${CLASS}__row__icon--lock`} />
<div className={`${CLASS}__row__body`}>
{i18n('icu:UsernameOnboardingModalBody__row__lock')}
</div>
</div>
<div className={`${CLASS}__row ${CLASS}__row--center`}>
<a
className={`${CLASS}__learn-more`}
href={SUPPORT_URL}
rel="noreferrer"
target="_blank"
>
{i18n('icu:UsernameOnboardingModalBody__learn-more')}
</a>
</div>
<Button className={`${CLASS}__submit`} onClick={onNext}>
{i18n('icu:UsernameOnboardingModalBody__continue')}
</Button>
</div>
);
}

View file

@ -1,11 +1,12 @@
// Copyright 2016 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useState } from 'react';
import React, { useCallback } from 'react';
import type { ShowToastAction } from '../../state/ducks/toast';
import type { AttachmentDraftType } from '../../types/Attachment';
import type { LocalizerType } from '../../types/Util';
import { ToastVoiceNoteMustBeOnlyAttachment } from '../ToastVoiceNoteMustBeOnlyAttachment';
import { ToastType } from '../../types/Toast';
import {
useStartRecordingShortcut,
useKeyboardShortcuts,
@ -16,6 +17,7 @@ export type PropsType = {
draftAttachments: ReadonlyArray<AttachmentDraftType>;
i18n: LocalizerType;
startRecording: (id: string) => unknown;
showToast: ShowToastAction;
};
export function AudioCapture({
@ -23,9 +25,8 @@ export function AudioCapture({
draftAttachments,
i18n,
startRecording,
showToast,
}: PropsType): JSX.Element {
const [showOnlyAttachmentToast, setShowOnlyAttachmentToast] = useState(false);
const recordConversation = useCallback(
() => startRecording(conversationId),
[conversationId, startRecording]
@ -33,40 +34,23 @@ export function AudioCapture({
const startRecordingShortcut = useStartRecordingShortcut(recordConversation);
useKeyboardShortcuts(startRecordingShortcut);
const handleCloseToast = useCallback(() => {
setShowOnlyAttachmentToast(false);
}, []);
const handleClick = useCallback(() => {
if (draftAttachments.length) {
setShowOnlyAttachmentToast(true);
showToast({ toastType: ToastType.VoiceNoteMustBeTheOnlyAttachment });
} else {
startRecording(conversationId);
}
}, [
conversationId,
draftAttachments,
setShowOnlyAttachmentToast,
startRecording,
]);
}, [conversationId, draftAttachments, showToast, startRecording]);
return (
<>
<div className="AudioCapture">
<button
aria-label={i18n('icu:voiceRecording--start')}
className="AudioCapture__microphone"
onClick={handleClick}
title={i18n('icu:voiceRecording--start')}
type="button"
/>
</div>
{showOnlyAttachmentToast && (
<ToastVoiceNoteMustBeOnlyAttachment
i18n={i18n}
onClose={handleCloseToast}
/>
)}
</>
<div className="AudioCapture">
<button
aria-label={i18n('icu:voiceRecording--start')}
className="AudioCapture__microphone"
onClick={handleClick}
title={i18n('icu:voiceRecording--start')}
type="button"
/>
</div>
);
}