diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 7fb51f5bd..45f1289f6 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -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; } } diff --git a/stylesheets/components/CallingLobby.scss b/stylesheets/components/CallingLobby.scss index 689bed018..0c7dcdfee 100644 --- a/stylesheets/components/CallingLobby.scss +++ b/stylesheets/components/CallingLobby.scss @@ -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; diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx index ba72fe605..4c8ede028 100644 --- a/ts/components/CallScreen.tsx +++ b/ts/components/CallScreen.tsx @@ -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 ? ( + + + + {i18n('icu:callReconnecting')} + + + ) : null} + + {isLonelyInCall && !isRinging ? ( + + {i18n('icu:calling__in-this-call--zero')} + + ) : null} + + {currentPresenter ? ( + + {i18n('icu:calling__presenting--person-ongoing', { + name: currentPresenter.title, + })} + + ) : null} + {showNeedsScreenRecordingPermissionsWarning ? ( diff --git a/ts/components/CallingHeader.stories.tsx b/ts/components/CallingHeader.stories.tsx index 1e83bab26..ee377143e 100644 --- a/ts/components/CallingHeader.stories.tsx +++ b/ts/components/CallingHeader.stories.tsx @@ -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 ( ); } - -export function WithParticipants(args: PropsType): JSX.Element { - return ; -} - -export function WithParticipantsShown(args: PropsType): JSX.Element { - return ; -} - -export function LongTitle(args: PropsType): JSX.Element { - return ( - - ); -} - -export function TitleWithMessage(args: PropsType): JSX.Element { - return ( - - ); -} diff --git a/ts/components/CallingHeader.tsx b/ts/components/CallingHeader.tsx index b2c7d0883..920cd8ba3 100644 --- a/ts/components/CallingHeader.tsx +++ b/ts/components/CallingHeader.tsx @@ -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 ( -
- {title ? ( -
{title}
- ) : null} - {message ? ( -
{message}
- ) : null} -
- {isGroupCall && - participantCount > 2 && - callViewMode && - changeCallView && ( -
- 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, - }, - ]} +
+ {isGroupCall && + participantCount > 2 && + callViewMode && + changeCallView && ( +
+ 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 + } + > + - -
- -
-
-
-
- )} +
+ +
+ + +
+ )} +
+ + + +
+ {togglePip && (
- {togglePip && ( -
- + + - -
- )} - {onCancel && ( -
- - - -
- )} -
+ + + +
+ )}
); } diff --git a/ts/components/CallingToast.tsx b/ts/components/CallingToast.tsx index 971c2f016..07878281c 100644 --- a/ts/components/CallingToast.tsx +++ b/ts/components/CallingToast.tsx @@ -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; - maxToasts?: number; + maxNonPersistentToasts?: number; }): JSX.Element { const [toasts, setToasts] = React.useState>([]); 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(uuid()); + useEffect(() => { + showToast({ + key: toastId.current, + content: children, + autoClose: false, + }); + }, [children, showToast]); + + return null; +} diff --git a/ts/components/CallingToastManager.tsx b/ts/components/CallingToastManager.tsx index c01bbdf97..a8b5f04a1 100644 --- a/ts/components/CallingToastManager.tsx +++ b/ts/components/CallingToastManager.tsx @@ -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: ( - - - {i18n('icu:callReconnecting')} - - ), - autoClose: false, - }); - } else { - hideToast(RECONNECTING_TOAST_KEY); - } - }, [activeCall, i18n, showToast, hideToast]); -} - const ME = Symbol('me'); function getCurrentPresenter( activeCall: Readonly -): 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 (
diff --git a/ts/test-both/util/setUtil_test.ts b/ts/test-both/util/setUtil_test.ts index 53d2735f3..ee9440e03 100644 --- a/ts/test-both/util/setUtil_test.ts +++ b/ts/test-both/util/setUtil_test.ts @@ -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']) + ); + }); + }); }); diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 3043a4652..941cbf6a2 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -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(uuid());", + "reasonCategory": "usageTrusted", + "updated": "2023-11-14T16:52:45.342Z" + }, { "rule": "React-useRef", "path": "ts/components/CallsList.tsx", diff --git a/ts/util/setUtil.ts b/ts/util/setUtil.ts index cb6c38d0c..9d0d361a1 100644 --- a/ts/util/setUtil.ts +++ b/ts/util/setUtil.ts @@ -27,3 +27,14 @@ export const isEqual = ( a: Readonly>, b: Readonly> ): boolean => a === b || (a.size === b.size && every(a, item => b.has(item))); + +export const difference = ( + a: Readonly>, + b: Readonly> +): Set => { + const result = new Set([...a]); + for (const item of b) { + result.delete(item); + } + return result; +};