Revert "Group Calls: Redesign (#2859)"
This reverts commit 8fc05e97ff70a23ff7692e399cb305c765b00b60.
This commit is contained in:
parent
7c17c02801
commit
cb8b2bc018
Binary file not shown.
Binary file not shown.
@ -1,296 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
345
src/components/calls/group/GroupCall.scss
Normal file
345
src/components/calls/group/GroupCall.scss
Normal file
@ -0,0 +1,345 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@ -1,43 +1,51 @@
|
||||
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, useEffect, useMemo, useRef, useState,
|
||||
memo, useCallback, useEffect, useMemo, useRef, useState,
|
||||
} from '../../../lib/teact/teact';
|
||||
import { getActions, withGlobal } from '../../../global';
|
||||
import '../../../global/actions/calls';
|
||||
|
||||
import type {
|
||||
GroupCallConnectionState, GroupCallParticipant as TypeGroupCallParticipant,
|
||||
} from '../../../lib/secret-sauce';
|
||||
import type { FC } from '../../../lib/teact/teact';
|
||||
import type { VideoParticipant } from './hooks/useGroupCallVideoLayout';
|
||||
import type { IAnchorPosition } from '../../../types';
|
||||
|
||||
import { IS_SCREENSHARE_SUPPORTED } from '../../../lib/secret-sauce';
|
||||
import {
|
||||
IS_ANDROID,
|
||||
IS_IOS,
|
||||
IS_REQUEST_FULLSCREEN_SUPPORTED,
|
||||
} from '../../../util/windowEnvironment';
|
||||
import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets';
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
import {
|
||||
selectCanInviteToActiveGroupCall,
|
||||
selectGroupCall,
|
||||
selectGroupCallParticipant,
|
||||
selectIsAdminInActiveGroupCall,
|
||||
} from '../../../global/selectors/calls';
|
||||
import { selectChat, selectTabState } from '../../../global/selectors';
|
||||
import { compact } from '../../../util/iteratees';
|
||||
import { selectTabState } from '../../../global/selectors';
|
||||
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 FloatingActionButton from '../../ui/FloatingActionButton';
|
||||
import GroupCallParticipantVideo from './GroupCallParticipantVideo';
|
||||
import GroupCallParticipantStreams from './GroupCallParticipantStreams';
|
||||
|
||||
import styles from './GroupCall.module.scss';
|
||||
import './GroupCall.scss';
|
||||
|
||||
const INTERSECTION_THROTTLE = 200;
|
||||
const CAMERA_FLIP_PLAY_SEGMENT: [number, number] = [0, 10];
|
||||
const PARTICIPANT_HEIGHT = 60;
|
||||
|
||||
export type OwnProps = {
|
||||
groupCallId: string;
|
||||
@ -49,21 +57,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,
|
||||
participantsCount,
|
||||
isSpeakerEnabled,
|
||||
title,
|
||||
meParticipant,
|
||||
isAdmin,
|
||||
participants,
|
||||
canInvite,
|
||||
|
||||
}) => {
|
||||
const {
|
||||
toggleGroupCallVideo,
|
||||
@ -72,60 +80,24 @@ 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);
|
||||
|
||||
// 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 [isLeaving, setIsLeaving] = useState(false);
|
||||
const [isFullscreen, openFullscreen, closeFullscreen] = useFlag();
|
||||
const [isSidebarOpen, openSidebar, closeSidebar] = useFlag(true);
|
||||
const isLandscapeLayout = Boolean(isFullscreen && isLandscape);
|
||||
const hasVideoParticipants = Object.values(participants).some(({ video, presentation }) => video || presentation);
|
||||
const isLandscapeLayout = isFullscreen && (!isMobile || isLandscape) && hasVideoParticipants;
|
||||
|
||||
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 [participantMenu, setParticipantMenu] = useState<{
|
||||
participant: TypeGroupCallParticipant;
|
||||
anchor: IAnchorPosition;
|
||||
} | undefined>();
|
||||
const [isParticipantMenuOpen, openParticipantMenu, closeParticipantMenu] = useFlag();
|
||||
|
||||
const [isConfirmLeaveModalOpen, openConfirmLeaveModal, closeConfirmLeaveModal] = useFlag();
|
||||
const [isEndGroupCallModal, setIsEndGroupCallModal] = useState(false);
|
||||
@ -133,10 +105,20 @@ 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 canRequestToSpeak = !canSelfUnmute && !hasAudioStream;
|
||||
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') {
|
||||
@ -144,360 +126,263 @@ const GroupCall: FC<OwnProps & StateProps> = ({
|
||||
} else if (connectionState === 'reconnecting') {
|
||||
playGroupCallSound({ sound: 'connecting' });
|
||||
}
|
||||
}, [connectionState]);
|
||||
}, [connectionState, playGroupCallSound]);
|
||||
|
||||
const handleCloseConfirmLeaveModal = useLastCallback(() => {
|
||||
const handleCloseConfirmLeaveModal = useCallback(() => {
|
||||
closeConfirmLeaveModal();
|
||||
setIsEndGroupCallModal(false);
|
||||
});
|
||||
}, [closeConfirmLeaveModal]);
|
||||
|
||||
const handleToggleFullscreen = useLastCallback(() => {
|
||||
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(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
if (isFullscreen) {
|
||||
closeFullscreen();
|
||||
document.exitFullscreen().then(closeFullscreen);
|
||||
} else {
|
||||
openFullscreen();
|
||||
containerRef.current.requestFullscreen().then(openFullscreen);
|
||||
}
|
||||
});
|
||||
}, [closeFullscreen, isFullscreen, openFullscreen]);
|
||||
|
||||
const handleToggleSidebar = useLastCallback(() => {
|
||||
const handleToggleSidebar = useCallback(() => {
|
||||
if (isSidebarOpen) {
|
||||
closeSidebar();
|
||||
} else {
|
||||
openSidebar();
|
||||
}
|
||||
});
|
||||
}, [closeSidebar, isSidebarOpen, openSidebar]);
|
||||
|
||||
const handleInviteMember = useLastCallback(() => {
|
||||
createGroupCallInviteLink();
|
||||
});
|
||||
const handleStreamsDoubleClick = useCallback(() => {
|
||||
if (!IS_REQUEST_FULLSCREEN_SUPPORTED) return;
|
||||
|
||||
const handleClickVideo = useLastCallback(() => {
|
||||
toggleGroupCallVideo();
|
||||
});
|
||||
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();
|
||||
} else {
|
||||
toggleGroupCallVideo();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
connectToActiveGroupCall();
|
||||
}, [connectToActiveGroupCall, groupCallId]);
|
||||
|
||||
const handleLeaveGroupCall = useLastCallback(() => {
|
||||
const endGroupCall = useCallback(() => {
|
||||
setIsEndGroupCallModal(true);
|
||||
setShouldEndGroupCall(true);
|
||||
openConfirmLeaveModal();
|
||||
if (isFullscreen) {
|
||||
handleToggleFullscreen();
|
||||
}
|
||||
}, [handleToggleFullscreen, isFullscreen, openConfirmLeaveModal]);
|
||||
|
||||
const handleLeaveGroupCall = useCallback(() => {
|
||||
if (isAdmin && !isConfirmLeaveModalOpen) {
|
||||
openConfirmLeaveModal();
|
||||
if (isFullscreen) {
|
||||
handleToggleFullscreen();
|
||||
}
|
||||
return;
|
||||
}
|
||||
playGroupCallSound({ sound: 'leave' });
|
||||
setIsLeaving(true);
|
||||
closeConfirmLeaveModal();
|
||||
});
|
||||
}, [
|
||||
closeConfirmLeaveModal, handleToggleFullscreen, isAdmin, isConfirmLeaveModalOpen, isFullscreen,
|
||||
openConfirmLeaveModal, playGroupCallSound,
|
||||
]);
|
||||
|
||||
const handleCloseAnimationEnd = useLastCallback(() => {
|
||||
if (!isLeaving) return;
|
||||
|
||||
leaveGroupCall({
|
||||
shouldDiscard: shouldEndGroupCall,
|
||||
});
|
||||
});
|
||||
|
||||
const handleToggleGroupCallPresentation = useLastCallback(() => {
|
||||
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);
|
||||
const handleCloseAnimationEnd = useCallback(() => {
|
||||
if (isLeaving) {
|
||||
leaveGroupCall({
|
||||
shouldDiscard: shouldEndGroupCall,
|
||||
});
|
||||
}
|
||||
}, [pinnedVideo, videoLayout, videoParticipants]);
|
||||
}, [isLeaving, leaveGroupCall, shouldEndGroupCall]);
|
||||
|
||||
const handleToggleGroupCallPresentation = useCallback(() => {
|
||||
toggleGroupCallPresentation();
|
||||
}, [toggleGroupCallPresentation]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
isOpen={!isCallPanelVisible && !isLeaving}
|
||||
onClose={toggleGroupCallPanel}
|
||||
className={buildClassName(
|
||||
styles.root,
|
||||
isFullscreen && styles.fullscreen,
|
||||
isLandscapeLayout && styles.landscape,
|
||||
!hasVideoParticipants && styles.noVideoParticipants,
|
||||
!isLandscapeLayout && styles.portrait,
|
||||
!isSidebarOpen && isLandscapeWithVideos && styles.noSidebar,
|
||||
'GroupCall',
|
||||
(isMobile && !isLandscape) && 'single-column',
|
||||
isLandscapeLayout && 'landscape',
|
||||
!isSidebarOpen && 'no-sidebar',
|
||||
)}
|
||||
dialogRef={containerRef}
|
||||
onCloseAnimationEnd={handleCloseAnimationEnd}
|
||||
>
|
||||
{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')}
|
||||
<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}
|
||||
>
|
||||
<i className="icon icon-sidebar" aria-hidden />
|
||||
</Button>
|
||||
{lang(hasPresentation ? 'VoipChatStopScreenCapture' : 'VoipChatStartScreenCapture')}
|
||||
</MenuItem>
|
||||
)}
|
||||
</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')}
|
||||
{isAdmin && (
|
||||
<MenuItem
|
||||
icon="phone-discard-outline"
|
||||
onClick={endGroupCall}
|
||||
destructive
|
||||
>
|
||||
<i className="icon icon-fullscreen" aria-hidden />
|
||||
</Button>
|
||||
{lang('VoipGroupLeaveAlertEndChat')}
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{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')}
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<Button
|
||||
round
|
||||
size="smaller"
|
||||
color="translucent"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<i className="icon icon-add-user-filled" aria-hidden />
|
||||
</FloatingActionButton>
|
||||
<i className="icon icon-close" />
|
||||
</Button>
|
||||
</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="scrollable custom-scroll">
|
||||
<GroupCallParticipantStreams onDoubleClick={handleStreamsDoubleClick} />
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
round
|
||||
size="default"
|
||||
ripple
|
||||
className={buildClassName(
|
||||
styles.actionButton,
|
||||
!hasAudioStream && styles.muted,
|
||||
canRequestToSpeak && styles.canRequestToSpeak,
|
||||
)}
|
||||
onClick={handleClickVideo}
|
||||
ariaLabel={lang(hasVideo ? 'VoipStopVideo' : 'VoipStartVideo')}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
<i className={buildClassName('icon', !hasVideo ? 'icon-video-stop' : 'icon-video')} aria-hidden />
|
||||
</Button>
|
||||
{(!isLandscapeLayout || isSidebarOpen)
|
||||
&& <GroupCallParticipantList openParticipantMenu={handleOpenParticipantMenu} />}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<GroupCallParticipantMenu
|
||||
participant={participantMenu?.participant}
|
||||
anchor={participantMenu?.anchor}
|
||||
isDropdownOpen={isParticipantMenuOpen}
|
||||
closeDropdown={closeParticipantMenu}
|
||||
/>
|
||||
|
||||
<MicrophoneButton className={styles.actionButton} />
|
||||
<div className="buttons">
|
||||
{isConnecting && <Loading />}
|
||||
|
||||
<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="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>
|
||||
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
@ -530,20 +415,18 @@ const GroupCall: FC<OwnProps & StateProps> = ({
|
||||
export default memo(withGlobal<OwnProps>(
|
||||
(global, { groupCallId }): StateProps => {
|
||||
const {
|
||||
connectionState, title, participants, participantsCount, chatId,
|
||||
connectionState, title, isSpeakerDisabled, participants, participantsCount,
|
||||
} = selectGroupCall(global, groupCallId)! || {};
|
||||
|
||||
const chat = chatId ? selectChat(global, chatId) : undefined;
|
||||
|
||||
return {
|
||||
connectionState,
|
||||
title: title || chat?.title,
|
||||
title,
|
||||
isSpeakerEnabled: !isSpeakerDisabled,
|
||||
participantsCount,
|
||||
meParticipant: selectGroupCallParticipant(global, groupCallId, global.currentUserId!),
|
||||
isCallPanelVisible: Boolean(selectTabState(global).isCallPanelVisible),
|
||||
isAdmin: selectIsAdminInActiveGroupCall(global),
|
||||
participants,
|
||||
canInvite: selectCanInviteToActiveGroupCall(global),
|
||||
};
|
||||
},
|
||||
)(GroupCall));
|
||||
|
||||
@ -1,55 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
78
src/components/calls/group/GroupCallParticipant.scss
Normal file
78
src/components/calls/group/GroupCallParticipant.scss
Normal file
@ -0,0 +1,78 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@ -1,31 +1,24 @@
|
||||
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, useCallback, useMemo, useRef,
|
||||
} from '../../../lib/teact/teact';
|
||||
import React, { memo, 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 useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
|
||||
import useMenuPosition from '../../../hooks/useMenuPosition';
|
||||
import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';
|
||||
|
||||
import Avatar from '../../common/Avatar';
|
||||
import OutlinedMicrophoneIcon from './OutlinedMicrophoneIcon';
|
||||
import ListItem from '../../ui/ListItem';
|
||||
import GroupCallParticipantMenu from './GroupCallParticipantMenu';
|
||||
import FullNameTitle from '../../common/FullNameTitle';
|
||||
|
||||
import styles from './GroupCallParticipant.module.scss';
|
||||
import './GroupCallParticipant.scss';
|
||||
|
||||
type OwnProps = {
|
||||
participant: TypeGroupCallParticipant;
|
||||
openParticipantMenu: (anchor: HTMLDivElement, participant: TypeGroupCallParticipant) => void;
|
||||
};
|
||||
|
||||
type StateProps = {
|
||||
@ -34,132 +27,67 @@ type StateProps = {
|
||||
};
|
||||
|
||||
const GroupCallParticipant: FC<OwnProps & StateProps> = ({
|
||||
openParticipantMenu,
|
||||
participant,
|
||||
user,
|
||||
chat,
|
||||
}) => {
|
||||
// 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 anchorRef = useRef<HTMLDivElement>(null);
|
||||
const lang = useLang();
|
||||
|
||||
const {
|
||||
isSelf, isMutedByMe, isMuted, hasVideoStream, hasPresentationStream,
|
||||
} = participant;
|
||||
const { isSelf, isMutedByMe, isMuted } = participant;
|
||||
const isSpeaking = (participant.amplitude || 0) > THRESHOLD;
|
||||
const isRaiseHand = Boolean(participant.raiseHandRating);
|
||||
|
||||
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 handleOnClick = () => {
|
||||
if (isSelf) return;
|
||||
openParticipantMenu(anchorRef.current!, participant);
|
||||
};
|
||||
|
||||
const [aboutText, aboutColor] = useMemo(() => {
|
||||
if (isMutedByMe) {
|
||||
return [lang('VoipGroupMutedForMe'), styles.subtitleRed];
|
||||
}
|
||||
|
||||
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 [lang('ThisIsYou'), 'blue'];
|
||||
}
|
||||
|
||||
return participant.about ? [participant.about, ''] : [lang('Listening'), styles.subtitleBlue];
|
||||
}, [
|
||||
isMutedByMe, isRaiseHand, isSelf, hasCustomVolume, isMuted, isSpeaking, participant.about, participant.volume, lang,
|
||||
]);
|
||||
if (isMutedByMe) {
|
||||
return [lang('VoipGroupMutedForMe'), 'red'];
|
||||
}
|
||||
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 (!user && !chat) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const name = user ? `${user.firstName || ''} ${user.lastName || ''}` : chat?.title;
|
||||
|
||||
return (
|
||||
<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}
|
||||
<div
|
||||
className={buildClassName(
|
||||
'GroupCallParticipant',
|
||||
participant.canSelfUnmute && 'can-self-unmute',
|
||||
)}
|
||||
onClick={handleOnClick}
|
||||
ref={anchorRef}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
.root {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 0.25rem;
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
|
||||
.portrait {
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
@ -1,22 +1,17 @@
|
||||
import React, { memo, useMemo } from '../../../lib/teact/teact';
|
||||
import { getActions, withGlobal } from '../../../global';
|
||||
|
||||
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 { getActions, withGlobal } from '../../../global';
|
||||
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
import useLang from '../../../hooks/useLang';
|
||||
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 = {
|
||||
panelOffset: number;
|
||||
isLandscape: boolean;
|
||||
openParticipantMenu: (anchor: HTMLDivElement, participant: TypeGroupCallParticipant) => void;
|
||||
};
|
||||
|
||||
type StateProps = {
|
||||
@ -26,22 +21,24 @@ type StateProps = {
|
||||
};
|
||||
|
||||
const GroupCallParticipantList: FC<OwnProps & StateProps> = ({
|
||||
panelOffset,
|
||||
participants,
|
||||
participantsCount,
|
||||
isLandscape,
|
||||
openParticipantMenu,
|
||||
}) => {
|
||||
const {
|
||||
createGroupCallInviteLink,
|
||||
loadMoreGroupCallParticipants,
|
||||
} = getActions();
|
||||
|
||||
const lang = useLang();
|
||||
|
||||
const participantsIds = useMemo(() => {
|
||||
return Object.keys(participants || {});
|
||||
}, [participants]);
|
||||
|
||||
const handleLoadMoreGroupCallParticipants = useLastCallback(() => {
|
||||
const handleLoadMoreGroupCallParticipants = useCallback(() => {
|
||||
loadMoreGroupCallParticipants();
|
||||
});
|
||||
}, [loadMoreGroupCallParticipants]);
|
||||
|
||||
const [viewportIds, getMore] = useInfiniteScroll(
|
||||
handleLoadMoreGroupCallParticipants,
|
||||
@ -49,24 +46,37 @@ const GroupCallParticipantList: FC<OwnProps & StateProps> = ({
|
||||
participantsIds.length >= participantsCount,
|
||||
);
|
||||
|
||||
function handleCreateGroupCallInviteLink() {
|
||||
createGroupCallInviteLink();
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,15 +1,12 @@
|
||||
@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;
|
||||
@ -24,7 +21,6 @@
|
||||
background: var(--color-background);
|
||||
border-radius: var(--border-radius-default);
|
||||
margin-bottom: 0.5rem;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,7 +44,7 @@
|
||||
padding: 0.75rem 1rem;
|
||||
|
||||
.AnimatedSticker {
|
||||
margin-right: 1rem;
|
||||
margin-right: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@ -90,7 +86,7 @@
|
||||
position: absolute;
|
||||
left: -1.5rem;
|
||||
top: 0;
|
||||
width: calc(100% + 1.5rem);
|
||||
width: calc(100% + 3rem);
|
||||
margin: 0;
|
||||
z-index: 0;
|
||||
|
||||
|
||||
@ -1,17 +1,19 @@
|
||||
import type { GroupCallParticipant } from '../../../lib/secret-sauce';
|
||||
import type { FC } from '../../../lib/teact/teact';
|
||||
import React, {
|
||||
memo, useEffect, useState,
|
||||
memo, useCallback, 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';
|
||||
@ -26,15 +28,9 @@ const SPEAKER_ICON_ENABLED_SEGMENT: [number, number] = [17, 34];
|
||||
|
||||
type OwnProps = {
|
||||
participant?: GroupCallParticipant;
|
||||
onCloseAnimationEnd: VoidFunction;
|
||||
onClose: VoidFunction;
|
||||
closeDropdown: VoidFunction;
|
||||
isDropdownOpen: boolean;
|
||||
positionX?: 'left' | 'right';
|
||||
positionY?: 'top' | 'bottom';
|
||||
transformOriginX?: number;
|
||||
transformOriginY?: number;
|
||||
style?: string;
|
||||
menuRef?: React.RefObject<HTMLDivElement>;
|
||||
anchor?: IAnchorPosition;
|
||||
};
|
||||
|
||||
type StateProps = {
|
||||
@ -52,16 +48,10 @@ const SPEAKER_ICON_SIZE = 24;
|
||||
|
||||
const GroupCallParticipantMenu: FC<OwnProps & StateProps> = ({
|
||||
participant,
|
||||
onCloseAnimationEnd,
|
||||
onClose,
|
||||
closeDropdown,
|
||||
isDropdownOpen,
|
||||
anchor,
|
||||
isAdmin,
|
||||
positionY,
|
||||
menuRef,
|
||||
positionX,
|
||||
style,
|
||||
transformOriginY,
|
||||
transformOriginX,
|
||||
}) => {
|
||||
const {
|
||||
toggleGroupCallMute,
|
||||
@ -85,22 +75,6 @@ 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
|
||||
@ -111,49 +85,49 @@ const GroupCallParticipantMenu: FC<OwnProps & StateProps> = ({
|
||||
|
||||
const runThrottled = useRunThrottled(VOLUME_CHANGE_THROTTLE);
|
||||
|
||||
const handleRemove = useLastCallback((e: React.SyntheticEvent<any>) => {
|
||||
const handleRemove = useCallback((e: React.SyntheticEvent<any>) => {
|
||||
e.stopPropagation();
|
||||
openDeleteUserModal();
|
||||
onClose();
|
||||
});
|
||||
closeDropdown();
|
||||
}, [openDeleteUserModal, closeDropdown]);
|
||||
|
||||
const handleCancelRequestToSpeak = useLastCallback((e: React.SyntheticEvent<any>) => {
|
||||
const handleCancelRequestToSpeak = useCallback((e: React.SyntheticEvent<any>) => {
|
||||
e.stopPropagation();
|
||||
requestToSpeak({
|
||||
value: false,
|
||||
});
|
||||
onClose();
|
||||
});
|
||||
closeDropdown();
|
||||
}, [requestToSpeak, closeDropdown]);
|
||||
|
||||
const handleMute = useLastCallback((e: React.SyntheticEvent<any>) => {
|
||||
const handleMute = useCallback((e: React.SyntheticEvent<any>) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
closeDropdown();
|
||||
|
||||
if (!isAdmin) {
|
||||
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);
|
||||
setLocalVolume(isMutedByMe ? GROUP_CALL_DEFAULT_VOLUME / GROUP_CALL_VOLUME_MULTIPLIER : VOLUME_ZERO);
|
||||
}
|
||||
|
||||
toggleGroupCallMute({
|
||||
participantId: id!,
|
||||
value: isAdmin ? !shouldRaiseHand : !isMutedByMe,
|
||||
});
|
||||
});
|
||||
}, [closeDropdown, toggleGroupCallMute, id, isAdmin, shouldRaiseHand, isMutedByMe]);
|
||||
|
||||
const handleOpenProfile = useLastCallback((e: React.SyntheticEvent<any>) => {
|
||||
const handleOpenProfile = useCallback((e: React.SyntheticEvent<any>) => {
|
||||
e.stopPropagation();
|
||||
toggleGroupCallPanel();
|
||||
openChat({
|
||||
id,
|
||||
});
|
||||
onClose();
|
||||
});
|
||||
closeDropdown();
|
||||
}, [toggleGroupCallPanel, closeDropdown, openChat, id]);
|
||||
|
||||
const isLocalVolumeZero = localVolume === VOLUME_ZERO;
|
||||
const speakerIconPlaySegment = isLocalVolumeZero ? SPEAKER_ICON_DISABLED_SEGMENT : SPEAKER_ICON_ENABLED_SEGMENT;
|
||||
|
||||
const handleChangeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = Number(e.target.value);
|
||||
handleSetLocalVolume(value);
|
||||
|
||||
setLocalVolume(value);
|
||||
runThrottled(() => {
|
||||
if (value === VOLUME_ZERO) {
|
||||
toggleGroupCallMute({
|
||||
@ -173,16 +147,11 @@ const GroupCallParticipantMenu: FC<OwnProps & StateProps> = ({
|
||||
<div>
|
||||
<Menu
|
||||
isOpen={isDropdownOpen}
|
||||
positionX={positionX}
|
||||
positionY={positionY}
|
||||
transformOriginX={transformOriginX}
|
||||
transformOriginY={transformOriginY}
|
||||
style={style}
|
||||
ref={menuRef}
|
||||
withPortal
|
||||
onClose={onClose}
|
||||
onCloseAnimationEnd={onCloseAnimationEnd}
|
||||
className="participant-menu with-menu-transitions"
|
||||
positionX="right"
|
||||
autoClose
|
||||
style={buildStyle(anchor && `right: 1rem; top: ${anchor.y}px`)}
|
||||
onClose={closeDropdown}
|
||||
className="participant-menu"
|
||||
>
|
||||
{!isSelf && !shouldRaiseHand && (
|
||||
<div className="group">
|
||||
@ -204,7 +173,6 @@ 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}
|
||||
/>
|
||||
|
||||
105
src/components/calls/group/GroupCallParticipantStreams.tsx
Normal file
105
src/components/calls/group/GroupCallParticipantStreams.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
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));
|
||||
@ -1,150 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
126
src/components/calls/group/GroupCallParticipantVideo.scss
Normal file
126
src/components/calls/group/GroupCallParticipantVideo.scss
Normal file
@ -0,0 +1,126 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@ -1,314 +1,77 @@
|
||||
import React, {
|
||||
memo, useCallback, useEffect, useMemo, useRef, useState,
|
||||
} from '../../../lib/teact/teact';
|
||||
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 { 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_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';
|
||||
import { getUserStreams, THRESHOLD } from '../../../lib/secret-sauce';
|
||||
import { GROUP_CALL_THUMB_VIDEO_DISABLED } from '../../../config';
|
||||
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 Button from '../../ui/Button';
|
||||
import OutlinedMicrophoneIcon from './OutlinedMicrophoneIcon';
|
||||
import FullNameTitle from '../../common/FullNameTitle';
|
||||
import GroupCallParticipantMenu from './GroupCallParticipantMenu';
|
||||
import Avatar from '../../common/Avatar';
|
||||
|
||||
import styles from './GroupCallParticipantVideo.module.scss';
|
||||
|
||||
const VIDEO_FALLBACK_UPDATE_INTERVAL = 1000;
|
||||
import './GroupCallParticipantVideo.scss';
|
||||
|
||||
type OwnProps = {
|
||||
layout: VideoLayout;
|
||||
setPinned: (participant?: VideoParticipant) => void;
|
||||
pinnedVideo: VideoParticipant | undefined;
|
||||
canPin: boolean;
|
||||
participant: TypeGroupCallParticipant;
|
||||
className?: string;
|
||||
type: 'video' | 'presentation';
|
||||
onClick?: (id: string, type: 'video' | 'presentation') => void;
|
||||
isFullscreen?: boolean;
|
||||
};
|
||||
|
||||
type StateProps = {
|
||||
user?: ApiUser;
|
||||
chat?: ApiChat;
|
||||
currentUserId?: string;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
const GroupCallParticipantVideo: FC<OwnProps & StateProps> = ({
|
||||
layout,
|
||||
pinnedVideo,
|
||||
setPinned,
|
||||
canPin,
|
||||
className,
|
||||
participant,
|
||||
type,
|
||||
onClick,
|
||||
user,
|
||||
chat,
|
||||
isActive,
|
||||
isFullscreen,
|
||||
}) => {
|
||||
const lang = useLang();
|
||||
|
||||
// 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);
|
||||
|
||||
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');
|
||||
const handleClick = useCallback(() => {
|
||||
if (onClick) {
|
||||
onClick(user?.id || chat!.id, type);
|
||||
}
|
||||
}, [chat, onClick, type, user?.id]);
|
||||
|
||||
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);
|
||||
if (!user && !chat) return undefined;
|
||||
|
||||
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(
|
||||
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}
|
||||
className={buildClassName('GroupCallParticipantVideo', isActive && 'active')}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<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 />
|
||||
{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>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@ -316,8 +79,10 @@ 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));
|
||||
|
||||
@ -39,7 +39,6 @@
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.125rem;
|
||||
|
||||
.title {
|
||||
font-size: 0.875rem;
|
||||
@ -59,16 +58,15 @@
|
||||
|
||||
.Avatar {
|
||||
margin: 0 0 0 -0.75rem;
|
||||
font-size: 0.75rem;
|
||||
|
||||
&:first-child {
|
||||
width: 2rem !important;
|
||||
height: 2rem !important;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
&:not(:first-child) {
|
||||
width: 2.25rem !important;
|
||||
height: 2.25rem !important;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 0.125rem solid var(--color-background);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,52 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
58
src/components/calls/group/MicrophoneButton.scss
Normal file
58
src/components/calls/group/MicrophoneButton.scss
Normal file
@ -0,0 +1,58 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import type { GroupCallConnectionState } 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';
|
||||
|
||||
@ -13,17 +13,11 @@ 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 styles from './MicrophoneButton.module.scss';
|
||||
import './MicrophoneButton.scss';
|
||||
|
||||
const CONNECTION_STATE_DEFAULT = 'discarded';
|
||||
|
||||
type OwnProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type StateProps = {
|
||||
connectionState?: GroupCallConnectionState;
|
||||
hasRequestedToSpeak: boolean;
|
||||
@ -34,13 +28,13 @@ type StateProps = {
|
||||
|
||||
const REQUEST_TO_SPEAK_THROTTLE = 3000;
|
||||
const HOLD_TO_SPEAK_TIME = 200;
|
||||
const ICON_SIZE = 36;
|
||||
const ICON_SIZE = 48;
|
||||
|
||||
const MicrophoneButton: FC<OwnProps & StateProps> = ({
|
||||
className,
|
||||
const MicrophoneButton: FC<StateProps> = ({
|
||||
noAudioStream,
|
||||
canSelfUnmute,
|
||||
isMuted,
|
||||
hasRequestedToSpeak,
|
||||
connectionState,
|
||||
}) => {
|
||||
const {
|
||||
@ -95,12 +89,12 @@ const MicrophoneButton: FC<OwnProps & StateProps> = ({
|
||||
|
||||
const animatedIconName = isRequestingToSpeak ? 'HandFilled' : 'VoiceMini';
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const toggleMute = () => {
|
||||
vibrateShort();
|
||||
toggleGroupCallMute();
|
||||
}, [toggleGroupCallMute]);
|
||||
};
|
||||
|
||||
const handleMouseDownMute = useCallback(() => {
|
||||
const handleMouseDownMute = () => {
|
||||
if (shouldRaiseHand) {
|
||||
if (isRequestingToSpeak) return;
|
||||
vibrateShort();
|
||||
@ -120,42 +114,47 @@ const MicrophoneButton: FC<OwnProps & StateProps> = ({
|
||||
}
|
||||
}, HOLD_TO_SPEAK_TIME);
|
||||
}
|
||||
}, [isRequestingToSpeak, noAudioStream, requestToSpeak, shouldRaiseHand, toggleMute]);
|
||||
};
|
||||
|
||||
const handleMouseUpMute = useCallback(() => {
|
||||
const handleMouseUpMute = () => {
|
||||
if (shouldRaiseHand) {
|
||||
return;
|
||||
}
|
||||
toggleMute();
|
||||
muteMouseDownState.current = 'up';
|
||||
}, [shouldRaiseHand, toggleMute]);
|
||||
};
|
||||
|
||||
const buttonText = lang(
|
||||
hasRequestedToSpeak ? 'VoipMutedTapedForSpeak' : (
|
||||
shouldRaiseHand ? 'VoipMutedByAdmin' : (
|
||||
noAudioStream ? 'VoipUnmute' : 'VoipTapToMute'
|
||||
)
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -11,13 +11,11 @@ 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;
|
||||
@ -53,37 +51,21 @@ const OutlinedMicrophoneIcon: FC<OwnProps> = ({
|
||||
// eslint-disable-next-line
|
||||
}, [isMuted, shouldRaiseHand, isRaiseHand]);
|
||||
|
||||
const microphoneColor: string = useMemo(() => {
|
||||
if (noColor) {
|
||||
return '#ffffff';
|
||||
}
|
||||
|
||||
if (isRaiseHand) {
|
||||
return '#4da6e0';
|
||||
}
|
||||
|
||||
if (shouldRaiseHand || isMutedByMe) {
|
||||
return '#ff706f';
|
||||
}
|
||||
|
||||
if (isSpeaking) {
|
||||
return '#57bc6c';
|
||||
}
|
||||
|
||||
return '#aaaaaa';
|
||||
const microphoneColor: string | undefined = useMemo(() => {
|
||||
return noColor ? '#ffffff' : (
|
||||
isRaiseHand ? '#4da6e0'
|
||||
: (shouldRaiseHand || isMutedByMe ? '#ff706f' : (
|
||||
isSpeaking ? '#57bc6c' : '#848d94'
|
||||
))
|
||||
);
|
||||
}, [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
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,237 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -39,7 +39,6 @@ export type OwnProps = {
|
||||
color?: string;
|
||||
isLowPriority?: boolean;
|
||||
forceOnHeavyAnimation?: boolean;
|
||||
forceInBackground?: boolean;
|
||||
sharedCanvas?: HTMLCanvasElement;
|
||||
sharedCanvasCoords?: { x: number; y: number };
|
||||
onClick?: NoneToVoidFunction;
|
||||
@ -66,7 +65,6 @@ const AnimatedSticker: FC<OwnProps> = ({
|
||||
isLowPriority,
|
||||
color,
|
||||
forceOnHeavyAnimation,
|
||||
forceInBackground,
|
||||
sharedCanvas,
|
||||
sharedCanvasCoords,
|
||||
onClick,
|
||||
@ -89,7 +87,7 @@ const AnimatedSticker: FC<OwnProps> = ({
|
||||
const shouldUseColorFilter = !sharedCanvas && color;
|
||||
const colorFilter = useColorFilter(shouldUseColorFilter ? color : undefined);
|
||||
|
||||
const playKey = play || (play === false ? false : playSegment);
|
||||
const playKey = play || playSegment;
|
||||
const playRef = useStateRef(play);
|
||||
const playSegmentRef = useStateRef(playSegment);
|
||||
|
||||
@ -179,13 +177,13 @@ const AnimatedSticker: FC<OwnProps> = ({
|
||||
if (
|
||||
!animation
|
||||
|| !(playRef.current || playSegmentRef.current)
|
||||
|| isFrozen(forceOnHeavyAnimation, forceInBackground)
|
||||
|| isFrozen(forceOnHeavyAnimation)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (playSegmentRef.current) {
|
||||
animation.playSegment(playSegmentRef.current, shouldRestart, viewId);
|
||||
animation.playSegment(playSegmentRef.current, viewId);
|
||||
} else {
|
||||
animation.play(shouldRestart, viewId);
|
||||
}
|
||||
@ -219,13 +217,13 @@ const AnimatedSticker: FC<OwnProps> = ({
|
||||
}
|
||||
|
||||
if (playKey) {
|
||||
if (!isFrozen(forceOnHeavyAnimation, forceInBackground)) {
|
||||
if (!isFrozen(forceOnHeavyAnimation)) {
|
||||
playAnimation(noLoop);
|
||||
}
|
||||
} else {
|
||||
pauseAnimation();
|
||||
}
|
||||
}, [animation, playKey, noLoop, playAnimation, pauseAnimation, forceOnHeavyAnimation, forceInBackground]);
|
||||
}, [animation, playKey, noLoop, playAnimation, pauseAnimation, forceOnHeavyAnimation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (animation) {
|
||||
@ -243,7 +241,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 || forceInBackground);
|
||||
useBackgroundMode(pauseAnimation, playAnimationOnRaf, !playKey);
|
||||
|
||||
if (sharedCanvas) {
|
||||
return undefined;
|
||||
@ -266,8 +264,6 @@ const AnimatedSticker: FC<OwnProps> = ({
|
||||
|
||||
export default memo(AnimatedSticker);
|
||||
|
||||
function isFrozen(forceOnHeavyAnimation = false, forceInBackground = false) {
|
||||
return (!forceOnHeavyAnimation && isHeavyAnimating())
|
||||
|| isPriorityPlaybackActive()
|
||||
|| (!forceInBackground && isBackgroundModeActive());
|
||||
function isFrozen(forceOnHeavyAnimation = false) {
|
||||
return (!forceOnHeavyAnimation && isHeavyAnimating()) || isPriorityPlaybackActive() || isBackgroundModeActive();
|
||||
}
|
||||
|
||||
@ -51,7 +51,6 @@ 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;
|
||||
@ -68,7 +67,6 @@ const Button: FC<OwnProps> = ({
|
||||
onClick,
|
||||
onContextMenu,
|
||||
onMouseDown,
|
||||
onMouseUp,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
onFocus,
|
||||
@ -187,7 +185,6 @@ 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}
|
||||
|
||||
@ -15,7 +15,6 @@ import useLastCallback from '../../hooks/useLastCallback';
|
||||
|
||||
type OwnProps = {
|
||||
ref?: RefObject<HTMLDivElement>;
|
||||
style?: string;
|
||||
className?: string;
|
||||
items?: any[];
|
||||
itemSelector?: string;
|
||||
@ -42,7 +41,6 @@ const DEFAULT_SENSITIVE_AREA = 800;
|
||||
|
||||
const InfiniteScroll: FC<OwnProps> = ({
|
||||
ref,
|
||||
style,
|
||||
className,
|
||||
items,
|
||||
itemSelector = DEFAULT_LIST_SELECTOR,
|
||||
@ -240,7 +238,6 @@ const InfiniteScroll: FC<OwnProps> = ({
|
||||
onKeyDown={onKeyDown}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
style={style}
|
||||
>
|
||||
{beforeChildren}
|
||||
{withAbsolutePositioning && items?.length ? (
|
||||
|
||||
@ -62,7 +62,6 @@ 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;
|
||||
@ -93,7 +92,6 @@ const ListItem: FC<OwnProps> = ({
|
||||
href,
|
||||
onMouseDown,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
clickArg,
|
||||
onSecondaryIconClick,
|
||||
onDragEnter,
|
||||
@ -227,7 +225,7 @@ const ListItem: FC<OwnProps> = ({
|
||||
tabIndex={!isStatic ? 0 : undefined}
|
||||
onClick={(!inactive && IS_TOUCH_ENV) ? handleClick : handleClickEvent}
|
||||
onMouseDown={handleMouseDown}
|
||||
onContextMenu={onContextMenu || ((!inactive && contextActions) ? handleContextMenu : undefined)}
|
||||
onContextMenu={(!inactive && contextActions) ? handleContextMenu : undefined}
|
||||
>
|
||||
{leftElement}
|
||||
{icon && (
|
||||
|
||||
@ -262,13 +262,7 @@ addActionHandler('joinGroupCall', async (global, actions, payload): Promise<void
|
||||
chatId, id, accessHash, inviteHash, tabId = getCurrentTabId(),
|
||||
} = payload;
|
||||
|
||||
if (!ARE_CALLS_SUPPORTED) {
|
||||
actions.showNotification({
|
||||
message: "Sorry, your browser doesn't support group calls",
|
||||
tabId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!ARE_CALLS_SUPPORTED) return;
|
||||
|
||||
if (global.phoneCall) {
|
||||
actions.toggleGroupCallPanel({ tabId });
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { GlobalState } from '../types';
|
||||
import { selectChat, selectChatFullInfo } from './chats';
|
||||
import { getMainUsername, isChatBasicGroup } from '../helpers';
|
||||
import { isChatBasicGroup } from '../helpers';
|
||||
import { selectUser } from './users';
|
||||
|
||||
export function selectChatGroupCall<T extends GlobalState>(global: T, chatId: string) {
|
||||
@ -49,24 +49,3 @@ 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);
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ 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';
|
||||
|
||||
@ -170,10 +171,10 @@ export function useIntersectionObserver({
|
||||
export function useOnIntersect(
|
||||
targetRef: RefObject<HTMLDivElement>, observe?: ObserveFn, callback?: TargetCallback,
|
||||
) {
|
||||
useEffect(() => {
|
||||
useEffectOnce(() => {
|
||||
return observe ? observe(targetRef.current!, callback) : undefined;
|
||||
// eslint-disable-next-line react-hooks-static-deps/exhaustive-deps
|
||||
}, [observe]);
|
||||
// Arguments should never change
|
||||
});
|
||||
}
|
||||
|
||||
export function useIsIntersecting(
|
||||
|
||||
@ -191,16 +191,12 @@ class RLottie {
|
||||
}
|
||||
}
|
||||
|
||||
playSegment([startFrameIndex, stopFrameIndex]: [number, number], forceRestart = false, viewId?: string) {
|
||||
playSegment([startFrameIndex, stopFrameIndex]: [number, number], viewId?: string) {
|
||||
if (viewId) {
|
||||
this.views.get(viewId)!.isPaused = false;
|
||||
}
|
||||
|
||||
const frameIndex = Math.round(this.approxFrameIndex);
|
||||
this.approxFrameIndex = Math.floor(startFrameIndex / this.reduceFactor);
|
||||
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();
|
||||
|
||||
@ -380,6 +380,7 @@ 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
|
||||
@ -481,6 +482,7 @@ export async function handleUpdateGroupCallParticipants(updatedParticipants: Gro
|
||||
}
|
||||
|
||||
const sdp = buildSdp(conference as Conference);
|
||||
console.log('build sdp!', sdp);
|
||||
await connection.setRemoteDescription({
|
||||
type: 'offer',
|
||||
sdp,
|
||||
@ -565,6 +567,8 @@ 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',
|
||||
@ -679,6 +683,7 @@ 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') {
|
||||
@ -702,6 +707,8 @@ function initializeConnection(
|
||||
offerToReceiveAudio: !isPresentation,
|
||||
});
|
||||
|
||||
console.log('created offer!', offer);
|
||||
|
||||
await connection.setLocalDescription(offer);
|
||||
|
||||
if (!offer.sdp) {
|
||||
@ -709,6 +716,7 @@ 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
@ -230,7 +230,6 @@ $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;
|
||||
|
||||
@ -49,12 +49,6 @@
|
||||
.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";
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user