diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 69006a042d46..fc6e1e6b4a6b 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1864,6 +1864,14 @@ "messageformat": "You won't receive their voice or video and they won't receive yours.", "description": "Shown in the modal dialog to describe how blocking works in a group call" }, + "icu:calling__missing-media-keys": { + "messageformat": "Can't receive audio and video from {name}", + "description": "When you can't view someone's audio and video in a call because their media keys are unavailable" + }, + "icu:calling__missing-media-keys-info": { + "messageformat": "This may be because they have not verified your safety number change, there's a problem with their device, or they have blocked you.", + "description": "Detailed explanation why you can't view someone's audio and video in a call because their media keys are unavailable." + }, "icu:calling__overflow__scroll-up": { "messageformat": "Scroll up", "description": "Label for the \"scroll up\" button in a call's overflow area" diff --git a/images/icons/v3/error/error-circle-solid.svg b/images/icons/v3/error/error-circle-solid.svg new file mode 100644 index 000000000000..a739416b14a8 --- /dev/null +++ b/images/icons/v3/error/error-circle-solid.svg @@ -0,0 +1,3 @@ + + + diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index c70e46f1369e..253a663462a2 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -3824,6 +3824,14 @@ button.module-image__border-overlay:focus { padding-block: 0 14px; padding-inline: 16px; } + + .module-ongoing-call__group-call-remote-participant__error-icon { + margin-block-end: 16px; + } + + .module-ongoing-call__group-call-remote-participant__error { + display: block; + } } &--hand-raised { @@ -3861,6 +3869,7 @@ button.module-image__border-overlay:focus { margin-bottom: 1rem; &__footer { + height: 40px; padding-block: 0 8px; padding-inline: 10px; } @@ -4029,23 +4038,38 @@ button.module-image__border-overlay:focus { background-color: $color-gray-78; } - &__blocked { - @include color-svg('../images/icons/v3/block/block.svg', $color-white); - height: 24px; - margin-bottom: 16px; - width: 24px; + &-background .module-calling__background--blur { + pointer-events: none; + } - &--info { - @include button-reset; - background-color: $color-gray-75; - border-radius: 16px; - color: $color-white; - line-height: 16px; - padding-block: 3px; - padding-inline: 10px; - } + &__error { + // Hide it here in the general case, and reveal it in the grid layout + // when @container size is big enough + display: none; - &--modal-title { + margin-block-end: 12px; + margin-inline: 8px; + font-size: 12px; + line-height: 16px; + color: $color-white; + text-align: center; + z-index: $z-index-base; + } + + &__more-info { + @include button-reset; + padding-block: 3px; + padding-inline: 10px; + border-radius: 16px; + background-color: $color-gray-75; + color: $color-white; + font-size: 12px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; + z-index: $z-index-above-base; + + &-modal-title { -webkit-box-orient: vertical; -webkit-line-clamp: 2; display: -webkit-box; @@ -4054,6 +4078,23 @@ button.module-image__border-overlay:focus { } } + &__error-icon { + width: 24px; + height: 24px; + margin-block-end: 8px; + + &--blocked { + @include color-svg('../images/icons/v3/block/block.svg', $color-white); + } + + &--missing-media-keys { + @include color-svg( + '../images/icons/v3/error/error-circle-solid.svg', + $color-white + ); + } + } + &__footer { display: flex; position: absolute; diff --git a/ts/components/CallScreen.stories.tsx b/ts/components/CallScreen.stories.tsx index 60cad971a3c7..390e2376ede0 100644 --- a/ts/components/CallScreen.stories.tsx +++ b/ts/components/CallScreen.stories.tsx @@ -326,6 +326,7 @@ export function GroupCall1(): JSX.Element { hasRemoteAudio: true, hasRemoteVideo: true, isHandRaised: false, + mediaKeysReceived: true, presenting: false, sharingScreen: false, videoAspectRatio: 1.3, @@ -353,6 +354,7 @@ export function GroupCallYourHandRaised(): JSX.Element { hasRemoteAudio: true, hasRemoteVideo: true, isHandRaised: false, + mediaKeysReceived: true, presenting: false, sharingScreen: false, videoAspectRatio: 1.3, @@ -372,26 +374,32 @@ export function GroupCallYourHandRaised(): JSX.Element { const PARTICIPANT_EMOJIS = ['❤️', '🤔', '✨', '😂', '🦄'] as const; // We generate these upfront so that the list is stable when you move the slider. -const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => ({ - aci: generateAci(), - demuxId: index, - hasRemoteAudio: index % 3 !== 0, - hasRemoteVideo: index % 4 !== 0, - isHandRaised: (index - 3) % 10 === 0, - presenting: false, - sharingScreen: false, - videoAspectRatio: Math.random() < 0.7 ? 1.3 : Math.random() * 0.4 + 0.6, - ...getDefaultConversationWithServiceId({ - isBlocked: index === 10 || index === MAX_PARTICIPANTS - 1, - title: `Participant ${ - (index - 2) % 4 === 0 - ? PARTICIPANT_EMOJIS[ - Math.floor((index - 2) / 4) % PARTICIPANT_EMOJIS.length - ] - : '' - } ${index + 1}`, - }), -})); +const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => { + const mediaKeysReceived = (index + 1) % 20 !== 0; + + return { + aci: generateAci(), + addedTime: Date.now() - 60000, + demuxId: index, + hasRemoteAudio: mediaKeysReceived ? index % 3 !== 0 : false, + hasRemoteVideo: mediaKeysReceived ? index % 4 !== 0 : false, + isHandRaised: (index - 3) % 10 === 0, + mediaKeysReceived, + presenting: false, + sharingScreen: false, + videoAspectRatio: Math.random() < 0.7 ? 1.3 : Math.random() * 0.4 + 0.6, + ...getDefaultConversationWithServiceId({ + isBlocked: index === 10 || index === MAX_PARTICIPANTS - 1, + title: `Participant ${ + (index - 2) % 4 === 0 + ? PARTICIPANT_EMOJIS[ + Math.floor((index - 2) / 4) % PARTICIPANT_EMOJIS.length + ] + : '' + } ${index + 1}`, + }), + }; +}); export function GroupCallManyPaginated(): JSX.Element { const props = createProps({ @@ -471,6 +479,7 @@ export function GroupCallReconnecting(): JSX.Element { hasRemoteAudio: true, hasRemoteVideo: true, isHandRaised: false, + mediaKeysReceived: true, presenting: false, sharingScreen: false, videoAspectRatio: 1.3, @@ -793,3 +802,38 @@ function useHandRaiser( }, [frequency, call, max, min]); return call; } + +export function GroupCallSomeoneMissingMediaKeys(): JSX.Element { + return ( + ({ + ...participant, + addedTime: index === 1 ? Date.now() - 60000 : undefined, + hasRemoteAudio: false, + hasRemoteVideo: false, + mediaKeysReceived: index !== 1, + })), + })} + /> + ); +} + +export function GroupCallSomeoneBlocked(): JSX.Element { + return ( + ({ + ...participant, + isBlocked: index === 1, + })), + })} + /> + ); +} diff --git a/ts/components/CallingParticipantsList.stories.tsx b/ts/components/CallingParticipantsList.stories.tsx index c127d591b09a..b325903e15a3 100644 --- a/ts/components/CallingParticipantsList.stories.tsx +++ b/ts/components/CallingParticipantsList.stories.tsx @@ -26,6 +26,7 @@ function createParticipant( hasRemoteAudio: Boolean(participantProps.hasRemoteAudio), hasRemoteVideo: Boolean(participantProps.hasRemoteVideo), isHandRaised: Boolean(participantProps.isHandRaised), + mediaKeysReceived: Boolean(participantProps.mediaKeysReceived), presenting: Boolean(participantProps.presenting), sharingScreen: Boolean(participantProps.sharingScreen), videoAspectRatio: 1.3, diff --git a/ts/components/GroupCallOverflowArea.stories.tsx b/ts/components/GroupCallOverflowArea.stories.tsx index 42bf75c73581..f1070e6e01eb 100644 --- a/ts/components/GroupCallOverflowArea.stories.tsx +++ b/ts/components/GroupCallOverflowArea.stories.tsx @@ -24,6 +24,7 @@ const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => ({ hasRemoteAudio: index % 3 !== 0, hasRemoteVideo: index % 4 !== 0, isHandRaised: (index - 2) % 8 === 0, + mediaKeysReceived: (index + 1) % 20 !== 0, presenting: false, sharingScreen: false, videoAspectRatio: 1.3, diff --git a/ts/components/GroupCallRemoteParticipant.stories.tsx b/ts/components/GroupCallRemoteParticipant.stories.tsx index 507a797a6b5c..639ebfa479fb 100644 --- a/ts/components/GroupCallRemoteParticipant.stories.tsx +++ b/ts/components/GroupCallRemoteParticipant.stories.tsx @@ -35,13 +35,17 @@ const getFrameBuffer = memoize(() => Buffer.alloc(FRAME_BUFFER_SIZE)); const createProps = ( overrideProps: OverridePropsType, { + addedTime, isBlocked = false, - hasRemoteAudio = false, - presenting = false, isHandRaised = false, + hasRemoteAudio = false, + mediaKeysReceived = true, + presenting = false, }: { + addedTime?: number; isBlocked?: boolean; hasRemoteAudio?: boolean; + mediaKeysReceived?: boolean; presenting?: boolean; isHandRaised?: boolean; } = {} @@ -54,10 +58,12 @@ const createProps = ( audioLevel: 0, remoteParticipant: { aci: generateAci(), + addedTime, demuxId: 123, hasRemoteAudio, hasRemoteVideo: true, isHandRaised, + mediaKeysReceived, presenting, sharingScreen: false, videoAspectRatio: 1.3, @@ -165,3 +171,24 @@ export function Blocked(): JSX.Element { /> ); } + +export function NoMediaKeys(): JSX.Element { + return ( + + ); +} diff --git a/ts/components/GroupCallRemoteParticipant.tsx b/ts/components/GroupCallRemoteParticipant.tsx index 274ca6a47d43..42c26cbe8d6b 100644 --- a/ts/components/GroupCallRemoteParticipant.tsx +++ b/ts/components/GroupCallRemoteParticipant.tsx @@ -27,9 +27,12 @@ import { ContactName } from './conversation/ContactName'; import { useIntersectionObserver } from '../hooks/useIntersectionObserver'; import { MAX_FRAME_HEIGHT, MAX_FRAME_WIDTH } from '../calling/constants'; import { useValueAtFixedRate } from '../hooks/useValueAtFixedRate'; +import { Theme } from '../util/theme'; +import { isOlderThan } from '../util/timestamp'; const MAX_TIME_TO_SHOW_STALE_VIDEO_FRAMES = 10000; const MAX_TIME_TO_SHOW_STALE_SCREENSHARE_FRAMES = 60000; +const DELAY_TO_SHOW_MISSING_MEDIA_KEYS = 5000; type BasePropsType = { getFrameBuffer: () => Buffer; @@ -77,6 +80,7 @@ export const GroupCallRemoteParticipant: React.FC = React.memo( const { acceptedMessageRequest, + addedTime, avatarPath, color, demuxId, @@ -85,6 +89,7 @@ export const GroupCallRemoteParticipant: React.FC = React.memo( isHandRaised, isBlocked, isMe, + mediaKeysReceived, profileName, sharedGroupNames, sharingScreen, @@ -102,7 +107,7 @@ export const GroupCallRemoteParticipant: React.FC = React.memo( const [isWide, setIsWide] = useState( videoAspectRatio ? videoAspectRatio >= 1 : true ); - const [showBlockInfo, setShowBlockInfo] = useState(false); + const [showErrorDialog, setShowErrorDialog] = useState(false); // We have some state (`hasReceivedVideoRecently`) and this ref. We can't have a // single state value like `lastReceivedVideoAt` because (1) it won't automatically @@ -129,6 +134,11 @@ export const GroupCallRemoteParticipant: React.FC = React.memo( const wantsToShowVideo = hasRemoteVideo && !isBlocked && isVisible; const hasVideoToShow = wantsToShowVideo && hasReceivedVideoRecently; + const showMissingMediaKeys = Boolean( + !mediaKeysReceived && + addedTime && + isOlderThan(addedTime, DELAY_TO_SHOW_MISSING_MEDIA_KEYS) + ); const videoFrameSource = useMemo( () => getGroupCallVideoFrameSource(demuxId), @@ -293,29 +303,94 @@ export const GroupCallRemoteParticipant: React.FC = React.memo( } } + let noVideoNode: ReactNode; + let errorDialogTitle: ReactNode; + let errorDialogBody = ''; + if (!hasVideoToShow) { + const showDialogButton = ( + + ); + if (isBlocked) { + noVideoNode = ( + <> + + {showDialogButton} + + ); + errorDialogTitle = ( +
+ , + }} + /> +
+ ); + errorDialogBody = i18n('icu:calling__block-info'); + } else if (showMissingMediaKeys) { + noVideoNode = ( + <> + +
+ {i18n('icu:calling__missing-media-keys', { name: title })} +
+ {showDialogButton} + + ); + errorDialogTitle = ( +
+ , + }} + /> +
+ ); + errorDialogBody = i18n('icu:calling__missing-media-keys-info'); + } else { + noVideoNode = ( + + ); + } + } + return ( <> - {showBlockInfo && ( + {showErrorDialog && ( { - setShowBlockInfo(false); - }} - title={ -
- , - }} - /> -
- } + onClose={() => setShowErrorDialog(false)} + theme={Theme.Dark} + title={errorDialogTitle} > - {i18n('icu:calling__block-info')} + {errorDialogBody}
)} @@ -372,40 +447,12 @@ export const GroupCallRemoteParticipant: React.FC = React.memo( }} /> )} - {!hasVideoToShow && ( + {noVideoNode && ( - {isBlocked ? ( - <> - - - - ) : ( - - )} + {noVideoNode} )} diff --git a/ts/services/calling.ts b/ts/services/calling.ts index 923a7d9bd93a..18cf8f768589 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -1086,11 +1086,14 @@ export class CallingClass { aci = '00000000-0000-4000-8000-000000000000'; } assertDev(isAciString(aci), 'remote participant aci must be a aci'); + return { aci, + addedTime: normalizeGroupCallTimestamp(remoteDeviceState.addedTime), demuxId: remoteDeviceState.demuxId, hasRemoteAudio: !remoteDeviceState.audioMuted, hasRemoteVideo: !remoteDeviceState.videoMuted, + mediaKeysReceived: remoteDeviceState.mediaKeysReceived, presenting: Boolean(remoteDeviceState.presenting), sharingScreen: Boolean(remoteDeviceState.sharingScreen), speakerTime: normalizeGroupCallTimestamp( diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index 89f6f40bfa98..ab1fb90108ec 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -76,9 +76,11 @@ export type GroupCallPeekInfoType = ReadonlyDeep<{ // eslint-disable-next-line local-rules/type-alias-readonlydeep export type GroupCallParticipantInfoType = { aci: AciString; + addedTime?: number; demuxId: number; hasRemoteAudio: boolean; hasRemoteVideo: boolean; + mediaKeysReceived: boolean; presenting: boolean; sharingScreen: boolean; speakerTime?: number; diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index 4c55d1495384..147885f68bed 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -246,10 +246,12 @@ const mapStateToActiveCallProp = ( remoteParticipants.push({ ...remoteConversation, aci: remoteParticipant.aci, + addedTime: remoteParticipant.addedTime, demuxId: remoteParticipant.demuxId, hasRemoteAudio: remoteParticipant.hasRemoteAudio, hasRemoteVideo: remoteParticipant.hasRemoteVideo, isHandRaised: raisedHands.has(remoteParticipant.demuxId), + mediaKeysReceived: remoteParticipant.mediaKeysReceived, presenting: remoteParticipant.presenting, sharingScreen: remoteParticipant.sharingScreen, speakerTime: remoteParticipant.speakerTime, diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index b2a43c9e99f4..55dae23ed0f9 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -118,6 +118,7 @@ describe('calling duck', () => { demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, + mediaKeysReceived: true, presenting: false, sharingScreen: false, videoAspectRatio: 4 / 3, @@ -906,6 +907,7 @@ describe('calling duck', () => { demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, + mediaKeysReceived: true, presenting: false, sharingScreen: false, videoAspectRatio: 4 / 3, @@ -935,6 +937,7 @@ describe('calling duck', () => { demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, + mediaKeysReceived: true, presenting: false, sharingScreen: false, videoAspectRatio: 4 / 3, @@ -966,6 +969,7 @@ describe('calling duck', () => { demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, + mediaKeysReceived: true, presenting: false, sharingScreen: false, videoAspectRatio: 16 / 9, @@ -993,6 +997,7 @@ describe('calling duck', () => { demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, + mediaKeysReceived: true, presenting: false, sharingScreen: false, videoAspectRatio: 16 / 9, @@ -1037,6 +1042,7 @@ describe('calling duck', () => { demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, + mediaKeysReceived: true, presenting: false, sharingScreen: false, videoAspectRatio: 16 / 9, @@ -1089,6 +1095,7 @@ describe('calling duck', () => { demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, + mediaKeysReceived: true, presenting: false, sharingScreen: false, videoAspectRatio: 16 / 9, @@ -1128,6 +1135,7 @@ describe('calling duck', () => { demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, + mediaKeysReceived: true, presenting: false, sharingScreen: false, videoAspectRatio: 16 / 9, @@ -1160,6 +1168,7 @@ describe('calling duck', () => { demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, + mediaKeysReceived: true, presenting: false, sharingScreen: false, videoAspectRatio: 16 / 9, @@ -1204,6 +1213,7 @@ describe('calling duck', () => { demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, + mediaKeysReceived: true, presenting: false, sharingScreen: false, videoAspectRatio: 16 / 9, @@ -1882,6 +1892,7 @@ describe('calling duck', () => { demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, + mediaKeysReceived: true, presenting: false, sharingScreen: false, videoAspectRatio: 4 / 3, @@ -1908,6 +1919,7 @@ describe('calling duck', () => { demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, + mediaKeysReceived: true, presenting: false, sharingScreen: false, videoAspectRatio: 4 / 3, @@ -1954,6 +1966,7 @@ describe('calling duck', () => { demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, + mediaKeysReceived: true, presenting: false, sharingScreen: false, videoAspectRatio: 4 / 3, @@ -2004,6 +2017,7 @@ describe('calling duck', () => { demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, + mediaKeysReceived: true, presenting: false, sharingScreen: false, videoAspectRatio: 4 / 3, @@ -2048,6 +2062,7 @@ describe('calling duck', () => { demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, + mediaKeysReceived: true, presenting: false, sharingScreen: false, videoAspectRatio: 4 / 3, diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts index b29da3b6e9bf..5aa89f3e3bd1 100644 --- a/ts/types/Calling.ts +++ b/ts/types/Calling.ts @@ -156,10 +156,12 @@ export enum GroupCallJoinState { export type GroupCallRemoteParticipantType = ConversationType & { aci: AciString; + addedTime?: number; demuxId: number; hasRemoteAudio: boolean; hasRemoteVideo: boolean; isHandRaised: boolean; + mediaKeysReceived: boolean; presenting: boolean; sharingScreen: boolean; speakerTime?: number;