diff --git a/_locales/en/messages.json b/_locales/en/messages.json index d959ae6a59..1c64e7f0af 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/stylesheets/components/Toast.scss b/stylesheets/components/Toast.scss index 6f591f633c..ccad1d94e1 100644 --- a/stylesheets/components/Toast.scss +++ b/stylesheets/components/Toast.scss @@ -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; + } + } } } diff --git a/ts/background.ts b/ts/background.ts index 33132acd19..9e2b5a2f6a 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -1428,7 +1428,15 @@ export async function startApp(): Promise { ) { 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. diff --git a/ts/components/Toast.tsx b/ts/components/Toast.tsx index b63739796d..44f5859ed8 100644 --- a/ts/components/Toast.tsx +++ b/ts/components/Toast.tsx @@ -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(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) { - if (ev.key === 'Enter' || ev.key === ' ') { - onClick(); - if (!disableCloseOnClick) { - onClose(); - } - } - }, - }; - } - return root ? createPortal(
{ + if (!disableCloseOnClick) { + onClose(); + } + }} + onKeyDown={(ev: KeyboardEvent) => { + if (ev.key === 'Enter' || ev.key === ' ') { + if (!disableCloseOnClick) { + onClose(); + } + } + }} + role="button" + tabIndex={0} > - {children} +
{children}
+ {toastAction && ( +
) => { + ev.stopPropagation(); + ev.preventDefault(); + toastAction.onClick(); + }} + onKeyDown={(ev: KeyboardEvent) => { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.stopPropagation(); + ev.preventDefault(); + toastAction.onClick(); + } + }} + ref={focusRef} + role="button" + tabIndex={0} + > + {toastAction.label} +
+ )}
, root ) diff --git a/ts/components/ToastConversationArchived.stories.tsx b/ts/components/ToastConversationArchived.stories.tsx index 14374e5cd6..58bf5cc342 100644 --- a/ts/components/ToastConversationArchived.stories.tsx +++ b/ts/components/ToastConversationArchived.stories.tsx @@ -14,6 +14,7 @@ const i18n = setupI18n('en', enMessages); const defaultProps = { i18n, onClose: action('onClose'), + undo: action('undo'), }; const story = storiesOf('Components/ToastConversationArchived', module); diff --git a/ts/components/ToastConversationArchived.tsx b/ts/components/ToastConversationArchived.tsx index 3d278c3b59..1b49047dbf 100644 --- a/ts/components/ToastConversationArchived.tsx +++ b/ts/components/ToastConversationArchived.tsx @@ -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 {i18n('conversationArchived')}; + return ( + { + undo(); + onClose(); + }, + }} + onClose={onClose} + > + {i18n('conversationArchived')} + + ); }; diff --git a/ts/components/ToastDecryptionError.tsx b/ts/components/ToastDecryptionError.tsx index d0d708c8b3..c933205fc0 100644 --- a/ts/components/ToastDecryptionError.tsx +++ b/ts/components/ToastDecryptionError.tsx @@ -23,8 +23,11 @@ export const ToastDecryptionError = ({ {i18n('decryptionErrorToast')} diff --git a/ts/components/ToastFileSaved.tsx b/ts/components/ToastFileSaved.tsx index afe3a2e43d..e0da4ccc80 100644 --- a/ts/components/ToastFileSaved.tsx +++ b/ts/components/ToastFileSaved.tsx @@ -20,7 +20,13 @@ export const ToastFileSaved = ({ onOpenFile, }: PropsType): JSX.Element => { return ( - + {i18n('attachmentSaved')} ); diff --git a/ts/components/ToastReactionFailed.tsx b/ts/components/ToastReactionFailed.tsx index cbc0371fc2..18a1dcf192 100644 --- a/ts/components/ToastReactionFailed.tsx +++ b/ts/components/ToastReactionFailed.tsx @@ -14,9 +14,5 @@ export const ToastReactionFailed = ({ i18n, onClose, }: PropsType): JSX.Element => { - return ( - - {i18n('Reactions--error')} - - ); + return {i18n('Reactions--error')}; }; diff --git a/ts/util/showToast.tsx b/ts/util/showToast.tsx index 9db24a9588..0b9d5d35d9 100644 --- a/ts/util/showToast.tsx +++ b/ts/util/showToast.tsx @@ -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; diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 507227970b..68f64d2457 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -472,7 +472,12 @@ export class ConversationView extends window.Backbone.View { 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);