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",
|
||||
"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": {
|
||||
"messageformat": "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
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
.CallingToast {
|
||||
@include button-reset();
|
||||
@include font-body-1-bold;
|
||||
background-color: $color-gray-75;
|
||||
border-radius: 8px;
|
||||
color: $color-white;
|
||||
max-width: 80%;
|
||||
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;
|
||||
.CallingToasts {
|
||||
position: fixed;
|
||||
z-index: $z-index-toast;
|
||||
top: 32px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&--hidden {
|
||||
opacity: 0;
|
||||
transform: translateY(-7px);
|
||||
.CallingToasts__inner {
|
||||
display: flex;
|
||||
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';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import { CallingToastProvider } from './CallingToast';
|
||||
|
||||
const GROUP_CALL_RING_DURATION = 60 * 1000;
|
||||
|
||||
|
@ -439,7 +440,11 @@ export function CallManager(props: PropsType): JSX.Element | null {
|
|||
if (activeCall) {
|
||||
// `props` should logically have an `activeCall` at this point, but TypeScript can't
|
||||
// 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.
|
||||
|
|
|
@ -18,7 +18,7 @@ import { generateAci } from '../types/ServiceId';
|
|||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
import type { PropsType } from './CallScreen';
|
||||
import { CallScreen } from './CallScreen';
|
||||
import { CallScreen as UnwrappedCallScreen } from './CallScreen';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import {
|
||||
|
@ -27,6 +27,7 @@ import {
|
|||
} from '../test-both/helpers/getDefaultConversation';
|
||||
import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { CallingToastProvider, useCallingToasts } from './CallingToast';
|
||||
|
||||
const MAX_PARTICIPANTS = 64;
|
||||
|
||||
|
@ -171,6 +172,14 @@ const createProps = (
|
|||
toggleSpeakerView: action('toggle-speaker-view'),
|
||||
});
|
||||
|
||||
function CallScreen(props: ReturnType<typeof createProps>): JSX.Element {
|
||||
return (
|
||||
<CallingToastProvider i18n={i18n}>
|
||||
<UnwrappedCallScreen {...props} />
|
||||
</CallingToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
title: 'Components/CallScreen',
|
||||
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';
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import { CallingToastManager } from './CallingToastManager';
|
||||
import {
|
||||
useReconnectingToast,
|
||||
useScreenSharingStoppedToast,
|
||||
} from './CallingToastManager';
|
||||
import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant';
|
||||
import { GroupCallRemoteParticipants } from './GroupCallRemoteParticipants';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
|
@ -257,6 +260,9 @@ export function CallScreen({
|
|||
};
|
||||
}, [toggleAudio, toggleVideo]);
|
||||
|
||||
useReconnectingToast({ activeCall, i18n });
|
||||
useScreenSharingStoppedToast({ activeCall, i18n });
|
||||
|
||||
const currentPresenter = remoteParticipants.find(
|
||||
participant => participant.presenting
|
||||
);
|
||||
|
@ -476,7 +482,6 @@ export function CallScreen({
|
|||
openSystemPreferencesAction={openSystemPreferencesAction}
|
||||
/>
|
||||
) : null}
|
||||
<CallingToastManager activeCall={activeCall} i18n={i18n} />
|
||||
<div
|
||||
className={classNames('module-ongoing-call__header', controlsFadeClass)}
|
||||
>
|
||||
|
|
|
@ -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> = {}): PropsType => {
|
|||
};
|
||||
};
|
||||
|
||||
function CallingLobby(props: ReturnType<typeof createProps>) {
|
||||
return (
|
||||
<CallingToastProvider i18n={i18n}>
|
||||
<UnwrappedCallingLobby {...props} />
|
||||
</CallingToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const fakePeekedParticipant = (conversationProps: Partial<ConversationType>) =>
|
||||
getDefaultConversationWithServiceId({
|
||||
...conversationProps,
|
||||
|
|
|
@ -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<MediaDeviceInfo>;
|
||||
|
@ -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 | HTMLVideoElement>(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({
|
|||
/>
|
||||
)}
|
||||
|
||||
<CallingToast
|
||||
isVisible={isMutedToastVisible}
|
||||
onClick={() => setIsMutedToastVisible(false)}
|
||||
>
|
||||
{i18n(
|
||||
'icu:calling__lobby-automatically-muted-because-there-are-a-lot-of-people'
|
||||
)}
|
||||
</CallingToast>
|
||||
|
||||
<CallingHeader
|
||||
i18n={i18n}
|
||||
isGroupCall={isGroupCall}
|
||||
|
@ -306,3 +284,32 @@ export function CallingLobby({
|
|||
</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
|
||||
// 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<CallingToastContextType | null>(null);
|
||||
|
||||
export function CallingToastProvider({
|
||||
i18n,
|
||||
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 (
|
||||
<button
|
||||
className={classNames(
|
||||
'CallingToast',
|
||||
!isVisible && 'CallingToast--hidden'
|
||||
<CallingToastContext.Provider value={contextValue}>
|
||||
{createPortal(
|
||||
<div className="CallingToasts">
|
||||
<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}
|
||||
</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
|
||||
// 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 { CallMode } from '../types/Calling';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
|
||||
import { CallingToast, DEFAULT_LIFETIME } from './CallingToast';
|
||||
import { isReconnecting } from '../util/callingIsReconnecting';
|
||||
import { useCallingToasts } from './CallingToast';
|
||||
import { Spinner } from './Spinner';
|
||||
|
||||
type PropsType = {
|
||||
activeCall: ActiveCallType;
|
||||
i18n: LocalizerType;
|
||||
};
|
||||
|
||||
type ToastType =
|
||||
| {
|
||||
message: string;
|
||||
type: 'dismissable' | 'static';
|
||||
}
|
||||
| undefined;
|
||||
export function useReconnectingToast({ activeCall, i18n }: PropsType): void {
|
||||
const { showToast, hideToast } = useCallingToasts();
|
||||
const RECONNECTING_TOAST_KEY = 'reconnecting';
|
||||
|
||||
function getReconnectingToast({ activeCall, i18n }: PropsType): ToastType {
|
||||
if (isReconnecting(activeCall)) {
|
||||
return {
|
||||
message: i18n('icu:callReconnecting'),
|
||||
type: 'static',
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
useEffect(() => {
|
||||
if (isReconnecting(activeCall)) {
|
||||
showToast({
|
||||
key: RECONNECTING_TOAST_KEY,
|
||||
content: (
|
||||
<span className="CallingToast__reconnecting">
|
||||
<Spinner svgSize="small" size="16px" />
|
||||
{i18n('icu:callReconnecting')}
|
||||
</span>
|
||||
),
|
||||
autoClose: false,
|
||||
});
|
||||
} else {
|
||||
hideToast(RECONNECTING_TOAST_KEY);
|
||||
}
|
||||
}, [activeCall, i18n, showToast, hideToast]);
|
||||
}
|
||||
|
||||
const ME = Symbol('me');
|
||||
|
@ -54,34 +59,14 @@ function getCurrentPresenter(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function useScreenSharingToast({ activeCall, i18n }: PropsType): ToastType {
|
||||
const [result, setResult] = useState<undefined | ToastType>(undefined);
|
||||
|
||||
export function useScreenSharingStoppedToast({
|
||||
activeCall,
|
||||
i18n,
|
||||
}: PropsType): void {
|
||||
const [previousPresenter, setPreviousPresenter] = useState<
|
||||
undefined | { id: string | typeof ME; title?: string }
|
||||
>(undefined);
|
||||
|
||||
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]);
|
||||
const { showToast } = useCallingToasts();
|
||||
|
||||
useEffect(() => {
|
||||
const currentPresenter = getCurrentPresenter(activeCall);
|
||||
|
@ -97,49 +82,19 @@ function useScreenSharingToast({ activeCall, i18n }: PropsType): ToastType {
|
|||
}
|
||||
}, [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(() => {
|
||||
setIsVisible(toast !== undefined);
|
||||
}, [toast]);
|
||||
const currentPresenter = getCurrentPresenter(activeCall);
|
||||
|
||||
useEffect(() => {
|
||||
if (toast?.type === 'dismissable') {
|
||||
clearTimeoutIfNecessary(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(dismissToast, DEFAULT_LIFETIME);
|
||||
if (!currentPresenter && previousPresenter && previousPresenter.title) {
|
||||
showToast({
|
||||
content:
|
||||
previousPresenter.id === ME
|
||||
? i18n('icu:calling__presenting--you-stopped')
|
||||
: i18n('icu:calling__presenting--person-stopped', {
|
||||
name: previousPresenter.title,
|
||||
}),
|
||||
autoClose: true,
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeoutIfNecessary(timeoutRef.current);
|
||||
};
|
||||
}, [dismissToast, setIsVisible, timeoutRef, toast]);
|
||||
|
||||
return (
|
||||
<CallingToast isVisible={isVisible} onClick={dismissToast}>
|
||||
{toast?.message}
|
||||
</CallingToast>
|
||||
);
|
||||
}, [activeCall, previousPresenter, showToast, i18n]);
|
||||
}
|
||||
|
|
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",
|
||||
"path": "ts/components/CallingToastManager.tsx",
|
||||
"line": " const timeoutRef = useRef<NodeJS.Timeout | null>(null);",
|
||||
"path": "ts/components/CallingToast.tsx",
|
||||
"line": " const shownToasts = React.useRef<Set<string>>(new Set());",
|
||||
"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",
|
||||
"path": "ts/components/CallsList.tsx",
|
||||
"line": " const infiniteLoaderRef = useRef<InfiniteLoader>(null);",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2023-08-02T00:21:37.858Z",
|
||||
"reasonDetail": "<optional>"
|
||||
"updated": "2023-08-02T00:21:37.858Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/CallsList.tsx",
|
||||
"line": " const listRef = useRef<List>(null);",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2023-08-02T00:21:37.858Z",
|
||||
"reasonDetail": "<optional>"
|
||||
"updated": "2023-08-02T00:21:37.858Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
|
@ -3700,6 +3719,13 @@
|
|||
"reasonCategory": "usageTrusted",
|
||||
"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",
|
||||
"path": "ts/hooks/usePrevious.ts",
|
||||
|
|
Loading…
Reference in a new issue