Convert CallingHeader texts to toasts

This commit is contained in:
trevor-signal 2023-11-14 17:05:17 -05:00 committed by GitHub
parent f180f66e77
commit 292ef1b6f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 268 additions and 270 deletions

View file

@ -3605,28 +3605,6 @@ button.module-image__border-overlay:focus {
z-index: $z-index-calling;
}
&__header {
position: fixed;
top: 0;
color: #ffffff;
font-style: normal;
padding-bottom: 19px;
padding-top: calc(24px + var(--title-bar-drag-area-height));
text-align: center;
@include calling-text-shadow;
width: 100%;
&--header-name {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.009em;
line-height: 21px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
&__background {
align-items: center;
display: flex;
@ -3808,21 +3786,6 @@ button.module-image__border-overlay:focus {
}
}
&__header {
background: linear-gradient($color-black-alpha-40, transparent);
top: 0;
width: 100%;
z-index: $z-index-above-above-base;
}
&__header-message {
font-weight: normal;
font-size: 13px;
font-variant: tabular-nums;
line-height: 18px;
letter-spacing: -0.0025em;
}
&__direct-call-ringing-spacer {
flex: 1;
}
@ -3833,6 +3796,7 @@ button.module-image__border-overlay:focus {
width: 100%;
margin-block-start: 24px;
z-index: $z-index-above-base;
-webkit-app-region: no-drag;
&__grid--wrapper {
margin-block-start: 26px;
@ -4158,11 +4122,11 @@ button.module-image__border-overlay:focus {
}
.module-calling-tools {
display: flex;
justify-content: flex-end;
position: absolute;
top: calc(32px + var(--title-bar-drag-area-height));
width: 100%;
inset-inline-end: 0;
z-index: $z-index-above-above-base;
display: flex;
&__button {
margin-inline-end: 12px;
@ -4173,6 +4137,7 @@ button.module-image__border-overlay:focus {
}
.ContextMenu__container {
background: none;
text-wrap: nowrap;
}
}

View file

@ -6,6 +6,7 @@
position: absolute;
z-index: $z-index-negative;
top: 28px;
-webkit-app-region: no-drag;
&--camera-is-on {
@include lonely-local-video-preview;

View file

@ -36,7 +36,6 @@ import { AvatarColors } from '../types/Colors';
import type { ConversationType } from '../state/ducks/conversations';
import {
CallingButtonToastsContainer,
useReconnectingToast,
useScreenSharingStoppedToast,
} from './CallingToastManager';
import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant';
@ -61,7 +60,8 @@ import {
import { useValueAtFixedRate } from '../hooks/useValueAtFixedRate';
import { isReconnecting as callingIsReconnecting } from '../util/callingIsReconnecting';
import { usePrevious } from '../hooks/usePrevious';
import { useCallingToasts } from './CallingToast';
import { PersistentCallingToast, useCallingToasts } from './CallingToast';
import { Spinner } from './Spinner';
export type PropsType = {
activeCall: ActiveCallType;
@ -256,7 +256,6 @@ export function CallScreen({
};
}, [toggleAudio, toggleVideo]);
useReconnectingToast({ activeCall, i18n });
useScreenSharingStoppedToast({ activeCall, i18n });
useViewModeChangedToast({ activeCall, i18n });
@ -273,7 +272,6 @@ export function CallScreen({
let isRinging: boolean;
let hasCallStarted: boolean;
let headerTitle: string | undefined;
let isConnected: boolean;
let participantCount: number;
let remoteParticipantsElement: ReactNode;
@ -307,16 +305,6 @@ export function CallScreen({
hasCallStarted = activeCall.joinState !== GroupCallJoinState.NotJoined;
participantCount = activeCall.remoteParticipants.length + 1;
if (isRinging) {
headerTitle = undefined;
} else if (currentPresenter) {
headerTitle = i18n('icu:calling__presenting--person-ongoing', {
name: currentPresenter.title,
});
} else if (!activeCall.remoteParticipants.length) {
headerTitle = i18n('icu:calling__in-this-call--zero');
}
isConnected =
activeCall.connectionState === GroupCallConnectionState.Connected;
remoteParticipantsElement = (
@ -487,6 +475,29 @@ export function CallScreen({
}}
role="group"
>
{isReconnecting ? (
<PersistentCallingToast>
<span className="CallingToast__reconnecting">
<Spinner svgSize="small" size="16px" />
{i18n('icu:callReconnecting')}
</span>
</PersistentCallingToast>
) : null}
{isLonelyInCall && !isRinging ? (
<PersistentCallingToast>
{i18n('icu:calling__in-this-call--zero')}
</PersistentCallingToast>
) : null}
{currentPresenter ? (
<PersistentCallingToast>
{i18n('icu:calling__presenting--person-ongoing', {
name: currentPresenter.title,
})}
</PersistentCallingToast>
) : null}
{showNeedsScreenRecordingPermissionsWarning ? (
<NeedsScreenRecordingPermissionsModal
toggleScreenRecordingPermissionsDialog={
@ -505,7 +516,6 @@ export function CallScreen({
i18n={i18n}
isGroupCall={isGroupCall}
participantCount={participantCount}
title={headerTitle}
togglePip={togglePip}
toggleSettings={toggleSettings}
/>

View file

@ -18,14 +18,11 @@ export default {
argTypes: {
isGroupCall: { control: { type: 'boolean' } },
participantCount: { control: { type: 'number' } },
title: { control: { type: 'text' } },
},
args: {
i18n,
isGroupCall: false,
message: '',
participantCount: 0,
title: 'With Someone',
togglePip: action('toggle-pip'),
callViewMode: CallViewMode.Paginated,
changeCallView: action('change-call-view'),
@ -40,32 +37,8 @@ export function LobbyStyle(args: PropsType): JSX.Element {
return (
<CallingHeader
{...args}
title={undefined}
togglePip={undefined}
onCancel={action('onClose')}
/>
);
}
export function WithParticipants(args: PropsType): JSX.Element {
return <CallingHeader {...args} isGroupCall participantCount={10} />;
}
export function WithParticipantsShown(args: PropsType): JSX.Element {
return <CallingHeader {...args} isGroupCall participantCount={10} />;
}
export function LongTitle(args: PropsType): JSX.Element {
return (
<CallingHeader
{...args}
title="What do I got to, what do I got to do to wake you up? To shake you up, to break the structure up?"
/>
);
}
export function TitleWithMessage(args: PropsType): JSX.Element {
return (
<CallingHeader {...args} title="Hello world" message="Goodbye earth" />
);
}

View file

@ -1,7 +1,6 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import classNames from 'classnames';
import React from 'react';
import type { LocalizerType } from '../types/Util';
@ -14,10 +13,8 @@ export type PropsType = {
callViewMode?: CallViewMode;
i18n: LocalizerType;
isGroupCall?: boolean;
message?: ReactNode;
onCancel?: () => void;
participantCount: number;
title?: string;
togglePip?: () => void;
toggleSettings: () => void;
changeCallView?: (mode: CallViewMode) => void;
@ -28,132 +25,122 @@ export function CallingHeader({
changeCallView,
i18n,
isGroupCall = false,
message,
onCancel,
participantCount,
title,
togglePip,
toggleSettings,
}: PropsType): JSX.Element {
return (
<div className="module-calling__header">
{title ? (
<div className="module-calling__header--header-name">{title}</div>
) : null}
{message ? (
<div className="module-ongoing-call__header-message">{message}</div>
) : null}
<div className="module-calling-tools">
{isGroupCall &&
participantCount > 2 &&
callViewMode &&
changeCallView && (
<div className="module-calling-tools__button">
<ContextMenu
ariaLabel={i18n('icu:calling__change-view')}
i18n={i18n}
menuOptions={[
{
icon: 'CallSettingsButton__Icon--PaginatedView',
label: i18n('icu:calling__view_mode--paginated'),
onClick: () => changeCallView(CallViewMode.Paginated),
value: CallViewMode.Paginated,
},
{
icon: 'CallSettingsButton__Icon--OverflowView',
label: i18n('icu:calling__view_mode--overflow'),
onClick: () => changeCallView(CallViewMode.Overflow),
value: CallViewMode.Overflow,
},
{
icon: 'CallSettingsButton__Icon--SpeakerView',
label: i18n('icu:calling__view_mode--speaker'),
onClick: () => changeCallView(CallViewMode.Speaker),
value: CallViewMode.Speaker,
},
]}
<div className="module-calling-tools">
{isGroupCall &&
participantCount > 2 &&
callViewMode &&
changeCallView && (
<div className="module-calling-tools__button">
<ContextMenu
ariaLabel={i18n('icu:calling__change-view')}
i18n={i18n}
menuOptions={[
{
icon: 'CallSettingsButton__Icon--PaginatedView',
label: i18n('icu:calling__view_mode--paginated'),
onClick: () => changeCallView(CallViewMode.Paginated),
value: CallViewMode.Paginated,
},
{
icon: 'CallSettingsButton__Icon--OverflowView',
label: i18n('icu:calling__view_mode--overflow'),
onClick: () => changeCallView(CallViewMode.Overflow),
value: CallViewMode.Overflow,
},
{
icon: 'CallSettingsButton__Icon--SpeakerView',
label: i18n('icu:calling__view_mode--speaker'),
onClick: () => changeCallView(CallViewMode.Speaker),
value: CallViewMode.Speaker,
},
]}
theme={Theme.Dark}
popperOptions={{
placement: 'bottom',
strategy: 'absolute',
}}
value={
// If it's Presentation we want to still show Speaker as selected
callViewMode === CallViewMode.Presentation
? CallViewMode.Speaker
: callViewMode
}
>
<Tooltip
content={i18n('icu:calling__change-view')}
className="CallingButton__tooltip"
theme={Theme.Dark}
popperOptions={{
placement: 'bottom',
strategy: 'absolute',
}}
value={
// If it's Presentation we want to still show Speaker as selected
callViewMode === CallViewMode.Presentation
? CallViewMode.Speaker
: callViewMode
}
>
<Tooltip
content={i18n('icu:calling__change-view')}
className="CallingButton__tooltip"
theme={Theme.Dark}
>
<div className="CallSettingsButton__Button">
<span
className={classNames(
'CallSettingsButton__Icon',
getCallViewIconClassname(callViewMode)
)}
/>
</div>
</Tooltip>
</ContextMenu>
</div>
)}
<div className="CallSettingsButton__Button">
<span
className={classNames(
'CallSettingsButton__Icon',
getCallViewIconClassname(callViewMode)
)}
/>
</div>
</Tooltip>
</ContextMenu>
</div>
)}
<div className="module-calling-tools__button">
<Tooltip
content={i18n('icu:callingDeviceSelection__settings')}
className="CallingButton__tooltip"
theme={Theme.Dark}
>
<button
aria-label={i18n('icu:callingDeviceSelection__settings')}
className="CallSettingsButton__Button"
onClick={toggleSettings}
type="button"
>
<span className="CallSettingsButton__Icon CallSettingsButton__Icon--Settings" />
</button>
</Tooltip>
</div>
{togglePip && (
<div className="module-calling-tools__button">
<Tooltip
content={i18n('icu:callingDeviceSelection__settings')}
content={i18n('icu:calling__pip--on')}
className="CallingButton__tooltip"
theme={Theme.Dark}
>
<button
aria-label={i18n('icu:callingDeviceSelection__settings')}
aria-label={i18n('icu:calling__pip--on')}
className="CallSettingsButton__Button"
onClick={toggleSettings}
onClick={togglePip}
type="button"
>
<span className="CallSettingsButton__Icon CallSettingsButton__Icon--Settings" />
<span className="CallSettingsButton__Icon CallSettingsButton__Icon--Pip" />
</button>
</Tooltip>
</div>
{togglePip && (
<div className="module-calling-tools__button">
<Tooltip
content={i18n('icu:calling__pip--on')}
className="CallingButton__tooltip"
theme={Theme.Dark}
)}
{onCancel && (
<div className="module-calling-tools__button">
<Tooltip
content={i18n('icu:cancel')}
theme={Theme.Dark}
className="CallingButton__tooltip"
>
<button
aria-label={i18n('icu:cancel')}
className="CallSettingsButton__Button CallSettingsButton__Button--Cancel"
onClick={onCancel}
type="button"
>
<button
aria-label={i18n('icu:calling__pip--on')}
className="CallSettingsButton__Button"
onClick={togglePip}
type="button"
>
<span className="CallSettingsButton__Icon CallSettingsButton__Icon--Pip" />
</button>
</Tooltip>
</div>
)}
{onCancel && (
<div className="module-calling-tools__button">
<Tooltip
content={i18n('icu:cancel')}
theme={Theme.Dark}
className="CallingButton__tooltip"
>
<button
aria-label={i18n('icu:cancel')}
className="CallSettingsButton__Button CallSettingsButton__Button--Cancel"
onClick={onCancel}
type="button"
>
<span className="CallSettingsButton__Icon CallSettingsButton__Icon--Cancel" />
</button>
</Tooltip>
</div>
)}
</div>
<span className="CallSettingsButton__Icon CallSettingsButton__Icon--Cancel" />
</button>
</Tooltip>
</div>
)}
</div>
);
}

View file

@ -17,6 +17,7 @@ import { v4 as uuid } from 'uuid';
import { useIsMounted } from '../hooks/useIsMounted';
import type { LocalizerType } from '../types/I18N';
import { usePrevious } from '../hooks/usePrevious';
import { difference } from '../util/setUtil';
const DEFAULT_LIFETIME = 5000;
@ -57,12 +58,12 @@ export function CallingToastProvider({
i18n,
children,
region,
maxToasts = 5,
maxNonPersistentToasts = 5,
}: {
i18n: LocalizerType;
children: React.ReactNode;
region?: React.RefObject<HTMLElement>;
maxToasts?: number;
maxNonPersistentToasts?: number;
}): JSX.Element {
const [toasts, setToasts] = React.useState<Array<CallingToastStateType>>([]);
const previousToasts = usePrevious([], toasts);
@ -137,8 +138,15 @@ export function CallingToastProvider({
return state;
}
if (state.length === maxToasts) {
const toastToBePushedOut = state.at(-1);
const persistentToasts = state.filter(({ autoClose }) => !autoClose);
const nonPersistentToasts = state.filter(({ autoClose }) => autoClose);
if (
nonPersistentToasts.length === maxNonPersistentToasts &&
maxNonPersistentToasts > 0
) {
const toastToBePushedOut = nonPersistentToasts.pop();
if (toastToBePushedOut) {
clearToastTimeout(toastToBePushedOut.key);
}
@ -146,15 +154,19 @@ export function CallingToastProvider({
if (toast.autoClose) {
startTimer(key, DEFAULT_LIFETIME);
nonPersistentToasts.unshift({ ...toast, key });
} else {
persistentToasts.unshift({ ...toast, key });
}
shownToasts.current.add(key);
return [{ ...toast, key }, ...state.slice(0, maxToasts - 1)];
// Show persistent toasts at top of list always
return [...persistentToasts, ...nonPersistentToasts];
});
return key;
},
[startTimer, clearToastTimeout, maxToasts]
[startTimer, clearToastTimeout, maxNonPersistentToasts]
);
const pauseAll = useCallback(() => {
@ -197,22 +209,43 @@ export function CallingToastProvider({
const TOAST_HEIGHT_PX = 42;
const TOAST_GAP_PX = 8;
const curToasts = new Set(toasts);
const prevToasts = new Set(previousToasts);
const toastsRemoved = difference(prevToasts, curToasts);
const toastsAdded = difference(curToasts, prevToasts);
const transitions = useTransition(toasts, {
from: item => ({
opacity: 0,
scale: 0.85,
marginTop:
// If this is the first toast shown, or if this is replacing the
// first toast, we just fade-in (and don't slide down)
previousToasts.length === 0 ||
item.key === previousToasts[0].key ||
maxToasts === toasts.length
? '0px'
: `${-1 * TOAST_HEIGHT_PX}px`,
}),
from: item => {
const enteringItemIndex = toasts.findIndex(
toast => toast.key === item.key
);
const isToastReplacingAnExistingOneAtThisPosition = toastsRemoved.has(
previousToasts[enteringItemIndex]
);
return {
opacity: 0,
zIndex: item.autoClose ? 1 : 2,
scale: 0.85,
marginTop:
// If this toast is replacing an existing one, don't slide-down, just fade-in
// Note: this just refers to toasts added / removed within one render cycle;
// this will almost always be when replacing toasts that are related
// Note: this
// Example:
// previous current
// "Muted" "Unmuted"
//
// The previous toast should disappear and the new one should fade-in in its
// place, so it looks like a replacement.
isToastReplacingAnExistingOneAtThisPosition
? '0px'
: `${-1 * TOAST_HEIGHT_PX}px`,
};
},
enter: {
opacity: 1,
zIndex: 1,
scale: 1,
marginTop: '0px',
config: (key: string) => {
@ -226,22 +259,23 @@ export function CallingToastProvider({
},
},
leave: item => {
const leavingItemIndex = previousToasts.findIndex(
toast => toast.key === item.key
);
const isToastBeingReplacedByANewOneAtThisPosition = toastsAdded.has(
toasts[leavingItemIndex]
);
return {
zIndex: 0,
opacity: 0,
// If the last toast in the list is leaving, we don't need to move it up.
marginTop:
previousToasts.findIndex(toast => toast.key === item.key) ===
previousToasts.length - 1
leavingItemIndex === previousToasts.length - 1
? '0px'
: `${-1 * (TOAST_HEIGHT_PX + TOAST_GAP_PX)}px`,
// If this toast is being replaced by another one with the same key, immediately
// hide it
display:
toasts.some(toast => toast.key === item.key) ||
maxToasts === toasts.length
? 'none'
: 'block',
// If this toast is being replaced by a new toast at this position, disappear
// immediately (don't interfere with new one coming in)
display: isToastBeingReplacedByANewOneAtThisPosition ? 'none' : 'block',
config: (key: string) => {
if (key === 'zIndex') {
return { duration: 0 };
@ -252,7 +286,7 @@ export function CallingToastProvider({
if (key === 'opacity') {
return { duration: 100 };
}
return { duration: 200 };
return { clamp: true, duration: 200 };
},
};
},
@ -301,6 +335,7 @@ function CallingToast(
): JSX.Element {
const className = classNames(
'CallingToast',
!props.autoClose && 'CallingToast--persistent',
props.dismissable && 'CallingToast--dismissable'
);
@ -360,3 +395,21 @@ export function useCallingToasts(): CallingToastContextType {
[wrappedShowToast, callingToastContext]
);
}
export function PersistentCallingToast({
children,
}: {
children: string | JSX.Element;
}): null {
const { showToast } = useCallingToasts();
const toastId = useRef<string>(uuid());
useEffect(() => {
showToast({
key: toastId.current,
content: children,
autoClose: false,
});
}, [children, showToast]);
return null;
}

View file

@ -1,14 +1,12 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef } 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 { isReconnecting } from '../util/callingIsReconnecting';
import { CallingToastProvider, useCallingToasts } from './CallingToast';
import { Spinner } from './Spinner';
import { usePrevious } from '../hooks/usePrevious';
type PropsType = {
@ -16,35 +14,13 @@ type PropsType = {
i18n: LocalizerType;
};
export function useReconnectingToast({ activeCall, i18n }: PropsType): void {
const { showToast, hideToast } = useCallingToasts();
const RECONNECTING_TOAST_KEY = 'reconnecting';
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');
function getCurrentPresenter(
activeCall: Readonly<ActiveCallType>
): ConversationType | typeof ME | undefined {
): ConversationType | { id: typeof ME } | undefined {
if (activeCall.presentingSource) {
return ME;
return { id: ME };
}
if (activeCall.callMode === CallMode.Direct) {
const isOtherPersonPresenting = activeCall.remoteParticipants.some(
@ -64,30 +40,21 @@ export function useScreenSharingStoppedToast({
activeCall,
i18n,
}: PropsType): void {
const [previousPresenter, setPreviousPresenter] = useState<
undefined | { id: string | typeof ME; title?: string }
>(undefined);
const { showToast } = useCallingToasts();
const { showToast, hideToast } = useCallingToasts();
const SOMEONE_STOPPED_PRESENTING_TOAST_KEY = 'someone_stopped_presenting';
const currentPresenter = useMemo(
() => getCurrentPresenter(activeCall),
[activeCall]
);
const previousPresenter = usePrevious(currentPresenter, currentPresenter);
useEffect(() => {
const currentPresenter = getCurrentPresenter(activeCall);
if (currentPresenter === ME) {
setPreviousPresenter({
id: ME,
});
} else if (!currentPresenter) {
setPreviousPresenter(undefined);
} else {
const { id, title } = currentPresenter;
setPreviousPresenter({ id, title });
}
}, [activeCall]);
useEffect(() => {
const currentPresenter = getCurrentPresenter(activeCall);
if (!currentPresenter && previousPresenter && previousPresenter.title) {
if (previousPresenter && !currentPresenter) {
hideToast(SOMEONE_STOPPED_PRESENTING_TOAST_KEY);
showToast({
key: SOMEONE_STOPPED_PRESENTING_TOAST_KEY,
content:
previousPresenter.id === ME
? i18n('icu:calling__presenting--you-stopped')
@ -97,7 +64,14 @@ export function useScreenSharingStoppedToast({
autoClose: true,
});
}
}, [activeCall, previousPresenter, showToast, i18n]);
}, [
activeCall,
hideToast,
currentPresenter,
previousPresenter,
showToast,
i18n,
]);
}
function useMutedToast({
@ -175,7 +149,7 @@ export function CallingButtonToastsContainer(
return (
<CallingToastProvider
i18n={props.i18n}
maxToasts={1}
maxNonPersistentToasts={1}
region={toastRegionRef}
>
<div className="CallingButtonToasts" ref={toastRegionRef} />

View file

@ -3,7 +3,7 @@
import { assert } from 'chai';
import { isEqual, remove, toggle } from '../../util/setUtil';
import { difference, isEqual, remove, toggle } from '../../util/setUtil';
describe('set utilities', () => {
const original = new Set([1, 2, 3]);
@ -82,4 +82,21 @@ describe('set utilities', () => {
assert.deepStrictEqual(original, new Set([1, 2, 3]));
});
});
describe('difference', () => {
it('returns the difference of two sets', () => {
assert.deepStrictEqual(
difference(new Set(['a', 'b', 'c']), new Set(['a', 'b', 'c'])),
new Set()
);
assert.deepStrictEqual(
difference(new Set(['a', 'b', 'c']), new Set([])),
new Set(['a', 'b', 'c'])
);
assert.deepStrictEqual(
difference(new Set(['a', 'b', 'c']), new Set(['d'])),
new Set(['a', 'b', 'c'])
);
});
});
});

View file

@ -3026,6 +3026,13 @@
"reasonCategory": "usageTrusted",
"updated": "2023-10-26T13:57:41.860Z"
},
{
"rule": "React-useRef",
"path": "ts/components/CallingToast.tsx",
"line": " const toastId = useRef<string>(uuid());",
"reasonCategory": "usageTrusted",
"updated": "2023-11-14T16:52:45.342Z"
},
{
"rule": "React-useRef",
"path": "ts/components/CallsList.tsx",

View file

@ -27,3 +27,14 @@ export const isEqual = (
a: Readonly<Set<unknown>>,
b: Readonly<Set<unknown>>
): boolean => a === b || (a.size === b.size && every(a, item => b.has(item)));
export const difference = <T>(
a: Readonly<Set<T>>,
b: Readonly<Set<T>>
): Set<T> => {
const result = new Set([...a]);
for (const item of b) {
result.delete(item);
}
return result;
};