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": {
"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."
},
"decryptionErrorToastAction": {
"message": "Submit log",
"description": "Label for the decryption error toast button"
},
"oneNonImageAtATimeToast": {
"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"
@ -950,14 +954,12 @@
"description": "Shown in a dialog to inform user that we experienced an unrecoverable error"
},
"attachmentSaved": {
"message": "Attachment saved. Click to show in folder.",
"description": "Shown after user selects to save to downloads",
"placeholders": {
"name": {
"content": "$1",
"example": "proof.jpg"
}
}
"message": "Attachment saved.",
"description": "Shown after user selects to save to downloads"
},
"attachmentSavedShow": {
"message": "Show in folder",
"description": "Button label for showing the attachment in your file system"
},
"you": {
"message": "You",
@ -2949,6 +2951,10 @@
"message": "Conversation archived",
"description": "A toast that shows up when user archives a conversation"
},
"conversationArchivedUndo": {
"message": "Undo",
"description": "Undo button for archiving a conversation"
},
"conversationReturnedToInbox": {
"message": "Conversation returned to inbox",
"description": "A toast that shows up when the user unarchives a conversation"

View file

@ -4,10 +4,12 @@
.Toast {
@include font-body-2;
align-items: center;
border-radius: 4px;
bottom: 62px;
display: flex;
justify-content: space-between;
left: 50%;
padding: 8px 16px;
position: absolute;
text-align: center;
transform: translate(-50%, 0);
@ -15,18 +17,40 @@
z-index: 100;
@include light-theme {
background-color: $color-gray-75;
background-color: $color-gray-80;
color: $color-white;
box-shadow: 0 4px 16px 0 $color-black-alpha-20,
0 0 0 0.5px $color-black-alpha-05;
}
@include dark-theme {
background-color: $color-gray-45;
color: $color-white;
background-color: $color-gray-75;
color: $color-gray-05;
box-shadow: 0 4px 16px 0 $color-white-alpha-20;
}
&--clickable {
&__content {
padding: 8px 12px;
}
&__button {
@include font-body-2-bold;
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.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
// '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
// 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 { createPortal } from 'react-dom';
import { onTimeout, removeTimeout } from '../services/timers';
import { useRestoreFocus } from '../hooks/useRestoreFocus';
export type PropsType = {
autoDismissDisabled?: boolean;
children: ReactNode;
className?: string;
disableCloseOnClick?: boolean;
onClick?: () => unknown;
onClose: () => unknown;
timeout?: number;
toastAction?: {
label: string;
onClick: () => unknown;
};
};
export const Toast = ({
@ -21,11 +25,12 @@ export const Toast = ({
children,
className,
disableCloseOnClick = false,
onClick,
onClose,
timeout = 8000,
toastAction,
}: PropsType): JSX.Element | null => {
const [root, setRoot] = React.useState<HTMLElement | null>(null);
const [focusRef] = useRestoreFocus();
useEffect(() => {
const div = document.createElement('div');
@ -52,38 +57,49 @@ export const Toast = ({
};
}, [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
? createPortal(
<div
className={classNames(
'Toast',
onClick ? 'Toast--clickable' : null,
className
)}
{...interactivityProps}
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}
>
{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>,
root
)

View file

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

View file

@ -5,14 +5,32 @@ import React from 'react';
import { LocalizerType } from '../types/Util';
import { Toast } from './Toast';
type PropsType = {
export type ToastPropsType = {
undo: () => unknown;
};
export type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
};
} & ToastPropsType;
export const ToastConversationArchived = ({
i18n,
onClose,
undo,
}: 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
autoDismissDisabled
className="decryption-error"
onClick={onShowDebugLog}
onClose={onClose}
toastAction={{
label: i18n('decryptionErrorToastAction'),
onClick: onShowDebugLog,
}}
>
{i18n('decryptionErrorToast')}
</Toast>

View file

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

View file

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

View file

@ -12,7 +12,10 @@ import { ToastCannotMixImageAndNonImageAttachments } from '../components/ToastCa
import { ToastCannotStartGroupCall } from '../components/ToastCannotStartGroupCall';
import { ToastCaptchaFailed } from '../components/ToastCaptchaFailed';
import { ToastCaptchaSolved } from '../components/ToastCaptchaSolved';
import { ToastConversationArchived } from '../components/ToastConversationArchived';
import {
ToastConversationArchived,
ToastPropsType as ToastConversationArchivedPropsType,
} from '../components/ToastConversationArchived';
import { ToastConversationMarkedUnread } from '../components/ToastConversationMarkedUnread';
import { ToastConversationUnarchived } from '../components/ToastConversationUnarchived';
import { ToastDangerousFileType } from '../components/ToastDangerousFileType';
@ -59,7 +62,10 @@ export function showToast(
export function showToast(Toast: typeof ToastCannotStartGroupCall): void;
export function showToast(Toast: typeof ToastCaptchaFailed): 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 ToastConversationUnarchived): 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.trigger('unload', 'archive');
showToast(ToastConversationArchived);
showToast(ToastConversationArchived, {
undo: () => {
this.model.setArchived(false);
this.openConversation(this.model.get('id'));
},
});
},
onMarkUnread: () => {
this.model.setMarkedUnread(true);