619 lines
		
	
	
	
		
			20 KiB
			
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			619 lines
		
	
	
	
		
			20 KiB
			
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
// Copyright 2020 Signal Messenger, LLC
 | 
						|
// SPDX-License-Identifier: AGPL-3.0-only
 | 
						|
 | 
						|
import type { CSSProperties, ReactNode } from 'react';
 | 
						|
import React, {
 | 
						|
  useState,
 | 
						|
  useRef,
 | 
						|
  useMemo,
 | 
						|
  useCallback,
 | 
						|
  useEffect,
 | 
						|
} from 'react';
 | 
						|
import classNames from 'classnames';
 | 
						|
import lodash from 'lodash';
 | 
						|
import type { VideoFrameSource } from '@signalapp/ringrtc';
 | 
						|
import type { GroupCallRemoteParticipantType } from '../types/Calling.js';
 | 
						|
import type { LocalizerType } from '../types/Util.js';
 | 
						|
import { AvatarColors } from '../types/Colors.js';
 | 
						|
import { CallBackgroundBlur } from './CallBackgroundBlur.js';
 | 
						|
import {
 | 
						|
  CallingAudioIndicator,
 | 
						|
  SPEAKING_LINGER_MS,
 | 
						|
} from './CallingAudioIndicator.js';
 | 
						|
import { Avatar, AvatarSize } from './Avatar.js';
 | 
						|
import { ConfirmationDialog } from './ConfirmationDialog.js';
 | 
						|
import { I18n } from './I18n.js';
 | 
						|
import { ContactName } from './conversation/ContactName.js';
 | 
						|
import { useIntersectionObserver } from '../hooks/useIntersectionObserver.js';
 | 
						|
import { MAX_FRAME_HEIGHT, MAX_FRAME_WIDTH } from '../calling/constants.js';
 | 
						|
import { useValueAtFixedRate } from '../hooks/useValueAtFixedRate.js';
 | 
						|
import { Theme } from '../util/theme.js';
 | 
						|
import { isOlderThan } from '../util/timestamp.js';
 | 
						|
import type { CallingImageDataCache } from './CallManager.js';
 | 
						|
import { usePrevious } from '../hooks/usePrevious.js';
 | 
						|
 | 
						|
const { debounce, noop } = lodash;
 | 
						|
 | 
						|
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;
 | 
						|
 | 
						|
// Should match transition time in .module-ongoing-call__group-call-remote-participant
 | 
						|
const CONTAINER_TRANSITION_TIME = 200;
 | 
						|
 | 
						|
type BasePropsType = {
 | 
						|
  getFrameBuffer: () => Buffer;
 | 
						|
  getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
 | 
						|
  i18n: LocalizerType;
 | 
						|
  imageDataCache: React.RefObject<CallingImageDataCache>;
 | 
						|
  isActiveSpeakerInSpeakerView: boolean;
 | 
						|
  isCallReconnecting: boolean;
 | 
						|
  isInOverflow?: boolean;
 | 
						|
  joinedAt: number | null;
 | 
						|
  onClickRaisedHand?: () => void;
 | 
						|
  onVisibilityChanged?: (demuxId: number, isVisible: boolean) => unknown;
 | 
						|
  remoteParticipant: GroupCallRemoteParticipantType;
 | 
						|
  remoteParticipantsCount: number;
 | 
						|
};
 | 
						|
 | 
						|
type InPipPropsType = {
 | 
						|
  isInPip: true;
 | 
						|
};
 | 
						|
 | 
						|
type InOverflowAreaPropsType = {
 | 
						|
  height: number;
 | 
						|
  isInPip?: false;
 | 
						|
  audioLevel: number;
 | 
						|
  width: number;
 | 
						|
};
 | 
						|
 | 
						|
type InGridPropsType = InOverflowAreaPropsType & {
 | 
						|
  left: number;
 | 
						|
  top: number;
 | 
						|
};
 | 
						|
 | 
						|
export type PropsType = BasePropsType &
 | 
						|
  (InPipPropsType | InOverflowAreaPropsType | InGridPropsType);
 | 
						|
 | 
						|
export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
 | 
						|
  function GroupCallRemoteParticipantInner(props) {
 | 
						|
    const {
 | 
						|
      getFrameBuffer,
 | 
						|
      getGroupCallVideoFrameSource,
 | 
						|
      imageDataCache,
 | 
						|
      i18n,
 | 
						|
      onClickRaisedHand,
 | 
						|
      onVisibilityChanged,
 | 
						|
      remoteParticipantsCount,
 | 
						|
      isActiveSpeakerInSpeakerView,
 | 
						|
      isCallReconnecting,
 | 
						|
      isInOverflow,
 | 
						|
      joinedAt,
 | 
						|
    } = props;
 | 
						|
 | 
						|
    const {
 | 
						|
      avatarPlaceholderGradient,
 | 
						|
      addedTime,
 | 
						|
      avatarUrl,
 | 
						|
      color,
 | 
						|
      demuxId,
 | 
						|
      hasAvatar,
 | 
						|
      hasRemoteAudio,
 | 
						|
      hasRemoteVideo,
 | 
						|
      isHandRaised,
 | 
						|
      isBlocked,
 | 
						|
      mediaKeysReceived,
 | 
						|
      profileName,
 | 
						|
      sharedGroupNames,
 | 
						|
      sharingScreen,
 | 
						|
      title,
 | 
						|
      titleNoDefault,
 | 
						|
      videoAspectRatio,
 | 
						|
    } = props.remoteParticipant;
 | 
						|
 | 
						|
    const isSpeaking = useValueAtFixedRate(
 | 
						|
      !props.isInPip ? props.audioLevel > 0 : false,
 | 
						|
      SPEAKING_LINGER_MS
 | 
						|
    );
 | 
						|
    const previousSharingScreen = usePrevious(sharingScreen, sharingScreen);
 | 
						|
    const prevIsActiveSpeakerInSpeakerView = usePrevious(
 | 
						|
      isActiveSpeakerInSpeakerView,
 | 
						|
      isActiveSpeakerInSpeakerView
 | 
						|
    );
 | 
						|
 | 
						|
    const isImageDataCached =
 | 
						|
      sharingScreen && imageDataCache.current?.has(demuxId);
 | 
						|
    const [hasReceivedVideoRecently, setHasReceivedVideoRecently] =
 | 
						|
      useState(isImageDataCached);
 | 
						|
    const [isWide, setIsWide] = useState<boolean>(
 | 
						|
      videoAspectRatio ? videoAspectRatio >= 1 : true
 | 
						|
    );
 | 
						|
    const [showErrorDialog, setShowErrorDialog] = useState(false);
 | 
						|
    const [isOnTop, setIsOnTop] = 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
 | 
						|
    //   trigger a re-render after the video has become stale (2) it would cause a full
 | 
						|
    //   re-render of the component for every frame, which is way too often.
 | 
						|
    //
 | 
						|
    // Alternatively, we could create a timeout that's reset every time we get a video
 | 
						|
    //   frame (perhaps using a debounce function), but that becomes harder to clean up
 | 
						|
    //   when the component unmounts.
 | 
						|
    const lastReceivedVideoAt = useRef(-Infinity);
 | 
						|
    const remoteVideoRef = useRef<HTMLCanvasElement | null>(null);
 | 
						|
    const canvasContextRef = useRef<CanvasRenderingContext2D | null>(null);
 | 
						|
    const imageDataRef = useRef<ImageData | null>(null);
 | 
						|
 | 
						|
    const [intersectionRef, intersectionObserverEntry] =
 | 
						|
      useIntersectionObserver();
 | 
						|
    const isVisible = intersectionObserverEntry
 | 
						|
      ? intersectionObserverEntry.isIntersecting
 | 
						|
      : true;
 | 
						|
 | 
						|
    useEffect(() => {
 | 
						|
      onVisibilityChanged?.(demuxId, isVisible);
 | 
						|
    }, [demuxId, isVisible, onVisibilityChanged]);
 | 
						|
 | 
						|
    useEffect(() => {
 | 
						|
      if (sharingScreen !== previousSharingScreen) {
 | 
						|
        imageDataCache.current?.delete(demuxId);
 | 
						|
      }
 | 
						|
    }, [demuxId, imageDataCache, previousSharingScreen, sharingScreen]);
 | 
						|
 | 
						|
    const wantsToShowVideo = hasRemoteVideo && !isBlocked && isVisible;
 | 
						|
    const hasVideoToShow = wantsToShowVideo && hasReceivedVideoRecently;
 | 
						|
 | 
						|
    // Use the later of participant join time (addedTime) vs your join time (joinedAt)
 | 
						|
    const timeForMissingMediaKeysCheck =
 | 
						|
      addedTime && joinedAt && addedTime > joinedAt ? addedTime : joinedAt;
 | 
						|
    const showMissingMediaKeys = Boolean(
 | 
						|
      !mediaKeysReceived &&
 | 
						|
        timeForMissingMediaKeysCheck &&
 | 
						|
        isOlderThan(
 | 
						|
          timeForMissingMediaKeysCheck,
 | 
						|
          DELAY_TO_SHOW_MISSING_MEDIA_KEYS
 | 
						|
        )
 | 
						|
    );
 | 
						|
 | 
						|
    const videoFrameSource = useMemo(
 | 
						|
      () => getGroupCallVideoFrameSource(demuxId),
 | 
						|
      [getGroupCallVideoFrameSource, demuxId]
 | 
						|
    );
 | 
						|
 | 
						|
    const renderVideoFrame = useCallback(() => {
 | 
						|
      const frameAge = Date.now() - lastReceivedVideoAt.current;
 | 
						|
      const maxFrameAge = sharingScreen
 | 
						|
        ? MAX_TIME_TO_SHOW_STALE_SCREENSHARE_FRAMES
 | 
						|
        : MAX_TIME_TO_SHOW_STALE_VIDEO_FRAMES;
 | 
						|
      if (frameAge > maxFrameAge) {
 | 
						|
        // We consider that we have received video recently from a remote participant if
 | 
						|
        // we have received it recently relative to the last time we had a connection. If
 | 
						|
        // we lost their video due to our reconnecting, we still want to show the last
 | 
						|
        // frame of video (blurred out) until we have reconnected.
 | 
						|
        if (!isCallReconnecting) {
 | 
						|
          setHasReceivedVideoRecently(false);
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      const canvasEl = remoteVideoRef.current;
 | 
						|
      if (!canvasEl) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      const canvasContext = canvasContextRef.current;
 | 
						|
      if (!canvasContext) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      // This frame buffer is shared by all participants, so it may contain pixel data
 | 
						|
      //   for other participants, or pixel data from a previous frame. That's why we
 | 
						|
      //   return early and use the `frameWidth` and `frameHeight`.
 | 
						|
      let frameWidth: number | undefined;
 | 
						|
      let frameHeight: number | undefined;
 | 
						|
      let imageData = imageDataRef.current;
 | 
						|
 | 
						|
      const frameBuffer = getFrameBuffer();
 | 
						|
      const frameDimensions = videoFrameSource.receiveVideoFrame(
 | 
						|
        frameBuffer,
 | 
						|
        MAX_FRAME_WIDTH,
 | 
						|
        MAX_FRAME_HEIGHT
 | 
						|
      );
 | 
						|
      if (frameDimensions) {
 | 
						|
        [frameWidth, frameHeight] = frameDimensions;
 | 
						|
 | 
						|
        if (
 | 
						|
          frameWidth < 2 ||
 | 
						|
          frameHeight < 2 ||
 | 
						|
          frameWidth > MAX_FRAME_WIDTH ||
 | 
						|
          frameHeight > MAX_FRAME_HEIGHT
 | 
						|
        ) {
 | 
						|
          return;
 | 
						|
        }
 | 
						|
 | 
						|
        if (
 | 
						|
          imageData?.width !== frameWidth ||
 | 
						|
          imageData?.height !== frameHeight ||
 | 
						|
          imageData?.data.buffer !== frameBuffer.buffer ||
 | 
						|
          imageData?.data.byteOffset !== frameBuffer.byteOffset
 | 
						|
        ) {
 | 
						|
          const view = new Uint8ClampedArray(
 | 
						|
            frameBuffer.buffer,
 | 
						|
            frameBuffer.byteOffset,
 | 
						|
            frameWidth * frameHeight * 4
 | 
						|
          );
 | 
						|
          imageData = new ImageData(view, frameWidth, frameHeight);
 | 
						|
          imageDataRef.current = imageData;
 | 
						|
        }
 | 
						|
 | 
						|
        // Screen share is at a slow FPS so updates slowly if we PiP then restore.
 | 
						|
        // Cache the image data so we can quickly show the most recent frame.
 | 
						|
        if (sharingScreen) {
 | 
						|
          imageDataCache.current?.set(demuxId, imageData);
 | 
						|
        }
 | 
						|
      } else if (sharingScreen && !imageData) {
 | 
						|
        // Try to use the screenshare cache the first time we show
 | 
						|
        const cachedImageData = imageDataCache.current?.get(demuxId);
 | 
						|
        if (cachedImageData) {
 | 
						|
          frameWidth = cachedImageData.width;
 | 
						|
          frameHeight = cachedImageData.height;
 | 
						|
          imageDataRef.current = cachedImageData;
 | 
						|
          imageData = cachedImageData;
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      if (!frameWidth || !frameHeight || !imageData) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      canvasEl.width = frameWidth;
 | 
						|
      canvasEl.height = frameHeight;
 | 
						|
      canvasContext.putImageData(imageData, 0, 0);
 | 
						|
      lastReceivedVideoAt.current = Date.now();
 | 
						|
 | 
						|
      setHasReceivedVideoRecently(true);
 | 
						|
      setIsWide(frameWidth > frameHeight);
 | 
						|
    }, [
 | 
						|
      demuxId,
 | 
						|
      imageDataCache,
 | 
						|
      isCallReconnecting,
 | 
						|
      sharingScreen,
 | 
						|
      videoFrameSource,
 | 
						|
      getFrameBuffer,
 | 
						|
    ]);
 | 
						|
 | 
						|
    useEffect(() => {
 | 
						|
      if (!hasRemoteVideo) {
 | 
						|
        setHasReceivedVideoRecently(false);
 | 
						|
      }
 | 
						|
    }, [hasRemoteVideo]);
 | 
						|
 | 
						|
    useEffect(() => {
 | 
						|
      if (!hasRemoteVideo || !isVisible) {
 | 
						|
        return noop;
 | 
						|
      }
 | 
						|
 | 
						|
      let rafId = requestAnimationFrame(tick);
 | 
						|
 | 
						|
      function tick() {
 | 
						|
        renderVideoFrame();
 | 
						|
        rafId = requestAnimationFrame(tick);
 | 
						|
      }
 | 
						|
 | 
						|
      return () => {
 | 
						|
        cancelAnimationFrame(rafId);
 | 
						|
      };
 | 
						|
    }, [hasRemoteVideo, isVisible, renderVideoFrame, videoFrameSource]);
 | 
						|
 | 
						|
    const setIsOnTopDebounced = useMemo(
 | 
						|
      () => debounce(setIsOnTop, CONTAINER_TRANSITION_TIME),
 | 
						|
      [setIsOnTop]
 | 
						|
    );
 | 
						|
 | 
						|
    // When in speaker view or while transitioning out of it, keep the main speaker
 | 
						|
    // z-indexed above all other participants
 | 
						|
    useEffect(() => {
 | 
						|
      if (isActiveSpeakerInSpeakerView !== prevIsActiveSpeakerInSpeakerView) {
 | 
						|
        if (isActiveSpeakerInSpeakerView) {
 | 
						|
          setIsOnTop(true);
 | 
						|
        } else {
 | 
						|
          setIsOnTopDebounced(false);
 | 
						|
        }
 | 
						|
      }
 | 
						|
    }, [
 | 
						|
      prevIsActiveSpeakerInSpeakerView,
 | 
						|
      isActiveSpeakerInSpeakerView,
 | 
						|
      setIsOnTopDebounced,
 | 
						|
    ]);
 | 
						|
 | 
						|
    let canvasStyles: CSSProperties;
 | 
						|
    let containerStyles: CSSProperties;
 | 
						|
 | 
						|
    // If our `width` and `height` props don't match the canvas's aspect ratio, we want to
 | 
						|
    //   fill the container. This can happen when RingRTC gives us an inaccurate
 | 
						|
    //   `videoAspectRatio`, or if the container is an unexpected size.
 | 
						|
    if (isWide) {
 | 
						|
      canvasStyles = { width: '100%' };
 | 
						|
    } else {
 | 
						|
      canvasStyles = { height: '100%' };
 | 
						|
    }
 | 
						|
 | 
						|
    let avatarSize: number;
 | 
						|
    let footerInfoElement: ReactNode;
 | 
						|
 | 
						|
    if (props.isInPip) {
 | 
						|
      containerStyles = canvasStyles;
 | 
						|
      avatarSize = AvatarSize.FORTY_EIGHT;
 | 
						|
    } else {
 | 
						|
      const { width, height } = props;
 | 
						|
      const shorterDimension = Math.min(width, height);
 | 
						|
 | 
						|
      if (shorterDimension >= 180) {
 | 
						|
        avatarSize = AvatarSize.NINETY_SIX;
 | 
						|
      } else {
 | 
						|
        avatarSize = AvatarSize.FORTY_EIGHT;
 | 
						|
      }
 | 
						|
 | 
						|
      containerStyles = {
 | 
						|
        height,
 | 
						|
        width,
 | 
						|
      };
 | 
						|
 | 
						|
      if ('top' in props) {
 | 
						|
        const isRTL = i18n.getLocaleDirection() === 'rtl';
 | 
						|
 | 
						|
        containerStyles.position = 'absolute';
 | 
						|
 | 
						|
        const left = isRTL ? -props.left : props.left;
 | 
						|
        containerStyles.transform = `translate(${left}px, ${props.top}px)`;
 | 
						|
      }
 | 
						|
 | 
						|
      const nameElement = (
 | 
						|
        <ContactName
 | 
						|
          module="module-ongoing-call__group-call-remote-participant__info__contact-name"
 | 
						|
          title={title}
 | 
						|
        />
 | 
						|
      );
 | 
						|
 | 
						|
      if (isHandRaised) {
 | 
						|
        footerInfoElement = (
 | 
						|
          <button
 | 
						|
            className="module-ongoing-call__group-call-remote-participant__info module-ongoing-call__group-call-remote-participant__info--clickable"
 | 
						|
            onClick={onClickRaisedHand}
 | 
						|
            type="button"
 | 
						|
          >
 | 
						|
            <div className="CallingStatusIndicator CallingStatusIndicator--HandRaised" />
 | 
						|
            {nameElement}
 | 
						|
          </button>
 | 
						|
        );
 | 
						|
      } else {
 | 
						|
        footerInfoElement = (
 | 
						|
          <div className="module-ongoing-call__group-call-remote-participant__info">
 | 
						|
            {nameElement}
 | 
						|
          </div>
 | 
						|
        );
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    let noVideoNode: ReactNode;
 | 
						|
    if (!hasVideoToShow) {
 | 
						|
      const showDialogButton = (
 | 
						|
        <button
 | 
						|
          type="button"
 | 
						|
          className="module-ongoing-call__group-call-remote-participant__more-info"
 | 
						|
          onClick={() => {
 | 
						|
            setShowErrorDialog(true);
 | 
						|
          }}
 | 
						|
        >
 | 
						|
          {i18n('icu:moreInfo')}
 | 
						|
        </button>
 | 
						|
      );
 | 
						|
 | 
						|
      if (isBlocked) {
 | 
						|
        if (isInOverflow) {
 | 
						|
          noVideoNode = (
 | 
						|
            <button
 | 
						|
              type="button"
 | 
						|
              className="module-ongoing-call__group-call-remote-participant__more-info module-ongoing-call__group-call-remote-participant__more-info--icon module-ongoing-call__group-call-remote-participant__more-info--icon-blocked"
 | 
						|
              onClick={() => {
 | 
						|
                setShowErrorDialog(true);
 | 
						|
              }}
 | 
						|
              aria-label={i18n('icu:calling__blocked-participant', {
 | 
						|
                name: title,
 | 
						|
              })}
 | 
						|
            />
 | 
						|
          );
 | 
						|
        } else {
 | 
						|
          noVideoNode = (
 | 
						|
            <>
 | 
						|
              <i className="module-ongoing-call__group-call-remote-participant__error-icon module-ongoing-call__group-call-remote-participant__error-icon--blocked" />
 | 
						|
              <div className="module-ongoing-call__group-call-remote-participant__error">
 | 
						|
                {i18n('icu:calling__blocked-participant', { name: title })}
 | 
						|
              </div>
 | 
						|
              {showDialogButton}
 | 
						|
            </>
 | 
						|
          );
 | 
						|
        }
 | 
						|
      } else if (showMissingMediaKeys) {
 | 
						|
        const errorMessage = titleNoDefault
 | 
						|
          ? i18n('icu:calling__missing-media-keys', {
 | 
						|
              name: titleNoDefault,
 | 
						|
            })
 | 
						|
          : i18n('icu:calling__missing-media-keys--unknown-contact');
 | 
						|
        if (isInOverflow) {
 | 
						|
          noVideoNode = (
 | 
						|
            <button
 | 
						|
              type="button"
 | 
						|
              className="module-ongoing-call__group-call-remote-participant__more-info module-ongoing-call__group-call-remote-participant__more-info--icon module-ongoing-call__group-call-remote-participant__more-info--icon-missing-media-keys"
 | 
						|
              onClick={() => {
 | 
						|
                setShowErrorDialog(true);
 | 
						|
              }}
 | 
						|
              aria-label={errorMessage}
 | 
						|
            />
 | 
						|
          );
 | 
						|
        } else {
 | 
						|
          noVideoNode = (
 | 
						|
            <>
 | 
						|
              <i className="module-ongoing-call__group-call-remote-participant__error-icon module-ongoing-call__group-call-remote-participant__error-icon--missing-media-keys" />
 | 
						|
              <div className="module-ongoing-call__group-call-remote-participant__error">
 | 
						|
                {errorMessage}
 | 
						|
              </div>
 | 
						|
              {showDialogButton}
 | 
						|
            </>
 | 
						|
          );
 | 
						|
        }
 | 
						|
      } else {
 | 
						|
        noVideoNode = (
 | 
						|
          <Avatar
 | 
						|
            avatarPlaceholderGradient={avatarPlaceholderGradient}
 | 
						|
            avatarUrl={avatarUrl}
 | 
						|
            badge={undefined}
 | 
						|
            color={color || AvatarColors[0]}
 | 
						|
            noteToSelf={false}
 | 
						|
            conversationType="direct"
 | 
						|
            hasAvatar={hasAvatar}
 | 
						|
            i18n={i18n}
 | 
						|
            profileName={profileName}
 | 
						|
            title={title}
 | 
						|
            sharedGroupNames={sharedGroupNames}
 | 
						|
            size={avatarSize}
 | 
						|
          />
 | 
						|
        );
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    // Error dialog maintains state, so if you have it open and the underlying
 | 
						|
    // error changes or resolves, you can keep reading the same dialog info.
 | 
						|
    const [errorDialogTitle, setErrorDialogTitle] = useState<ReactNode | null>(
 | 
						|
      null
 | 
						|
    );
 | 
						|
    const [errorDialogBody, setErrorDialogBody] = useState<string>('');
 | 
						|
    useEffect(() => {
 | 
						|
      if (hasVideoToShow || showErrorDialog) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      if (isBlocked) {
 | 
						|
        setErrorDialogTitle(
 | 
						|
          <div className="module-ongoing-call__group-call-remote-participant__more-info-modal-title">
 | 
						|
            <I18n
 | 
						|
              i18n={i18n}
 | 
						|
              id="icu:calling__block-info-title"
 | 
						|
              components={{
 | 
						|
                name: <ContactName key="name" title={title} />,
 | 
						|
              }}
 | 
						|
            />
 | 
						|
          </div>
 | 
						|
        );
 | 
						|
        setErrorDialogBody(i18n('icu:calling__block-info'));
 | 
						|
      } else if (showMissingMediaKeys) {
 | 
						|
        setErrorDialogTitle(
 | 
						|
          <div className="module-ongoing-call__group-call-remote-participant__more-info-modal-title">
 | 
						|
            {titleNoDefault ? (
 | 
						|
              <I18n
 | 
						|
                i18n={i18n}
 | 
						|
                id="icu:calling__missing-media-keys"
 | 
						|
                components={{
 | 
						|
                  name: <ContactName key="name" title={titleNoDefault} />,
 | 
						|
                }}
 | 
						|
              />
 | 
						|
            ) : (
 | 
						|
              i18n('icu:calling__missing-media-keys--unknown-contact')
 | 
						|
            )}
 | 
						|
          </div>
 | 
						|
        );
 | 
						|
        setErrorDialogBody(i18n('icu:calling__missing-media-keys-info'));
 | 
						|
      } else {
 | 
						|
        setErrorDialogTitle(null);
 | 
						|
        setErrorDialogBody('');
 | 
						|
      }
 | 
						|
    }, [
 | 
						|
      hasVideoToShow,
 | 
						|
      i18n,
 | 
						|
      isBlocked,
 | 
						|
      showErrorDialog,
 | 
						|
      showMissingMediaKeys,
 | 
						|
      title,
 | 
						|
      titleNoDefault,
 | 
						|
    ]);
 | 
						|
 | 
						|
    return (
 | 
						|
      <>
 | 
						|
        {showErrorDialog && (
 | 
						|
          <ConfirmationDialog
 | 
						|
            dialogName="GroupCallRemoteParticipant.blockInfo"
 | 
						|
            cancelText={i18n('icu:ok')}
 | 
						|
            i18n={i18n}
 | 
						|
            onClose={() => setShowErrorDialog(false)}
 | 
						|
            theme={Theme.Dark}
 | 
						|
            title={errorDialogTitle}
 | 
						|
          >
 | 
						|
            {errorDialogBody}
 | 
						|
          </ConfirmationDialog>
 | 
						|
        )}
 | 
						|
 | 
						|
        <div
 | 
						|
          className={classNames(
 | 
						|
            'module-ongoing-call__group-call-remote-participant',
 | 
						|
            isSpeaking &&
 | 
						|
              !isActiveSpeakerInSpeakerView &&
 | 
						|
              remoteParticipantsCount > 1 &&
 | 
						|
              'module-ongoing-call__group-call-remote-participant--speaking',
 | 
						|
            isHandRaised &&
 | 
						|
              'module-ongoing-call__group-call-remote-participant--hand-raised',
 | 
						|
            isOnTop &&
 | 
						|
              'module-ongoing-call__group-call-remote-participant--is-on-top'
 | 
						|
          )}
 | 
						|
          ref={intersectionRef}
 | 
						|
          style={containerStyles}
 | 
						|
        >
 | 
						|
          {!props.isInPip && (
 | 
						|
            <>
 | 
						|
              <CallingAudioIndicator
 | 
						|
                hasAudio={hasRemoteAudio}
 | 
						|
                audioLevel={props.audioLevel}
 | 
						|
                shouldShowSpeaking={isSpeaking}
 | 
						|
              />
 | 
						|
              <div className="module-ongoing-call__group-call-remote-participant__footer">
 | 
						|
                {footerInfoElement}
 | 
						|
              </div>
 | 
						|
            </>
 | 
						|
          )}
 | 
						|
          {wantsToShowVideo && (
 | 
						|
            <canvas
 | 
						|
              className={classNames(
 | 
						|
                'module-ongoing-call__group-call-remote-participant__remote-video',
 | 
						|
                isCallReconnecting &&
 | 
						|
                  'module-ongoing-call__group-call-remote-participant__remote-video--reconnecting'
 | 
						|
              )}
 | 
						|
              style={{
 | 
						|
                ...canvasStyles,
 | 
						|
                // If we want to show video but don't have any yet, we still render the
 | 
						|
                //   canvas invisibly. This lets us render frame data immediately without
 | 
						|
                //   having to juggle anything.
 | 
						|
                ...(hasVideoToShow ? {} : { display: 'none' }),
 | 
						|
              }}
 | 
						|
              ref={canvasEl => {
 | 
						|
                remoteVideoRef.current = canvasEl;
 | 
						|
                if (canvasEl) {
 | 
						|
                  canvasContextRef.current = canvasEl.getContext('2d', {
 | 
						|
                    alpha: false,
 | 
						|
                  });
 | 
						|
                } else {
 | 
						|
                  canvasContextRef.current = null;
 | 
						|
                }
 | 
						|
              }}
 | 
						|
            />
 | 
						|
          )}
 | 
						|
          {noVideoNode && (
 | 
						|
            <CallBackgroundBlur
 | 
						|
              avatarUrl={isBlocked ? undefined : avatarUrl}
 | 
						|
              className="module-ongoing-call__group-call-remote-participant-background"
 | 
						|
            >
 | 
						|
              {noVideoNode}
 | 
						|
            </CallBackgroundBlur>
 | 
						|
          )}
 | 
						|
        </div>
 | 
						|
      </>
 | 
						|
    );
 | 
						|
  }
 | 
						|
);
 |