Group Calls: Redesign (#2859)

This commit is contained in:
Alexander Zinchuk 2023-06-18 12:03:30 +02:00
parent 750cb2689b
commit 8fc05e97ff
34 changed files with 2054 additions and 1404 deletions

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,296 @@
.root {
--group-call-panel-color: #212121;
--group-call-panel-header-border-color: #3b3b3b;
--group-call-background-color: #000000;
--green-button-color: rgba(1, 200, 80, 0.3);
--blue-button-color: rgb(60, 135, 247, 0.2);
--purple-button-color: rgb(61, 82, 223, 0.2);
--gradient-blue: linear-gradient(225deg, #4EABF8 14.73%, #3478F6 85.27%);
--gradient-green: linear-gradient(230.46deg, #00A3B4 12.94%, #00CB47 86.29%);
--gradient-purple: linear-gradient(230.46deg, #CE4D74 0%, #3D52DF 100%);
--gradient-speaking: linear-gradient(135deg, #5CC85C 0%, #48A1B3 101.27%);
--red-button-color: rgba(255, 89, 90, 0.3);
--disabled-button-color: #333333;
--color-text-secondary: #AAAAAA;
--color-text: #FFFFFF;
--default-width: 26.25rem;
--max-height: 40rem;
color: var(--color-text);
:global {
.modal-dialog {
max-width: var(--default-width);
max-height: min(var(--max-height), 100vh);
height: 100%;
min-height: min(80vh, var(--max-height));
overflow: hidden;
background: var(--group-call-background-color);
}
.modal-content {
min-height: 100%;
display: flex;
padding: 0;
}
}
}
.panelWrapper {
max-width: var(--default-width);
width: 100%;
}
.panel {
background: var(--group-call-panel-color);
display: flex;
flex-direction: column;
height: 100%;
overflow: auto;
position: relative;
}
.panelScrollTrigger {
position: absolute;
top: 0;
width: 100%;
}
.panelHeader {
display: flex;
align-items: center;
position: sticky;
top: 0;
border-bottom: 0.0625rem solid transparent;
padding: 0.375rem 0.875rem;
user-select: none;
z-index: 1;
background: var(--group-call-panel-color);
transition: 0.25s ease-in-out border-bottom-color;
&.scrolled {
border-bottom-color: var(--group-call-panel-header-border-color);
}
}
.headerButton {
color: var(--color-text) !important;
}
.firstButton {
margin-right: 1.375rem;
}
.lastButton {
margin-left: auto;
}
.panelHeaderText {
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
}
.title {
line-height: 1.375rem;
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
unicode-bidi: plaintext;
font-size: 1rem;
font-weight: 500;
margin: 0;
}
.bigger {
font-size: 1.25rem;
}
.subtitle {
font-size: 0.875rem;
line-height: 1.125rem;
margin: 0;
color: var(--color-text-secondary);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: inline-block;
}
.participants {
position: relative;
margin: 0.125rem 0.5rem 0;
}
.participantVideos {
width: 100%;
position: relative;
}
.addParticipantButton {
position: fixed;
}
.videos {
display: flex;
flex-direction: column;
width: calc(100% - var(--default-width));
}
.videosHeader {
display: flex;
align-items: center;
padding: 0.375rem 0.875rem;
}
.videosHeaderLastButton {
margin-left: auto;
}
.videosContent {
flex-grow: 1;
margin: 0.5rem 0.625rem;
}
.actions {
--actions-max-width: 0px;
position: absolute;
left: 50%;
transform: translateX(calc(-50% - var(--actions-max-width) / 2));
bottom: 1.75rem;
display: flex;
gap: 1.25rem;
z-index: 2;
}
.actionButton {
width: 3.375rem !important;
height: 3.375rem !important;
color: var(--color-text) !important;
background-color: var(--green-button-color) !important;
transition: 0.15s filter, 0.25s ease-out background-color;
backdrop-filter: blur(25px);
&:global(.disabled) {
background: var(--disabled-button-color) !important;
}
&:hover {
filter: brightness(1.1);
}
}
.destructive {
background: var(--red-button-color) !important;
}
.canRequestToSpeak {
background: var(--purple-button-color) !important;
}
.muted {
background: var(--blue-button-color) !important;
}
.fullscreen {
:global {
.modal-dialog {
min-width: 100%;
min-height: 100%;
border-radius: 0;
}
.modal-content {
max-height: initial;
}
}
&.portrait .panelWrapper {
max-width: 100%;
}
}
.landscape {
.panelWrapper {
position: absolute;
right: 0;
top: 0;
bottom: 0;
transform: translateZ(0);
}
&:not(.noVideoParticipants) {
.actions {
--actions-max-width: var(--default-width);
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(50px);
border-radius: 1.25rem;
padding: 0.75rem;
bottom: 2.5rem;
transition: var(--layer-transition) transform, 250ms ease-in-out opacity;
opacity: 0;
}
.videos:hover ~ .actions, .video:hover ~ .actions, .actions:hover {
opacity: 1;
}
}
&.noVideoParticipants {
.panelWrapper {
max-width: max(50vw, 30rem);
width: 100%;
transform: translateX(-50%);
left: 50%;
right: 0;
}
:global(.modal-content) {
background: var(--group-call-panel-color);
}
}
}
.portrait {
.panelWrapper::after {
display: block;
content: '';
position: fixed;
width: 100%;
height: 7.5rem;
left: 0;
bottom: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba(33, 33, 33, 0) 0%, rgba(33, 33, 33, 0.65) 48.54%, #212121 100%);
}
}
.noSidebar {
.panelWrapper {
transform: translate3d(100%, 0, 0);
}
.videos {
width: 100%;
}
.actions {
--actions-max-width: 0px !important;
}
}

View File

@ -1,345 +0,0 @@
.GroupCall {
.modal-content {
display: flex;
flex-direction: column;
align-items: center;
height: 37.5rem;
}
.modal-dialog {
max-height: calc(100% - 4rem);
background: #181f27;
}
.Menu {
--color-text: white;
--color-background-compact-menu: #212121DD;
--color-background-compact-menu-hover: #00000066;
.bubble {
box-shadow: 0 0.25rem 0.5rem 0.125rem rgba(16, 16, 16, 0.3);
}
}
&.single-column {
opacity: 1 !important;
.modal-dialog {
max-width: 100% !important;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
margin-top: auto;
margin-bottom: 0;
transform: translate3d(0, 100%, 0);
transition: transform 0.3s ease, opacity 0.3s ease;
}
.modal-backdrop {
opacity: 0;
transition: opacity 0.2s ease;
}
&.open {
.modal-backdrop {
opacity: 1;
}
.modal-dialog {
transform: translate3d(0, 0, 0);
}
}
}
.header {
width: 100%;
display: flex;
align-items: center;
color: #fff;
margin-bottom: 0.5rem;
h3 {
font-size: 1.25rem;
font-weight: 500;
margin: 0 auto 0 0.5rem;
}
}
.videos {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.participants {
margin-top: 0.75rem;
background: #222b34;
border-radius: 0.75rem;
.Loading {
padding: 2rem 0;
}
.invite-btn {
padding: 0.25rem 0.75rem;
display: flex;
align-items: center;
border-radius: 0.75rem;
transition: 0.15s ease-out background-color;
cursor: var(--custom-cursor, pointer);
color: var(--color-text-secondary);
&:hover {
background: #2f363e;
}
.text {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.icon-wrapper {
display: flex;
justify-content: center;
align-items: center;
width: 2.75rem;
height: 2.75rem;
font-size: 1.5rem;
margin-right: 1rem;
}
}
}
.scrollable {
overflow: auto;
padding-bottom: 2rem;
max-width: 37.5rem;
width: 100%;
}
.buttons {
max-width: 37.5rem;
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-around;
width: 100%;
position: relative;
height: 8.75rem;
button {
cursor: var(--custom-cursor, pointer);
}
&::before {
position: absolute;
content: "";
width: 100%;
height: 2rem;
background: linear-gradient(0deg, #181f27, rgba(24, 31, 39, 0));
z-index: 0;
top: -2rem;
}
.button-wrapper {
width: 4rem;
display: flex;
flex-direction: column;
align-items: center;
.button-text {
white-space: nowrap;
font-size: 0.75rem;
margin-top: 0.5rem;
color: #fff;
}
&.microphone-wrapper {
width: 6rem;
.button-text {
margin-top: 0.75rem;
font-size: 1rem;
}
}
}
.Loading {
position: absolute;
transform: translate(0, -1.125rem);
.Spinner {
--spinner-size: 6.5rem;
}
}
.video-buttons {
display: flex;
flex-direction: column;
align-items: center;
}
.small-button,
.smaller-button {
outline: none;
border: 0;
background: #15415b;
border-radius: 50%;
width: 3rem;
height: 3rem;
color: #fff;
font-size: 1.375rem;
display: flex;
align-items: center;
justify-content: center;
transition: 0.25s ease-out background-color;
&:hover {
background: #11364b;
}
}
.small-button.camera.active {
background: #15415b;
&:hover {
background: #11364b;
}
}
.small-button.speaker {
background: #2b3a51;
&.active {
background: #496092;
}
}
.small-button.leave {
background: #5a2824;
&:hover {
background: #49201d;
}
}
.smaller-button {
width: 2.5rem;
height: 2.5rem;
margin-bottom: 0.5rem;
padding: 0;
}
}
&.landscape .scrollable {
display: flex;
flex-direction: row;
flex-grow: 1;
gap: 1rem;
align-items: flex-start;
max-width: 100%;
max-height: 100%;
}
&.landscape .GroupCallParticipantVideo {
max-height: initial;
video {
height: 100%;
}
}
&.landscape .buttons {
position: absolute;
left: calc(50% - 15.625rem / 2);
transform: translateX(-50%);
width: auto;
gap: 1rem;
bottom: 4rem;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(10px);
border-radius: 1rem;
z-index: 5;
padding: 0.75rem 1rem;
height: auto;
.button-text {
display: none;
}
.video-buttons {
flex-direction: row;
gap: 1rem;
.smaller-button {
margin-bottom: 0;
}
}
.Loading {
transform: none;
.Spinner {
--spinner-size: 3.25rem;
}
}
.MicrophoneButton {
canvas {
width: 2rem !important;
height: 2rem !important;
}
}
.MicrophoneButton,
.microphone-wrapper {
width: 3rem;
height: 3rem;
.AnimatedSticker {
display: flex;
align-items: center;
justify-content: center;
}
}
&::before {
display: none;
}
}
&.landscape.no-sidebar .buttons {
left: calc(50%);
}
&.landscape .streams {
width: 100%;
height: 100%;
}
&.landscape .videos {
width: 100%;
height: 100%;
display: grid;
--column-count: 1;
grid-template-columns: repeat(var(--column-count), 1fr);
grid-auto-rows: 1fr;
.GroupCallParticipantVideo {
max-height: 100%;
width: 100%;
.thumbnail-wrapper {
height: 100%;
}
}
&.span-last-video .GroupCallParticipantVideo:last-child {
grid-column: span var(--column-count);
}
}
&.landscape .participants {
width: 15.625rem;
margin-top: 0;
}
}

View File

@ -1,51 +1,43 @@
import type {
GroupCallConnectionState, GroupCallParticipant as TypeGroupCallParticipant,
} from '../../../lib/secret-sauce';
import {
IS_SCREENSHARE_SUPPORTED, switchCameraInput, toggleSpeaker,
} from '../../../lib/secret-sauce';
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useCallback, useEffect, useMemo, useRef, useState,
memo, useEffect, useMemo, useRef, useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import '../../../global/actions/calls';
import type { IAnchorPosition } from '../../../types';
import type {
GroupCallConnectionState, GroupCallParticipant as TypeGroupCallParticipant,
} from '../../../lib/secret-sauce';
import type { FC } from '../../../lib/teact/teact';
import type { VideoParticipant } from './hooks/useGroupCallVideoLayout';
import {
IS_ANDROID,
IS_IOS,
IS_REQUEST_FULLSCREEN_SUPPORTED,
} from '../../../util/windowEnvironment';
import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets';
import { IS_SCREENSHARE_SUPPORTED } from '../../../lib/secret-sauce';
import buildClassName from '../../../util/buildClassName';
import {
selectCanInviteToActiveGroupCall,
selectGroupCall,
selectGroupCallParticipant,
selectIsAdminInActiveGroupCall,
} from '../../../global/selectors/calls';
import { selectTabState } from '../../../global/selectors';
import { selectChat, selectTabState } from '../../../global/selectors';
import { compact } from '../../../util/iteratees';
import useFlag from '../../../hooks/useFlag';
import useLang from '../../../hooks/useLang';
import useAppLayout from '../../../hooks/useAppLayout';
import useGroupCallVideoLayout from './hooks/useGroupCallVideoLayout';
import { useIntersectionObserver, useIsIntersecting } from '../../../hooks/useIntersectionObserver';
import useLastCallback from '../../../hooks/useLastCallback';
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 FloatingActionButton from '../../ui/FloatingActionButton';
import GroupCallParticipantVideo from './GroupCallParticipantVideo';
import './GroupCall.scss';
import styles from './GroupCall.module.scss';
const CAMERA_FLIP_PLAY_SEGMENT: [number, number] = [0, 10];
const PARTICIPANT_HEIGHT = 60;
const INTERSECTION_THROTTLE = 200;
export type OwnProps = {
groupCallId: string;
@ -57,21 +49,21 @@ type StateProps = {
title?: string;
meParticipant?: TypeGroupCallParticipant;
participantsCount?: number;
isSpeakerEnabled?: boolean;
isAdmin: boolean;
participants: Record<string, TypeGroupCallParticipant>;
canInvite: boolean;
};
const GroupCall: FC<OwnProps & StateProps> = ({
groupCallId,
isCallPanelVisible,
connectionState,
isSpeakerEnabled,
participantsCount,
title,
meParticipant,
isAdmin,
participants,
canInvite,
}) => {
const {
toggleGroupCallVideo,
@ -80,24 +72,60 @@ const GroupCall: FC<OwnProps & StateProps> = ({
toggleGroupCallPanel,
connectToActiveGroupCall,
playGroupCallSound,
createGroupCallInviteLink,
} = getActions();
const lang = useLang();
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
const { isMobile, isLandscape } = useAppLayout();
// eslint-disable-next-line no-null/no-null
const primaryVideoContainerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const secondaryVideoContainerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const panelScrollTriggerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const panelRef = useRef<HTMLDivElement>(null);
const [isLeaving, setIsLeaving] = useState(false);
const isOpen = !isCallPanelVisible && !isLeaving;
const { observe } = useIntersectionObserver({
rootRef: panelRef,
throttleMs: INTERSECTION_THROTTLE,
isDisabled: !isOpen,
});
const hasScrolled = !useIsIntersecting(panelScrollTriggerRef, isOpen ? observe : undefined);
const { isMobile, isLandscape } = useAppLayout();
const [isFullscreen, openFullscreen, closeFullscreen] = useFlag();
const [isSidebarOpen, openSidebar, closeSidebar] = useFlag(true);
const hasVideoParticipants = Object.values(participants).some(({ video, presentation }) => video || presentation);
const isLandscapeLayout = isFullscreen && (!isMobile || isLandscape) && hasVideoParticipants;
const isLandscapeLayout = Boolean(isFullscreen && isLandscape);
const [participantMenu, setParticipantMenu] = useState<{
participant: TypeGroupCallParticipant;
anchor: IAnchorPosition;
} | undefined>();
const [isParticipantMenuOpen, openParticipantMenu, closeParticipantMenu] = useFlag();
const firstPresentation = useMemo(() => {
return Object.values(participants).find(({ presentation }) => presentation);
}, [participants]);
const videoParticipants = useMemo(() => Object.values(participants)
.filter(({ video, presentation }) => video || presentation)
.flatMap(({ id, video, presentation }) => compact([
video ? {
id,
type: 'video' as const,
} : undefined,
presentation ? {
id,
type: 'screen' as const,
} : undefined,
])),
[participants]);
const hasVideoParticipants = videoParticipants.length > 0;
const groupCallTitle = title || lang('VoipGroupVoiceChat');
const membersString = lang('Participants', participantsCount, 'i');
const [isConfirmLeaveModalOpen, openConfirmLeaveModal, closeConfirmLeaveModal] = useFlag();
const [isEndGroupCallModal, setIsEndGroupCallModal] = useState(false);
@ -105,20 +133,10 @@ const GroupCall: FC<OwnProps & StateProps> = ({
const hasVideo = meParticipant?.hasVideoStream;
const hasPresentation = meParticipant?.hasPresentationStream;
const hasAudioStream = meParticipant?.hasAudioStream;
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]);
const canRequestToSpeak = !canSelfUnmute && !hasAudioStream;
useEffect(() => {
if (connectionState === 'connected') {
@ -126,263 +144,360 @@ const GroupCall: FC<OwnProps & StateProps> = ({
} else if (connectionState === 'reconnecting') {
playGroupCallSound({ sound: 'connecting' });
}
}, [connectionState, playGroupCallSound]);
}, [connectionState]);
const handleCloseConfirmLeaveModal = useCallback(() => {
const handleCloseConfirmLeaveModal = useLastCallback(() => {
closeConfirmLeaveModal();
setIsEndGroupCallModal(false);
}, [closeConfirmLeaveModal]);
});
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 icon-more" />
</Button>
);
}, [lang]);
const handleToggleFullscreen = useCallback(() => {
const handleToggleFullscreen = useLastCallback(() => {
if (!containerRef.current) return;
if (isFullscreen) {
document.exitFullscreen().then(closeFullscreen);
} else {
containerRef.current.requestFullscreen().then(openFullscreen);
}
}, [closeFullscreen, isFullscreen, openFullscreen]);
const handleToggleSidebar = useCallback(() => {
if (isSidebarOpen) {
closeSidebar();
} else {
openSidebar();
}
}, [closeSidebar, isSidebarOpen, 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 = useCallback(() => {
toggleGroupCallPanel();
if (isFullscreen) {
closeFullscreen();
}
}, [closeFullscreen, isFullscreen, toggleGroupCallPanel]);
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();
const handleToggleSidebar = useLastCallback(() => {
if (isSidebarOpen) {
closeSidebar();
} else {
toggleGroupCallVideo();
openSidebar();
}
};
});
const handleInviteMember = useLastCallback(() => {
createGroupCallInviteLink();
});
const handleClickVideo = useLastCallback(() => {
toggleGroupCallVideo();
});
useEffect(() => {
connectToActiveGroupCall();
}, [connectToActiveGroupCall, groupCallId]);
const endGroupCall = useCallback(() => {
setIsEndGroupCallModal(true);
setShouldEndGroupCall(true);
openConfirmLeaveModal();
if (isFullscreen) {
handleToggleFullscreen();
}
}, [handleToggleFullscreen, isFullscreen, openConfirmLeaveModal]);
const handleLeaveGroupCall = useCallback(() => {
const handleLeaveGroupCall = useLastCallback(() => {
if (isAdmin && !isConfirmLeaveModalOpen) {
openConfirmLeaveModal();
if (isFullscreen) {
handleToggleFullscreen();
}
return;
}
playGroupCallSound({ sound: 'leave' });
setIsLeaving(true);
closeConfirmLeaveModal();
}, [
closeConfirmLeaveModal, handleToggleFullscreen, isAdmin, isConfirmLeaveModalOpen, isFullscreen,
openConfirmLeaveModal, playGroupCallSound,
]);
});
const handleCloseAnimationEnd = useCallback(() => {
if (isLeaving) {
leaveGroupCall({
shouldDiscard: shouldEndGroupCall,
});
}
}, [isLeaving, leaveGroupCall, shouldEndGroupCall]);
const handleCloseAnimationEnd = useLastCallback(() => {
if (!isLeaving) return;
const handleToggleGroupCallPresentation = useCallback(() => {
leaveGroupCall({
shouldDiscard: shouldEndGroupCall,
});
});
const handleToggleGroupCallPresentation = useLastCallback(() => {
toggleGroupCallPresentation();
}, [toggleGroupCallPresentation]);
});
const canPinVideo = videoParticipants.length > 1 && isLandscapeLayout;
const isLandscapeWithVideos = isLandscapeLayout && hasVideoParticipants;
const [pinnedVideo, setPinnedVideo] = useState<VideoParticipant | undefined>(undefined);
const {
videoLayout,
panelOffset,
} = useGroupCallVideoLayout({
primaryContainerRef: primaryVideoContainerRef,
secondaryContainerRef: secondaryVideoContainerRef,
videoParticipants,
isLandscapeLayout,
pinnedVideo,
});
const handleOpenFirstPresentation = useLastCallback(() => {
if (!firstPresentation) return;
setPinnedVideo({
id: firstPresentation.id,
type: 'screen',
});
});
useEffect(handleOpenFirstPresentation, [handleOpenFirstPresentation, Boolean(firstPresentation)]);
useEffect(() => {
if (!pinnedVideo) return;
if (!videoParticipants.some((l) => l.type === pinnedVideo.type && l.id === pinnedVideo.id)) {
setPinnedVideo(undefined);
}
}, [pinnedVideo, videoLayout, videoParticipants]);
return (
<Modal
isOpen={!isCallPanelVisible && !isLeaving}
isOpen={isOpen}
onClose={toggleGroupCallPanel}
className={buildClassName(
'GroupCall',
(isMobile && !isLandscape) && 'single-column',
isLandscapeLayout && 'landscape',
!isSidebarOpen && 'no-sidebar',
styles.root,
isFullscreen && styles.fullscreen,
isLandscapeLayout && styles.landscape,
!hasVideoParticipants && styles.noVideoParticipants,
!isLandscapeLayout && styles.portrait,
!isSidebarOpen && isLandscapeWithVideos && styles.noSidebar,
)}
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={buildClassName('icon', isFullscreen ? 'icon-smallscreen' : 'icon-fullscreen')} />
</Button>
)}
{isLandscapeLayout && (
<Button
round
size="smaller"
color="translucent"
onClick={handleToggleSidebar}
>
<i className="icon icon-sidebar" />
</Button>
)}
{((IS_SCREENSHARE_SUPPORTED && !shouldRaiseHand) || isAdmin) && (
<DropdownMenu
positionX="right"
trigger={MainButton}
>
{IS_SCREENSHARE_SUPPORTED && !shouldRaiseHand && (
<MenuItem
icon="share-screen-outlined"
onClick={handleToggleGroupCallPresentation}
{isLandscapeWithVideos && (
<div className={styles.videos}>
<div className={styles.videosHeader}>
<Button
round
size="smaller"
color="translucent"
onClick={handleToggleFullscreen}
className={buildClassName(styles.headerButton, styles.firstButton)}
ariaLabel={lang(isFullscreen ? 'AccExitFullscreen' : 'AccSwitchToFullscreen')}
>
<i
className={buildClassName('icon', isFullscreen ? 'icon-smallscreen' : 'icon-fullscreen')}
aria-hidden
/>
</Button>
<h3 className={buildClassName(styles.title, styles.bigger)}>
{title || lang('VoipGroupVoiceChat')}
</h3>
{isLandscapeWithVideos && !isSidebarOpen && (
<Button
round
size="smaller"
color="translucent"
className={buildClassName(styles.headerButton, styles.videosHeaderLastButton)}
onClick={handleToggleSidebar}
ariaLabel={lang('AccDescrExpandPanel')}
>
{lang(hasPresentation ? 'VoipChatStopScreenCapture' : 'VoipChatStartScreenCapture')}
</MenuItem>
<i className="icon icon-sidebar" aria-hidden />
</Button>
)}
{isAdmin && (
<MenuItem
icon="phone-discard-outline"
onClick={endGroupCall}
destructive
</div>
<div
className={styles.videosContent}
ref={primaryVideoContainerRef}
/>
</div>
)}
<div className={styles.panelWrapper} ref={panelRef}>
<div className={buildClassName(styles.panel, 'custom-scroll')}>
<div className={styles.panelScrollTrigger} ref={panelScrollTriggerRef} />
<div className={buildClassName(styles.panelHeader, hasScrolled && styles.scrolled)}>
{!isLandscapeWithVideos && (
<Button
round
size="smaller"
color="translucent"
ripple={!isMobile}
className={buildClassName(
styles.firstButton,
styles.headerButton,
)}
onClick={handleToggleFullscreen}
ariaLabel={lang('AccSwitchToFullscreen')}
>
{lang('VoipGroupLeaveAlertEndChat')}
</MenuItem>
<i className="icon icon-fullscreen" aria-hidden />
</Button>
)}
</DropdownMenu>
)}
{isLandscapeWithVideos && (
<Button
round
size="smaller"
ripple={!isMobile}
className={buildClassName(
styles.firstButton,
styles.headerButton,
)}
color="translucent"
onClick={handleToggleSidebar}
ariaLabel={lang('AccDescrCollapsePanel')}
>
<i className="icon icon-sidebar" aria-hidden />
</Button>
)}
<div className={styles.panelHeaderText}>
<h3 className={buildClassName(styles.title, isLandscapeWithVideos && styles.bigger)}>
{isLandscapeWithVideos ? membersString : groupCallTitle}
</h3>
{!isLandscapeWithVideos && (
<span className={styles.subtitle}>
{membersString}
</span>
)}
</div>
{!isLandscapeWithVideos && canInvite && (
<Button
round
size="smaller"
ripple={!isMobile}
className={buildClassName(
styles.lastButton,
styles.headerButton,
)}
color="translucent"
onClick={handleInviteMember}
ariaLabel={lang('VoipGroupInviteMember')}
>
<i className="icon icon-add-user" aria-hidden />
</Button>
)}
</div>
<div className={styles.participants}>
<div
className={styles.participantVideos}
ref={secondaryVideoContainerRef}
style={`height: ${panelOffset}px;`}
>
{videoLayout.map((layout) => {
const participant = participants[layout.participantId];
if (!layout.isRemounted || !participant) {
return (
<div
teactOrderKey={layout.orderKey}
key={`${layout.participantId}_${layout.type}`}
/>
);
}
return (
<GroupCallParticipantVideo
teactOrderKey={layout.orderKey}
key={`${layout.participantId}_${layout.type}`}
layout={layout}
canPin={canPinVideo}
setPinned={setPinnedVideo}
pinnedVideo={pinnedVideo}
participant={participant}
/>
);
})}
</div>
<GroupCallParticipantList
panelOffset={panelOffset}
isLandscape={isLandscapeWithVideos}
/>
</div>
</div>
<FloatingActionButton
key="add-participant"
isShown={isLandscapeWithVideos && canInvite}
onClick={handleInviteMember}
className={styles.addParticipantButton}
ariaLabel={lang('VoipGroupInviteMember')}
>
<i className="icon icon-add-user-filled" aria-hidden />
</FloatingActionButton>
</div>
{videoLayout.map((layout) => {
const participant = participants[layout.participantId];
if (layout.isRemounted || !participant) {
return (
<div
teactOrderKey={layout.orderKey}
key={`${layout.participantId}_${layout.type}`}
/>
);
}
return (
<GroupCallParticipantVideo
teactOrderKey={layout.orderKey}
key={`${layout.participantId}_${layout.type}`}
layout={layout}
canPin={canPinVideo}
setPinned={setPinnedVideo}
pinnedVideo={pinnedVideo}
participant={participant}
className={styles.video}
/>
);
})}
<div className={styles.actions}>
<Button
round
size="smaller"
color="translucent"
onClick={handleClose}
size="default"
ripple
className={buildClassName(
styles.actionButton,
!hasAudioStream && styles.muted,
canRequestToSpeak && styles.canRequestToSpeak,
)}
onClick={handleClickVideo}
ariaLabel={lang(hasVideo ? 'VoipStopVideo' : 'VoipStartVideo')}
disabled={isConnecting}
>
<i className="icon icon-close" />
<i className={buildClassName('icon', !hasVideo ? 'icon-video-stop' : 'icon-video')} aria-hidden />
</Button>
</div>
<div className="scrollable custom-scroll">
<GroupCallParticipantStreams onDoubleClick={handleStreamsDoubleClick} />
<Button
round
size="default"
ripple
className={buildClassName(
styles.actionButton,
!hasAudioStream && styles.muted,
canRequestToSpeak && styles.canRequestToSpeak,
)}
onClick={handleToggleGroupCallPresentation}
ariaLabel={lang(hasPresentation ? 'lng_group_call_screen_share_stop' : 'lng_group_call_tooltip_screen')}
disabled={isConnecting || !IS_SCREENSHARE_SUPPORTED}
>
<i
className={buildClassName('icon', !hasPresentation ? 'icon-share-screen-stop' : 'icon-share-screen')}
aria-hidden
/>
</Button>
{(!isLandscapeLayout || isSidebarOpen)
&& <GroupCallParticipantList openParticipantMenu={handleOpenParticipantMenu} />}
</div>
<MicrophoneButton className={styles.actionButton} />
<GroupCallParticipantMenu
participant={participantMenu?.participant}
anchor={participantMenu?.anchor}
isDropdownOpen={isParticipantMenuOpen}
closeDropdown={closeParticipantMenu}
/>
<Button
round
size="default"
ripple
className={buildClassName(
styles.actionButton,
!hasAudioStream && styles.muted,
canRequestToSpeak && styles.canRequestToSpeak,
)}
ariaLabel={lang('lng_group_call_settings')}
disabled
>
<i className="icon icon-settings-filled" aria-hidden />
</Button>
<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
tgsUrl={LOCAL_TGS_URLS.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={buildClassName(
'icon',
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 icon-phone-discard" />
</button>
<div className="button-text">
{lang('VoipGroupLeave')}
</div>
</div>
<Button
round
size="default"
ripple
className={buildClassName(
styles.actionButton,
styles.destructive,
)}
onClick={handleLeaveGroupCall}
ariaLabel={lang('lng_group_call_leave')}
>
<i className="icon icon-close" aria-hidden />
</Button>
</div>
<Modal
@ -415,18 +530,20 @@ const GroupCall: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>(
(global, { groupCallId }): StateProps => {
const {
connectionState, title, isSpeakerDisabled, participants, participantsCount,
connectionState, title, participants, participantsCount, chatId,
} = selectGroupCall(global, groupCallId)! || {};
const chat = chatId ? selectChat(global, chatId) : undefined;
return {
connectionState,
title,
isSpeakerEnabled: !isSpeakerDisabled,
title: title || chat?.title,
participantsCount,
meParticipant: selectGroupCallParticipant(global, groupCallId, global.currentUserId!),
isCallPanelVisible: Boolean(selectTabState(global).isCallPanelVisible),
isAdmin: selectIsAdminInActiveGroupCall(global),
participants,
canInvite: selectCanInviteToActiveGroupCall(global),
};
},
)(GroupCall));

View File

@ -0,0 +1,55 @@
.root {
:global {
.ListItem-button {
--color-chat-hover: rgba(255, 255, 255, 0.04);
padding: 0.5rem;
}
.multiline-item {
align-self: center;
}
.title {
display: flex !important;
.fullName {
font-weight: 500;
font-size: 1rem;
}
}
}
}
.subtitle {
display: flex !important;
align-items: center;
gap: 0.375rem;
font-size: 1rem !important;
}
.subtitleText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon {
margin-left: auto;
margin-right: 0.25rem;
}
.subtitleBlue {
--color-text-secondary: #4da6e0;
}
.subtitleRed {
--color-text-secondary: #ff706f;
}
.subtitleGreen {
--color-text-secondary: #57bc6c;
}
.avatar {
margin-right: 0.5rem;
}

View File

@ -1,78 +0,0 @@
.GroupCallParticipant {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
color: #fff;
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
transition: 0.15s ease-out background-color;
cursor: var(--custom-cursor, pointer);
&:hover {
background: #2f363e;
}
audio {
display: none;
}
.Avatar {
margin-right: 1rem;
}
.info {
min-width: 0;
display: flex;
flex-direction: column;
.name {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.about {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
color: #848d94;
font-size: 0.75rem;
&.blue {
color: #4da6e0;
}
&.green {
color: #57bc6c;
}
&.red {
color: #ff706f;
}
}
}
.microphone {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
margin-left: auto;
font-size: 1.5rem;
color: #ff706f;
}
&.can-self-unmute {
.microphone {
color: #848d94;
}
}
.streams {
cursor: var(--custom-cursor, pointer);
display: flex;
}
}

View File

@ -1,24 +1,31 @@
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce';
import { THRESHOLD } from '../../../lib/secret-sauce';
import type { FC } from '../../../lib/teact/teact';
import React, { memo, useMemo, useRef } from '../../../lib/teact/teact';
import React, {
memo, useCallback, useMemo, useRef,
} from '../../../lib/teact/teact';
import { withGlobal } from '../../../global';
import type { ApiChat, ApiUser } from '../../../api/types';
import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';
import buildClassName from '../../../util/buildClassName';
import renderText from '../../common/helpers/renderText';
import { selectChat, selectUser } from '../../../global/selectors';
import useLang from '../../../hooks/useLang';
import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
import useMenuPosition from '../../../hooks/useMenuPosition';
import Avatar from '../../common/Avatar';
import OutlinedMicrophoneIcon from './OutlinedMicrophoneIcon';
import ListItem from '../../ui/ListItem';
import GroupCallParticipantMenu from './GroupCallParticipantMenu';
import FullNameTitle from '../../common/FullNameTitle';
import './GroupCallParticipant.scss';
import styles from './GroupCallParticipant.module.scss';
type OwnProps = {
participant: TypeGroupCallParticipant;
openParticipantMenu: (anchor: HTMLDivElement, participant: TypeGroupCallParticipant) => void;
};
type StateProps = {
@ -27,67 +34,132 @@ type StateProps = {
};
const GroupCallParticipant: FC<OwnProps & StateProps> = ({
openParticipantMenu,
participant,
user,
chat,
}) => {
// eslint-disable-next-line no-null/no-null
const anchorRef = useRef<HTMLDivElement>(null);
const ref = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const menuRef = useRef<HTMLDivElement>(null);
const lang = useLang();
const { isSelf, isMutedByMe, isMuted } = participant;
const {
isSelf, isMutedByMe, isMuted, hasVideoStream, hasPresentationStream,
} = participant;
const isSpeaking = (participant.amplitude || 0) > THRESHOLD;
const isRaiseHand = Boolean(participant.raiseHandRating);
const handleOnClick = () => {
if (isSelf) return;
openParticipantMenu(anchorRef.current!, participant);
};
const {
isContextMenuOpen,
contextMenuPosition,
handleContextMenu,
handleBeforeContextMenu,
handleContextMenuClose,
handleContextMenuHide,
} = useContextMenuHandlers(ref, isSelf);
const getTriggerElement = useCallback(() => ref.current, []);
const getRootElement = useCallback(
() => ref.current!.closest('.custom-scroll, .no-scrollbar'),
[],
);
const getMenuElement = useCallback(
() => menuRef.current!,
[],
);
const getLayout = useCallback(
() => ({ withPortal: true }),
[],
);
const {
positionX, positionY, transformOriginX, transformOriginY, style: menuStyle,
} = useMenuPosition(
contextMenuPosition,
getTriggerElement,
getRootElement,
getMenuElement,
getLayout,
);
const hasCustomVolume = Boolean(
!isMuted && isSpeaking && participant.volume && participant.volume !== GROUP_CALL_DEFAULT_VOLUME,
);
const [aboutText, aboutColor] = useMemo(() => {
if (isSelf) {
return [lang('ThisIsYou'), 'blue'];
}
if (isMutedByMe) {
return [lang('VoipGroupMutedForMe'), 'red'];
return [lang('VoipGroupMutedForMe'), styles.subtitleRed];
}
return isRaiseHand
? [lang('WantsToSpeak'), 'blue']
: (!isMuted && isSpeaking ? [
participant.volume && participant.volume !== GROUP_CALL_DEFAULT_VOLUME
? lang('SpeakingWithVolume',
(participant.volume / GROUP_CALL_VOLUME_MULTIPLIER).toString())
.replace('%%', '%') : lang('Speaking'),
'green',
]
: (participant.about ? [participant.about, ''] : [lang('Listening'), 'blue']));
}, [isSpeaking, participant.volume, lang, isSelf, isMutedByMe, isRaiseHand, isMuted, participant.about]);
if (isRaiseHand) {
return [lang('WantsToSpeak'), styles.subtitleBlue];
}
if (hasCustomVolume) {
return [
lang('SpeakingWithVolume',
(participant.volume! / GROUP_CALL_VOLUME_MULTIPLIER).toString())
.replace('%%', '%'),
styles.subtitleGreen,
];
}
if (!isMuted && isSpeaking) {
return [
lang('Speaking'),
styles.subtitleGreen,
];
}
if (isSelf) {
return [lang('ThisIsYou'), styles.subtitleBlue];
}
return participant.about ? [participant.about, ''] : [lang('Listening'), styles.subtitleBlue];
}, [
isMutedByMe, isRaiseHand, isSelf, hasCustomVolume, isMuted, isSpeaking, participant.about, participant.volume, lang,
]);
if (!user && !chat) {
return undefined;
}
const name = user ? `${user.firstName || ''} ${user.lastName || ''}` : chat?.title;
return (
<div
className={buildClassName(
'GroupCallParticipant',
participant.canSelfUnmute && 'can-self-unmute',
)}
onClick={handleOnClick}
ref={anchorRef}
<ListItem
leftElement={<Avatar user={user} chat={chat} className={styles.avatar} />}
rightElement={<OutlinedMicrophoneIcon participant={participant} className={styles.icon} />}
className={styles.root}
onClick={handleContextMenu}
onMouseDown={handleBeforeContextMenu}
onContextMenu={handleContextMenu}
multiline
ripple
ref={ref}
>
<Avatar user={user} chat={chat} size="medium" />
<div className="info">
<span className="name">{name}</span>
<span className={buildClassName('about', aboutColor)}>{aboutText}</span>
</div>
<div className="microphone">
<OutlinedMicrophoneIcon participant={participant} />
</div>
</div>
<FullNameTitle peer={user || chat!} withEmojiStatus className={styles.title} />
<span className={buildClassName(styles.subtitle, 'subtitle', aboutColor)}>
{hasPresentationStream && <i className="icon icon-share-screen" aria-hidden />}
{hasVideoStream && <i className="icon icon-video" aria-hidden />}
{hasCustomVolume && <i className="icon icon-speaker" aria-hidden />}
<span className={styles.subtitleText}>{renderText(aboutText)}</span>
</span>
<GroupCallParticipantMenu
participant={participant}
isDropdownOpen={isContextMenuOpen}
positionX={positionX}
positionY={positionY}
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
style={menuStyle}
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
menuRef={menuRef}
/>
</ListItem>
);
};

View File

@ -0,0 +1,10 @@
.root {
position: absolute;
width: 100%;
top: 0.25rem;
padding-bottom: 5rem;
}
.portrait {
padding-bottom: 6rem;
}

View File

@ -1,17 +1,22 @@
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce';
import type { FC } from '../../../lib/teact/teact';
import React, { memo, useCallback, useMemo } from '../../../lib/teact/teact';
import React, { memo, useMemo } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import useLang from '../../../hooks/useLang';
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce';
import type { FC } from '../../../lib/teact/teact';
import buildClassName from '../../../util/buildClassName';
import { selectActiveGroupCall } from '../../../global/selectors/calls';
import useInfiniteScroll from '../../../hooks/useInfiniteScroll';
import useLastCallback from '../../../hooks/useLastCallback';
import GroupCallParticipant from './GroupCallParticipant';
import InfiniteScroll from '../../ui/InfiniteScroll';
import styles from './GroupCallParticipantList.module.scss';
type OwnProps = {
openParticipantMenu: (anchor: HTMLDivElement, participant: TypeGroupCallParticipant) => void;
panelOffset: number;
isLandscape: boolean;
};
type StateProps = {
@ -21,24 +26,22 @@ type StateProps = {
};
const GroupCallParticipantList: FC<OwnProps & StateProps> = ({
panelOffset,
participants,
participantsCount,
openParticipantMenu,
isLandscape,
}) => {
const {
createGroupCallInviteLink,
loadMoreGroupCallParticipants,
} = getActions();
const lang = useLang();
const participantsIds = useMemo(() => {
return Object.keys(participants || {});
}, [participants]);
const handleLoadMoreGroupCallParticipants = useCallback(() => {
const handleLoadMoreGroupCallParticipants = useLastCallback(() => {
loadMoreGroupCallParticipants();
}, [loadMoreGroupCallParticipants]);
});
const [viewportIds, getMore] = useInfiniteScroll(
handleLoadMoreGroupCallParticipants,
@ -46,37 +49,24 @@ const GroupCallParticipantList: FC<OwnProps & StateProps> = ({
participantsIds.length >= participantsCount,
);
function handleCreateGroupCallInviteLink() {
createGroupCallInviteLink();
}
return (
<div className="participants">
<div className="invite-btn" onClick={handleCreateGroupCallInviteLink}>
<div className="icon-wrapper">
<i className="icon icon-add-user" />
</div>
<div className="text">{lang('VoipGroupInviteMember')}</div>
</div>
<InfiniteScroll
items={viewportIds}
onLoadMore={getMore}
>
{viewportIds?.map(
(participantId) => (
participants![participantId] && (
<GroupCallParticipant
key={participantId}
openParticipantMenu={openParticipantMenu}
participant={participants![participantId]}
/>
)
),
)}
</InfiniteScroll>
</div>
<InfiniteScroll
items={viewportIds}
onLoadMore={getMore}
style={`transform: translateY(${panelOffset}px);`}
className={buildClassName(styles.root, !isLandscape && styles.portrait)}
>
{participants && viewportIds?.map(
(participantId) => (
participants[participantId] && (
<GroupCallParticipant
key={participantId}
participant={participants[participantId]}
/>
)
),
)}
</InfiniteScroll>
);
};

View File

@ -1,12 +1,15 @@
@import '../../../styles/mixins';
.participant-menu {
position: absolute;
--color-text: white;
--color-background-compact-menu: #212121DD;
--color-background-compact-menu-hover: #00000066;
position: absolute;
z-index: var(--z-modal-menu);
.bubble {
backdrop-filter: none !important;
background: none !important;
border-radius: 0;
padding: 0;
@ -21,6 +24,7 @@
background: var(--color-background);
border-radius: var(--border-radius-default);
margin-bottom: 0.5rem;
backdrop-filter: blur(10px);
}
}
@ -44,7 +48,7 @@
padding: 0.75rem 1rem;
.AnimatedSticker {
margin-right: 2rem;
margin-right: 1rem;
}
}
@ -86,7 +90,7 @@
position: absolute;
left: -1.5rem;
top: 0;
width: calc(100% + 3rem);
width: calc(100% + 1.5rem);
margin: 0;
z-index: 0;

View File

@ -1,19 +1,17 @@
import type { GroupCallParticipant } from '../../../lib/secret-sauce';
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useCallback, useEffect, useState,
memo, useEffect, useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { IAnchorPosition } from '../../../types';
import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';
import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets';
import buildClassName from '../../../util/buildClassName';
import buildStyle from '../../../util/buildStyle';
import useRunThrottled from '../../../hooks/useRunThrottled';
import useFlag from '../../../hooks/useFlag';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import { selectIsAdminInActiveGroupCall } from '../../../global/selectors/calls';
import Menu from '../../ui/Menu';
@ -28,9 +26,15 @@ const SPEAKER_ICON_ENABLED_SEGMENT: [number, number] = [17, 34];
type OwnProps = {
participant?: GroupCallParticipant;
closeDropdown: VoidFunction;
onCloseAnimationEnd: VoidFunction;
onClose: VoidFunction;
isDropdownOpen: boolean;
anchor?: IAnchorPosition;
positionX?: 'left' | 'right';
positionY?: 'top' | 'bottom';
transformOriginX?: number;
transformOriginY?: number;
style?: string;
menuRef?: React.RefObject<HTMLDivElement>;
};
type StateProps = {
@ -48,10 +52,16 @@ const SPEAKER_ICON_SIZE = 24;
const GroupCallParticipantMenu: FC<OwnProps & StateProps> = ({
participant,
closeDropdown,
onCloseAnimationEnd,
onClose,
isDropdownOpen,
anchor,
isAdmin,
positionY,
menuRef,
positionX,
style,
transformOriginY,
transformOriginX,
}) => {
const {
toggleGroupCallMute,
@ -75,6 +85,22 @@ const GroupCallParticipantMenu: FC<OwnProps & StateProps> = ({
isMutedByMe ? VOLUME_ZERO : ((participant?.volume || GROUP_CALL_DEFAULT_VOLUME) / GROUP_CALL_VOLUME_MULTIPLIER),
);
const [shouldPlay, setShouldPlay] = useState(false);
const isLocalVolumeZero = localVolume === VOLUME_ZERO;
const speakerIconPlaySegment = isLocalVolumeZero ? SPEAKER_ICON_DISABLED_SEGMENT : SPEAKER_ICON_ENABLED_SEGMENT;
useEffect(() => {
if (isDropdownOpen) return;
setShouldPlay(false);
}, [isDropdownOpen]);
const handleSetLocalVolume = useLastCallback((volume: number) => {
setLocalVolume(volume);
const isNewLocalVolumeZero = volume === VOLUME_ZERO;
setShouldPlay(isNewLocalVolumeZero !== isLocalVolumeZero);
});
useEffect(() => {
setLocalVolume(isMutedByMe
? VOLUME_ZERO
@ -85,49 +111,49 @@ const GroupCallParticipantMenu: FC<OwnProps & StateProps> = ({
const runThrottled = useRunThrottled(VOLUME_CHANGE_THROTTLE);
const handleRemove = useCallback((e: React.SyntheticEvent<any>) => {
const handleRemove = useLastCallback((e: React.SyntheticEvent<any>) => {
e.stopPropagation();
openDeleteUserModal();
closeDropdown();
}, [openDeleteUserModal, closeDropdown]);
onClose();
});
const handleCancelRequestToSpeak = useCallback((e: React.SyntheticEvent<any>) => {
const handleCancelRequestToSpeak = useLastCallback((e: React.SyntheticEvent<any>) => {
e.stopPropagation();
requestToSpeak({
value: false,
});
closeDropdown();
}, [requestToSpeak, closeDropdown]);
onClose();
});
const handleMute = useCallback((e: React.SyntheticEvent<any>) => {
const handleMute = useLastCallback((e: React.SyntheticEvent<any>) => {
e.stopPropagation();
closeDropdown();
onClose();
if (!isAdmin) {
setLocalVolume(isMutedByMe ? GROUP_CALL_DEFAULT_VOLUME / GROUP_CALL_VOLUME_MULTIPLIER : VOLUME_ZERO);
handleSetLocalVolume(isMutedByMe ? GROUP_CALL_DEFAULT_VOLUME / GROUP_CALL_VOLUME_MULTIPLIER : VOLUME_ZERO);
} else if (shouldRaiseHand) {
handleSetLocalVolume((participant?.volume ?? GROUP_CALL_DEFAULT_VOLUME) / GROUP_CALL_VOLUME_MULTIPLIER);
}
toggleGroupCallMute({
participantId: id!,
value: isAdmin ? !shouldRaiseHand : !isMutedByMe,
});
}, [closeDropdown, toggleGroupCallMute, id, isAdmin, shouldRaiseHand, isMutedByMe]);
});
const handleOpenProfile = useCallback((e: React.SyntheticEvent<any>) => {
const handleOpenProfile = useLastCallback((e: React.SyntheticEvent<any>) => {
e.stopPropagation();
toggleGroupCallPanel();
openChat({
id,
});
closeDropdown();
}, [toggleGroupCallPanel, closeDropdown, openChat, id]);
const isLocalVolumeZero = localVolume === VOLUME_ZERO;
const speakerIconPlaySegment = isLocalVolumeZero ? SPEAKER_ICON_DISABLED_SEGMENT : SPEAKER_ICON_ENABLED_SEGMENT;
onClose();
});
const handleChangeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = Number(e.target.value);
setLocalVolume(value);
handleSetLocalVolume(value);
runThrottled(() => {
if (value === VOLUME_ZERO) {
toggleGroupCallMute({
@ -147,11 +173,16 @@ const GroupCallParticipantMenu: FC<OwnProps & StateProps> = ({
<div>
<Menu
isOpen={isDropdownOpen}
positionX="right"
autoClose
style={buildStyle(anchor && `right: 1rem; top: ${anchor.y}px`)}
onClose={closeDropdown}
className="participant-menu"
positionX={positionX}
positionY={positionY}
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
style={style}
ref={menuRef}
withPortal
onClose={onClose}
onCloseAnimationEnd={onCloseAnimationEnd}
className="participant-menu with-menu-transitions"
>
{!isSelf && !shouldRaiseHand && (
<div className="group">
@ -173,6 +204,7 @@ const GroupCallParticipantMenu: FC<OwnProps & StateProps> = ({
<div className="info">
<AnimatedIcon
tgsUrl={LOCAL_TGS_URLS.Speaker}
play={shouldPlay ? speakerIconPlaySegment.toString() : false}
playSegment={speakerIconPlaySegment}
size={SPEAKER_ICON_SIZE}
/>

View File

@ -1,105 +0,0 @@
import type { GroupCallParticipant } from '../../../lib/secret-sauce';
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useCallback, useMemo, useState,
} from '../../../lib/teact/teact';
import { withGlobal } from '../../../global';
import GroupCallParticipantVideo from './GroupCallParticipantVideo';
import { selectActiveGroupCall } from '../../../global/selectors/calls';
import buildClassName from '../../../util/buildClassName';
type OwnProps = {
onDoubleClick?: VoidFunction;
};
type StateProps = {
participants?: Record<string, GroupCallParticipant>;
};
type SelectedVideo = {
type: 'video' | 'presentation';
id: string;
};
const GroupCallParticipantStreams: FC<OwnProps & StateProps> = ({
participants,
onDoubleClick,
}) => {
const [selectedVideo, setSelectedVideo] = useState<SelectedVideo | undefined>(undefined);
const presentationParticipants = useMemo(() => {
return Object.values(participants || {}).filter((participant) => participant.hasPresentationStream);
}, [participants]);
const videoParticipants = useMemo(() => {
return Object.values(participants || {}).filter((participant) => participant.hasVideoStream);
}, [participants]);
const totalVideoCount = videoParticipants.length + presentationParticipants.length;
// TODO replace with more adequate solution.
// There's a max of 30 videos or so right now
const columnCount = totalVideoCount <= 2 ? 1 : (
totalVideoCount <= 6 ? 2 : (
totalVideoCount <= 9 ? 3 : 4
)
);
const shouldSpanLastVideo = totalVideoCount === 3 || (columnCount === 2 && totalVideoCount % 2 !== 0);
const handleClickVideo = useCallback((id: string, type: 'video' | 'presentation') => {
if (!selectedVideo || (id !== selectedVideo.id || type !== selectedVideo.type)) {
setSelectedVideo({
id,
type,
});
} else {
setSelectedVideo(undefined);
}
}, [selectedVideo]);
return (
<div className="streams" onDoubleClick={onDoubleClick}>
<div
className={buildClassName(
'videos',
shouldSpanLastVideo && 'span-last-video',
)}
style={`--column-count: ${selectedVideo ? 1 : columnCount}`}
>
{selectedVideo && (
<GroupCallParticipantVideo
key={selectedVideo.id}
isFullscreen
onClick={handleClickVideo}
participant={participants![selectedVideo.id]}
type={selectedVideo.type}
/>
)}
{!selectedVideo ? presentationParticipants.map((participant) => (
<GroupCallParticipantVideo
key={participant.id}
onClick={handleClickVideo}
participant={participant}
type="presentation"
/>
)) : undefined}
{!selectedVideo ? videoParticipants.map((participant) => (
<GroupCallParticipantVideo
key={participant.id}
onClick={handleClickVideo}
participant={participant}
type="video"
/>
)) : undefined}
</div>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const { participants } = selectActiveGroupCall(global) || {};
return {
participants,
};
},
)(GroupCallParticipantStreams));

View File

@ -0,0 +1,150 @@
.wrapper {
position: absolute;
opacity: 1;
transform: translate(var(--x), var(--y)) scale(1);
width: var(--width);
height: var(--height);
}
.hidden {
opacity: 0;
transform: translate(var(--x), var(--y)) scale(0.6);
}
.noAnimate {
transition: none;
}
.root {
position: relative;
width: 100%;
height: 100%;
display: flex;
border-radius: 0.625rem;
user-select: none;
&::before {
content: '';
display: block;
position: absolute;
inset: -0.125rem;
border-radius: 0.75rem;
background: var(--gradient-speaking);
transform: scale(0.96);
transition: 0.25s ease-in-out transform;
}
&::after {
content: '';
display: block;
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 55.62%, rgba(0, 0, 0, 0.5) 86.46%);
z-index: 2;
border-radius: 0.625rem;
}
&.speaking::before {
transform: scale(1);
}
}
.video {
width: 100%;
display: block;
object-fit: contain;
border-radius: 0.625rem;
z-index: 2;
}
.videoFallback {
composes: video;
position: absolute;
height: 100%;
z-index: 1;
}
.thumbnailWrapper {
position: absolute;
z-index: 0;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 0.625rem;
background: #000;
}
.thumbnail {
object-fit: cover;
width: 100%;
height: 100%;
}
.flipped {
transform: rotateY(180deg);
}
.pinButton {
position: absolute;
inset-inline-end: 0.25rem;
inset-block-start: 0.25rem;
z-index: 3;
color: #FFFFFF !important;
}
.bottomPanel {
position: absolute;
inset-block-end: 0;
inset-inline: 0;
border-end-end-radius: 0.625rem;
border-end-start-radius: 0.625rem;
padding: 0.5rem 0.75rem;
display: flex;
align-items: center;
gap: 0.25rem;
z-index: 3;
}
.info {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 1rem;
min-width: 0;
}
.pinned .bottomPanel, .pinned::after {
opacity: 0;
transition: 0.25s ease-in-out opacity;
}
.pinned:hover .bottomPanel, .pinned:hover::after {
opacity: 1;
}
.name {
color: #FFFFFF;
font-weight: 500;
line-height: 1.125rem;
:global(.fullName) {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
font-size: 1rem;
}
}
.status {
color: #FFFFFF;
opacity: 0.6;
line-height: 1.125rem;
}
.icon {
margin-left: auto;
}

View File

@ -1,126 +0,0 @@
.GroupCallParticipantVideo {
border-radius: 0.75rem;
overflow: hidden;
position: relative;
max-height: 12.875rem;
width: calc(50% - 0.25rem);
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
transition: 0.25s ease-out width;
cursor: var(--custom-cursor, pointer);
.thumbnail-avatar {
position: absolute;
border-radius: 0;
width: 100%;
height: 100%;
transform: scale(1.1);
img {
filter: blur(10px);
border-radius: 0;
object-fit: cover;
}
}
&:last-child:nth-child(odd) {
width: 100%;
}
&::before {
box-shadow: 0 0 0 3px transparent inset;
width: 100%;
height: 100%;
position: absolute;
display: block;
content: "";
z-index: 5;
border-radius: 0.75rem;
transition: 0.25s ease-out box-shadow;
}
&.active::before {
box-shadow: 0px 0px 0px 3px #78ee7e inset;
}
.back-button {
position: absolute;
z-index: 5;
top: 0.75rem;
left: 0.75rem;
background: rgba(0, 0, 0, 0.3);
border: 0;
color: white;
border-radius: 1rem;
padding: 0.25rem 0.75rem;
display: flex;
align-items: center;
gap: 0.25rem;
transition: 0.25s ease-out opacity, 0.25s ease-out background-color;
opacity: 0;
cursor: var(--custom-cursor, pointer);
outline: none !important;
&:hover {
background: rgba(0, 0, 0, 0.4);
}
}
video {
display: block;
width: 100%;
}
.video {
object-fit: contain;
height: 12.5rem;
position: relative;
}
.thumbnail-wrapper {
position: absolute;
top: 50%;
left: 50%;
z-index: 0;
width: 100%;
transform: translate(-50%, -50%) scale(1.5);
background: black;
}
.thumbnail {
filter: blur(10px) brightness(0.5);
object-fit: cover;
}
.info {
position: absolute;
bottom: 0;
color: #fff;
display: flex;
align-items: center;
padding: 0 0.5rem 0.25rem;
width: 100%;
height: 2rem;
background: linear-gradient(0deg, #000, transparent);
transition: 0.25s ease-out opacity;
opacity: 0;
.name {
margin-left: 0.5rem;
}
.last-icon {
margin-left: auto;
}
}
}
.videos:hover .GroupCallParticipantVideo {
.info {
opacity: 1;
}
.back-button {
opacity: 1;
}
}

View File

@ -1,77 +1,314 @@
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce';
import { getUserStreams, THRESHOLD } from '../../../lib/secret-sauce';
import type { FC } from '../../../lib/teact/teact';
import React, { memo, useCallback } from '../../../lib/teact/teact';
import React, {
memo, useCallback, useEffect, useMemo, useRef, useState,
} from '../../../lib/teact/teact';
import { withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { VideoLayout, VideoParticipant } from './hooks/useGroupCallVideoLayout';
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce';
import type { ApiChat, ApiUser } from '../../../api/types';
import { GROUP_CALL_THUMB_VIDEO_DISABLED } from '../../../config';
import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';
import { getUserStreams, THRESHOLD } from '../../../lib/secret-sauce';
import buildClassName from '../../../util/buildClassName';
import { selectChat, selectUser } from '../../../global/selectors';
import { animate } from '../../../util/animation';
import { fastRaf } from '../../../util/schedulers';
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import useLang from '../../../hooks/useLang';
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
import useMenuPosition from '../../../hooks/useMenuPosition';
import useLastCallback from '../../../hooks/useLastCallback';
import useInterval from '../../../hooks/useInterval';
import Avatar from '../../common/Avatar';
import Button from '../../ui/Button';
import OutlinedMicrophoneIcon from './OutlinedMicrophoneIcon';
import FullNameTitle from '../../common/FullNameTitle';
import GroupCallParticipantMenu from './GroupCallParticipantMenu';
import './GroupCallParticipantVideo.scss';
import styles from './GroupCallParticipantVideo.module.scss';
const VIDEO_FALLBACK_UPDATE_INTERVAL = 1000;
type OwnProps = {
layout: VideoLayout;
setPinned: (participant?: VideoParticipant) => void;
pinnedVideo: VideoParticipant | undefined;
canPin: boolean;
participant: TypeGroupCallParticipant;
type: 'video' | 'presentation';
onClick?: (id: string, type: 'video' | 'presentation') => void;
isFullscreen?: boolean;
className?: string;
};
type StateProps = {
user?: ApiUser;
chat?: ApiChat;
currentUserId?: string;
isActive?: boolean;
};
const GroupCallParticipantVideo: FC<OwnProps & StateProps> = ({
type,
onClick,
layout,
pinnedVideo,
setPinned,
canPin,
className,
participant,
user,
chat,
isActive,
isFullscreen,
}) => {
const lang = useLang();
const handleClick = useCallback(() => {
if (onClick) {
onClick(user?.id || chat!.id, type);
}
}, [chat, onClick, type, user?.id]);
// eslint-disable-next-line no-null/no-null
const thumbnailRef = useRef<HTMLCanvasElement>(null);
// eslint-disable-next-line no-null/no-null
const videoRef = useRef<HTMLVideoElement>(null);
// eslint-disable-next-line no-null/no-null
const videoFallbackRef = useRef<HTMLCanvasElement>(null);
if (!user && !chat) return undefined;
const {
x, y, width, height, noAnimate, isRemoved,
type,
} = layout;
const {
isSelf, isMutedByMe, isMuted,
} = participant;
const isPinned = pinnedVideo?.id === participant.id && pinnedVideo?.type === type;
const isSpeaking = (participant.amplitude || 0) > THRESHOLD;
const isRaiseHand = Boolean(participant.raiseHandRating);
const shouldFlipVideo = type === 'video' && participant.isSelf;
const status = useMemo(() => {
if (isSelf) {
return lang('ThisIsYou');
}
if (isMutedByMe) {
return lang('VoipGroupMutedForMe');
}
if (isRaiseHand) {
return lang('WantsToSpeak');
}
if (isMuted || !isSpeaking) {
return lang('Listening');
}
if (participant.volume && participant.volume !== GROUP_CALL_DEFAULT_VOLUME) {
return lang('SpeakingWithVolume',
(participant.volume / GROUP_CALL_VOLUME_MULTIPLIER).toString())
.replace('%%', '%');
}
return lang('Speaking');
}, [isSpeaking, participant.volume, lang, isSelf, isMutedByMe, isRaiseHand, isMuted]);
const prevLayoutRef = useRef<VideoLayout>();
if (!isRemoved) {
prevLayoutRef.current = layout;
}
const {
x: prevX, y: prevY, width: prevWidth, height: prevHeight,
} = prevLayoutRef.current || {};
const [currentX, currentY, currentWidth, currentHeight] = isRemoved
? [prevX, prevY, prevWidth, prevHeight] : [x, y, width, height];
const [isHidden, setIsHidden] = useState(!noAnimate);
const streams = getUserStreams(user?.id || chat!.id);
const actualStream = type === 'video' ? streams?.video : streams?.presentation;
const streamRef = useRef(actualStream);
if (actualStream?.active && actualStream?.getVideoTracks()[0].enabled) {
streamRef.current = actualStream;
}
const stream = streamRef.current;
const handleInactive = useLastCallback(() => {
const video = videoRef.current;
if (!video) return;
// eslint-disable-next-line no-null/no-null
video.srcObject = null;
});
useEffect(() => {
stream?.addEventListener('inactive', handleInactive);
return () => {
stream?.removeEventListener('inactive', handleInactive);
};
}, [handleInactive, stream]);
useEffect(() => {
setIsHidden(false);
}, []);
// When video stream is removed, the video element starts showing empty black screen.
// To avoid that, we hide the video element and show the fallback frame instead, which is constantly updated
// every VIDEO_FALLBACK_UPDATE_INTERVAL milliseconds.
useInterval(() => {
if (!stream?.active) return;
const video = videoRef.current!;
const canvas = videoFallbackRef.current!;
requestMutation(() => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d')!.drawImage(video, 0, 0, canvas.width, canvas.height);
});
}, VIDEO_FALLBACK_UPDATE_INTERVAL);
useEffect(() => {
const video = videoRef.current;
const thumbnail = thumbnailRef.current;
if (!video || !thumbnail || !stream) return undefined;
const ctx = thumbnail.getContext('2d', { alpha: false });
if (!ctx) return undefined;
let isDrawing = true;
requestMutation(() => {
if (!isDrawing) return;
thumbnail.width = 16;
thumbnail.height = 16;
ctx.filter = 'blur(2px)';
const draw = () => {
if (!isDrawing) return false;
if (!stream.active) {
return false;
}
ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight, 0, 0, thumbnail.width, thumbnail.height);
return true;
};
animate(draw, fastRaf);
});
return () => {
isDrawing = false;
};
}, [stream]);
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const menuRef = useRef<HTMLDivElement>(null);
const {
isContextMenuOpen,
contextMenuPosition,
handleContextMenu,
handleContextMenuClose,
handleContextMenuHide,
} = useContextMenuHandlers(ref, isSelf);
const getTriggerElement = useCallback(() => ref.current, []);
const getRootElement = useCallback(
() => ref.current!.closest('.custom-scroll, .no-scrollbar'),
[],
);
const getMenuElement = useCallback(
() => menuRef.current!,
[],
);
const getLayout = useCallback(
() => ({ withPortal: true }),
[],
);
const {
positionX, positionY, transformOriginX, transformOriginY, style: menuStyle,
} = useMenuPosition(
contextMenuPosition,
getTriggerElement,
getRootElement,
getMenuElement,
getLayout,
);
const handleClickPin = useCallback(() => {
setPinned(!isPinned ? {
id: user?.id || chat!.id,
type,
} : undefined);
}, [chat, isPinned, setPinned, type, user?.id]);
return (
<div
className={buildClassName('GroupCallParticipantVideo', isActive && 'active')}
onClick={handleClick}
className={buildClassName(
styles.wrapper,
(isHidden || isRemoved) && styles.hidden,
noAnimate && styles.noAnimate,
className,
isPinned && styles.pinned,
)}
style={`--x: ${currentX}px; --y: ${currentY}px; --width: ${currentWidth}px; --height: ${currentHeight}px;`}
ref={ref}
onContextMenu={handleContextMenu}
onDoubleClick={canPin ? handleClickPin : undefined}
>
{isFullscreen && (
<button className="back-button">
<i className="icon icon-arrow-left" />
{lang('Back')}
</button>
)}
<Avatar user={user} chat={chat} className="thumbnail-avatar" />
{!GROUP_CALL_THUMB_VIDEO_DISABLED && (
<div className="thumbnail-wrapper">
<video className="thumbnail" muted autoPlay playsInline srcObject={streams?.[type]} />
<div
className={buildClassName(
styles.root,
isSpeaking && styles.speaking,
)}
>
{stream && (
<video
className={buildClassName(styles.video, shouldFlipVideo && styles.flipped)}
muted
autoPlay
playsInline
srcObject={stream}
ref={videoRef}
/>
)}
<canvas
className={buildClassName(styles.videoFallback, shouldFlipVideo && styles.flipped)}
ref={videoFallbackRef}
/>
<div className={styles.thumbnailWrapper}>
<canvas
className={buildClassName(styles.thumbnail, shouldFlipVideo && styles.flipped)}
ref={thumbnailRef}
/>
</div>
{canPin && (
<Button
round
size="smaller"
ripple
color="translucent"
className={styles.pinButton}
ariaLabel={lang(isPinned ? 'lng_group_call_context_unpin_camera' : 'lng_group_call_context_pin_camera')}
onClick={handleClickPin}
>
<i className={buildClassName('icon', isPinned ? 'icon-unpin' : 'icon-pin')} />
</Button>
)}
<div className={styles.bottomPanel}>
<div className={styles.info}>
<FullNameTitle peer={user || chat!} className={styles.name} />
<div className={styles.status}>{status}</div>
</div>
<OutlinedMicrophoneIcon participant={participant} className={styles.icon} noColor />
</div>
)}
<video className="video" muted autoPlay playsInline srcObject={streams?.[type]} />
<div className="info">
<i className="icon icon-microphone-alt" />
<span className="name">{user?.firstName || chat?.title}</span>
{type === 'presentation' && <i className="icon last-icon icon-active-sessions" />}
</div>
<GroupCallParticipantMenu
participant={participant}
isDropdownOpen={isContextMenuOpen}
positionX={positionX}
positionY={positionY}
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
style={menuStyle}
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
menuRef={menuRef}
/>
</div>
);
};
@ -79,10 +316,8 @@ const GroupCallParticipantVideo: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>(
(global, { participant }): StateProps => {
return {
currentUserId: global.currentUserId,
user: participant.isUser ? selectUser(global, participant.id) : undefined,
chat: !participant.isUser ? selectChat(global, participant.id) : undefined,
isActive: (participant.amplitude || 0) > THRESHOLD,
};
},
)(GroupCallParticipantVideo));

View File

@ -39,6 +39,7 @@
.info {
display: flex;
flex-direction: column;
line-height: 1.125rem;
.title {
font-size: 0.875rem;
@ -58,15 +59,16 @@
.Avatar {
margin: 0 0 0 -0.75rem;
font-size: 0.75rem;
&:first-child {
width: 2rem;
height: 2rem;
width: 2rem !important;
height: 2rem !important;
}
&:not(:first-child) {
width: 2.25rem;
height: 2.25rem;
width: 2.25rem !important;
height: 2.25rem !important;
border: 0.125rem solid var(--color-background);
}
}

View File

@ -0,0 +1,52 @@
.root {
position: relative;
background: var(--gradient-green);
&::before, &::after {
position: absolute;
inset: 0;
content: '';
display: block;
transition: 0.25s ease-in-out opacity, 0.25s ease-in-out transform;
opacity: 0;
}
&::before {
background: var(--gradient-blue);
transform: rotate(45deg);
}
&::after {
background: var(--gradient-purple);
transform: rotate(-45deg);
}
}
.spinner {
position: absolute;
width: 100%;
height: 100%;
z-index: 2;
opacity: 0;
transition: 0.25s ease-in-out opacity;
}
.spinnerVisible {
opacity: 1;
}
.canUnmute::before {
opacity: 1;
transform: rotate(0deg);
}
.mutedByAdmin::after {
opacity: 1;
transform: rotate(0deg);
}
.icon {
z-index: 1;
}

View File

@ -1,58 +0,0 @@
.MicrophoneButton {
display: flex;
justify-content: center;
align-items: center;
outline: none !important;
position: relative;
width: 6rem;
height: 6rem;
border: 0;
background: radial-gradient(100% 100% at 100% 0%, #00a0b9 0%, #33c659 55%, #33c659 100%);
border-radius: 50%;
font-size: 2rem;
color: #fff;
transition: 0.25s ease-out filter;
&::before {
content: "";
display: block;
position: absolute;
width: 8rem;
height: 8rem;
background: #64C166;
border-radius: 50%;
filter: blur(10px);
opacity: 0.2;
pointer-events: none;
body.is-ios & {
display: none;
}
}
&:hover {
filter: brightness(0.9);
}
&.crossed {
background: radial-gradient(100% 100% at 100% 0%, #00AFFE 0%, #00AFFE 55%, #007FFF 100%);
&::before {
background: #00AFFE;
}
}
&.muted-by-admin {
background: radial-gradient(85.5% 103.5% at 87.5% 20.65%, #CE4D74 0%, #3D52DF 100%);
&::before {
background: #3D52DF;
}
}
&.is-connecting, &.is-connecting:hover {
background: #222B34;
&::before {
background: transparent;
}
}
}

View File

@ -1,7 +1,7 @@
import type { GroupCallConnectionState } from '../../../lib/secret-sauce';
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useEffect, useMemo, useRef, useState,
memo, useCallback, useEffect, useMemo, useRef, useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
@ -13,11 +13,17 @@ import { selectActiveGroupCall, selectGroupCallParticipant } from '../../../glob
import useLang from '../../../hooks/useLang';
import AnimatedIcon from '../../common/AnimatedIcon';
import Button from '../../ui/Button';
import Spinner from '../../ui/Spinner';
import './MicrophoneButton.scss';
import styles from './MicrophoneButton.module.scss';
const CONNECTION_STATE_DEFAULT = 'discarded';
type OwnProps = {
className?: string;
};
type StateProps = {
connectionState?: GroupCallConnectionState;
hasRequestedToSpeak: boolean;
@ -28,13 +34,13 @@ type StateProps = {
const REQUEST_TO_SPEAK_THROTTLE = 3000;
const HOLD_TO_SPEAK_TIME = 200;
const ICON_SIZE = 48;
const ICON_SIZE = 36;
const MicrophoneButton: FC<StateProps> = ({
const MicrophoneButton: FC<OwnProps & StateProps> = ({
className,
noAudioStream,
canSelfUnmute,
isMuted,
hasRequestedToSpeak,
connectionState,
}) => {
const {
@ -89,12 +95,12 @@ const MicrophoneButton: FC<StateProps> = ({
const animatedIconName = isRequestingToSpeak ? 'HandFilled' : 'VoiceMini';
const toggleMute = () => {
const toggleMute = useCallback(() => {
vibrateShort();
toggleGroupCallMute();
};
}, [toggleGroupCallMute]);
const handleMouseDownMute = () => {
const handleMouseDownMute = useCallback(() => {
if (shouldRaiseHand) {
if (isRequestingToSpeak) return;
vibrateShort();
@ -114,47 +120,42 @@ const MicrophoneButton: FC<StateProps> = ({
}
}, HOLD_TO_SPEAK_TIME);
}
};
}, [isRequestingToSpeak, noAudioStream, requestToSpeak, shouldRaiseHand, toggleMute]);
const handleMouseUpMute = () => {
const handleMouseUpMute = useCallback(() => {
if (shouldRaiseHand) {
return;
}
toggleMute();
muteMouseDownState.current = 'up';
};
const buttonText = lang(
hasRequestedToSpeak ? 'VoipMutedTapedForSpeak' : (
shouldRaiseHand ? 'VoipMutedByAdmin' : (
noAudioStream ? 'VoipUnmute' : 'VoipTapToMute'
)
),
);
}, [shouldRaiseHand, toggleMute]);
return (
<div className="button-wrapper microphone-wrapper">
<button
className={buildClassName(
'MicrophoneButton',
noAudioStream && 'crossed',
canSelfUnmute && 'can-self-unmute',
isConnecting && 'is-connecting',
shouldRaiseHand && 'muted-by-admin',
)}
onMouseDown={handleMouseDownMute}
onMouseUp={handleMouseUpMute}
>
<AnimatedIcon
tgsUrl={LOCAL_TGS_URLS[animatedIconName]}
size={ICON_SIZE}
playSegment={playSegment}
/>
</button>
<div className="button-text">
{buttonText}
</div>
</div>
<Button
round
size="default"
className={buildClassName(
styles.root,
!isConnecting && noAudioStream && styles.canUnmute,
!isConnecting && shouldRaiseHand && styles.mutedByAdmin,
className,
)}
onMouseDown={handleMouseDownMute}
onMouseUp={handleMouseUpMute}
ariaLabel={lang(isMuted ? 'VoipUnmute' : 'VoipMute')}
disabled={isConnecting}
>
<AnimatedIcon
tgsUrl={LOCAL_TGS_URLS[animatedIconName]}
size={ICON_SIZE}
play={playSegment.toString()}
playSegment={playSegment}
className={styles.icon}
forceOnHeavyAnimation
forceInBackground
/>
<Spinner className={buildClassName(styles.spinner, isConnecting && styles.spinnerVisible)} color="white" />
</Button>
);
};

View File

@ -11,11 +11,13 @@ import AnimatedIcon from '../../common/AnimatedIcon';
type OwnProps = {
participant: GroupCallParticipant;
noColor?: boolean;
className?: string;
};
const OutlinedMicrophoneIcon: FC<OwnProps> = ({
participant,
noColor,
className,
}) => {
const { isMuted, isMutedByMe } = participant;
const isSpeaking = (participant.amplitude || 0) > THRESHOLD;
@ -51,21 +53,37 @@ const OutlinedMicrophoneIcon: FC<OwnProps> = ({
// eslint-disable-next-line
}, [isMuted, shouldRaiseHand, isRaiseHand]);
const microphoneColor: string | undefined = useMemo(() => {
return noColor ? '#ffffff' : (
isRaiseHand ? '#4da6e0'
: (shouldRaiseHand || isMutedByMe ? '#ff706f' : (
isSpeaking ? '#57bc6c' : '#848d94'
))
);
const microphoneColor: string = useMemo(() => {
if (noColor) {
return '#ffffff';
}
if (isRaiseHand) {
return '#4da6e0';
}
if (shouldRaiseHand || isMutedByMe) {
return '#ff706f';
}
if (isSpeaking) {
return '#57bc6c';
}
return '#aaaaaa';
}, [noColor, isRaiseHand, shouldRaiseHand, isMutedByMe, isSpeaking]);
return (
<AnimatedIcon
tgsUrl={LOCAL_TGS_URLS.VoiceOutlined}
play={playSegment.toString()}
playSegment={playSegment}
size={28}
color={microphoneColor}
className={className}
forceOnHeavyAnimation
forceInBackground
nonInteractive
/>
);
};

View File

@ -0,0 +1,237 @@
import type { RefObject } from 'react';
import {
useEffect, useMemo, useState,
} from '../../../../lib/teact/teact';
import useResizeObserver from '../../../../hooks/useResizeObserver';
import usePrevious from '../../../../hooks/usePrevious';
import useLastCallback from '../../../../hooks/useLastCallback';
const PADDING_HORIZONTAL = 10;
const PADDING_VERTICAL = 8;
const PREFERRED_PANEL_VIDEO_HEIGHT = 240;
export type VideoLayout = {
participantId: string;
type: 'video' | 'screen';
x: number;
y: number;
width: number;
height: number;
shouldRemount?: boolean;
isRemounted?: boolean;
noAnimate?: boolean;
isRemoved?: boolean;
orderKey: number;
};
export type VideoParticipant = {
id: string;
type: 'video' | 'screen';
};
export default function useGroupCallVideoLayout({
primaryContainerRef,
secondaryContainerRef,
videoParticipants,
isLandscapeLayout,
pinnedVideo,
}: {
primaryContainerRef: RefObject<HTMLDivElement>;
secondaryContainerRef: RefObject<HTMLDivElement>;
videoParticipants: VideoParticipant[];
isLandscapeLayout: boolean;
pinnedVideo: VideoParticipant | undefined;
}) {
const [videoLayout, setVideoLayout] = useState<VideoLayout[]>([]);
const [panelOffset, setPanelOffset] = useState(0);
const videosCount = videoParticipants.length;
const prevVideosCount = usePrevious(videosCount);
const prevVideoParticipants = usePrevious(videoParticipants);
const removedVideoParticipants = useMemo(() => {
return prevVideoParticipants?.filter(
({ id, type }) => !videoParticipants.some((p) => p.id === id && p.type === type),
);
}, [prevVideoParticipants, videoParticipants]);
const recalculateLayout = useLastCallback(() => {
const primaryContainer = primaryContainerRef.current;
const secondaryContainer = secondaryContainerRef.current;
if (!secondaryContainer) return;
const removed = prevVideosCount !== undefined && prevVideosCount > videosCount
? prevVideosCount - videosCount : 0;
const {
x: secondaryInitialX,
y: secondaryInitialY,
width: secondaryContainerWidth,
} = secondaryContainer.getBoundingClientRect();
const layout: VideoLayout[] = [];
if (pinnedVideo !== undefined || !primaryContainer || !isLandscapeLayout) {
const isRemounted = true;
let skip = false;
let pinnedSkipIndex = 0;
let pinnedPush: VideoLayout | undefined;
let participants = videoParticipants;
if (pinnedVideo && primaryContainer && isLandscapeLayout) {
pinnedSkipIndex = participants
.findIndex(({ id, type }) => id === pinnedVideo.id && type === pinnedVideo.type);
if (pinnedSkipIndex !== -1) {
const {
x: initialX,
y: initialY,
width: containerWidth,
height: containerHeight,
} = primaryContainer.getBoundingClientRect();
const { id: participantId, type } = pinnedVideo;
pinnedPush = {
x: initialX,
y: initialY,
width: containerWidth,
height: containerHeight,
participantId,
type,
orderKey: pinnedSkipIndex,
};
skip = true;
participants = participants
.filter(({ id, type: videoType }) => id !== participantId || videoType !== pinnedVideo.type);
}
}
const secondaryVideosCounts = skip ? videosCount - 1 : videosCount;
const isFirstBig = secondaryVideosCounts % 2 === 1;
const columns = 2;
const rows = Math.ceil(secondaryVideosCounts / columns);
const smallWidth = (secondaryContainerWidth - (columns - 1) * PADDING_HORIZONTAL) / columns;
const heightTotal = Math.max(0, isFirstBig
? (PREFERRED_PANEL_VIDEO_HEIGHT + (rows - 1) * smallWidth + (rows - 1) * PADDING_VERTICAL)
: rows * smallWidth + (rows - 1) * PADDING_VERTICAL);
for (let i = 0; i < secondaryVideosCounts; i++) {
const isBig = isFirstBig && i === 0;
const width = isBig ? secondaryContainerWidth : smallWidth;
const height = isBig ? PREFERRED_PANEL_VIDEO_HEIGHT : smallWidth;
const realIndex = isFirstBig && i !== 0 ? i + 1 : i;
const x = (isRemounted ? 0 : secondaryInitialX) + (realIndex % columns) * (width + PADDING_HORIZONTAL);
const y = (isRemounted ? 0 : secondaryInitialY) + (isFirstBig && i !== 0 ? (
PREFERRED_PANEL_VIDEO_HEIGHT + PADDING_VERTICAL
+ (Math.floor(realIndex / columns) - 1) * (height + PADDING_VERTICAL)
) : (
Math.floor(realIndex / columns) * (height + PADDING_VERTICAL)
));
layout.push({
x,
y,
width,
height,
shouldRemount: !isRemounted,
isRemounted,
noAnimate: true,
participantId: participants[i].id,
type: participants[i].type,
orderKey: i >= pinnedSkipIndex ? i + 1 : i,
});
}
if (pinnedPush) {
layout.splice(pinnedSkipIndex, 0, pinnedPush);
}
if (removedVideoParticipants) {
for (let i = 0; i < removed; i++) {
layout.push({
x: 0,
y: 0,
width: 0,
height: 0,
isRemounted,
isRemoved: true,
participantId: removedVideoParticipants[i].id,
type: removedVideoParticipants[i].type,
orderKey: i + videosCount,
});
}
}
setPanelOffset(heightTotal);
setVideoLayout(layout);
return;
}
const {
x: initialX,
y: initialY,
width: containerWidth,
height: containerHeight,
} = primaryContainer.getBoundingClientRect();
const columns = calculateColumnsCount(videosCount);
const rows = Math.ceil(videosCount / columns);
const width = (containerWidth - (columns - 1) * PADDING_HORIZONTAL) / columns;
const height = (containerHeight - (rows - 1) * PADDING_VERTICAL) / rows;
for (let i = 0; i < videosCount; i++) {
const x = initialX + (i % columns) * (width + PADDING_HORIZONTAL);
const y = initialY + Math.floor(i / columns) * (height + PADDING_VERTICAL);
layout.push({
x,
y,
width,
height,
participantId: videoParticipants[i].id,
type: videoParticipants[i].type,
orderKey: i,
});
}
if (removedVideoParticipants) {
for (let i = 0; i < removed; i++) {
layout.push({
x: 0,
y: 0,
width: 0,
height: 0,
isRemoved: true,
participantId: removedVideoParticipants[i].id,
type: removedVideoParticipants[i].type,
orderKey: i + videosCount,
});
}
}
setPanelOffset(0);
setVideoLayout(layout);
});
useEffect(recalculateLayout, [
recalculateLayout, videoParticipants, isLandscapeLayout, pinnedVideo,
]);
useResizeObserver(primaryContainerRef, recalculateLayout, !primaryContainerRef.current);
useResizeObserver(secondaryContainerRef, recalculateLayout, !secondaryContainerRef.current);
return {
videoLayout, panelOffset,
};
}
function calculateColumnsCount(videosCount: number) {
if (videosCount >= 25) {
return 5;
} else if (videosCount >= 13) {
return 4;
} else if (videosCount >= 7) {
return 3;
} else if (videosCount >= 3) {
return 2;
} else {
return 1;
}
}

View File

@ -39,6 +39,7 @@ export type OwnProps = {
color?: string;
isLowPriority?: boolean;
forceOnHeavyAnimation?: boolean;
forceInBackground?: boolean;
sharedCanvas?: HTMLCanvasElement;
sharedCanvasCoords?: { x: number; y: number };
onClick?: NoneToVoidFunction;
@ -65,6 +66,7 @@ const AnimatedSticker: FC<OwnProps> = ({
isLowPriority,
color,
forceOnHeavyAnimation,
forceInBackground,
sharedCanvas,
sharedCanvasCoords,
onClick,
@ -87,7 +89,7 @@ const AnimatedSticker: FC<OwnProps> = ({
const shouldUseColorFilter = !sharedCanvas && color;
const colorFilter = useColorFilter(shouldUseColorFilter ? color : undefined);
const playKey = play || playSegment;
const playKey = play || (play === false ? false : playSegment);
const playRef = useStateRef(play);
const playSegmentRef = useStateRef(playSegment);
@ -177,13 +179,13 @@ const AnimatedSticker: FC<OwnProps> = ({
if (
!animation
|| !(playRef.current || playSegmentRef.current)
|| isFrozen(forceOnHeavyAnimation)
|| isFrozen(forceOnHeavyAnimation, forceInBackground)
) {
return;
}
if (playSegmentRef.current) {
animation.playSegment(playSegmentRef.current, viewId);
animation.playSegment(playSegmentRef.current, shouldRestart, viewId);
} else {
animation.play(shouldRestart, viewId);
}
@ -217,13 +219,13 @@ const AnimatedSticker: FC<OwnProps> = ({
}
if (playKey) {
if (!isFrozen(forceOnHeavyAnimation)) {
if (!isFrozen(forceOnHeavyAnimation, forceInBackground)) {
playAnimation(noLoop);
}
} else {
pauseAnimation();
}
}, [animation, playKey, noLoop, playAnimation, pauseAnimation, forceOnHeavyAnimation]);
}, [animation, playKey, noLoop, playAnimation, pauseAnimation, forceOnHeavyAnimation, forceInBackground]);
useEffect(() => {
if (animation) {
@ -241,7 +243,7 @@ const AnimatedSticker: FC<OwnProps> = ({
// Pausing frame may not happen in background,
// so we need to make sure it happens right after focusing,
// then we can play again.
useBackgroundMode(pauseAnimation, playAnimationOnRaf, !playKey);
useBackgroundMode(pauseAnimation, playAnimationOnRaf, !playKey || forceInBackground);
if (sharedCanvas) {
return undefined;
@ -264,6 +266,8 @@ const AnimatedSticker: FC<OwnProps> = ({
export default memo(AnimatedSticker);
function isFrozen(forceOnHeavyAnimation = false) {
return (!forceOnHeavyAnimation && isHeavyAnimating()) || isPriorityPlaybackActive() || isBackgroundModeActive();
function isFrozen(forceOnHeavyAnimation = false, forceInBackground = false) {
return (!forceOnHeavyAnimation && isHeavyAnimating())
|| isPriorityPlaybackActive()
|| (!forceInBackground && isBackgroundModeActive());
}

View File

@ -51,6 +51,7 @@ export type OwnProps = {
onClick?: (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => void;
onContextMenu?: (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => void;
onMouseDown?: (e: ReactMouseEvent<HTMLButtonElement>) => void;
onMouseUp?: (e: ReactMouseEvent<HTMLButtonElement>) => void;
onMouseEnter?: (e: ReactMouseEvent<HTMLButtonElement>) => void;
onMouseLeave?: NoneToVoidFunction;
onFocus?: NoneToVoidFunction;
@ -67,6 +68,7 @@ const Button: FC<OwnProps> = ({
onClick,
onContextMenu,
onMouseDown,
onMouseUp,
onMouseEnter,
onMouseLeave,
onFocus,
@ -185,6 +187,7 @@ const Button: FC<OwnProps> = ({
onClick={IS_TOUCH_ENV || noFastClick ? handleClick : undefined}
onContextMenu={onContextMenu}
onMouseDown={handleMouseDown}
onMouseUp={onMouseUp}
onMouseEnter={onMouseEnter && !disabled ? onMouseEnter : undefined}
onMouseLeave={onMouseLeave && !disabled ? onMouseLeave : undefined}
onTransitionEnd={onTransitionEnd}

View File

@ -15,6 +15,7 @@ import useLastCallback from '../../hooks/useLastCallback';
type OwnProps = {
ref?: RefObject<HTMLDivElement>;
style?: string;
className?: string;
items?: any[];
itemSelector?: string;
@ -41,6 +42,7 @@ const DEFAULT_SENSITIVE_AREA = 800;
const InfiniteScroll: FC<OwnProps> = ({
ref,
style,
className,
items,
itemSelector = DEFAULT_LIST_SELECTOR,
@ -238,6 +240,7 @@ const InfiniteScroll: FC<OwnProps> = ({
onKeyDown={onKeyDown}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
style={style}
>
{beforeChildren}
{withAbsolutePositioning && items?.length ? (

View File

@ -62,6 +62,7 @@ interface OwnProps {
href?: string;
onMouseDown?: (e: React.MouseEvent<HTMLDivElement>) => void;
onClick?: (e: React.MouseEvent<HTMLElement>, arg?: any) => void;
onContextMenu?: (e: React.MouseEvent<HTMLElement>) => void;
clickArg?: any;
onSecondaryIconClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
onDragEnter?: (e: React.DragEvent<HTMLDivElement>) => void;
@ -92,6 +93,7 @@ const ListItem: FC<OwnProps> = ({
href,
onMouseDown,
onClick,
onContextMenu,
clickArg,
onSecondaryIconClick,
onDragEnter,
@ -225,7 +227,7 @@ const ListItem: FC<OwnProps> = ({
tabIndex={!isStatic ? 0 : undefined}
onClick={(!inactive && IS_TOUCH_ENV) ? handleClick : handleClickEvent}
onMouseDown={handleMouseDown}
onContextMenu={(!inactive && contextActions) ? handleContextMenu : undefined}
onContextMenu={onContextMenu || ((!inactive && contextActions) ? handleContextMenu : undefined)}
>
{leftElement}
{icon && (

View File

@ -262,7 +262,13 @@ addActionHandler('joinGroupCall', async (global, actions, payload): Promise<void
chatId, id, accessHash, inviteHash, tabId = getCurrentTabId(),
} = payload;
if (!ARE_CALLS_SUPPORTED) return;
if (!ARE_CALLS_SUPPORTED) {
actions.showNotification({
message: "Sorry, your browser doesn't support group calls",
tabId,
});
return;
}
if (global.phoneCall) {
actions.toggleGroupCallPanel({ tabId });

View File

@ -1,6 +1,6 @@
import type { GlobalState } from '../types';
import { selectChat, selectChatFullInfo } from './chats';
import { isChatBasicGroup } from '../helpers';
import { getMainUsername, isChatBasicGroup } from '../helpers';
import { selectUser } from './users';
export function selectChatGroupCall<T extends GlobalState>(global: T, chatId: string) {
@ -49,3 +49,24 @@ export function selectPhoneCallUser<T extends GlobalState>(global: T) {
const id = phoneCall.adminId === currentUserId ? phoneCall.participantId : phoneCall.adminId;
return selectUser(global, id);
}
export function selectCanInviteToActiveGroupCall<T extends GlobalState>(global: T) {
const groupCall = selectActiveGroupCall(global);
if (!groupCall || !groupCall.chatId) {
return false;
}
const chat = selectChat(global, groupCall.chatId);
if (!chat) {
return false;
}
const hasPublicUsername = Boolean(getMainUsername(chat));
if (hasPublicUsername) {
return true;
}
const inviteLink = selectChatFullInfo(global, chat.id)?.inviteLink;
return Boolean(inviteLink);
}

View File

@ -6,7 +6,6 @@ import type { Scheduler } from '../util/schedulers';
import {
throttle, debounce, throttleWith,
} from '../util/schedulers';
import useEffectOnce from './useEffectOnce';
import useHeavyAnimationCheck from './useHeavyAnimationCheck';
import useLastCallback from './useLastCallback';
@ -171,10 +170,10 @@ export function useIntersectionObserver({
export function useOnIntersect(
targetRef: RefObject<HTMLDivElement>, observe?: ObserveFn, callback?: TargetCallback,
) {
useEffectOnce(() => {
useEffect(() => {
return observe ? observe(targetRef.current!, callback) : undefined;
// Arguments should never change
});
// eslint-disable-next-line react-hooks-static-deps/exhaustive-deps
}, [observe]);
}
export function useIsIntersecting(

View File

@ -191,12 +191,16 @@ class RLottie {
}
}
playSegment([startFrameIndex, stopFrameIndex]: [number, number], viewId?: string) {
playSegment([startFrameIndex, stopFrameIndex]: [number, number], forceRestart = false, viewId?: string) {
if (viewId) {
this.views.get(viewId)!.isPaused = false;
}
this.approxFrameIndex = Math.floor(startFrameIndex / this.reduceFactor);
const frameIndex = Math.round(this.approxFrameIndex);
this.stopFrameIndex = Math.floor(stopFrameIndex / this.reduceFactor);
if (frameIndex !== stopFrameIndex || forceRestart) {
this.approxFrameIndex = Math.floor(startFrameIndex / this.reduceFactor);
}
this.direction = startFrameIndex < stopFrameIndex ? 1 : -1;
this.doPlay();

View File

@ -380,7 +380,6 @@ export async function handleUpdateGroupCallParticipants(updatedParticipants: Gro
const newEndpoints: string[] = [];
updatedParticipants.forEach((participant) => {
console.log('handleUpdateGroupCallParticipants', participant);
if (participant.isSelf) {
if (participant.isMuted && !participant.canSelfUnmute) {
// Muted by admin
@ -482,7 +481,6 @@ export async function handleUpdateGroupCallParticipants(updatedParticipants: Gro
}
const sdp = buildSdp(conference as Conference);
console.log('build sdp!', sdp);
await connection.setRemoteDescription({
type: 'offer',
sdp,
@ -567,8 +565,6 @@ export async function handleUpdateGroupCallConnection(data: GroupCallConnectionD
...(!isPresentation ? { conference: newConference } : { screenshareConference: newConference }),
};
console.warn('update remote description', newConference, buildSdp(newConference, true, isPresentation));
try {
await connection.setRemoteDescription({
type: 'answer',
@ -683,7 +679,6 @@ function initializeConnection(
if (!isPresentation) {
connection.oniceconnectionstatechange = () => {
const connectionState = connection.iceConnectionState;
console.log('ice', connectionState);
if (connectionState === 'connected' || connectionState === 'completed') {
updateConnectionState('connected');
} else if (connectionState === 'checking' || connectionState === 'new') {
@ -707,8 +702,6 @@ function initializeConnection(
offerToReceiveAudio: !isPresentation,
});
console.log('created offer!', offer);
await connection.setLocalDescription(offer);
if (!offer.sdp) {
@ -716,7 +709,6 @@ function initializeConnection(
}
const sdp = parseSdp(offer);
console.log('parsed sdp', sdp);
const audioSsrc: Ssrc | undefined = !isPresentation ? {
userId: '',
sourceGroups: [

File diff suppressed because it is too large Load Diff

View File

@ -230,6 +230,7 @@ $color-message-reaction-own-hover: #b5e0a4;
--z-header-menu: 990;
--z-header-menu-backdrop: 980;
--z-modal: 1510;
--z-modal-menu: 1600;
--z-media-viewer: 1500;
--z-video-player-controls: 3;
--z-drop-area: 55;

View File

@ -49,6 +49,12 @@
.icon-volume-3:before {
content: "\e991";
}
.icon-settings-filled:before {
content: "\e9c1";
}
.icon-share-screen-stop:before {
content: "\e9c2";
}
.icon-user-online:before {
content: "\e9c0";
}