Actionable toasts

This commit is contained in:
Josh Perez 2021-10-06 17:00:51 -04:00 committed by GitHub
parent d542f450a1
commit b9134f8332
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 148 additions and 59 deletions

View file

@ -640,9 +640,13 @@
} }
}, },
"decryptionErrorToast": { "decryptionErrorToast": {
"message": "Desktop ran into a decryption error. Click to submit a debug log.", "message": "Desktop ran into a decryption error.",
"description": "An error popup when we haven't added an error for decryption error." "description": "An error popup when we haven't added an error for decryption error."
}, },
"decryptionErrorToastAction": {
"message": "Submit log",
"description": "Label for the decryption error toast button"
},
"oneNonImageAtATimeToast": { "oneNonImageAtATimeToast": {
"message": "When including a non-image attachment, the limit is one attachment per message.", "message": "When including a non-image attachment, the limit is one attachment per message.",
"description": "An error popup when the user has attempted to add an attachment" "description": "An error popup when the user has attempted to add an attachment"
@ -950,14 +954,12 @@
"description": "Shown in a dialog to inform user that we experienced an unrecoverable error" "description": "Shown in a dialog to inform user that we experienced an unrecoverable error"
}, },
"attachmentSaved": { "attachmentSaved": {
"message": "Attachment saved. Click to show in folder.", "message": "Attachment saved.",
"description": "Shown after user selects to save to downloads", "description": "Shown after user selects to save to downloads"
"placeholders": { },
"name": { "attachmentSavedShow": {
"content": "$1", "message": "Show in folder",
"example": "proof.jpg" "description": "Button label for showing the attachment in your file system"
}
}
}, },
"you": { "you": {
"message": "You", "message": "You",
@ -2949,6 +2951,10 @@
"message": "Conversation archived", "message": "Conversation archived",
"description": "A toast that shows up when user archives a conversation" "description": "A toast that shows up when user archives a conversation"
}, },
"conversationArchivedUndo": {
"message": "Undo",
"description": "Undo button for archiving a conversation"
},
"conversationReturnedToInbox": { "conversationReturnedToInbox": {
"message": "Conversation returned to inbox", "message": "Conversation returned to inbox",
"description": "A toast that shows up when the user unarchives a conversation" "description": "A toast that shows up when the user unarchives a conversation"

View file

@ -4,10 +4,12 @@
.Toast { .Toast {
@include font-body-2; @include font-body-2;
align-items: center;
border-radius: 4px; border-radius: 4px;
bottom: 62px; bottom: 62px;
display: flex;
justify-content: space-between;
left: 50%; left: 50%;
padding: 8px 16px;
position: absolute; position: absolute;
text-align: center; text-align: center;
transform: translate(-50%, 0); transform: translate(-50%, 0);
@ -15,18 +17,40 @@
z-index: 100; z-index: 100;
@include light-theme { @include light-theme {
background-color: $color-gray-75; background-color: $color-gray-80;
color: $color-white; color: $color-white;
box-shadow: 0 4px 16px 0 $color-black-alpha-20, box-shadow: 0 4px 16px 0 $color-black-alpha-20,
0 0 0 0.5px $color-black-alpha-05; 0 0 0 0.5px $color-black-alpha-05;
} }
@include dark-theme { @include dark-theme {
background-color: $color-gray-45; background-color: $color-gray-75;
color: $color-white; color: $color-gray-05;
box-shadow: 0 4px 16px 0 $color-white-alpha-20; box-shadow: 0 4px 16px 0 $color-white-alpha-20;
} }
&--clickable { &__content {
padding: 8px 12px;
}
&__button {
@include font-body-2-bold;
cursor: pointer; cursor: pointer;
padding: 8px 12px;
@include light-theme {
border-left: 1px solid $color-gray-65;
}
@include dark-theme {
border-left: 1px solid $color-gray-60;
}
&:hover {
@include light-theme {
background-color: $color-gray-65;
}
@include dark-theme {
background-color: $color-gray-60;
}
}
} }
} }

View file

@ -1428,7 +1428,15 @@ export async function startApp(): Promise<void> {
) { ) {
conversation.setArchived(true); conversation.setArchived(true);
conversation.trigger('unload', 'keyboard shortcut archive'); conversation.trigger('unload', 'keyboard shortcut archive');
showToast(ToastConversationArchived); showToast(ToastConversationArchived, {
undo: () => {
conversation.setArchived(false);
window.Whisper.events.trigger(
'showConversation',
conversation.get('id')
);
},
});
// It's very likely that the act of archiving a conversation will set focus to // It's very likely that the act of archiving a conversation will set focus to
// 'none,' or the top-level body element. This resets it to the left pane. // 'none,' or the top-level body element. This resets it to the left pane.

View file

@ -1,19 +1,23 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { KeyboardEvent, ReactNode, useEffect } from 'react'; import React, { KeyboardEvent, MouseEvent, ReactNode, useEffect } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { onTimeout, removeTimeout } from '../services/timers'; import { onTimeout, removeTimeout } from '../services/timers';
import { useRestoreFocus } from '../hooks/useRestoreFocus';
export type PropsType = { export type PropsType = {
autoDismissDisabled?: boolean; autoDismissDisabled?: boolean;
children: ReactNode; children: ReactNode;
className?: string; className?: string;
disableCloseOnClick?: boolean; disableCloseOnClick?: boolean;
onClick?: () => unknown;
onClose: () => unknown; onClose: () => unknown;
timeout?: number; timeout?: number;
toastAction?: {
label: string;
onClick: () => unknown;
};
}; };
export const Toast = ({ export const Toast = ({
@ -21,11 +25,12 @@ export const Toast = ({
children, children,
className, className,
disableCloseOnClick = false, disableCloseOnClick = false,
onClick,
onClose, onClose,
timeout = 8000, timeout = 8000,
toastAction,
}: PropsType): JSX.Element | null => { }: PropsType): JSX.Element | null => {
const [root, setRoot] = React.useState<HTMLElement | null>(null); const [root, setRoot] = React.useState<HTMLElement | null>(null);
const [focusRef] = useRestoreFocus();
useEffect(() => { useEffect(() => {
const div = document.createElement('div'); const div = document.createElement('div');
@ -52,38 +57,49 @@ export const Toast = ({
}; };
}, [autoDismissDisabled, onClose, root, timeout]); }, [autoDismissDisabled, onClose, root, timeout]);
let interactivityProps = {};
if (onClick) {
interactivityProps = {
role: 'button',
onClick() {
onClick();
if (!disableCloseOnClick) {
onClose();
}
},
onKeyDown(ev: KeyboardEvent<HTMLDivElement>) {
if (ev.key === 'Enter' || ev.key === ' ') {
onClick();
if (!disableCloseOnClick) {
onClose();
}
}
},
};
}
return root return root
? createPortal( ? createPortal(
<div <div
className={classNames( aria-live="assertive"
'Toast', className={classNames('Toast', className)}
onClick ? 'Toast--clickable' : null, onClick={() => {
className if (!disableCloseOnClick) {
)} onClose();
{...interactivityProps} }
}}
onKeyDown={(ev: KeyboardEvent<HTMLDivElement>) => {
if (ev.key === 'Enter' || ev.key === ' ') {
if (!disableCloseOnClick) {
onClose();
}
}
}}
role="button"
tabIndex={0}
> >
{children} <div className="Toast__content">{children}</div>
{toastAction && (
<div
className="Toast__button"
onClick={(ev: MouseEvent<HTMLDivElement>) => {
ev.stopPropagation();
ev.preventDefault();
toastAction.onClick();
}}
onKeyDown={(ev: KeyboardEvent<HTMLDivElement>) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.stopPropagation();
ev.preventDefault();
toastAction.onClick();
}
}}
ref={focusRef}
role="button"
tabIndex={0}
>
{toastAction.label}
</div>
)}
</div>, </div>,
root root
) )

View file

@ -14,6 +14,7 @@ const i18n = setupI18n('en', enMessages);
const defaultProps = { const defaultProps = {
i18n, i18n,
onClose: action('onClose'), onClose: action('onClose'),
undo: action('undo'),
}; };
const story = storiesOf('Components/ToastConversationArchived', module); const story = storiesOf('Components/ToastConversationArchived', module);

View file

@ -5,14 +5,32 @@ import React from 'react';
import { LocalizerType } from '../types/Util'; import { LocalizerType } from '../types/Util';
import { Toast } from './Toast'; import { Toast } from './Toast';
type PropsType = { export type ToastPropsType = {
undo: () => unknown;
};
export type PropsType = {
i18n: LocalizerType; i18n: LocalizerType;
onClose: () => unknown; onClose: () => unknown;
}; } & ToastPropsType;
export const ToastConversationArchived = ({ export const ToastConversationArchived = ({
i18n, i18n,
onClose, onClose,
undo,
}: PropsType): JSX.Element => { }: PropsType): JSX.Element => {
return <Toast onClose={onClose}>{i18n('conversationArchived')}</Toast>; return (
<Toast
toastAction={{
label: i18n('conversationArchivedUndo'),
onClick: () => {
undo();
onClose();
},
}}
onClose={onClose}
>
{i18n('conversationArchived')}
</Toast>
);
}; };

View file

@ -23,8 +23,11 @@ export const ToastDecryptionError = ({
<Toast <Toast
autoDismissDisabled autoDismissDisabled
className="decryption-error" className="decryption-error"
onClick={onShowDebugLog}
onClose={onClose} onClose={onClose}
toastAction={{
label: i18n('decryptionErrorToastAction'),
onClick: onShowDebugLog,
}}
> >
{i18n('decryptionErrorToast')} {i18n('decryptionErrorToast')}
</Toast> </Toast>

View file

@ -20,7 +20,13 @@ export const ToastFileSaved = ({
onOpenFile, onOpenFile,
}: PropsType): JSX.Element => { }: PropsType): JSX.Element => {
return ( return (
<Toast onClick={onOpenFile} onClose={onClose}> <Toast
onClose={onClose}
toastAction={{
label: i18n('attachmentSavedShow'),
onClick: onOpenFile,
}}
>
{i18n('attachmentSaved')} {i18n('attachmentSaved')}
</Toast> </Toast>
); );

View file

@ -14,9 +14,5 @@ export const ToastReactionFailed = ({
i18n, i18n,
onClose, onClose,
}: PropsType): JSX.Element => { }: PropsType): JSX.Element => {
return ( return <Toast onClose={onClose}>{i18n('Reactions--error')}</Toast>;
<Toast onClick={onClose} onClose={onClose}>
{i18n('Reactions--error')}
</Toast>
);
}; };

View file

@ -12,7 +12,10 @@ import { ToastCannotMixImageAndNonImageAttachments } from '../components/ToastCa
import { ToastCannotStartGroupCall } from '../components/ToastCannotStartGroupCall'; import { ToastCannotStartGroupCall } from '../components/ToastCannotStartGroupCall';
import { ToastCaptchaFailed } from '../components/ToastCaptchaFailed'; import { ToastCaptchaFailed } from '../components/ToastCaptchaFailed';
import { ToastCaptchaSolved } from '../components/ToastCaptchaSolved'; import { ToastCaptchaSolved } from '../components/ToastCaptchaSolved';
import { ToastConversationArchived } from '../components/ToastConversationArchived'; import {
ToastConversationArchived,
ToastPropsType as ToastConversationArchivedPropsType,
} from '../components/ToastConversationArchived';
import { ToastConversationMarkedUnread } from '../components/ToastConversationMarkedUnread'; import { ToastConversationMarkedUnread } from '../components/ToastConversationMarkedUnread';
import { ToastConversationUnarchived } from '../components/ToastConversationUnarchived'; import { ToastConversationUnarchived } from '../components/ToastConversationUnarchived';
import { ToastDangerousFileType } from '../components/ToastDangerousFileType'; import { ToastDangerousFileType } from '../components/ToastDangerousFileType';
@ -59,7 +62,10 @@ export function showToast(
export function showToast(Toast: typeof ToastCannotStartGroupCall): void; export function showToast(Toast: typeof ToastCannotStartGroupCall): void;
export function showToast(Toast: typeof ToastCaptchaFailed): void; export function showToast(Toast: typeof ToastCaptchaFailed): void;
export function showToast(Toast: typeof ToastCaptchaSolved): void; export function showToast(Toast: typeof ToastCaptchaSolved): void;
export function showToast(Toast: typeof ToastConversationArchived): void; export function showToast(
Toast: typeof ToastConversationArchived,
props: ToastConversationArchivedPropsType
): void;
export function showToast(Toast: typeof ToastConversationMarkedUnread): void; export function showToast(Toast: typeof ToastConversationMarkedUnread): void;
export function showToast(Toast: typeof ToastConversationUnarchived): void; export function showToast(Toast: typeof ToastConversationUnarchived): void;
export function showToast(Toast: typeof ToastDangerousFileType): void; export function showToast(Toast: typeof ToastDangerousFileType): void;

View file

@ -472,7 +472,12 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
this.model.setArchived(true); this.model.setArchived(true);
this.model.trigger('unload', 'archive'); this.model.trigger('unload', 'archive');
showToast(ToastConversationArchived); showToast(ToastConversationArchived, {
undo: () => {
this.model.setArchived(false);
this.openConversation(this.model.get('id'));
},
});
}, },
onMarkUnread: () => { onMarkUnread: () => {
this.model.setMarkedUnread(true); this.model.setMarkedUnread(true);