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;
+};