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; 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 { &__background {
align-items: center; align-items: center;
display: flex; 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 { &__direct-call-ringing-spacer {
flex: 1; flex: 1;
} }
@ -3833,6 +3796,7 @@ button.module-image__border-overlay:focus {
width: 100%; width: 100%;
margin-block-start: 24px; margin-block-start: 24px;
z-index: $z-index-above-base; z-index: $z-index-above-base;
-webkit-app-region: no-drag;
&__grid--wrapper { &__grid--wrapper {
margin-block-start: 26px; margin-block-start: 26px;
@ -4158,11 +4122,11 @@ button.module-image__border-overlay:focus {
} }
.module-calling-tools { .module-calling-tools {
display: flex;
justify-content: flex-end;
position: absolute; position: absolute;
top: calc(32px + var(--title-bar-drag-area-height)); 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 { &__button {
margin-inline-end: 12px; margin-inline-end: 12px;
@ -4173,6 +4137,7 @@ button.module-image__border-overlay:focus {
} }
.ContextMenu__container { .ContextMenu__container {
background: none; background: none;
text-wrap: nowrap;
} }
} }

View file

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

View file

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

View file

@ -18,14 +18,11 @@ export default {
argTypes: { argTypes: {
isGroupCall: { control: { type: 'boolean' } }, isGroupCall: { control: { type: 'boolean' } },
participantCount: { control: { type: 'number' } }, participantCount: { control: { type: 'number' } },
title: { control: { type: 'text' } },
}, },
args: { args: {
i18n, i18n,
isGroupCall: false, isGroupCall: false,
message: '',
participantCount: 0, participantCount: 0,
title: 'With Someone',
togglePip: action('toggle-pip'), togglePip: action('toggle-pip'),
callViewMode: CallViewMode.Paginated, callViewMode: CallViewMode.Paginated,
changeCallView: action('change-call-view'), changeCallView: action('change-call-view'),
@ -40,32 +37,8 @@ export function LobbyStyle(args: PropsType): JSX.Element {
return ( return (
<CallingHeader <CallingHeader
{...args} {...args}
title={undefined}
togglePip={undefined} togglePip={undefined}
onCancel={action('onClose')} 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 // Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import React from 'react'; import React from 'react';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
@ -14,10 +13,8 @@ export type PropsType = {
callViewMode?: CallViewMode; callViewMode?: CallViewMode;
i18n: LocalizerType; i18n: LocalizerType;
isGroupCall?: boolean; isGroupCall?: boolean;
message?: ReactNode;
onCancel?: () => void; onCancel?: () => void;
participantCount: number; participantCount: number;
title?: string;
togglePip?: () => void; togglePip?: () => void;
toggleSettings: () => void; toggleSettings: () => void;
changeCallView?: (mode: CallViewMode) => void; changeCallView?: (mode: CallViewMode) => void;
@ -28,21 +25,12 @@ export function CallingHeader({
changeCallView, changeCallView,
i18n, i18n,
isGroupCall = false, isGroupCall = false,
message,
onCancel, onCancel,
participantCount, participantCount,
title,
togglePip, togglePip,
toggleSettings, toggleSettings,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
return ( 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"> <div className="module-calling-tools">
{isGroupCall && {isGroupCall &&
participantCount > 2 && participantCount > 2 &&
@ -154,7 +142,6 @@ export function CallingHeader({
</div> </div>
)} )}
</div> </div>
</div>
); );
} }

View file

@ -17,6 +17,7 @@ import { v4 as uuid } from 'uuid';
import { useIsMounted } from '../hooks/useIsMounted'; import { useIsMounted } from '../hooks/useIsMounted';
import type { LocalizerType } from '../types/I18N'; import type { LocalizerType } from '../types/I18N';
import { usePrevious } from '../hooks/usePrevious'; import { usePrevious } from '../hooks/usePrevious';
import { difference } from '../util/setUtil';
const DEFAULT_LIFETIME = 5000; const DEFAULT_LIFETIME = 5000;
@ -57,12 +58,12 @@ export function CallingToastProvider({
i18n, i18n,
children, children,
region, region,
maxToasts = 5, maxNonPersistentToasts = 5,
}: { }: {
i18n: LocalizerType; i18n: LocalizerType;
children: React.ReactNode; children: React.ReactNode;
region?: React.RefObject<HTMLElement>; region?: React.RefObject<HTMLElement>;
maxToasts?: number; maxNonPersistentToasts?: number;
}): JSX.Element { }): JSX.Element {
const [toasts, setToasts] = React.useState<Array<CallingToastStateType>>([]); const [toasts, setToasts] = React.useState<Array<CallingToastStateType>>([]);
const previousToasts = usePrevious([], toasts); const previousToasts = usePrevious([], toasts);
@ -137,8 +138,15 @@ export function CallingToastProvider({
return state; return state;
} }
if (state.length === maxToasts) { const persistentToasts = state.filter(({ autoClose }) => !autoClose);
const toastToBePushedOut = state.at(-1); const nonPersistentToasts = state.filter(({ autoClose }) => autoClose);
if (
nonPersistentToasts.length === maxNonPersistentToasts &&
maxNonPersistentToasts > 0
) {
const toastToBePushedOut = nonPersistentToasts.pop();
if (toastToBePushedOut) { if (toastToBePushedOut) {
clearToastTimeout(toastToBePushedOut.key); clearToastTimeout(toastToBePushedOut.key);
} }
@ -146,15 +154,19 @@ export function CallingToastProvider({
if (toast.autoClose) { if (toast.autoClose) {
startTimer(key, DEFAULT_LIFETIME); startTimer(key, DEFAULT_LIFETIME);
nonPersistentToasts.unshift({ ...toast, key });
} else {
persistentToasts.unshift({ ...toast, key });
} }
shownToasts.current.add(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; return key;
}, },
[startTimer, clearToastTimeout, maxToasts] [startTimer, clearToastTimeout, maxNonPersistentToasts]
); );
const pauseAll = useCallback(() => { const pauseAll = useCallback(() => {
@ -197,22 +209,43 @@ export function CallingToastProvider({
const TOAST_HEIGHT_PX = 42; const TOAST_HEIGHT_PX = 42;
const TOAST_GAP_PX = 8; 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, { const transitions = useTransition(toasts, {
from: item => ({ from: item => {
const enteringItemIndex = toasts.findIndex(
toast => toast.key === item.key
);
const isToastReplacingAnExistingOneAtThisPosition = toastsRemoved.has(
previousToasts[enteringItemIndex]
);
return {
opacity: 0, opacity: 0,
zIndex: item.autoClose ? 1 : 2,
scale: 0.85, scale: 0.85,
marginTop: marginTop:
// If this is the first toast shown, or if this is replacing the // If this toast is replacing an existing one, don't slide-down, just fade-in
// first toast, we just fade-in (and don't slide down) // Note: this just refers to toasts added / removed within one render cycle;
previousToasts.length === 0 || // this will almost always be when replacing toasts that are related
item.key === previousToasts[0].key || // Note: this
maxToasts === toasts.length // 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' ? '0px'
: `${-1 * TOAST_HEIGHT_PX}px`, : `${-1 * TOAST_HEIGHT_PX}px`,
}), };
},
enter: { enter: {
opacity: 1, opacity: 1,
zIndex: 1,
scale: 1, scale: 1,
marginTop: '0px', marginTop: '0px',
config: (key: string) => { config: (key: string) => {
@ -226,22 +259,23 @@ export function CallingToastProvider({
}, },
}, },
leave: item => { leave: item => {
const leavingItemIndex = previousToasts.findIndex(
toast => toast.key === item.key
);
const isToastBeingReplacedByANewOneAtThisPosition = toastsAdded.has(
toasts[leavingItemIndex]
);
return { return {
zIndex: 0, zIndex: 0,
opacity: 0, opacity: 0,
// If the last toast in the list is leaving, we don't need to move it up. // If the last toast in the list is leaving, we don't need to move it up.
marginTop: marginTop:
previousToasts.findIndex(toast => toast.key === item.key) === leavingItemIndex === previousToasts.length - 1
previousToasts.length - 1
? '0px' ? '0px'
: `${-1 * (TOAST_HEIGHT_PX + TOAST_GAP_PX)}px`, : `${-1 * (TOAST_HEIGHT_PX + TOAST_GAP_PX)}px`,
// If this toast is being replaced by another one with the same key, immediately // If this toast is being replaced by a new toast at this position, disappear
// hide it // immediately (don't interfere with new one coming in)
display: display: isToastBeingReplacedByANewOneAtThisPosition ? 'none' : 'block',
toasts.some(toast => toast.key === item.key) ||
maxToasts === toasts.length
? 'none'
: 'block',
config: (key: string) => { config: (key: string) => {
if (key === 'zIndex') { if (key === 'zIndex') {
return { duration: 0 }; return { duration: 0 };
@ -252,7 +286,7 @@ export function CallingToastProvider({
if (key === 'opacity') { if (key === 'opacity') {
return { duration: 100 }; return { duration: 100 };
} }
return { duration: 200 }; return { clamp: true, duration: 200 };
}, },
}; };
}, },
@ -301,6 +335,7 @@ function CallingToast(
): JSX.Element { ): JSX.Element {
const className = classNames( const className = classNames(
'CallingToast', 'CallingToast',
!props.autoClose && 'CallingToast--persistent',
props.dismissable && 'CallingToast--dismissable' props.dismissable && 'CallingToast--dismissable'
); );
@ -360,3 +395,21 @@ export function useCallingToasts(): CallingToastContextType {
[wrappedShowToast, callingToastContext] [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 // Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // 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 type { ActiveCallType } from '../types/Calling';
import { CallMode } from '../types/Calling'; import { CallMode } from '../types/Calling';
import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationType } from '../state/ducks/conversations';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import { isReconnecting } from '../util/callingIsReconnecting';
import { CallingToastProvider, useCallingToasts } from './CallingToast'; import { CallingToastProvider, useCallingToasts } from './CallingToast';
import { Spinner } from './Spinner';
import { usePrevious } from '../hooks/usePrevious'; import { usePrevious } from '../hooks/usePrevious';
type PropsType = { type PropsType = {
@ -16,35 +14,13 @@ type PropsType = {
i18n: LocalizerType; 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'); const ME = Symbol('me');
function getCurrentPresenter( function getCurrentPresenter(
activeCall: Readonly<ActiveCallType> activeCall: Readonly<ActiveCallType>
): ConversationType | typeof ME | undefined { ): ConversationType | { id: typeof ME } | undefined {
if (activeCall.presentingSource) { if (activeCall.presentingSource) {
return ME; return { id: ME };
} }
if (activeCall.callMode === CallMode.Direct) { if (activeCall.callMode === CallMode.Direct) {
const isOtherPersonPresenting = activeCall.remoteParticipants.some( const isOtherPersonPresenting = activeCall.remoteParticipants.some(
@ -64,30 +40,21 @@ export function useScreenSharingStoppedToast({
activeCall, activeCall,
i18n, i18n,
}: PropsType): void { }: PropsType): void {
const [previousPresenter, setPreviousPresenter] = useState< const { showToast, hideToast } = useCallingToasts();
undefined | { id: string | typeof ME; title?: string }
>(undefined); const SOMEONE_STOPPED_PRESENTING_TOAST_KEY = 'someone_stopped_presenting';
const { showToast } = useCallingToasts();
const currentPresenter = useMemo(
() => getCurrentPresenter(activeCall),
[activeCall]
);
const previousPresenter = usePrevious(currentPresenter, currentPresenter);
useEffect(() => { useEffect(() => {
const currentPresenter = getCurrentPresenter(activeCall); if (previousPresenter && !currentPresenter) {
if (currentPresenter === ME) { hideToast(SOMEONE_STOPPED_PRESENTING_TOAST_KEY);
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) {
showToast({ showToast({
key: SOMEONE_STOPPED_PRESENTING_TOAST_KEY,
content: content:
previousPresenter.id === ME previousPresenter.id === ME
? i18n('icu:calling__presenting--you-stopped') ? i18n('icu:calling__presenting--you-stopped')
@ -97,7 +64,14 @@ export function useScreenSharingStoppedToast({
autoClose: true, autoClose: true,
}); });
} }
}, [activeCall, previousPresenter, showToast, i18n]); }, [
activeCall,
hideToast,
currentPresenter,
previousPresenter,
showToast,
i18n,
]);
} }
function useMutedToast({ function useMutedToast({
@ -175,7 +149,7 @@ export function CallingButtonToastsContainer(
return ( return (
<CallingToastProvider <CallingToastProvider
i18n={props.i18n} i18n={props.i18n}
maxToasts={1} maxNonPersistentToasts={1}
region={toastRegionRef} region={toastRegionRef}
> >
<div className="CallingButtonToasts" ref={toastRegionRef} /> <div className="CallingButtonToasts" ref={toastRegionRef} />

View file

@ -3,7 +3,7 @@
import { assert } from 'chai'; import { assert } from 'chai';
import { isEqual, remove, toggle } from '../../util/setUtil'; import { difference, isEqual, remove, toggle } from '../../util/setUtil';
describe('set utilities', () => { describe('set utilities', () => {
const original = new Set([1, 2, 3]); const original = new Set([1, 2, 3]);
@ -82,4 +82,21 @@ describe('set utilities', () => {
assert.deepStrictEqual(original, new Set([1, 2, 3])); 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", "reasonCategory": "usageTrusted",
"updated": "2023-10-26T13:57:41.860Z" "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", "rule": "React-useRef",
"path": "ts/components/CallsList.tsx", "path": "ts/components/CallsList.tsx",

View file

@ -27,3 +27,14 @@ export const isEqual = (
a: Readonly<Set<unknown>>, a: Readonly<Set<unknown>>,
b: Readonly<Set<unknown>> b: Readonly<Set<unknown>>
): boolean => a === b || (a.size === b.size && every(a, item => b.has(item))); ): 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;
};