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;
};