411 lines
12 KiB
TypeScript
411 lines
12 KiB
TypeScript
import {
|
|
GroupCallConnectionState, GroupCallParticipant as TypeGroupCallParticipant,
|
|
IS_SCREENSHARE_SUPPORTED, switchCameraInput, toggleSpeaker,
|
|
} from '../../../lib/secret-sauce';
|
|
import React, {
|
|
FC, memo, useCallback, useEffect, useMemo, useRef, useState,
|
|
} from '../../../lib/teact/teact';
|
|
import { getActions, withGlobal } from '../../../modules';
|
|
import '../../../modules/actions/calls';
|
|
|
|
import { IAnchorPosition } from '../../../types';
|
|
|
|
import {
|
|
IS_ANDROID,
|
|
IS_IOS,
|
|
IS_REQUEST_FULLSCREEN_SUPPORTED,
|
|
IS_SINGLE_COLUMN_LAYOUT,
|
|
} from '../../../util/environment';
|
|
import buildClassName from '../../../util/buildClassName';
|
|
import {
|
|
selectGroupCall,
|
|
selectGroupCallParticipant,
|
|
selectIsAdminInActiveGroupCall,
|
|
} from '../../../modules/selectors/calls';
|
|
import useFlag from '../../../hooks/useFlag';
|
|
import useLang from '../../../hooks/useLang';
|
|
|
|
import Loading from '../../ui/Loading';
|
|
import Button from '../../ui/Button';
|
|
import DropdownMenu from '../../ui/DropdownMenu';
|
|
import MenuItem from '../../ui/MenuItem';
|
|
import Modal from '../../ui/Modal';
|
|
import MicrophoneButton from './MicrophoneButton';
|
|
import AnimatedIcon from '../../common/AnimatedIcon';
|
|
import Checkbox from '../../ui/Checkbox';
|
|
import GroupCallParticipantMenu from './GroupCallParticipantMenu';
|
|
import GroupCallParticipantList from './GroupCallParticipantList';
|
|
import GroupCallParticipantStreams from './GroupCallParticipantStreams';
|
|
|
|
import './GroupCall.scss';
|
|
|
|
const CAMERA_FLIP_PLAY_SEGMENT: [number, number] = [0, 10];
|
|
const PARTICIPANT_HEIGHT = 60;
|
|
|
|
export type OwnProps = {
|
|
groupCallId: string;
|
|
};
|
|
|
|
type StateProps = {
|
|
isGroupCallPanelHidden: boolean;
|
|
connectionState: GroupCallConnectionState;
|
|
title?: string;
|
|
meParticipant?: TypeGroupCallParticipant;
|
|
participantsCount?: number;
|
|
isSpeakerEnabled?: boolean;
|
|
isAdmin: boolean;
|
|
participants: Record<string, TypeGroupCallParticipant>;
|
|
};
|
|
|
|
const GroupCall: FC<OwnProps & StateProps> = ({
|
|
groupCallId,
|
|
isGroupCallPanelHidden,
|
|
connectionState,
|
|
isSpeakerEnabled,
|
|
title,
|
|
meParticipant,
|
|
isAdmin,
|
|
participants,
|
|
|
|
}) => {
|
|
const {
|
|
toggleGroupCallVideo,
|
|
toggleGroupCallPresentation,
|
|
leaveGroupCall,
|
|
toggleGroupCallPanel,
|
|
connectToActiveGroupCall,
|
|
playGroupCallSound,
|
|
} = getActions();
|
|
|
|
const lang = useLang();
|
|
// eslint-disable-next-line no-null/no-null
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const [isLeaving, setIsLeaving] = useState(false);
|
|
const [isFullscreen, openFullscreen, closeFullscreen] = useFlag();
|
|
const [isSidebarOpen, openSidebar, closeSidebar] = useFlag(true);
|
|
const hasVideoParticipants = participants && Object.values(participants).some((l) => l.video || l.presentation);
|
|
const isLandscape = isFullscreen && !IS_SINGLE_COLUMN_LAYOUT && hasVideoParticipants;
|
|
|
|
const [participantMenu, setParticipantMenu] = useState<{
|
|
participant: TypeGroupCallParticipant;
|
|
anchor: IAnchorPosition;
|
|
} | undefined>();
|
|
const [isParticipantMenuOpen, openParticipantMenu, closeParticipantMenu] = useFlag();
|
|
|
|
const [isConfirmLeaveModalOpen, openConfirmLeaveModal, closeConfirmLeaveModal] = useFlag();
|
|
const [isEndGroupCallModal, setIsEndGroupCallModal] = useState(false);
|
|
const [shouldEndGroupCall, setShouldEndGroupCall] = useState(false);
|
|
|
|
const hasVideo = meParticipant?.hasVideoStream;
|
|
const hasPresentation = meParticipant?.hasPresentationStream;
|
|
const isConnecting = connectionState !== 'connected';
|
|
const canSelfUnmute = meParticipant?.canSelfUnmute;
|
|
const shouldRaiseHand = !canSelfUnmute && meParticipant?.isMuted;
|
|
|
|
const handleOpenParticipantMenu = useCallback((anchor: HTMLDivElement, participant: TypeGroupCallParticipant) => {
|
|
const rect = anchor.getBoundingClientRect();
|
|
const container = containerRef.current!;
|
|
|
|
setParticipantMenu({
|
|
anchor: { x: rect.left, y: rect.top - container.offsetTop + PARTICIPANT_HEIGHT },
|
|
participant,
|
|
});
|
|
|
|
openParticipantMenu();
|
|
}, [openParticipantMenu]);
|
|
|
|
useEffect(() => {
|
|
if (connectionState === 'connected') {
|
|
playGroupCallSound({ sound: 'join' });
|
|
} else if (connectionState === 'reconnecting') {
|
|
playGroupCallSound({ sound: 'connecting' });
|
|
}
|
|
}, [connectionState, playGroupCallSound]);
|
|
|
|
const handleCloseConfirmLeaveModal = () => {
|
|
closeConfirmLeaveModal();
|
|
setIsEndGroupCallModal(false);
|
|
};
|
|
|
|
const MainButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => {
|
|
return ({ onTrigger, isOpen }) => (
|
|
<Button
|
|
round
|
|
size="smaller"
|
|
color="translucent"
|
|
className={isOpen ? 'active' : undefined}
|
|
onClick={onTrigger}
|
|
ariaLabel={lang('AccDescrMoreOptions')}
|
|
>
|
|
<i className="icon-more" />
|
|
</Button>
|
|
);
|
|
}, [lang]);
|
|
|
|
const handleToggleFullscreen = useCallback(() => {
|
|
if (!containerRef.current) return;
|
|
|
|
if (isFullscreen) {
|
|
document.exitFullscreen().then(closeFullscreen);
|
|
} else {
|
|
containerRef.current.requestFullscreen().then(openFullscreen);
|
|
}
|
|
}, [closeFullscreen, isFullscreen, openFullscreen]);
|
|
|
|
const handleToggleSidebar = () => {
|
|
if (isSidebarOpen) {
|
|
closeSidebar();
|
|
} else {
|
|
openSidebar();
|
|
}
|
|
};
|
|
|
|
const handleStreamsDoubleClick = useCallback(() => {
|
|
if (!IS_REQUEST_FULLSCREEN_SUPPORTED) return;
|
|
|
|
if (!isFullscreen) {
|
|
closeSidebar();
|
|
handleToggleFullscreen();
|
|
} else {
|
|
handleToggleFullscreen();
|
|
}
|
|
}, [closeSidebar, handleToggleFullscreen, isFullscreen]);
|
|
|
|
const toggleFullscreen = useCallback(() => {
|
|
if (isFullscreen) {
|
|
closeFullscreen();
|
|
} else {
|
|
openFullscreen();
|
|
}
|
|
}, [closeFullscreen, isFullscreen, openFullscreen]);
|
|
|
|
const handleClose = () => {
|
|
toggleGroupCallPanel();
|
|
if (isFullscreen) {
|
|
closeFullscreen();
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!IS_REQUEST_FULLSCREEN_SUPPORTED) return undefined;
|
|
const container = containerRef.current;
|
|
if (!container) return undefined;
|
|
|
|
container.addEventListener('fullscreenchange', toggleFullscreen);
|
|
|
|
return () => {
|
|
container.removeEventListener('fullscreenchange', toggleFullscreen);
|
|
};
|
|
}, [toggleFullscreen]);
|
|
|
|
const handleClickVideoOrSpeaker = () => {
|
|
if (shouldRaiseHand) {
|
|
toggleSpeaker();
|
|
} else {
|
|
toggleGroupCallVideo();
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
connectToActiveGroupCall();
|
|
}, [connectToActiveGroupCall, groupCallId]);
|
|
|
|
const endGroupCall = () => {
|
|
setIsEndGroupCallModal(true);
|
|
setShouldEndGroupCall(true);
|
|
openConfirmLeaveModal();
|
|
if (isFullscreen) {
|
|
handleToggleFullscreen();
|
|
}
|
|
};
|
|
|
|
const handleLeaveGroupCall = () => {
|
|
if (isAdmin && !isConfirmLeaveModalOpen) {
|
|
openConfirmLeaveModal();
|
|
if (isFullscreen) {
|
|
handleToggleFullscreen();
|
|
}
|
|
return;
|
|
}
|
|
playGroupCallSound({ sound: 'leave' });
|
|
setIsLeaving(true);
|
|
closeConfirmLeaveModal();
|
|
};
|
|
|
|
const handleCloseAnimationEnd = () => {
|
|
if (isLeaving) {
|
|
leaveGroupCall({
|
|
shouldDiscard: shouldEndGroupCall,
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
isOpen={!isGroupCallPanelHidden && !isLeaving}
|
|
onClose={toggleGroupCallPanel}
|
|
className={buildClassName(
|
|
'GroupCall',
|
|
IS_SINGLE_COLUMN_LAYOUT && 'single-column',
|
|
isLandscape && 'landscape',
|
|
!isSidebarOpen && 'no-sidebar',
|
|
)}
|
|
dialogRef={containerRef}
|
|
onCloseAnimationEnd={handleCloseAnimationEnd}
|
|
>
|
|
<div className="header">
|
|
<h3>{title || lang('VoipGroupVoiceChat')}</h3>
|
|
{IS_REQUEST_FULLSCREEN_SUPPORTED && (
|
|
<Button
|
|
round
|
|
size="smaller"
|
|
color="translucent"
|
|
onClick={handleToggleFullscreen}
|
|
ariaLabel={lang(isFullscreen ? 'AccExitFullscreen' : 'AccSwitchToFullscreen')}
|
|
>
|
|
<i className={isFullscreen ? 'icon-smallscreen' : 'icon-fullscreen'} />
|
|
</Button>
|
|
)}
|
|
{isLandscape && (
|
|
<Button
|
|
round
|
|
size="smaller"
|
|
color="translucent"
|
|
onClick={handleToggleSidebar}
|
|
>
|
|
<i className="icon-sidebar" />
|
|
</Button>
|
|
)}
|
|
{((IS_SCREENSHARE_SUPPORTED && !shouldRaiseHand) || isAdmin) && (
|
|
<DropdownMenu
|
|
positionX="right"
|
|
trigger={MainButton}
|
|
>
|
|
{IS_SCREENSHARE_SUPPORTED && !shouldRaiseHand && (
|
|
<MenuItem
|
|
icon="share-screen"
|
|
onClick={toggleGroupCallPresentation}
|
|
>
|
|
{lang(hasPresentation ? 'VoipChatStopScreenCapture' : 'VoipChatStartScreenCapture')}
|
|
</MenuItem>
|
|
)}
|
|
{isAdmin && (
|
|
<MenuItem
|
|
icon="phone-discard-outline"
|
|
onClick={endGroupCall}
|
|
destructive
|
|
>
|
|
{lang('VoipGroupLeaveAlertEndChat')}
|
|
</MenuItem>
|
|
)}
|
|
</DropdownMenu>
|
|
)}
|
|
<Button
|
|
round
|
|
size="smaller"
|
|
color="translucent"
|
|
onClick={handleClose}
|
|
>
|
|
<i className="icon-close" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="scrollable custom-scroll">
|
|
<GroupCallParticipantStreams onDoubleClick={handleStreamsDoubleClick} />
|
|
|
|
{(!isLandscape || isSidebarOpen)
|
|
&& <GroupCallParticipantList openParticipantMenu={handleOpenParticipantMenu} />}
|
|
</div>
|
|
|
|
<GroupCallParticipantMenu
|
|
participant={participantMenu?.participant}
|
|
anchor={participantMenu?.anchor}
|
|
isDropdownOpen={isParticipantMenuOpen}
|
|
closeDropdown={closeParticipantMenu}
|
|
/>
|
|
|
|
<div className="buttons">
|
|
{isConnecting && <Loading />}
|
|
|
|
<div className="button-wrapper">
|
|
<div className="video-buttons">
|
|
{hasVideo && (IS_ANDROID || IS_IOS) && (
|
|
<button className="smaller-button" onClick={switchCameraInput}>
|
|
<AnimatedIcon name="CameraFlip" playSegment={CAMERA_FLIP_PLAY_SEGMENT} size={24} />
|
|
</button>
|
|
)}
|
|
<button
|
|
className={buildClassName(
|
|
'small-button',
|
|
shouldRaiseHand ? 'speaker' : 'camera',
|
|
(hasVideo || (shouldRaiseHand && isSpeakerEnabled)) && 'active',
|
|
)}
|
|
onClick={handleClickVideoOrSpeaker}
|
|
>
|
|
<i className={shouldRaiseHand ? 'icon-speaker' : (hasVideo ? 'icon-video-stop' : 'icon-video')} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="button-text">
|
|
{lang(shouldRaiseHand ? 'VoipSpeaker' : 'VoipCamera')}
|
|
</div>
|
|
</div>
|
|
|
|
<MicrophoneButton />
|
|
|
|
<div className="button-wrapper">
|
|
<button className="small-button leave" onClick={handleLeaveGroupCall}>
|
|
<i className="icon-phone-discard" />
|
|
</button>
|
|
|
|
<div className="button-text">
|
|
{lang('VoipGroupLeave')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Modal
|
|
isOpen={isConfirmLeaveModalOpen}
|
|
onClose={handleCloseConfirmLeaveModal}
|
|
className="error"
|
|
title={lang(isEndGroupCallModal ? 'VoipGroupEndAlertTitle' : 'VoipGroupLeaveAlertTitle')}
|
|
>
|
|
<p>{lang(isEndGroupCallModal ? 'VoipGroupEndAlertText' : 'VoipGroupLeaveAlertText')}</p>
|
|
{!isEndGroupCallModal && (
|
|
<Checkbox
|
|
label={lang('VoipGroupEndChat')}
|
|
checked={shouldEndGroupCall}
|
|
onCheck={setShouldEndGroupCall}
|
|
/>
|
|
)}
|
|
<Button isText className="confirm-dialog-button" onClick={handleLeaveGroupCall}>
|
|
{lang(isEndGroupCallModal ? 'VoipGroupEnd' : 'VoipGroupLeave')}
|
|
</Button>
|
|
<Button isText className="confirm-dialog-button" onClick={handleCloseConfirmLeaveModal}>
|
|
{lang('Cancel')}
|
|
</Button>
|
|
</Modal>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
export default memo(withGlobal<OwnProps>(
|
|
(global, { groupCallId }): StateProps => {
|
|
const {
|
|
connectionState, title, isSpeakerDisabled, participants, participantsCount,
|
|
} = selectGroupCall(global, groupCallId)! || {};
|
|
|
|
return {
|
|
connectionState,
|
|
title,
|
|
isSpeakerEnabled: !isSpeakerDisabled,
|
|
participantsCount,
|
|
meParticipant: selectGroupCallParticipant(global, groupCallId, global.currentUserId!),
|
|
isGroupCallPanelHidden: Boolean(global.groupCalls.isGroupCallPanelHidden),
|
|
isAdmin: selectIsAdminInActiveGroupCall(global),
|
|
participants,
|
|
};
|
|
},
|
|
)(GroupCall));
|