diff --git a/ts/components/CallingLobby.stories.tsx b/ts/components/CallingLobby.stories.tsx
index adff3383014d..e8e70d25e709 100644
--- a/ts/components/CallingLobby.stories.tsx
+++ b/ts/components/CallingLobby.stories.tsx
@@ -10,7 +10,7 @@ import type { Meta } from '@storybook/react';
import { AvatarColors } from '../types/Colors';
import type { ConversationType } from '../state/ducks/conversations';
import type { PropsType } from './CallingLobby';
-import { CallingLobby } from './CallingLobby';
+import { CallingLobby as UnwrappedCallingLobby } from './CallingLobby';
import { setupI18n } from '../util/setupI18n';
import { generateAci } from '../types/ServiceId';
import enMessages from '../../_locales/en/messages.json';
@@ -18,6 +18,7 @@ import {
getDefaultConversation,
getDefaultConversationWithServiceId,
} from '../test-both/helpers/getDefaultConversation';
+import { CallingToastProvider } from './CallingToast';
const i18n = setupI18n('en', enMessages);
@@ -74,6 +75,14 @@ const createProps = (overrideProps: Partial
= {}): PropsType => {
};
};
+function CallingLobby(props: ReturnType) {
+ return (
+
+
+
+ );
+}
+
const fakePeekedParticipant = (conversationProps: Partial) =>
getDefaultConversationWithServiceId({
...conversationProps,
diff --git a/ts/components/CallingLobby.tsx b/ts/components/CallingLobby.tsx
index 48431b8639d4..5d911ef90ce7 100644
--- a/ts/components/CallingLobby.tsx
+++ b/ts/components/CallingLobby.tsx
@@ -13,7 +13,6 @@ import { CallingButton, CallingButtonType } from './CallingButton';
import { TooltipPlacement } from './Tooltip';
import { CallBackgroundBlur } from './CallBackgroundBlur';
import { CallingHeader } from './CallingHeader';
-import { CallingToast, DEFAULT_LIFETIME } from './CallingToast';
import { CallingPreCallInfo, RingMode } from './CallingPreCallInfo';
import {
CallingLobbyJoinButton,
@@ -23,6 +22,7 @@ import type { LocalizerType } from '../types/Util';
import { useIsOnline } from '../hooks/useIsOnline';
import * as KeyboardLayout from '../services/keyboardLayout';
import type { ConversationType } from '../state/ducks/conversations';
+import { useCallingToasts } from './CallingToast';
export type PropsType = {
availableCameras: Array;
@@ -89,21 +89,6 @@ export function CallingLobby({
toggleSettings,
outgoingRing,
}: PropsType): JSX.Element {
- const [isMutedToastVisible, setIsMutedToastVisible] = React.useState(
- !hasLocalAudio
- );
- React.useEffect(() => {
- if (!isMutedToastVisible) {
- return;
- }
- const timeout = setTimeout(() => {
- setIsMutedToastVisible(false);
- }, DEFAULT_LIFETIME);
- return () => {
- clearTimeout(timeout);
- };
- }, [isMutedToastVisible]);
-
const localVideoRef = React.useRef(null);
const shouldShowLocalVideo = hasLocalVideo && availableCameras.length > 0;
@@ -158,6 +143,8 @@ export function CallingLobby({
const [isCallConnecting, setIsCallConnecting] = React.useState(false);
+ useWasInitiallyMutedToast(hasLocalAudio, i18n);
+
// eslint-disable-next-line no-nested-ternary
const videoButtonType = hasLocalVideo
? CallingButtonType.VIDEO_ON
@@ -231,15 +218,6 @@ export function CallingLobby({
/>
)}
- setIsMutedToastVisible(false)}
- >
- {i18n(
- 'icu:calling__lobby-automatically-muted-because-there-are-a-lot-of-people'
- )}
-
-
);
}
+
+function useWasInitiallyMutedToast(
+ hasLocalAudio: boolean,
+ i18n: LocalizerType
+) {
+ const [wasInitiallyMuted] = React.useState(!hasLocalAudio);
+ const { showToast, hideToast } = useCallingToasts();
+ const INITIALLY_MUTED_KEY = 'initially-muted-group-size';
+ React.useEffect(() => {
+ if (wasInitiallyMuted) {
+ showToast({
+ key: INITIALLY_MUTED_KEY,
+ content: i18n(
+ 'icu:calling__lobby-automatically-muted-because-there-are-a-lot-of-people'
+ ),
+ autoClose: true,
+ dismissable: true,
+ onlyShowOnce: true,
+ });
+ }
+ }, [wasInitiallyMuted, i18n, showToast]);
+
+ // Hide this toast if the user unmutes
+ React.useEffect(() => {
+ if (wasInitiallyMuted && hasLocalAudio) {
+ hideToast(INITIALLY_MUTED_KEY);
+ }
+ }, [hideToast, wasInitiallyMuted, hasLocalAudio]);
+}
diff --git a/ts/components/CallingToast.tsx b/ts/components/CallingToast.tsx
index 64ad1bc6f53c..334438e12624 100644
--- a/ts/components/CallingToast.tsx
+++ b/ts/components/CallingToast.tsx
@@ -1,32 +1,324 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
-import React from 'react';
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+} from 'react';
+import { useFocusWithin, useHover, mergeProps } from 'react-aria';
+import { createPortal } from 'react-dom';
+import { useTransition, animated } from '@react-spring/web';
import classNames from 'classnames';
+import { v4 as uuid } from 'uuid';
+import { useIsMounted } from '../hooks/useIsMounted';
+import type { LocalizerType } from '../types/I18N';
-type PropsType = {
- isVisible: boolean;
- onClick: () => unknown;
- children?: JSX.Element | string;
+const DEFAULT_LIFETIME = 5000;
+
+export type CallingToastType = {
+ // If key is provided, calls to showToast will be idempotent; otherwise an
+ // auto-generated key will be returned
+ key?: string;
+ content: JSX.Element | string;
+ autoClose: boolean;
+ dismissable?: boolean;
+} & (
+ | {
+ // key must be provided if the toast is 'only-show-once'
+ key: string;
+ onlyShowOnce: true;
+ }
+ | {
+ onlyShowOnce?: never;
+ }
+);
+
+type CallingToastStateType = CallingToastType & {
+ key: string;
};
-export const DEFAULT_LIFETIME = 5000;
+type CallingToastContextType = {
+ showToast: (toast: CallingToastType) => string;
+ hideToast: (id: string) => void;
+};
-export function CallingToast({
- isVisible,
- onClick,
+type TimeoutType =
+ | { status: 'active'; timeout: NodeJS.Timeout; endAt: number }
+ | { status: 'paused'; remaining: number };
+
+const CallingToastContext = createContext(null);
+
+export function CallingToastProvider({
+ i18n,
children,
-}: PropsType): JSX.Element {
+}: {
+ i18n: LocalizerType;
+ children: React.ReactNode;
+}): JSX.Element {
+ const [toasts, setToasts] = React.useState>([]);
+ const timeouts = React.useRef