Centralize calling toasts and add imperative API
This commit is contained in:
parent
dd45b08b0f
commit
0c896ca1f2
11 changed files with 568 additions and 158 deletions
|
@ -1652,6 +1652,10 @@
|
||||||
"messageformat": "Microphone muted due to the size of the call",
|
"messageformat": "Microphone muted due to the size of the call",
|
||||||
"description": "Shown in a call lobby toast if there are a lot of people already on the call"
|
"description": "Shown in a call lobby toast if there are a lot of people already on the call"
|
||||||
},
|
},
|
||||||
|
"icu:calling__toasts--aria-label": {
|
||||||
|
"messageformat": "Call notifications",
|
||||||
|
"description": "Aria label for region of toasts shown during a call (for, e.g. reconnecting, person joined, audio device changed notifications)"
|
||||||
|
},
|
||||||
"icu:calling__call-is-full": {
|
"icu:calling__call-is-full": {
|
||||||
"messageformat": "Call is full",
|
"messageformat": "Call is full",
|
||||||
"description": "Text in the call lobby when you can't join because the call is full"
|
"description": "Text in the call lobby when you can't join because the call is full"
|
||||||
|
|
|
@ -1,25 +1,39 @@
|
||||||
// Copyright 2022 Signal Messenger, LLC
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
.CallingToast {
|
.CallingToasts {
|
||||||
@include button-reset();
|
position: fixed;
|
||||||
@include font-body-1-bold;
|
z-index: $z-index-toast;
|
||||||
background-color: $color-gray-75;
|
top: 32px;
|
||||||
border-radius: 8px;
|
width: 100%;
|
||||||
color: $color-white;
|
display: flex;
|
||||||
max-width: 80%;
|
justify-content: center;
|
||||||
opacity: 1;
|
}
|
||||||
padding: 12px;
|
|
||||||
position: absolute;
|
|
||||||
text-align: center;
|
|
||||||
top: 12px;
|
|
||||||
transform: translateY(0);
|
|
||||||
transition: transform 200ms ease-out, opacity 200ms ease-out;
|
|
||||||
user-select: none;
|
|
||||||
z-index: $z-index-above-above-base;
|
|
||||||
|
|
||||||
&--hidden {
|
.CallingToasts__inner {
|
||||||
opacity: 0;
|
display: flex;
|
||||||
transform: translateY(-7px);
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallingToast--dismissable {
|
||||||
|
@include button-reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallingToast {
|
||||||
|
@include font-body-1;
|
||||||
|
background-color: $color-gray-75;
|
||||||
|
border-radius: 22px;
|
||||||
|
color: $color-white;
|
||||||
|
padding-block: 11px;
|
||||||
|
padding-inline: 20px;
|
||||||
|
text-align: center;
|
||||||
|
user-select: none;
|
||||||
|
&__reconnecting {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,6 +41,7 @@ import type {
|
||||||
} from '../state/ducks/calling';
|
} from '../state/ducks/calling';
|
||||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||||
import { missingCaseError } from '../util/missingCaseError';
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
|
import { CallingToastProvider } from './CallingToast';
|
||||||
|
|
||||||
const GROUP_CALL_RING_DURATION = 60 * 1000;
|
const GROUP_CALL_RING_DURATION = 60 * 1000;
|
||||||
|
|
||||||
|
@ -439,7 +440,11 @@ export function CallManager(props: PropsType): JSX.Element | null {
|
||||||
if (activeCall) {
|
if (activeCall) {
|
||||||
// `props` should logically have an `activeCall` at this point, but TypeScript can't
|
// `props` should logically have an `activeCall` at this point, but TypeScript can't
|
||||||
// figure that out, so we pass it in again.
|
// figure that out, so we pass it in again.
|
||||||
return <ActiveCallManager {...props} activeCall={activeCall} />;
|
return (
|
||||||
|
<CallingToastProvider i18n={props.i18n}>
|
||||||
|
<ActiveCallManager {...props} activeCall={activeCall} />
|
||||||
|
</CallingToastProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// In the future, we may want to show the incoming call bar when a call is active.
|
// In the future, we may want to show the incoming call bar when a call is active.
|
||||||
|
|
|
@ -18,7 +18,7 @@ import { generateAci } from '../types/ServiceId';
|
||||||
import type { ConversationType } from '../state/ducks/conversations';
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
import { AvatarColors } from '../types/Colors';
|
import { AvatarColors } from '../types/Colors';
|
||||||
import type { PropsType } from './CallScreen';
|
import type { PropsType } from './CallScreen';
|
||||||
import { CallScreen } from './CallScreen';
|
import { CallScreen as UnwrappedCallScreen } from './CallScreen';
|
||||||
import { setupI18n } from '../util/setupI18n';
|
import { setupI18n } from '../util/setupI18n';
|
||||||
import { missingCaseError } from '../util/missingCaseError';
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
import {
|
import {
|
||||||
|
@ -27,6 +27,7 @@ import {
|
||||||
} from '../test-both/helpers/getDefaultConversation';
|
} from '../test-both/helpers/getDefaultConversation';
|
||||||
import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource';
|
import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource';
|
||||||
import enMessages from '../../_locales/en/messages.json';
|
import enMessages from '../../_locales/en/messages.json';
|
||||||
|
import { CallingToastProvider, useCallingToasts } from './CallingToast';
|
||||||
|
|
||||||
const MAX_PARTICIPANTS = 64;
|
const MAX_PARTICIPANTS = 64;
|
||||||
|
|
||||||
|
@ -171,6 +172,14 @@ const createProps = (
|
||||||
toggleSpeakerView: action('toggle-speaker-view'),
|
toggleSpeakerView: action('toggle-speaker-view'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function CallScreen(props: ReturnType<typeof createProps>): JSX.Element {
|
||||||
|
return (
|
||||||
|
<CallingToastProvider i18n={i18n}>
|
||||||
|
<UnwrappedCallScreen {...props} />
|
||||||
|
</CallingToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'Components/CallScreen',
|
title: 'Components/CallScreen',
|
||||||
argTypes: {},
|
argTypes: {},
|
||||||
|
@ -382,3 +391,59 @@ export function GroupCallSomeoneIsSharingScreenAndYoureReconnecting(): JSX.Eleme
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GroupCallSomeoneStoppedSharingScreen(): JSX.Element {
|
||||||
|
const [remoteParticipants, setRemoteParticipants] = React.useState(
|
||||||
|
allRemoteParticipants.slice(0, 5).map((participant, index) => ({
|
||||||
|
...participant,
|
||||||
|
presenting: index === 1,
|
||||||
|
sharingScreen: index === 1,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setTimeout(
|
||||||
|
() => setRemoteParticipants(allRemoteParticipants.slice(0, 5)),
|
||||||
|
1000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CallScreen
|
||||||
|
{...createProps({
|
||||||
|
callMode: CallMode.Group,
|
||||||
|
remoteParticipants,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToastEmitter(): null {
|
||||||
|
const { showToast } = useCallingToasts();
|
||||||
|
const toastCount = React.useRef(0);
|
||||||
|
React.useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const autoClose = toastCount.current % 2 === 0;
|
||||||
|
showToast({
|
||||||
|
key: Date.now().toString(),
|
||||||
|
content: `${
|
||||||
|
autoClose ? 'Disappearing' : 'Non-disappearing'
|
||||||
|
} toast sent: ${Date.now()}`,
|
||||||
|
dismissable: true,
|
||||||
|
autoClose,
|
||||||
|
});
|
||||||
|
toastCount.current += 1;
|
||||||
|
}, 1500);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [showToast]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CallScreenToastAPalooza(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<CallingToastProvider i18n={i18n}>
|
||||||
|
<UnwrappedCallScreen {...createProps()} />
|
||||||
|
<ToastEmitter />
|
||||||
|
</CallingToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -32,7 +32,10 @@ import {
|
||||||
} from '../types/Calling';
|
} from '../types/Calling';
|
||||||
import { AvatarColors } from '../types/Colors';
|
import { AvatarColors } from '../types/Colors';
|
||||||
import type { ConversationType } from '../state/ducks/conversations';
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
import { CallingToastManager } from './CallingToastManager';
|
import {
|
||||||
|
useReconnectingToast,
|
||||||
|
useScreenSharingStoppedToast,
|
||||||
|
} from './CallingToastManager';
|
||||||
import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant';
|
import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant';
|
||||||
import { GroupCallRemoteParticipants } from './GroupCallRemoteParticipants';
|
import { GroupCallRemoteParticipants } from './GroupCallRemoteParticipants';
|
||||||
import type { LocalizerType } from '../types/Util';
|
import type { LocalizerType } from '../types/Util';
|
||||||
|
@ -257,6 +260,9 @@ export function CallScreen({
|
||||||
};
|
};
|
||||||
}, [toggleAudio, toggleVideo]);
|
}, [toggleAudio, toggleVideo]);
|
||||||
|
|
||||||
|
useReconnectingToast({ activeCall, i18n });
|
||||||
|
useScreenSharingStoppedToast({ activeCall, i18n });
|
||||||
|
|
||||||
const currentPresenter = remoteParticipants.find(
|
const currentPresenter = remoteParticipants.find(
|
||||||
participant => participant.presenting
|
participant => participant.presenting
|
||||||
);
|
);
|
||||||
|
@ -476,7 +482,6 @@ export function CallScreen({
|
||||||
openSystemPreferencesAction={openSystemPreferencesAction}
|
openSystemPreferencesAction={openSystemPreferencesAction}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<CallingToastManager activeCall={activeCall} i18n={i18n} />
|
|
||||||
<div
|
<div
|
||||||
className={classNames('module-ongoing-call__header', controlsFadeClass)}
|
className={classNames('module-ongoing-call__header', controlsFadeClass)}
|
||||||
>
|
>
|
||||||
|
|
|
@ -10,7 +10,7 @@ import type { Meta } from '@storybook/react';
|
||||||
import { AvatarColors } from '../types/Colors';
|
import { AvatarColors } from '../types/Colors';
|
||||||
import type { ConversationType } from '../state/ducks/conversations';
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
import type { PropsType } from './CallingLobby';
|
import type { PropsType } from './CallingLobby';
|
||||||
import { CallingLobby } from './CallingLobby';
|
import { CallingLobby as UnwrappedCallingLobby } from './CallingLobby';
|
||||||
import { setupI18n } from '../util/setupI18n';
|
import { setupI18n } from '../util/setupI18n';
|
||||||
import { generateAci } from '../types/ServiceId';
|
import { generateAci } from '../types/ServiceId';
|
||||||
import enMessages from '../../_locales/en/messages.json';
|
import enMessages from '../../_locales/en/messages.json';
|
||||||
|
@ -18,6 +18,7 @@ import {
|
||||||
getDefaultConversation,
|
getDefaultConversation,
|
||||||
getDefaultConversationWithServiceId,
|
getDefaultConversationWithServiceId,
|
||||||
} from '../test-both/helpers/getDefaultConversation';
|
} from '../test-both/helpers/getDefaultConversation';
|
||||||
|
import { CallingToastProvider } from './CallingToast';
|
||||||
|
|
||||||
const i18n = setupI18n('en', enMessages);
|
const i18n = setupI18n('en', enMessages);
|
||||||
|
|
||||||
|
@ -74,6 +75,14 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function CallingLobby(props: ReturnType<typeof createProps>) {
|
||||||
|
return (
|
||||||
|
<CallingToastProvider i18n={i18n}>
|
||||||
|
<UnwrappedCallingLobby {...props} />
|
||||||
|
</CallingToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const fakePeekedParticipant = (conversationProps: Partial<ConversationType>) =>
|
const fakePeekedParticipant = (conversationProps: Partial<ConversationType>) =>
|
||||||
getDefaultConversationWithServiceId({
|
getDefaultConversationWithServiceId({
|
||||||
...conversationProps,
|
...conversationProps,
|
||||||
|
|
|
@ -13,7 +13,6 @@ import { CallingButton, CallingButtonType } from './CallingButton';
|
||||||
import { TooltipPlacement } from './Tooltip';
|
import { TooltipPlacement } from './Tooltip';
|
||||||
import { CallBackgroundBlur } from './CallBackgroundBlur';
|
import { CallBackgroundBlur } from './CallBackgroundBlur';
|
||||||
import { CallingHeader } from './CallingHeader';
|
import { CallingHeader } from './CallingHeader';
|
||||||
import { CallingToast, DEFAULT_LIFETIME } from './CallingToast';
|
|
||||||
import { CallingPreCallInfo, RingMode } from './CallingPreCallInfo';
|
import { CallingPreCallInfo, RingMode } from './CallingPreCallInfo';
|
||||||
import {
|
import {
|
||||||
CallingLobbyJoinButton,
|
CallingLobbyJoinButton,
|
||||||
|
@ -23,6 +22,7 @@ import type { LocalizerType } from '../types/Util';
|
||||||
import { useIsOnline } from '../hooks/useIsOnline';
|
import { useIsOnline } from '../hooks/useIsOnline';
|
||||||
import * as KeyboardLayout from '../services/keyboardLayout';
|
import * as KeyboardLayout from '../services/keyboardLayout';
|
||||||
import type { ConversationType } from '../state/ducks/conversations';
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
|
import { useCallingToasts } from './CallingToast';
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
availableCameras: Array<MediaDeviceInfo>;
|
availableCameras: Array<MediaDeviceInfo>;
|
||||||
|
@ -89,21 +89,6 @@ export function CallingLobby({
|
||||||
toggleSettings,
|
toggleSettings,
|
||||||
outgoingRing,
|
outgoingRing,
|
||||||
}: PropsType): JSX.Element {
|
}: 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 | HTMLVideoElement>(null);
|
const localVideoRef = React.useRef<null | HTMLVideoElement>(null);
|
||||||
|
|
||||||
const shouldShowLocalVideo = hasLocalVideo && availableCameras.length > 0;
|
const shouldShowLocalVideo = hasLocalVideo && availableCameras.length > 0;
|
||||||
|
@ -158,6 +143,8 @@ export function CallingLobby({
|
||||||
|
|
||||||
const [isCallConnecting, setIsCallConnecting] = React.useState(false);
|
const [isCallConnecting, setIsCallConnecting] = React.useState(false);
|
||||||
|
|
||||||
|
useWasInitiallyMutedToast(hasLocalAudio, i18n);
|
||||||
|
|
||||||
// eslint-disable-next-line no-nested-ternary
|
// eslint-disable-next-line no-nested-ternary
|
||||||
const videoButtonType = hasLocalVideo
|
const videoButtonType = hasLocalVideo
|
||||||
? CallingButtonType.VIDEO_ON
|
? CallingButtonType.VIDEO_ON
|
||||||
|
@ -231,15 +218,6 @@ export function CallingLobby({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CallingToast
|
|
||||||
isVisible={isMutedToastVisible}
|
|
||||||
onClick={() => setIsMutedToastVisible(false)}
|
|
||||||
>
|
|
||||||
{i18n(
|
|
||||||
'icu:calling__lobby-automatically-muted-because-there-are-a-lot-of-people'
|
|
||||||
)}
|
|
||||||
</CallingToast>
|
|
||||||
|
|
||||||
<CallingHeader
|
<CallingHeader
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
isGroupCall={isGroupCall}
|
isGroupCall={isGroupCall}
|
||||||
|
@ -306,3 +284,32 @@ export function CallingLobby({
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
|
@ -1,32 +1,324 @@
|
||||||
// Copyright 2022 Signal Messenger, LLC
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// 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 classNames from 'classnames';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { useIsMounted } from '../hooks/useIsMounted';
|
||||||
|
import type { LocalizerType } from '../types/I18N';
|
||||||
|
|
||||||
type PropsType = {
|
const DEFAULT_LIFETIME = 5000;
|
||||||
isVisible: boolean;
|
|
||||||
onClick: () => unknown;
|
export type CallingToastType = {
|
||||||
children?: JSX.Element | string;
|
// 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({
|
type TimeoutType =
|
||||||
isVisible,
|
| { status: 'active'; timeout: NodeJS.Timeout; endAt: number }
|
||||||
onClick,
|
| { status: 'paused'; remaining: number };
|
||||||
|
|
||||||
|
const CallingToastContext = createContext<CallingToastContextType | null>(null);
|
||||||
|
|
||||||
|
export function CallingToastProvider({
|
||||||
|
i18n,
|
||||||
children,
|
children,
|
||||||
}: PropsType): JSX.Element {
|
}: {
|
||||||
|
i18n: LocalizerType;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}): JSX.Element {
|
||||||
|
const [toasts, setToasts] = React.useState<Array<CallingToastStateType>>([]);
|
||||||
|
const timeouts = React.useRef<Map<string, TimeoutType>>(new Map());
|
||||||
|
// All toasts are paused on hover or focus so that toasts don't disappear while a user
|
||||||
|
// is attempting to interact with them
|
||||||
|
const timeoutsStatus = React.useRef<'active' | 'paused'>('active');
|
||||||
|
const shownToasts = React.useRef<Set<string>>(new Set());
|
||||||
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
|
const hideToast = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
if (!isMounted()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = timeouts.current.get(key);
|
||||||
|
if (timeout?.status === 'active') {
|
||||||
|
clearTimeout(timeout.timeout);
|
||||||
|
}
|
||||||
|
timeouts.current.delete(key);
|
||||||
|
|
||||||
|
setToasts(state => {
|
||||||
|
const existingIndex = state.findIndex(toast => toast.key === key);
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
// Important to return the same state object here to avoid infinite recursion if
|
||||||
|
// hideToast is in a useEffect dependency array
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...state.slice(0, existingIndex),
|
||||||
|
...state.slice(existingIndex + 1),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[isMounted]
|
||||||
|
);
|
||||||
|
|
||||||
|
const startTimer = useCallback(
|
||||||
|
(key: string, duration: number) => {
|
||||||
|
if (timeoutsStatus.current === 'paused') {
|
||||||
|
timeouts.current.set(key, { status: 'paused', remaining: duration });
|
||||||
|
} else {
|
||||||
|
timeouts.current.set(key, {
|
||||||
|
timeout: setTimeout(() => hideToast(key), duration),
|
||||||
|
status: 'active',
|
||||||
|
endAt: Date.now() + duration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hideToast]
|
||||||
|
);
|
||||||
|
|
||||||
|
const showToast = useCallback(
|
||||||
|
(toast: CallingToastType): string => {
|
||||||
|
if (toast.onlyShowOnce && shownToasts.current.has(toast.key)) {
|
||||||
|
return toast.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = toast.key ?? uuid();
|
||||||
|
|
||||||
|
setToasts(state => {
|
||||||
|
const isCurrentlyBeingShown = state.some(
|
||||||
|
existingToast => toast.key === existingToast.key
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isCurrentlyBeingShown) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toast.autoClose) {
|
||||||
|
startTimer(key, DEFAULT_LIFETIME);
|
||||||
|
}
|
||||||
|
shownToasts.current.add(key);
|
||||||
|
|
||||||
|
return [{ ...toast, key }, ...state];
|
||||||
|
});
|
||||||
|
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
[startTimer]
|
||||||
|
);
|
||||||
|
|
||||||
|
const pauseAll = useCallback(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
timeoutsStatus.current = 'paused';
|
||||||
|
|
||||||
|
for (const [key, timeout] of [...timeouts.current.entries()]) {
|
||||||
|
if (!timeout || timeout.status !== 'active') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(timeout.timeout);
|
||||||
|
|
||||||
|
timeouts.current.set(key, {
|
||||||
|
status: 'paused',
|
||||||
|
remaining: timeout.endAt - now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resumeAll = useCallback(() => {
|
||||||
|
timeoutsStatus.current = 'active';
|
||||||
|
|
||||||
|
for (const [key, timeout] of [...timeouts.current.entries()]) {
|
||||||
|
if (!timeout || timeout.status !== 'paused') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTimer(key, timeout.remaining);
|
||||||
|
}
|
||||||
|
}, [startTimer]);
|
||||||
|
|
||||||
|
const { hoverProps } = useHover({
|
||||||
|
onHoverStart: () => pauseAll(),
|
||||||
|
onHoverEnd: () => resumeAll(),
|
||||||
|
});
|
||||||
|
const { focusWithinProps } = useFocusWithin({
|
||||||
|
onFocusWithin: () => pauseAll(),
|
||||||
|
onBlurWithin: () => resumeAll(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const TOAST_HEIGHT_PX = 42;
|
||||||
|
const TOAST_GAP_PX = 8;
|
||||||
|
const transitions = useTransition(toasts, {
|
||||||
|
from: { opacity: 0, marginTop: `${-1 * TOAST_HEIGHT_PX}px` },
|
||||||
|
enter: {
|
||||||
|
opacity: 1,
|
||||||
|
zIndex: 1,
|
||||||
|
marginTop: '0px',
|
||||||
|
config: (key: string) => {
|
||||||
|
if (key === 'marginTop') {
|
||||||
|
return {
|
||||||
|
velocity: 0.005,
|
||||||
|
friction: 30,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
leave: (_item, index) => ({
|
||||||
|
zIndex: 0,
|
||||||
|
opacity: 0,
|
||||||
|
marginTop:
|
||||||
|
// If the last toast in the list is leaving, we don't need to move it up. Its
|
||||||
|
// index is toasts.length instead of toasts.length - 1 since it has already been
|
||||||
|
// removed from state
|
||||||
|
index === toasts.length
|
||||||
|
? 0
|
||||||
|
: `${-1 * (TOAST_HEIGHT_PX + TOAST_GAP_PX)}px`,
|
||||||
|
config: (key: string) => {
|
||||||
|
if (key === 'zIndex') {
|
||||||
|
return { duration: 0 };
|
||||||
|
}
|
||||||
|
if (key === 'opacity') {
|
||||||
|
return { duration: 100 };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
duration: 300,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const contextValue = useMemo(() => {
|
||||||
|
return {
|
||||||
|
showToast,
|
||||||
|
hideToast,
|
||||||
|
};
|
||||||
|
}, [showToast, hideToast]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<CallingToastContext.Provider value={contextValue}>
|
||||||
className={classNames(
|
{createPortal(
|
||||||
'CallingToast',
|
<div className="CallingToasts">
|
||||||
!isVisible && 'CallingToast--hidden'
|
<div
|
||||||
|
className="CallingToasts__inner"
|
||||||
|
role="region"
|
||||||
|
aria-label={i18n('icu:calling__toasts--aria-label')}
|
||||||
|
{...mergeProps(hoverProps, focusWithinProps)}
|
||||||
|
>
|
||||||
|
{transitions((style, item) => (
|
||||||
|
<animated.div style={style}>
|
||||||
|
<CallingToast
|
||||||
|
{...item}
|
||||||
|
onClick={
|
||||||
|
item.dismissable ? () => hideToast(item.key) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</animated.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</CallingToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CallingToast(
|
||||||
|
props: CallingToastType & {
|
||||||
|
onClick?: VoidFunction;
|
||||||
|
}
|
||||||
|
): JSX.Element {
|
||||||
|
const className = classNames(
|
||||||
|
'CallingToast',
|
||||||
|
props.dismissable && 'CallingToast--dismissable'
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementHtmlProps = {
|
||||||
|
role: 'alert',
|
||||||
|
ariaLive: props.autoClose ? 'assertive' : 'polite',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (props.dismissable) {
|
||||||
|
return (
|
||||||
|
<div {...elementHtmlProps}>
|
||||||
|
<button className={className} type="button" onClick={props.onClick}>
|
||||||
|
{props.content}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div {...elementHtmlProps} className={className}>
|
||||||
|
{props.content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preferred way of showing/hiding toasts: useCallingToasts is a helper function which
|
||||||
|
// will hide all toasts shown when the component from which you are using it unmounts
|
||||||
|
export function useCallingToasts(): CallingToastContextType {
|
||||||
|
const callingToastContext = useContext(CallingToastContext);
|
||||||
|
|
||||||
|
if (!callingToastContext) {
|
||||||
|
throw new Error('Calling Toasts must be wrapped in CallingToastProvider');
|
||||||
|
}
|
||||||
|
const toastsShown = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const wrappedShowToast = useCallback(
|
||||||
|
(toast: CallingToastType) => {
|
||||||
|
const key = callingToastContext.showToast(toast);
|
||||||
|
toastsShown.current.add(key);
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
[callingToastContext]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hideAllShownToasts = useCallback(() => {
|
||||||
|
[...toastsShown.current].forEach(callingToastContext.hideToast);
|
||||||
|
}, [callingToastContext]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return hideAllShownToasts;
|
||||||
|
}, [hideAllShownToasts]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
...callingToastContext,
|
||||||
|
showToast: wrappedShowToast,
|
||||||
|
}),
|
||||||
|
[wrappedShowToast, callingToastContext]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,35 +1,40 @@
|
||||||
// Copyright 2020 Signal Messenger, LLC
|
// Copyright 2020 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import type { ActiveCallType } from '../types/Calling';
|
import type { ActiveCallType } from '../types/Calling';
|
||||||
import { CallMode } from '../types/Calling';
|
import { CallMode } from '../types/Calling';
|
||||||
import type { ConversationType } from '../state/ducks/conversations';
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
import type { LocalizerType } from '../types/Util';
|
import type { LocalizerType } from '../types/Util';
|
||||||
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
|
|
||||||
import { CallingToast, DEFAULT_LIFETIME } from './CallingToast';
|
|
||||||
import { isReconnecting } from '../util/callingIsReconnecting';
|
import { isReconnecting } from '../util/callingIsReconnecting';
|
||||||
|
import { useCallingToasts } from './CallingToast';
|
||||||
|
import { Spinner } from './Spinner';
|
||||||
|
|
||||||
type PropsType = {
|
type PropsType = {
|
||||||
activeCall: ActiveCallType;
|
activeCall: ActiveCallType;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ToastType =
|
export function useReconnectingToast({ activeCall, i18n }: PropsType): void {
|
||||||
| {
|
const { showToast, hideToast } = useCallingToasts();
|
||||||
message: string;
|
const RECONNECTING_TOAST_KEY = 'reconnecting';
|
||||||
type: 'dismissable' | 'static';
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
function getReconnectingToast({ activeCall, i18n }: PropsType): ToastType {
|
useEffect(() => {
|
||||||
if (isReconnecting(activeCall)) {
|
if (isReconnecting(activeCall)) {
|
||||||
return {
|
showToast({
|
||||||
message: i18n('icu:callReconnecting'),
|
key: RECONNECTING_TOAST_KEY,
|
||||||
type: 'static',
|
content: (
|
||||||
};
|
<span className="CallingToast__reconnecting">
|
||||||
}
|
<Spinner svgSize="small" size="16px" />
|
||||||
return undefined;
|
{i18n('icu:callReconnecting')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
autoClose: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
hideToast(RECONNECTING_TOAST_KEY);
|
||||||
|
}
|
||||||
|
}, [activeCall, i18n, showToast, hideToast]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ME = Symbol('me');
|
const ME = Symbol('me');
|
||||||
|
@ -54,34 +59,14 @@ function getCurrentPresenter(
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useScreenSharingToast({ activeCall, i18n }: PropsType): ToastType {
|
export function useScreenSharingStoppedToast({
|
||||||
const [result, setResult] = useState<undefined | ToastType>(undefined);
|
activeCall,
|
||||||
|
i18n,
|
||||||
|
}: PropsType): void {
|
||||||
const [previousPresenter, setPreviousPresenter] = useState<
|
const [previousPresenter, setPreviousPresenter] = useState<
|
||||||
undefined | { id: string | typeof ME; title?: string }
|
undefined | { id: string | typeof ME; title?: string }
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
const { showToast } = useCallingToasts();
|
||||||
const previousPresenterId = previousPresenter?.id;
|
|
||||||
const previousPresenterTitle = previousPresenter?.title;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const currentPresenter = getCurrentPresenter(activeCall);
|
|
||||||
if (!currentPresenter && previousPresenterId) {
|
|
||||||
if (previousPresenterId === ME) {
|
|
||||||
setResult({
|
|
||||||
type: 'dismissable',
|
|
||||||
message: i18n('icu:calling__presenting--you-stopped'),
|
|
||||||
});
|
|
||||||
} else if (previousPresenterTitle) {
|
|
||||||
setResult({
|
|
||||||
type: 'dismissable',
|
|
||||||
message: i18n('icu:calling__presenting--person-stopped', {
|
|
||||||
name: previousPresenterTitle,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [activeCall, i18n, previousPresenterId, previousPresenterTitle]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentPresenter = getCurrentPresenter(activeCall);
|
const currentPresenter = getCurrentPresenter(activeCall);
|
||||||
|
@ -97,49 +82,19 @@ function useScreenSharingToast({ activeCall, i18n }: PropsType): ToastType {
|
||||||
}
|
}
|
||||||
}, [activeCall]);
|
}, [activeCall]);
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// In the future, this component should show toasts when users join or leave. See
|
|
||||||
// DESKTOP-902.
|
|
||||||
export function CallingToastManager(props: PropsType): JSX.Element {
|
|
||||||
const reconnectingToast = getReconnectingToast(props);
|
|
||||||
const screenSharingToast = useScreenSharingToast(props);
|
|
||||||
|
|
||||||
let toast: ToastType;
|
|
||||||
if (reconnectingToast) {
|
|
||||||
toast = reconnectingToast;
|
|
||||||
} else if (screenSharingToast) {
|
|
||||||
toast = screenSharingToast;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
const dismissToast = useCallback(() => {
|
|
||||||
if (timeoutRef) {
|
|
||||||
setIsVisible(false);
|
|
||||||
}
|
|
||||||
}, [setIsVisible, timeoutRef]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsVisible(toast !== undefined);
|
const currentPresenter = getCurrentPresenter(activeCall);
|
||||||
}, [toast]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (!currentPresenter && previousPresenter && previousPresenter.title) {
|
||||||
if (toast?.type === 'dismissable') {
|
showToast({
|
||||||
clearTimeoutIfNecessary(timeoutRef.current);
|
content:
|
||||||
timeoutRef.current = setTimeout(dismissToast, DEFAULT_LIFETIME);
|
previousPresenter.id === ME
|
||||||
|
? i18n('icu:calling__presenting--you-stopped')
|
||||||
|
: i18n('icu:calling__presenting--person-stopped', {
|
||||||
|
name: previousPresenter.title,
|
||||||
|
}),
|
||||||
|
autoClose: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
}, [activeCall, previousPresenter, showToast, i18n]);
|
||||||
return () => {
|
|
||||||
clearTimeoutIfNecessary(timeoutRef.current);
|
|
||||||
};
|
|
||||||
}, [dismissToast, setIsVisible, timeoutRef, toast]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CallingToast isVisible={isVisible} onClick={dismissToast}>
|
|
||||||
{toast?.message}
|
|
||||||
</CallingToast>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
28
ts/hooks/useIsMounted.ts
Normal file
28
ts/hooks/useIsMounted.ts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If you get a warning like:
|
||||||
|
*
|
||||||
|
* Warning: Can't perform a React state update on an unmounted component.
|
||||||
|
*
|
||||||
|
* your component is probably trying to set state after it has unmounted, e.g. after a
|
||||||
|
* timeout or async call. If you can, clear the timeout when the component unmounts (e.g.
|
||||||
|
* on useEffect cleanup). Otherwise, use this hook to check if the component is mounted
|
||||||
|
* before updating state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useIsMounted(): () => boolean {
|
||||||
|
const isMounted = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isMounted.current = true;
|
||||||
|
return () => {
|
||||||
|
isMounted.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return useCallback(() => isMounted.current === true, []);
|
||||||
|
}
|
|
@ -2994,26 +2994,45 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CallingToastManager.tsx",
|
"path": "ts/components/CallingToast.tsx",
|
||||||
"line": " const timeoutRef = useRef<NodeJS.Timeout | null>(null);",
|
"line": " const shownToasts = React.useRef<Set<string>>(new Set());",
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2021-07-30T16:57:33.618Z"
|
"updated": "2023-10-04T20:50:45.297Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "React-useRef",
|
||||||
|
"path": "ts/components/CallingToast.tsx",
|
||||||
|
"line": " const timeouts = React.useRef<Map<string, TimeoutType>>(new Map());",
|
||||||
|
"reasonCategory": "usageTrusted",
|
||||||
|
"updated": "2023-10-10T17:05:02.468Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "React-useRef",
|
||||||
|
"path": "ts/components/CallingToast.tsx",
|
||||||
|
"line": " const timeoutsStatus = React.useRef<'active' | 'paused'>('active');",
|
||||||
|
"reasonCategory": "usageTrusted",
|
||||||
|
"updated": "2023-10-10T17:05:02.468Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "React-useRef",
|
||||||
|
"path": "ts/components/CallingToast.tsx",
|
||||||
|
"line": " const toastsShown = useRef<Set<string>>(new Set());",
|
||||||
|
"reasonCategory": "usageTrusted",
|
||||||
|
"updated": "2023-10-10T17:05:02.468Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CallsList.tsx",
|
"path": "ts/components/CallsList.tsx",
|
||||||
"line": " const infiniteLoaderRef = useRef<InfiniteLoader>(null);",
|
"line": " const infiniteLoaderRef = useRef<InfiniteLoader>(null);",
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2023-08-02T00:21:37.858Z",
|
"updated": "2023-08-02T00:21:37.858Z"
|
||||||
"reasonDetail": "<optional>"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CallsList.tsx",
|
"path": "ts/components/CallsList.tsx",
|
||||||
"line": " const listRef = useRef<List>(null);",
|
"line": " const listRef = useRef<List>(null);",
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2023-08-02T00:21:37.858Z",
|
"updated": "2023-08-02T00:21:37.858Z"
|
||||||
"reasonDetail": "<optional>"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
|
@ -3700,6 +3719,13 @@
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2021-09-17T20:16:37.959Z"
|
"updated": "2021-09-17T20:16:37.959Z"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"rule": "React-useRef",
|
||||||
|
"path": "ts/hooks/useIsMounted.ts",
|
||||||
|
"line": " const isMounted = useRef(false);",
|
||||||
|
"reasonCategory": "usageTrusted",
|
||||||
|
"updated": "2023-10-04T20:50:45.297Z"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/hooks/usePrevious.ts",
|
"path": "ts/hooks/usePrevious.ts",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue