diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html index 98c5172db..37148d33d 100644 --- a/.storybook/preview-head.html +++ b/.storybook/preview-head.html @@ -23,7 +23,7 @@ }; window.SignalContext = { activeWindowService: { - isActive: () => true; + isActive: () => true, registerForActive: noop, unregisterForActive: noop, registerForChange: noop, diff --git a/_locales/en/messages.json b/_locales/en/messages.json index b94d383e5..f0c0f9013 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -7161,6 +7161,14 @@ } } }, + "Stories__toast--sending-reply": { + "message": "Sending reply...", + "description": "Toast message" + }, + "Stories__toast--sending-reaction": { + "message": "Sending reaction...", + "description": "Toast message" + }, "StoryViewer__pause": { "message": "Pause", "description": "Aria label for pausing a story" diff --git a/ts/background.ts b/ts/background.ts index ce781611a..bf23cbc9f 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -1108,6 +1108,7 @@ export async function startApp(): Promise { actionCreators.storyDistributionLists, store.dispatch ), + toast: bindActionCreators(actionCreators.toast, store.dispatch), updates: bindActionCreators(actionCreators.updates, store.dispatch), user: bindActionCreators(actionCreators.user, store.dispatch), }; diff --git a/ts/components/App.tsx b/ts/components/App.tsx index 3cc08a379..1ac4b0b32 100644 --- a/ts/components/App.tsx +++ b/ts/components/App.tsx @@ -6,18 +6,20 @@ import React, { useEffect } from 'react'; import { Globals } from '@react-spring/web'; import classNames from 'classnames'; +import type { ExecuteMenuRoleType } from './TitleBarContainer'; +import type { LocaleMessagesType } from '../types/I18N'; +import type { MenuOptionsType, MenuActionType } from '../types/menu'; +import type { SelectedStoryDataType } from '../state/ducks/stories'; +import type { ToastType } from '../state/ducks/toast'; import { AppViewType } from '../state/ducks/app'; import { Inbox } from './Inbox'; import { SmartInstallScreen } from '../state/smart/InstallScreen'; import { StandaloneRegistration } from './StandaloneRegistration'; import { ThemeType } from '../types/Util'; -import type { LocaleMessagesType } from '../types/I18N'; +import { TitleBarContainer } from './TitleBarContainer'; +import { ToastManager } from './ToastManager'; import { usePageVisibility } from '../hooks/usePageVisibility'; import { useReducedMotion } from '../hooks/useReducedMotion'; -import type { MenuOptionsType, MenuActionType } from '../types/menu'; -import { TitleBarContainer } from './TitleBarContainer'; -import type { ExecuteMenuRoleType } from './TitleBarContainer'; -import type { SelectedStoryDataType } from '../state/ducks/stories'; type PropsType = { appView: AppViewType; @@ -45,6 +47,8 @@ type PropsType = { executeMenuRole: ExecuteMenuRoleType; executeMenuAction: (action: MenuActionType) => void; titleBarDoubleClick: () => void; + toastType?: ToastType; + hideToast: () => unknown; } & ComponentProps; export const App = ({ @@ -56,6 +60,7 @@ export const App = ({ getPreferredBadge, hasInitialLoadCompleted, hideMenuBar, + hideToast, i18n, isCustomizingPreferredReactions, isFullScreen, @@ -81,6 +86,7 @@ export const App = ({ showWhatsNewModal, theme, titleBarDoubleClick, + toastType, verifyConversationsStoppingSend, }: PropsType): JSX.Element => { let contents; @@ -171,6 +177,7 @@ export const App = ({ 'dark-theme': theme === ThemeType.dark, })} > + {renderGlobalModalContainer()} {renderCallManager()} {isShowingStoriesView && renderStories()} diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index 8a1fbda75..d6f474f30 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -11,6 +11,7 @@ import type { EmojiPickDataType } from './emoji/EmojiPicker'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { RenderEmojiPickerProps } from './conversation/ReactionPicker'; import type { ReplyStateType, StoryViewType } from '../types/Stories'; +import type { ShowToastActionCreatorType } from '../state/ducks/toast'; import type { ViewStoryActionCreatorType } from '../state/ducks/stories'; import * as log from '../logging/log'; import { AnimatedEmojiGalore } from './AnimatedEmojiGalore'; @@ -24,6 +25,7 @@ import { StoryImage } from './StoryImage'; import { StoryViewDirectionType, StoryViewModeType } from '../types/Stories'; import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal'; import { Theme } from '../util/theme'; +import { ToastType } from '../state/ducks/toast'; import { getAvatarColor } from '../types/Colors'; import { getStoryBackground } from '../util/getStoryBackground'; import { getStoryDuration } from '../util/getStoryDuration'; @@ -66,6 +68,7 @@ export type PropsType = { recentEmojis?: Array; renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element; replyState?: ReplyStateType; + showToast: ShowToastActionCreatorType; skinTone?: number; story: StoryViewType; storyViewMode?: StoryViewModeType; @@ -105,6 +108,7 @@ export const StoryViewer = ({ recentEmojis, renderEmojiPicker, replyState, + showToast, skinTone, story, storyViewMode, @@ -650,12 +654,14 @@ export const StoryViewer = ({ onReactToStory(emoji, story); setHasReplyModal(false); setReactionEmoji(emoji); + showToast(ToastType.StoryReact); }} onReply={(message, mentions, replyTimestamp) => { if (!isGroupStory) { setHasReplyModal(false); } onReplyToStory(message, mentions, replyTimestamp, story); + showToast(ToastType.StoryReply); }} onSetSkinTone={onSetSkinTone} onTextTooLong={onTextTooLong} diff --git a/ts/components/ToastManager.stories.tsx b/ts/components/ToastManager.stories.tsx new file mode 100644 index 000000000..06bd47674 --- /dev/null +++ b/ts/components/ToastManager.stories.tsx @@ -0,0 +1,52 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Meta, Story } from '@storybook/react'; +import React from 'react'; + +import type { PropsType } from './ToastManager'; +import enMessages from '../../_locales/en/messages.json'; +import { ToastManager } from './ToastManager'; +import { ToastType } from '../state/ducks/toast'; +import { setupI18n } from '../util/setupI18n'; + +const i18n = setupI18n('en', enMessages); + +export default { + title: 'Components/ToastManager', + component: ToastManager, + argTypes: { + hideToast: { action: true }, + i18n: { + defaultValue: i18n, + }, + toastType: { + defaultValue: undefined, + }, + }, +} as Meta; + +const Template: Story = args => ; + +export const UndefinedToast = Template.bind({}); +UndefinedToast.args = {}; + +export const InvalidToast = Template.bind({}); +InvalidToast.args = { + toastType: 'this is a toast that does not exist' as ToastType, +}; + +export const StoryReact = Template.bind({}); +StoryReact.args = { + toastType: ToastType.StoryReact, +}; + +export const StoryReply = Template.bind({}); +StoryReply.args = { + toastType: ToastType.StoryReply, +}; + +export const MessageBodyTooLong = Template.bind({}); +MessageBodyTooLong.args = { + toastType: ToastType.MessageBodyTooLong, +}; diff --git a/ts/components/ToastManager.tsx b/ts/components/ToastManager.tsx new file mode 100644 index 000000000..554087c8a --- /dev/null +++ b/ts/components/ToastManager.tsx @@ -0,0 +1,49 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import type { LocalizerType } from '../types/Util'; +import { SECOND } from '../util/durations'; +import { Toast } from './Toast'; +import { ToastMessageBodyTooLong } from './ToastMessageBodyTooLong'; +import { ToastType } from '../state/ducks/toast'; +import { strictAssert } from '../util/assert'; + +export type PropsType = { + hideToast: () => unknown; + i18n: LocalizerType; + toastType?: ToastType; +}; + +export const ToastManager = ({ + hideToast, + i18n, + toastType, +}: PropsType): JSX.Element | null => { + if (toastType === ToastType.MessageBodyTooLong) { + return ; + } + + if (toastType === ToastType.StoryReact) { + return ( + + {i18n('Stories__toast--sending-reaction')} + + ); + } + + if (toastType === ToastType.StoryReply) { + return ( + + {i18n('Stories__toast--sending-reply')} + + ); + } + + strictAssert( + toastType === undefined, + `Unhandled toast of type: ${toastType}` + ); + + return null; +}; diff --git a/ts/state/actions.ts b/ts/state/actions.ts index 57162632d..ccf4c1c95 100644 --- a/ts/state/actions.ts +++ b/ts/state/actions.ts @@ -21,6 +21,7 @@ import { actions as search } from './ducks/search'; import { actions as stickers } from './ducks/stickers'; import { actions as stories } from './ducks/stories'; import { actions as storyDistributionLists } from './ducks/storyDistributionLists'; +import { actions as toast } from './ducks/toast'; import { actions as updates } from './ducks/updates'; import { actions as user } from './ducks/user'; import type { ReduxActions } from './types'; @@ -46,6 +47,7 @@ export const actionCreators: ReduxActions = { stickers, stories, storyDistributionLists, + toast, updates, user, }; @@ -71,6 +73,7 @@ export const mapDispatchToProps = { ...stickers, ...stories, ...storyDistributionLists, + ...toast, ...updates, ...user, }; diff --git a/ts/state/ducks/toast.ts b/ts/state/ducks/toast.ts new file mode 100644 index 000000000..86ef093b9 --- /dev/null +++ b/ts/state/ducks/toast.ts @@ -0,0 +1,85 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { useBoundActions } from '../../hooks/useBoundActions'; + +export enum ToastType { + MessageBodyTooLong = 'MessageBodyTooLong', + StoryReact = 'StoryReact', + StoryReply = 'StoryReply', +} + +// State + +export type ToastStateType = { + toastType?: ToastType; +}; + +// Actions + +const HIDE_TOAST = 'toast/HIDE_TOAST'; +const SHOW_TOAST = 'toast/SHOW_TOAST'; + +type HideToastActionType = { + type: typeof HIDE_TOAST; +}; + +type ShowToastActionType = { + type: typeof SHOW_TOAST; + payload: ToastType; +}; + +export type ToastActionType = HideToastActionType | ShowToastActionType; + +// Action Creators + +function hideToast(): HideToastActionType { + return { + type: HIDE_TOAST, + }; +} + +export type ShowToastActionCreatorType = ( + toastType: ToastType +) => ShowToastActionType; + +const showToast: ShowToastActionCreatorType = toastType => { + return { + type: SHOW_TOAST, + payload: toastType, + }; +}; + +export const actions = { + hideToast, + showToast, +}; + +export const useToastActions = (): typeof actions => useBoundActions(actions); + +// Reducer + +export function getEmptyState(): ToastStateType { + return {}; +} + +export function reducer( + state: Readonly = getEmptyState(), + action: Readonly +): ToastStateType { + if (action.type === HIDE_TOAST) { + return { + ...state, + toastType: undefined, + }; + } + + if (action.type === SHOW_TOAST) { + return { + ...state, + toastType: action.payload, + }; + } + + return state; +} diff --git a/ts/state/getInitialState.ts b/ts/state/getInitialState.ts index a8a6fa7df..c5e90f65f 100644 --- a/ts/state/getInitialState.ts +++ b/ts/state/getInitialState.ts @@ -18,6 +18,7 @@ import { getEmptyState as safetyNumber } from './ducks/safetyNumber'; import { getEmptyState as search } from './ducks/search'; import { getEmptyState as getStoriesEmptyState } from './ducks/stories'; import { getEmptyState as getStoryDistributionListsEmptyState } from './ducks/storyDistributionLists'; +import { getEmptyState as getToastEmptyState } from './ducks/toast'; import { getEmptyState as updates } from './ducks/updates'; import { getEmptyState as user } from './ducks/user'; @@ -115,6 +116,7 @@ export function getInitialState({ ...getStoryDistributionListsEmptyState(), distributionLists: storyDistributionLists || [], }, + toast: getToastEmptyState(), updates: updates(), user: { ...user(), diff --git a/ts/state/reducer.ts b/ts/state/reducer.ts index 4465cd60f..85987e412 100644 --- a/ts/state/reducer.ts +++ b/ts/state/reducer.ts @@ -24,6 +24,7 @@ import { reducer as search } from './ducks/search'; import { reducer as stickers } from './ducks/stickers'; import { reducer as stories } from './ducks/stories'; import { reducer as storyDistributionLists } from './ducks/storyDistributionLists'; +import { reducer as toast } from './ducks/toast'; import { reducer as updates } from './ducks/updates'; import { reducer as user } from './ducks/user'; @@ -49,6 +50,7 @@ export const reducer = combineReducers({ stickers, stories, storyDistributionLists, + toast, updates, user, }); diff --git a/ts/state/smart/App.tsx b/ts/state/smart/App.tsx index 0a14086d6..0b08d99d1 100644 --- a/ts/state/smart/App.tsx +++ b/ts/state/smart/App.tsx @@ -89,6 +89,7 @@ const mapStateToProps = (state: StateType) => { titleBarDoubleClick: (): void => { window.titleBarDoubleClick(); }, + toastType: state.toast.toastType, }; }; diff --git a/ts/state/smart/StoryViewer.tsx b/ts/state/smart/StoryViewer.tsx index 040c6be8e..5e98e64f4 100644 --- a/ts/state/smart/StoryViewer.tsx +++ b/ts/state/smart/StoryViewer.tsx @@ -10,7 +10,7 @@ import type { StoryViewModeType } from '../../types/Stories'; import type { StateType } from '../reducer'; import type { SelectedStoryDataType } from '../ducks/stories'; import { StoryViewer } from '../../components/StoryViewer'; -import { ToastMessageBodyTooLong } from '../../components/ToastMessageBodyTooLong'; +import { ToastType, useToastActions } from '../ducks/toast'; import { getConversationSelector } from '../selectors/conversations'; import { getEmojiSkinTone, @@ -26,7 +26,6 @@ import { getStoryView, } from '../selectors/stories'; import { renderEmojiPicker } from './renderEmojiPicker'; -import { showToast } from '../../util/showToast'; import { strictAssert } from '../../util/assert'; import { useActions as useEmojisActions } from '../ducks/emojis'; import { useActions as useItemsActions } from '../ducks/items'; @@ -39,6 +38,7 @@ export function SmartStoryViewer(): JSX.Element | null { const { onSetSkinTone, toggleHasAllStoriesMuted } = useItemsActions(); const { onUseEmoji } = useEmojisActions(); const { showConversation, toggleHideStories } = useConversationsActions(); + const { showToast } = useToastActions(); const i18n = useSelector(getIntl); const getPreferredBadge = useSelector(getPreferredBadgeSelector); @@ -100,12 +100,13 @@ export function SmartStoryViewer(): JSX.Element | null { ); }} onSetSkinTone={onSetSkinTone} - onTextTooLong={() => showToast(ToastMessageBodyTooLong)} + onTextTooLong={() => showToast(ToastType.MessageBodyTooLong)} onUseEmoji={onUseEmoji} preferredReactionEmoji={preferredReactionEmoji} recentEmojis={recentEmojis} renderEmojiPicker={renderEmojiPicker} replyState={replyState} + showToast={showToast} skinTone={skinTone} story={storyView} storyViewMode={storyViewMode} diff --git a/ts/state/types.ts b/ts/state/types.ts index df590f0a3..16da874bd 100644 --- a/ts/state/types.ts +++ b/ts/state/types.ts @@ -21,6 +21,7 @@ import type { actions as search } from './ducks/search'; import type { actions as stickers } from './ducks/stickers'; import type { actions as stories } from './ducks/stories'; import type { actions as storyDistributionLists } from './ducks/storyDistributionLists'; +import type { actions as toast } from './ducks/toast'; import type { actions as updates } from './ducks/updates'; import type { actions as user } from './ducks/user'; @@ -45,6 +46,7 @@ export type ReduxActions = { stickers: typeof stickers; stories: typeof stories; storyDistributionLists: typeof storyDistributionLists; + toast: typeof toast; updates: typeof updates; user: typeof user; };