Introduce Group Calls (#1520)

This commit is contained in:
Alexander Zinchuk 2021-11-27 17:41:10 +01:00
parent 40930e07dc
commit 41f2c3e26b
113 changed files with 5016 additions and 341 deletions

View File

@ -74,6 +74,7 @@
},
"ignorePatterns": [
"webpack.config.js",
"jest.config.js"
"jest.config.js",
"src/lib/secret-sauce"
]
}

14
package-lock.json generated
View File

@ -8633,11 +8633,11 @@
}
},
"ext": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/ext/-/ext-1.4.0.tgz",
"integrity": "sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/ext/-/ext-1.5.0.tgz",
"integrity": "sha512-+ONcYoWj/SoQwUofMr94aGu05Ou4FepKi7N7b+O8T4jVfyIsZQV1/xeS8jpaBzF0csAk0KLXoHCxU7cKYZjo1Q==",
"requires": {
"type": "^2.0.0"
"type": "^2.5.0"
},
"dependencies": {
"type": {
@ -15127,9 +15127,9 @@
}
},
"node-gyp-build": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.2.3.tgz",
"integrity": "sha512-MN6ZpzmfNCRM+3t57PTJHgHyw/h4OWnZ6mR8P5j/uZtqQr46RRuDE/P+g3n0YR/AiYXeWixZZzaip77gdICfRg=="
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.3.0.tgz",
"integrity": "sha512-iWjXZvmboq0ja1pUGULQBexmxq8CV4xBhX7VDOTbL7ZR4FOowwY/VOtRxBN/yKxmdGoIp4j5ysNT4u3S2pDQ3Q=="
},
"node-int64": {
"version": "0.4.0",

Binary file not shown.

BIN
public/voicechat_join.mp3 Normal file

Binary file not shown.

BIN
public/voicechat_leave.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -14,6 +14,10 @@ declare namespace React {
interface ImgHTMLAttributes<T> extends HTMLAttributes<T> {
loading?: 'auto' | 'eager' | 'lazy';
}
interface VideoHTMLAttributes {
srcObject?: MediaStream;
}
}
type AnyLiteral = Record<string, any>;

View File

@ -0,0 +1,98 @@
import { GroupCallParticipant, GroupCallParticipantVideo, SsrcGroup } from '../../../lib/secret-sauce';
import { Api as GramJs } from '../../../lib/gramjs';
import { ApiGroupCall } from '../../types';
import { getApiChatIdFromMtpPeer, isPeerUser } from './peers';
export function buildApiGroupCallParticipant(participant: GramJs.GroupCallParticipant): GroupCallParticipant {
const {
self, min, about, date, versioned, canSelfUnmute, justJoined, left, muted, mutedByYou, source, volume,
volumeByAdmin, videoJoined, peer, video, presentation, raiseHandRating,
} = participant;
return {
isSelf: self,
isMin: min,
canSelfUnmute,
isLeft: left,
isMuted: muted,
isMutedByMe: mutedByYou,
hasJustJoined: justJoined,
isVolumeByAdmin: volumeByAdmin,
isVersioned: versioned,
isVideoJoined: videoJoined,
about,
source,
raiseHandRating: raiseHandRating?.toString(),
volume,
date: new Date(date),
isUser: isPeerUser(peer),
id: getApiChatIdFromMtpPeer(peer),
video: video ? buildApiGroupCallParticipantVideo(video) : undefined,
presentation: presentation ? buildApiGroupCallParticipantVideo(presentation) : undefined,
};
}
function buildApiGroupCallParticipantVideo(
participantVideo: GramJs.GroupCallParticipantVideo,
): GroupCallParticipantVideo {
const {
audioSource, endpoint, paused, sourceGroups,
} = participantVideo;
return {
audioSource,
endpoint,
isPaused: paused,
sourceGroups: sourceGroups.map(buildApiGroupCallParticipantVideoSourceGroup),
};
}
function buildApiGroupCallParticipantVideoSourceGroup(
participantVideoSourceGroup: GramJs.GroupCallParticipantVideoSourceGroup,
): SsrcGroup {
return {
semantics: participantVideoSourceGroup.semantics,
sources: participantVideoSourceGroup.sources,
};
}
export function buildApiGroupCall(groupCall: GramJs.TypeGroupCall): ApiGroupCall {
const {
id, accessHash,
} = groupCall;
if (groupCall instanceof GramJs.GroupCallDiscarded) {
return {
connectionState: 'discarded',
id: id.toString(),
accessHash: accessHash.toString(),
participantsCount: 0,
version: 0,
participants: {},
};
}
const {
version, participantsCount, streamDcId, scheduleDate, canChangeJoinMuted, joinMuted, canStartVideo,
scheduleStartSubscribed,
} = groupCall;
return {
connectionState: 'disconnected',
isLoaded: true,
id: id.toString(),
accessHash: accessHash.toString(),
version,
participantsCount,
streamDcId,
scheduleDate,
canChangeJoinMuted,
joinMuted,
canStartVideo,
scheduleStartSubscribed,
participants: {},
};
}
export function getGroupCallId(groupCall: GramJs.TypeInputGroupCall) {
return groupCall.id.toString();
}

View File

@ -21,6 +21,7 @@ import {
ApiChat,
ApiThreadInfo,
ApiInvoice,
ApiGroupCall,
} from '../../types';
import {
@ -593,6 +594,7 @@ function buildAction(
return undefined;
}
let call: Partial<ApiGroupCall> | undefined;
let amount: number | undefined;
let currency: string | undefined;
let text: string;
@ -677,6 +679,13 @@ function buildAction(
const mins = Math.max(Math.round(action.duration! / 60), 1);
translationValues.push(`${mins} min${mins > 1 ? 's' : ''}`);
}
} else if (action instanceof GramJs.MessageActionInviteToGroupCall) {
text = 'Notification.VoiceChatInvitation';
call = {
id: action.call.id.toString(),
accessHash: action.call.accessHash.toString(),
};
translationValues.push('%action_origin%', '%target_user%');
} else if (action instanceof GramJs.MessageActionContactSignUp) {
text = 'Notification.Joined';
translationValues.push('%action_origin%');
@ -696,6 +705,10 @@ function buildAction(
translationValues.push(`${mins} min${mins > 1 ? 's' : ''}`);
} else {
text = 'Notification.VoiceChatStartedChannel';
call = {
id: action.call.id.toString(),
accessHash: action.call.accessHash.toString(),
};
}
} else if (action instanceof GramJs.MessageActionBotAllowed) {
text = 'Chat.Service.BotPermissionAllowed';
@ -718,6 +731,7 @@ function buildAction(
amount,
currency,
translationValues,
call,
};
}

View File

@ -5,15 +5,16 @@ import { ApiPrivacyKey } from '../../../types';
import { generateRandomBytes, readBigIntFromBuffer } from '../../../lib/gramjs/Helpers';
import {
ApiSticker,
ApiVideo,
ApiNewPoll,
ApiChatAdminRights,
ApiChatBannedRights,
ApiChatFolder,
ApiGroupCall,
ApiMessageEntity,
ApiMessageEntityTypes,
ApiChatFolder,
ApiChatBannedRights,
ApiChatAdminRights,
ApiNewPoll,
ApiReportReason,
ApiSticker,
ApiVideo,
} from '../../types';
import localDb from '../localDb';
import { pick } from '../../../util/iteratees';
@ -237,6 +238,10 @@ export function generateRandomBigInt() {
return readBigIntFromBuffer(generateRandomBytes(8), true, true);
}
export function generateRandomInt() {
return readBigIntFromBuffer(generateRandomBytes(4), true, true).toJSNumber();
}
export function buildMessageFromUpdate(
id: number,
chatId: string,
@ -424,3 +429,10 @@ export function buildMtpPeerId(id: string, type: 'user' | 'chat' | 'channel') {
return type === 'user' ? BigInt(id) : BigInt(id.slice(1));
}
export function buildInputGroupCall(groupCall: Partial<ApiGroupCall>) {
return new GramJs.InputGroupCall({
id: BigInt(groupCall.id!),
accessHash: BigInt(groupCall.accessHash!),
});
}

View File

@ -47,3 +47,7 @@ export function addChatToLocalDb(chat: GramJs.TypeChat) {
localDb.chats[buildApiPeerId(chat.id, chat instanceof GramJs.Chat ? 'chat' : 'channel')] = chat;
}
}
export function addUserToLocalDb(user: GramJs.User) {
localDb.users[buildApiPeerId(user.id, 'user')] = user;
}

View File

@ -9,7 +9,7 @@ import { buildInputPeer, generateRandomBigInt } from '../gramjsBuilders';
import { buildApiUser } from '../apiBuilders/users';
import { buildApiBotInlineMediaResult, buildApiBotInlineResult, buildBotSwitchPm } from '../apiBuilders/bots';
import { buildApiChatFromPreview } from '../apiBuilders/chats';
import { buildApiPeerId } from '../apiBuilders/peers';
import { addUserToLocalDb } from '../helpers';
export function init() {
}
@ -158,10 +158,6 @@ function getInlineBotResultsNextOffset(username: string, nextOffset?: string) {
return username === 'gif' && nextOffset === '0' ? '' : nextOffset;
}
function addUserToLocalDb(user: GramJs.User) {
localDb.users[buildApiPeerId(user.id, 'user')] = user;
}
function addDocumentToLocalDb(document: GramJs.Document) {
localDb.documents[String(document.id)] = document;
}

View File

@ -0,0 +1,236 @@
import { JoinGroupCallPayload } from '../../../lib/secret-sauce';
import {
ApiChat, ApiUser, OnApiUpdate, ApiGroupCall,
} from '../../types';
import { Api as GramJs } from '../../../lib/gramjs';
import { invokeRequest } from './client';
import { buildInputGroupCall, buildInputPeer, generateRandomInt } from '../gramjsBuilders';
import {
buildApiGroupCall,
buildApiGroupCallParticipant,
} from '../apiBuilders/calls';
import { buildApiUser } from '../apiBuilders/users';
import { buildApiChatFromPreview } from '../apiBuilders/chats';
import { addChatToLocalDb, addUserToLocalDb } from '../helpers';
import { GROUP_CALL_PARTICIPANTS_LIMIT } from '../../../config';
let onUpdate: OnApiUpdate;
export function init(_onUpdate: OnApiUpdate) {
onUpdate = _onUpdate;
}
export async function getGroupCall({
call,
}: {
call: Partial<ApiGroupCall>;
}) {
const result = await invokeRequest(new GramJs.phone.GetGroupCall({
call: buildInputGroupCall(call),
}));
if (!result) {
return undefined;
}
result.users.map(addUserToLocalDb);
result.chats.map(addChatToLocalDb);
const users = result.users.map(buildApiUser).filter<ApiUser>(Boolean as any);
const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter<ApiChat>(Boolean as any);
return {
groupCall: buildApiGroupCall(result.call),
users,
chats,
};
}
export function discardGroupCall({
call,
}: {
call: ApiGroupCall;
}) {
return invokeRequest(new GramJs.phone.DiscardGroupCall({
call: buildInputGroupCall(call),
}), true);
}
export function editGroupCallParticipant({
call, participant, muted, presentationPaused, videoStopped, videoPaused, volume,
raiseHand,
}: {
call: ApiGroupCall; participant: ApiUser; muted?: boolean; presentationPaused?: boolean;
videoStopped?: boolean; videoPaused?: boolean; raiseHand?: boolean; volume?: number;
}) {
return invokeRequest(new GramJs.phone.EditGroupCallParticipant({
call: buildInputGroupCall(call),
participant: buildInputPeer(participant.id, participant.accessHash),
...(videoStopped !== undefined && { videoStopped }),
...(videoPaused !== undefined && { videoPaused }),
...(muted !== undefined && { muted }),
...(presentationPaused !== undefined && { presentationPaused }),
...(raiseHand !== undefined && { raiseHand }),
...(volume !== undefined && { volume }),
}), true);
}
export function editGroupCallTitle({
groupCall, title,
}: {
groupCall: ApiGroupCall; title: string;
}) {
return invokeRequest(new GramJs.phone.EditGroupCallTitle({
title,
call: buildInputGroupCall(groupCall),
}), true);
}
export async function exportGroupCallInvite({
call, canSelfUnmute,
}: {
call: ApiGroupCall; canSelfUnmute: boolean;
}) {
const result = await invokeRequest(new GramJs.phone.ExportGroupCallInvite({
canSelfUnmute: canSelfUnmute || undefined,
call: buildInputGroupCall(call),
}));
if (!result) {
return undefined;
}
return result.link;
}
export async function fetchGroupCallParticipants({
call, offset,
}: {
call: ApiGroupCall; offset?: string;
}) {
const result = await invokeRequest(new GramJs.phone.GetGroupParticipants({
call: buildInputGroupCall(call),
ids: [],
sources: [],
offset: offset || '',
limit: GROUP_CALL_PARTICIPANTS_LIMIT,
}));
if (!result) {
return undefined;
}
result.users.map(addUserToLocalDb);
result.chats.map(addChatToLocalDb);
const users = result.users.map(buildApiUser).filter<ApiUser>(Boolean as any);
const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter<ApiChat>(Boolean as any);
onUpdate({
'@type': 'updateGroupCallParticipants',
groupCallId: call.id,
participants: result.participants.map(buildApiGroupCallParticipant),
nextOffset: result.nextOffset,
});
return {
users, chats,
};
}
export function leaveGroupCall({
call,
}: {
call: ApiGroupCall;
}) {
return invokeRequest(new GramJs.phone.LeaveGroupCall({
call: buildInputGroupCall(call),
}), true);
}
export async function joinGroupCall({
call, inviteHash, params,
}: {
call: ApiGroupCall; inviteHash?: string; params: JoinGroupCallPayload;
}) {
const result = await invokeRequest(new GramJs.phone.JoinGroupCall({
call: buildInputGroupCall(call),
joinAs: new GramJs.InputPeerSelf(),
muted: true,
videoStopped: true,
params: new GramJs.DataJSON({
data: JSON.stringify(params),
}),
inviteHash,
}), true);
if (!result) return undefined;
if (result instanceof GramJs.Updates) {
const update = result.updates.find((u) => u instanceof GramJs.UpdateGroupCall);
if (!(update instanceof GramJs.UpdateGroupCall)) return undefined;
return buildApiGroupCall(update.call);
}
return undefined;
}
export async function createGroupCall({
peer,
}: {
peer: ApiChat;
}) {
const randomId = generateRandomInt();
const result = await invokeRequest(new GramJs.phone.CreateGroupCall({
peer: buildInputPeer(peer.id, peer.accessHash),
randomId,
}), true);
if (!result) return undefined;
if (result instanceof GramJs.Updates) {
const update = result.updates[0];
if (update instanceof GramJs.UpdateGroupCall) {
return buildApiGroupCall(update.call);
}
}
return undefined;
}
export function joinGroupCallPresentation({
call, params,
}: {
call: ApiGroupCall; params: JoinGroupCallPayload;
}) {
return invokeRequest(new GramJs.phone.JoinGroupCallPresentation({
call: buildInputGroupCall(call),
params: new GramJs.DataJSON({
data: JSON.stringify(params),
}),
}), true);
}
export function toggleGroupCallStartSubscription({
call, subscribed,
}: {
call: ApiGroupCall; subscribed: boolean;
}) {
return invokeRequest(new GramJs.phone.ToggleGroupCallStartSubscription({
call: buildInputGroupCall(call),
subscribed,
}), true);
}
export function leaveGroupCallPresentation({
call,
}: {
call: ApiGroupCall;
}) {
return invokeRequest(new GramJs.phone.LeaveGroupCallPresentation({
call: buildInputGroupCall(call),
}), true);
}

View File

@ -11,6 +11,7 @@ import {
ApiChatFolder,
ApiChatBannedRights,
ApiChatAdminRights,
ApiGroupCall,
} from '../../types';
import {
@ -323,6 +324,7 @@ export function clearDraft(chat: ApiChat) {
async function getFullChatInfo(chatId: string): Promise<{
fullInfo: ApiChatFullInfo;
users?: ApiUser[];
groupCall?: Partial<ApiGroupCall>;
} | undefined> {
const result = await invokeRequest(new GramJs.messages.GetFullChat({
chatId: buildInputEntity(chatId) as BigInt.BigInteger,
@ -339,6 +341,7 @@ async function getFullChatInfo(chatId: string): Promise<{
participants,
exportedInvite,
botInfo,
call,
} = result.fullChat;
const members = buildChatMembers(participants);
@ -355,8 +358,19 @@ async function getFullChatInfo(chatId: string): Promise<{
...(exportedInvite && {
inviteLink: exportedInvite.link,
}),
groupCallId: call?.id.toString(),
},
users: result.users.map(buildApiUser).filter<ApiUser>(Boolean as any),
groupCall: call ? {
chatId,
isLoaded: false,
id: call.id.toString(),
accessHash: call.accessHash.toString(),
connectionState: 'disconnected',
participantsCount: 0,
version: 0,
participants: {},
} : undefined,
};
}
@ -364,7 +378,11 @@ async function getFullChannelInfo(
id: string,
accessHash: string,
adminRights?: ApiChatAdminRights,
) {
): Promise<{
fullInfo: ApiChatFullInfo;
users?: ApiUser[];
groupCall?: Partial<ApiGroupCall>;
} | undefined> {
const result = await invokeRequest(new GramJs.channels.GetFullChannel({
channel: buildInputEntity(id, accessHash) as GramJs.InputChannel,
}));
@ -438,6 +456,16 @@ async function getFullChannelInfo(
botCommands,
},
users: [...(users || []), ...(bannedUsers || []), ...(adminUsers || [])],
groupCall: call ? {
chatId: id,
isLoaded: false,
id: call.id.toString(),
accessHash: call?.accessHash.toString(),
participants: {},
version: 0,
participantsCount: 0,
connectionState: 'disconnected',
} : undefined,
};
}

View File

@ -60,3 +60,9 @@ export {
export {
validateRequestedInfo, sendPaymentForm, getPaymentForm, getReceipt,
} from './payments';
export {
getGroupCall, joinGroupCall, discardGroupCall, createGroupCall,
editGroupCallTitle, editGroupCallParticipant, exportGroupCallInvite, fetchGroupCallParticipants,
joinGroupCallPresentation, leaveGroupCall, leaveGroupCallPresentation, toggleGroupCallStartSubscription,
} from './calls';

View File

@ -17,6 +17,7 @@ import { init as initClient } from './methods/client';
import { init as initStickers } from './methods/symbols';
import { init as initManagement } from './methods/management';
import { init as initTwoFaSettings } from './methods/twoFaSettings';
import { init as initCalls } from './methods/calls';
import * as methods from './methods';
let onUpdate: OnApiUpdate;
@ -32,6 +33,7 @@ export async function initApi(_onUpdate: OnApiUpdate, initialArgs: ApiInitialArg
initStickers(handleUpdate);
initManagement(handleUpdate);
initTwoFaSettings(handleUpdate);
initCalls(handleUpdate);
await initClient(handleUpdate, initialArgs);
}

View File

@ -1,3 +1,4 @@
import { GroupCallConnectionData } from '../../lib/secret-sauce';
import { Api as GramJs, connection } from '../../lib/gramjs';
import { ApiMessage, ApiUpdateConnectionStateType, OnApiUpdate } from '../types';
@ -33,6 +34,11 @@ import { DEBUG } from '../../config';
import { addMessageToLocalDb, addPhotoToLocalDb, resolveMessageApiChatId } from './helpers';
import { buildApiNotifyException, buildPrivacyKey, buildPrivacyRules } from './apiBuilders/misc';
import { buildApiPhoto } from './apiBuilders/common';
import {
buildApiGroupCall,
buildApiGroupCallParticipant,
getGroupCallId,
} from './apiBuilders/calls';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './apiBuilders/peers';
type Update = (
@ -50,6 +56,39 @@ export function init(_onUpdate: OnApiUpdate) {
const sentMessageIds = new Set();
let serverTimeOffset = 0;
function addEntities(entities: (GramJs.TypeUser | GramJs.TypeChat)[] | undefined) {
if (entities?.length) {
entities
.filter((e) => e instanceof GramJs.User)
.map(buildApiUser)
.forEach((user) => {
if (!user) {
return;
}
onUpdate({
'@type': 'updateUser',
id: user.id,
user,
});
});
entities
.filter((e) => e instanceof GramJs.Chat || e instanceof GramJs.Channel)
.map((e) => buildApiChatFromPreview(e))
.forEach((chat) => {
if (!chat) {
return;
}
onUpdate({
'@type': 'updateChat',
id: chat.id,
chat,
});
});
}
}
export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
if (update instanceof connection.UpdateServerTimeOffset) {
serverTimeOffset = update.timeOffset;
@ -111,37 +150,7 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
}
// eslint-disable-next-line no-underscore-dangle
const entities = update._entities;
if (entities?.length) {
entities
.filter((e) => e instanceof GramJs.User)
.map(buildApiUser)
.forEach((user) => {
if (!user) {
return;
}
onUpdate({
'@type': 'updateUser',
id: user.id,
user,
});
});
entities
.filter((e) => e instanceof GramJs.Chat || e instanceof GramJs.Channel)
.map((e) => buildApiChatFromPreview(e))
.forEach((chat) => {
if (!chat) {
return;
}
onUpdate({
'@type': 'updateChat',
id: chat.id,
chat,
});
});
}
addEntities(update._entities);
if (update instanceof GramJs.UpdateNewScheduledMessage) {
onUpdate({
@ -232,6 +241,17 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
id: message.chatId,
});
}
} else if (action instanceof GramJs.MessageActionGroupCall) {
if (!action.duration && action.call) {
onUpdate({
'@type': 'updateGroupCallChatId',
chatId: message.chatId,
call: {
id: action.call.id.toString(),
accessHash: action.call.accessHash.toString(),
},
});
}
}
}
} else if (
@ -785,6 +805,26 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
onUpdate({ '@type': 'updateResetContactList' });
} else if (update instanceof GramJs.UpdateFavedStickers) {
onUpdate({ '@type': 'updateFavoriteStickers' });
} else if (update instanceof GramJs.UpdateGroupCall) {
onUpdate({
'@type': 'updateGroupCall',
call: buildApiGroupCall(update.call),
});
} else if (update instanceof GramJs.UpdateGroupCallConnection) {
onUpdate({
'@type': 'updateGroupCallConnection',
data: JSON.parse(update.params.data) as GroupCallConnectionData,
presentation: Boolean(update.presentation),
});
} else if (update instanceof GramJs.UpdateGroupCallParticipants) {
// eslint-disable-next-line no-underscore-dangle
addEntities(update._entities);
onUpdate({
'@type': 'updateGroupCallParticipants',
groupCallId: getGroupCallId(update.call),
participants: update.participants.map(buildApiGroupCallParticipant),
});
} else if (DEBUG) {
const params = typeof update === 'object' && 'className' in update ? update.className : update;
// eslint-disable-next-line no-console

26
src/api/types/calls.ts Normal file
View File

@ -0,0 +1,26 @@
import { GroupCallParticipant, GroupCallConnectionState } from '../../lib/secret-sauce';
export interface ApiGroupCall {
chatId?: string;
isLoaded?: boolean;
id: string;
accessHash: string;
joinMuted?: true;
canChangeJoinMuted?: true;
canStartVideo?: true;
joinDateAsc?: true;
scheduleStartSubscribed?: true;
participantsCount: number;
params?: any;
title?: string;
streamDcId?: number;
recordStartDate?: number;
scheduleDate?: number;
version: number;
inviteHash?: string;
nextOffset?: string;
participants: Record<string, GroupCallParticipant>;
connectionState: GroupCallConnectionState;
isSpeakerDisabled?: boolean;
}

View File

@ -108,6 +108,7 @@ export interface ApiChatAdminRights {
pinMessages?: true;
addAdmins?: true;
anonymous?: true;
manageCall?: true;
}
export interface ApiChatBannedRights {

View File

@ -7,3 +7,4 @@ export * from './payments';
export * from './settings';
export * from './bots';
export * from './misc';
export * from './calls';

View File

@ -1,3 +1,5 @@
import { ApiGroupCall } from './calls';
export interface ApiDimensions {
width: number;
height: number;
@ -155,6 +157,7 @@ export interface ApiAction {
amount?: number;
currency?: string;
translationValues: string[];
call?: Partial<ApiGroupCall>;
}
export interface ApiWebPage {

View File

@ -1,3 +1,4 @@
import { GroupCallConnectionData, GroupCallParticipant, GroupCallConnectionState } from '../../lib/secret-sauce';
import {
ApiChat,
ApiChatFullInfo,
@ -12,6 +13,9 @@ import { ApiUser, ApiUserFullInfo, ApiUserStatus } from './users';
import {
ApiError, ApiInviteInfo, ApiNotifyException, ApiSessionData,
} from './misc';
import {
ApiGroupCall,
} from './calls';
export type ApiUpdateReady = {
'@type': 'updateApiReady';
@ -378,6 +382,48 @@ export type ApiUpdateServerTimeOffset = {
serverTimeOffset: number;
};
export type ApiUpdateGroupCall = {
'@type': 'updateGroupCall';
call: ApiGroupCall;
};
export type ApiUpdateGroupCallChatId = {
'@type': 'updateGroupCallChatId';
call: Partial<ApiGroupCall>;
chatId: string;
};
export type ApiUpdateGroupCallLeavePresentation = {
'@type': 'updateGroupCallLeavePresentation';
};
export type ApiUpdateGroupCallParticipants = {
'@type': 'updateGroupCallParticipants';
groupCallId: string;
participants: GroupCallParticipant[];
nextOffset?: string;
};
export type ApiUpdateGroupCallConnection = {
'@type': 'updateGroupCallConnection';
data: GroupCallConnectionData;
presentation: boolean;
};
export type ApiUpdateGroupCallStreams = {
'@type': 'updateGroupCallStreams';
userId: string;
hasAudioStream: boolean;
hasVideoStream: boolean;
hasPresentationStream: boolean;
};
export type ApiUpdateGroupCallConnectionState = {
'@type': 'updateGroupCallConnectionState';
connectionState: GroupCallConnectionState;
isSpeakerDisabled?: boolean;
};
export type ApiUpdate = (
ApiUpdateReady | ApiUpdateSession |
ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser |
@ -395,7 +441,9 @@ export type ApiUpdate = (
ApiUpdateDeleteScheduledMessages | ApiUpdateResetMessages |
ApiUpdateTwoFaError | ApiUpdateTwoFaStateWaitCode |
ApiUpdateNotifySettings | ApiUpdateNotifyExceptions | ApiUpdatePeerBlocked | ApiUpdatePrivacy |
ApiUpdateServerTimeOffset | ApiUpdateShowInvite
ApiUpdateServerTimeOffset | ApiUpdateShowInvite |
ApiUpdateGroupCallParticipants | ApiUpdateGroupCallConnection | ApiUpdateGroupCall | ApiUpdateGroupCallStreams |
ApiUpdateGroupCallConnectionState | ApiUpdateGroupCallLeavePresentation | ApiUpdateGroupCallChatId
);
export type OnApiUpdate = (update: ApiUpdate) => void;

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

2
src/bundles/calls.ts Normal file
View File

@ -0,0 +1,2 @@
export { default as GroupCall } from '../components/calls/group/GroupCall';
export { default as ActiveCallHeader } from '../components/calls/ActiveCallHeader';

View File

@ -0,0 +1,16 @@
import React, { FC, memo } from '../../lib/teact/teact';
import useModuleLoader from '../../hooks/useModuleLoader';
import { Bundles } from '../../util/moduleLoader';
type OwnProps = {
groupCallId?: string;
};
const ActiveCallHeaderAsync: FC<OwnProps> = (props) => {
const { groupCallId } = props;
const ActiveCallHeader = useModuleLoader(Bundles.Calls, 'ActiveCallHeader', !groupCallId);
return ActiveCallHeader ? <ActiveCallHeader /> : undefined;
};
export default memo(ActiveCallHeaderAsync);

View File

@ -0,0 +1,22 @@
.ActiveCallHeader {
position: absolute;
top: 0;
left: 0;
height: 2rem;
width: 100%;
z-index: 1;
display: flex;
justify-content: center;
font-weight: 500;
font-size: 0.875rem;
color: #fff;
align-items: center;
padding: 0 1rem;
background: linear-gradient(90deg, rgb(82, 206, 93), rgb(0, 177, 192));
transform: translateY(-100%);
&.open {
transform: translateY(0);
}
}

View File

@ -0,0 +1,63 @@
import { GroupCallParticipant } from '../../lib/secret-sauce';
import React, {
FC, memo, useEffect,
} from '../../lib/teact/teact';
import { withGlobal } from '../../lib/teact/teactn';
import { GlobalActions } from '../../global/types';
import { ApiGroupCall } from '../../api/types';
import { selectActiveGroupCall, selectGroupCallParticipant } from '../../modules/selectors/calls';
import { pick } from '../../util/iteratees';
import buildClassName from '../../util/buildClassName';
import useLang from '../../hooks/useLang';
import './ActiveCallHeader.scss';
type StateProps = {
isGroupCallPanelHidden?: boolean;
meParticipant: GroupCallParticipant;
groupCall?: ApiGroupCall;
};
type DispatchProps = Pick<GlobalActions, 'toggleGroupCallPanel'>;
const ActiveCallHeader: FC<StateProps & DispatchProps> = ({
groupCall,
meParticipant,
isGroupCallPanelHidden,
toggleGroupCallPanel,
}) => {
const lang = useLang();
useEffect(() => {
document.body.classList.toggle('has-group-call-header', isGroupCallPanelHidden);
}, [isGroupCallPanelHidden]);
if (!groupCall || !meParticipant) return undefined;
return (
<div
className={buildClassName(
'ActiveCallHeader',
isGroupCallPanelHidden && 'open',
)}
onClick={toggleGroupCallPanel}
>
<span className="title">{groupCall.title || lang('VoipGroupVoiceChat')}</span>
</div>
);
};
export default memo(withGlobal(
(global): StateProps => {
return {
groupCall: selectActiveGroupCall(global),
isGroupCallPanelHidden: global.groupCalls.isGroupCallPanelHidden,
meParticipant: selectGroupCallParticipant(global, global.groupCalls.activeGroupCallId!, global.currentUserId!),
};
},
(setGlobal, actions) => pick(actions, [
'toggleGroupCallPanel',
]),
)(ActiveCallHeader));

View File

@ -0,0 +1,14 @@
import React, { FC, memo } from '../../../lib/teact/teact';
import useModuleLoader from '../../../hooks/useModuleLoader';
import { Bundles } from '../../../util/moduleLoader';
import { OwnProps } from './GroupCall';
const GroupCallAsync: FC<OwnProps> = (props) => {
const { groupCallId } = props;
const GroupCall = useModuleLoader(Bundles.Calls, 'GroupCall', !groupCallId);
// eslint-disable-next-line react/jsx-props-no-spreading
return GroupCall ? <GroupCall {...props} /> : undefined;
};
export default memo(GroupCallAsync);

View File

@ -0,0 +1,348 @@
.GroupCall {
.modal-content {
display: flex;
flex-direction: column;
align-items: center;
height: 37.5rem;
}
.modal-dialog {
max-height: calc(100% - 4rem);
background: #181F27;
}
.Menu .bubble {
--color-background: #232A34;
--color-chat-hover: #2F363E;
--color-item-active: #2F363E;
--color-text: #fff;
box-shadow: 0 0.25rem 0.5rem 0.125rem rgba(16, 16, 16, 0.3);
}
// Compact menu items
.MenuItem {
padding: 0.75rem 1rem !important;
}
&.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 .3s ease, opacity .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: .15s ease-out background-color;
cursor: pointer;
color: var(--color-text-secondary);
&:hover {
background: #2F363E;
}
.text {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.icon {
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: pointer;
}
&::before {
position: absolute;
content: "";
width: 100%;
height: 2rem;
background: linear-gradient(0deg, #181F27, rgba(24, 31, 39, 0));
z-index: 0;
top: -2rem;
}
.button-wrapper {
width: 4rem;
display: flex;
flex-direction: column;
align-items: center;
.button-text {
white-space: nowrap;
font-size: 0.75rem;
margin-top: 0.5rem;
color: #fff;
}
&.microphone-wrapper {
width: 6rem;
.button-text {
margin-top: 0.75rem;
font-size: 1rem;
}
}
}
.Loading {
position: absolute;
transform: translate(0, -1.125rem);
.Spinner {
--spinner-size: 6.5rem;
}
}
.video-buttons {
display: flex;
flex-direction: column;
align-items: center;
}
.small-button, .smaller-button {
outline: none;
border: 0;
background: #15415b;
border-radius: 50%;
width: 3rem;
height: 3rem;
color: #fff;
font-size: 1.375rem;
display: flex;
align-items: center;
justify-content: center;
transition: 0.25s ease-out background-color;
&:hover {
background: #11364b;
}
}
.small-button.camera.active {
background: #15415b;
&:hover {
background: #11364b;
}
}
.small-button.speaker {
background: #2B3A51;
&.active {
background: #496092;
}
}
.small-button.leave {
background: #5A2824;
&:hover {
background: #49201d;
}
}
.smaller-button {
width: 2.5rem;
height: 2.5rem;
margin-bottom: 0.5rem;
padding: 0;
}
}
&.landscape .scrollable {
display: flex;
flex-direction: row;
flex-grow: 1;
gap: 1rem;
align-items: flex-start;
max-width: 100%;
max-height: 100%;
}
&.landscape .GroupCallParticipantVideo {
max-height: initial;
video {
height: 100%;
}
}
&.landscape .buttons {
position: absolute;
left: calc(50% - 15.625rem / 2);
transform: translateX(-50%);
width: auto;
gap: 1rem;
bottom: 4rem;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(10px);
border-radius: 1rem;
z-index: 5;
padding: 0.75rem 1rem;
height: auto;
.button-text {
display: none;
}
.video-buttons {
flex-direction: row;
gap: 1rem;
.smaller-button {
margin-bottom: 0;
}
}
.Loading {
transform: none;
.Spinner {
--spinner-size: 3.25rem;
}
}
.MicrophoneButton {
canvas {
width: 2rem !important;
height: 2rem !important;
}
}
.MicrophoneButton, .microphone-wrapper {
width: 3rem;
height: 3rem;
.AnimatedSticker {
display: flex;
align-items: center;
justify-content: center;
}
}
&::before {
display: none;
}
}
&.landscape.no-sidebar .buttons {
left: calc(50%);
}
&.landscape .streams {
width: 100%;
height: 100%;
}
&.landscape .videos {
width: 100%;
height: 100%;
display: grid;
--column-count: 1;
grid-template-columns: repeat(var(--column-count), 1fr);
grid-auto-rows: 1fr;
.GroupCallParticipantVideo {
max-height: 100%;
width: 100%;
.thumbnail-wrapper {
height: 100%;
}
}
&.span-last-video .GroupCallParticipantVideo:last-child {
grid-column: span var(--column-count);
}
}
&.landscape .participants {
width: 15.625rem;
margin-top: 0;
}
}

View File

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

View 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: .15s ease-out background-color;
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: pointer;
display: flex;
}
}

View File

@ -0,0 +1,101 @@
import { GroupCallParticipant as TypeGroupCallParticipant, THRESHOLD } from '../../../lib/secret-sauce';
import React, {
FC, memo, useMemo, useRef,
} from '../../../lib/teact/teact';
import { withGlobal } from '../../../lib/teact/teactn';
import { ApiChat, ApiUser } from '../../../api/types';
import buildClassName from '../../../util/buildClassName';
import { selectChat, selectUser } from '../../../modules/selectors';
import useLang from '../../../hooks/useLang';
import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';
import Avatar from '../../common/Avatar';
import OutlinedMicrophoneIcon from './OutlinedMicrophoneIcon';
import './GroupCallParticipant.scss';
type OwnProps = {
participant: TypeGroupCallParticipant;
openParticipantMenu: (anchor: HTMLDivElement, participant: TypeGroupCallParticipant) => void;
};
type StateProps = {
user?: ApiUser;
chat?: ApiChat;
};
const GroupCallParticipant: FC<OwnProps & StateProps> = ({
openParticipantMenu,
participant,
user,
chat,
}) => {
// eslint-disable-next-line no-null/no-null
const anchorRef = useRef<HTMLDivElement>(null);
const lang = useLang();
const { isSelf, isMutedByMe, isMuted } = participant;
const isSpeaking = (participant.amplitude || 0) > THRESHOLD;
const isRaiseHand = Boolean(participant.raiseHandRating);
const handleOnClick = () => {
if (isSelf) return;
openParticipantMenu(anchorRef.current!, participant);
};
const [aboutText, aboutColor] = useMemo(() => {
if (isSelf) {
return [lang('ThisIsYou'), 'blue'];
}
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 (
<div
className={buildClassName(
'GroupCallParticipant',
participant.canSelfUnmute && 'can-self-unmute',
)}
onClick={handleOnClick}
ref={anchorRef}
>
<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>
);
};
export default memo(withGlobal<OwnProps>(
(global, { participant }): StateProps => {
return {
user: participant.isUser ? selectUser(global, participant.id) : undefined,
chat: !participant.isUser ? selectChat(global, participant.id) : undefined,
};
},
)(GroupCallParticipant));

View File

@ -0,0 +1,95 @@
import { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce';
import React, { FC, memo, useMemo } from '../../../lib/teact/teact';
import { withGlobal } from '../../../lib/teact/teactn';
import { GlobalActions } from '../../../global/types';
import { pick } from '../../../util/iteratees';
import useLang from '../../../hooks/useLang';
import { selectActiveGroupCall } from '../../../modules/selectors/calls';
import useInfiniteScroll from '../../../hooks/useInfiniteScroll';
import { selectChat } from '../../../modules/selectors';
import GroupCallParticipant from './GroupCallParticipant';
import InfiniteScroll from '../../ui/InfiniteScroll';
type OwnProps = {
openParticipantMenu: (anchor: HTMLDivElement, participant: TypeGroupCallParticipant) => void;
};
type StateProps = {
participantsCount: number;
participants?: Record<string, TypeGroupCallParticipant>;
canInvite?: boolean;
};
type DispatchProps = Pick<GlobalActions, 'createGroupCallInviteLink' | 'loadMoreGroupCallParticipants'>;
const GroupCallParticipantList: FC<OwnProps & StateProps & DispatchProps> = ({
createGroupCallInviteLink,
loadMoreGroupCallParticipants,
participants,
participantsCount,
openParticipantMenu,
canInvite,
}) => {
const lang = useLang();
const participantsIds = useMemo(() => {
return Object.keys(participants || {});
}, [participants]);
const [viewportIds, getMore] = useInfiniteScroll(
loadMoreGroupCallParticipants,
participantsIds,
participantsIds.length >= participantsCount,
);
return (
<div className="participants">
{canInvite && (
<div className="invite-btn" onClick={createGroupCallInviteLink}>
<div className="icon">
<i className="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>
);
};
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const { participantsCount, participants, chatId } = selectActiveGroupCall(global) || {};
const chat = chatId && selectChat(global, chatId);
return {
participants,
participantsCount: participantsCount || 0,
canInvite: !!chat && !!chat.username,
};
},
(setGlobal, actions): DispatchProps => pick(actions, [
'createGroupCallInviteLink',
'loadMoreGroupCallParticipants',
]),
)(GroupCallParticipantList));

View File

@ -0,0 +1,98 @@
@import '../../../styles/mixins';
.participant-menu {
position: absolute;
.bubble {
background: none;
border-radius: 0;
padding: 0;
border: none !important;
box-shadow: none !important;
overflow: visible;
color: #ffffff;
.group {
box-shadow: 0 0.25rem 0.5rem 0.125rem rgba(16, 16, 16, 0.3);
overflow: hidden;
background: var(--color-background);
border-radius: var(--border-radius-default);
margin-bottom: 0.5rem;
}
}
.volume-control {
height: 3rem;
.info {
pointer-events: none;
position: relative;
z-index: 1;
height: 100%;
display: flex;
flex-direction: row;
align-items: center;
padding: 0.75rem 1rem;
.AnimatedSticker {
margin-right: 2rem;
}
}
&.high {
--range-color: #4DA6E0;
}
&.normal {
--range-color: #57BC6C;
}
&.medium {
--range-color: #CAA53B;
}
&.low {
--range-color: #CB5757;
}
position: relative;
overflow: hidden;
cursor: pointer;
@mixin thumb-styles() {
border: none;
height: 3rem;
width: 1.5rem;
background: var(--range-color);
border-radius: var(--border-radius-default);
box-shadow: -13.5rem 0 0 12.75rem var(--range-color);
transition: 0.25s ease-in-out background-color, 0.25s ease-in-out box-shadow;
}
@include reset-range();
// Apply custom styles
input[type="range"] {
height: 3rem;
position: absolute;
left: -1.5rem;
top: 0;
width: calc(100% + 3rem);
margin: 0;
z-index: 0;
// Note that while we're repeating code here, that's necessary as you can't comma-separate these type of selectors.
// Browsers will drop the entire selector if it doesn't understand a part of it.
&::-webkit-slider-thumb {
@include thumb-styles();
}
&::-moz-range-thumb {
@include thumb-styles();
}
&::-ms-thumb {
@include thumb-styles();
}
}
}
}

View File

@ -0,0 +1,239 @@
import { GroupCallParticipant } from '../../../lib/secret-sauce';
import React, {
FC, memo, useCallback, useEffect, useState,
} from '../../../lib/teact/teact';
import { withGlobal } from '../../../lib/teact/teactn';
import { IAnchorPosition } from '../../../types';
import { GlobalActions } from '../../../global/types';
import buildClassName from '../../../util/buildClassName';
import useThrottle from '../../../hooks/useThrottle';
import useFlag from '../../../hooks/useFlag';
import useLang from '../../../hooks/useLang';
import { selectIsAdminInActiveGroupCall } from '../../../modules/selectors/calls';
import { pick } from '../../../util/iteratees';
import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';
import Menu from '../../ui/Menu';
import MenuItem from '../../ui/MenuItem';
import AnimatedIcon from '../../common/AnimatedIcon';
import DeleteMemberModal from '../../right/DeleteMemberModal';
import './GroupCallParticipantMenu.scss';
const SPEAKER_ICON_DISABLED_SEGMENT: [number, number] = [0, 17];
const SPEAKER_ICON_ENABLED_SEGMENT: [number, number] = [17, 34];
type OwnProps = {
participant?: GroupCallParticipant;
closeDropdown: VoidFunction;
isDropdownOpen: boolean;
anchor?: IAnchorPosition;
};
type StateProps = {
isAdmin: boolean;
};
type DispatchProps = Pick<GlobalActions, (
'toggleGroupCallMute' | 'setGroupCallParticipantVolume' | 'toggleGroupCallPanel' | 'openChat' | 'requestToSpeak'
)>;
const VOLUME_ZERO = 0;
const VOLUME_LOW = 50;
const VOLUME_MEDIUM = 100;
const VOLUME_NORMAL = 150;
const VOLUME_CHANGE_THROTTLE = 500;
const SPEAKER_ICON_SIZE = 24;
const GroupCallParticipantMenu: FC<OwnProps & StateProps & DispatchProps> = ({
participant,
closeDropdown,
isDropdownOpen,
anchor,
isAdmin,
toggleGroupCallMute,
setGroupCallParticipantVolume,
toggleGroupCallPanel,
openChat,
requestToSpeak,
}) => {
const lang = useLang();
const [isDeleteUserModalOpen, openDeleteUserModal, closeDeleteUserModal] = useFlag();
const id = participant?.id;
const {
isMutedByMe, isMuted, isSelf, canSelfUnmute,
} = participant || {};
const isRaiseHand = Boolean(participant?.raiseHandRating);
const shouldRaiseHand = !canSelfUnmute && isMuted;
const [localVolume, setLocalVolume] = useState(
isMutedByMe ? VOLUME_ZERO : ((participant?.volume || GROUP_CALL_DEFAULT_VOLUME) / GROUP_CALL_VOLUME_MULTIPLIER),
);
useEffect(() => {
setLocalVolume(isMutedByMe
? VOLUME_ZERO
: ((participant?.volume || GROUP_CALL_DEFAULT_VOLUME) / GROUP_CALL_VOLUME_MULTIPLIER));
// We only want to initialize local volume when switching participants and ignore following updates from server
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
const runThrottled = useThrottle(VOLUME_CHANGE_THROTTLE);
const handleRemove = useCallback((e: React.SyntheticEvent<any>) => {
e.stopPropagation();
openDeleteUserModal();
closeDropdown();
}, [openDeleteUserModal, closeDropdown]);
const handleCancelRequestToSpeak = useCallback((e: React.SyntheticEvent<any>) => {
e.stopPropagation();
requestToSpeak({
value: false,
});
closeDropdown();
}, [requestToSpeak, closeDropdown]);
const handleMute = useCallback((e: React.SyntheticEvent<any>) => {
e.stopPropagation();
closeDropdown();
if (!isAdmin) {
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 = useCallback((e: React.SyntheticEvent<any>) => {
e.stopPropagation();
toggleGroupCallPanel();
openChat({
id,
});
closeDropdown();
}, [toggleGroupCallPanel, closeDropdown, openChat, id]);
const isLocalVolumeZero = localVolume === VOLUME_ZERO;
const speakerIconPlaySegment = isLocalVolumeZero ? SPEAKER_ICON_DISABLED_SEGMENT : SPEAKER_ICON_ENABLED_SEGMENT;
const handleChangeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = Number(e.target.value);
setLocalVolume(value);
runThrottled(() => {
if (value === VOLUME_ZERO) {
toggleGroupCallMute({
participantId: id,
value: true,
});
} else {
setGroupCallParticipantVolume({
participantId: id,
volume: Math.floor(value * GROUP_CALL_VOLUME_MULTIPLIER),
});
}
});
};
return (
<div>
<Menu
isOpen={isDropdownOpen}
positionX="right"
autoClose
style={anchor ? `right: 1rem; top: ${anchor.y}px;` : undefined}
onClose={closeDropdown}
className="participant-menu"
>
{!isSelf && !shouldRaiseHand && (
<div className="group">
<div className={buildClassName(
'volume-control',
localVolume < VOLUME_LOW && 'low',
localVolume >= VOLUME_LOW && localVolume < VOLUME_MEDIUM && 'medium',
localVolume >= VOLUME_MEDIUM && localVolume < VOLUME_NORMAL && 'normal',
localVolume >= VOLUME_NORMAL && 'high',
)}
>
<input
type="range"
min="0"
max="200"
value={localVolume}
onChange={handleChangeVolume}
/>
<div className="info">
<AnimatedIcon
name="Speaker"
playSegment={speakerIconPlaySegment}
size={SPEAKER_ICON_SIZE}
/>
<span>{localVolume}%</span>
</div>
</div>
</div>
)}
<div className="group">
{(isRaiseHand && isSelf) && (
<MenuItem
icon="stop-raising-hand"
onClick={handleCancelRequestToSpeak}
>
{lang('VoipGroupCancelRaiseHand')}
</MenuItem>
)}
{!isSelf && <MenuItem icon="user" onClick={handleOpenProfile}>{lang('VoipGroupOpenProfile')}</MenuItem>}
{!isSelf && (
// TODO cross mic
<MenuItem
icon={isMuted ? (isAdmin ? 'allow-speak' : 'microphone-alt') : 'microphone-alt'}
onClick={handleMute}
>
{isAdmin
? lang(shouldRaiseHand ? 'VoipGroupAllowToSpeak' : 'VoipMute')
: lang(isMutedByMe ? 'VoipGroupUnmuteForMe' : 'VoipGroupMuteForMe')}
</MenuItem>
)}
{!isSelf && isAdmin && (
// TODO replace with hand
<MenuItem icon="delete-user" destructive onClick={handleRemove}>
{lang('VoipGroupUserRemove')}
</MenuItem>
)}
</div>
</Menu>
{!isSelf && isAdmin && (
<DeleteMemberModal
isOpen={isDeleteUserModalOpen}
userId={id}
onClose={closeDeleteUserModal}
/>
)}
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
return {
isAdmin: selectIsAdminInActiveGroupCall(global),
};
},
(setGlobal, actions): DispatchProps => pick(actions, [
'setGroupCallParticipantVolume',
'toggleGroupCallMute',
'openChat',
'toggleGroupCallPanel',
'requestToSpeak',
]),
)(GroupCallParticipantMenu));

View File

@ -0,0 +1,105 @@
import { GroupCallParticipant } from '../../../lib/secret-sauce';
import React, {
FC, memo, useCallback, useMemo, useState,
} from '../../../lib/teact/teact';
import { withGlobal } from '../../../lib/teact/teactn';
import GroupCallParticipantVideo from './GroupCallParticipantVideo';
import { selectActiveGroupCall } from '../../../modules/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((l) => l.hasPresentationStream);
}, [participants]);
const videoParticipants = useMemo(() => {
return Object.values(participants || {}).filter((l) => l.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',
)}
// @ts-ignore teact feature
style={`--column-count: ${selectedVideo ? 1 : columnCount}`}
>
{selectedVideo && (
<GroupCallParticipantVideo
key={selectedVideo.id}
isFullscreen
onClick={handleClickVideo}
participant={participants![selectedVideo.id]}
type={selectedVideo.type}
/>
)}
{!selectedVideo ? presentationParticipants.map((participant) => (
<GroupCallParticipantVideo
key={participant.id}
onClick={handleClickVideo}
participant={participant}
type="presentation"
/>
)) : undefined}
{!selectedVideo ? videoParticipants.map((participant) => (
<GroupCallParticipantVideo
key={participant.id}
onClick={handleClickVideo}
participant={participant}
type="video"
/>
)) : undefined}
</div>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const { participants } = selectActiveGroupCall(global) || {};
return {
participants,
};
},
)(GroupCallParticipantStreams));

View File

@ -0,0 +1,125 @@
.GroupCallParticipantVideo {
border-radius: 0.75rem;
overflow: hidden;
position: relative;
max-height: 12.875rem;
width: calc(50% - 0.25rem);
transition: 0.25s ease-out width;
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: pointer;
outline: none !important;
&:hover {
background: rgba(0, 0, 0, 0.4);
}
}
video {
display: block;
width: 100%;
}
.video {
object-fit: contain;
height: 12.5rem;
position: relative;
}
.thumbnail-wrapper {
position: absolute;
top: 50%;
left: 50%;
z-index: 0;
width: 100%;
transform: translate(-50%, -50%) scale(1.5);
background: black;
}
.thumbnail {
filter: blur(10px) brightness(0.5);
object-fit: cover;
}
.info {
position: absolute;
bottom: 0;
color: #fff;
display: flex;
align-items: center;
padding: 0 0.5rem 0.25rem;
width: 100%;
height: 2rem;
background: linear-gradient(0deg, #000, transparent);
transition: 0.25s ease-out opacity;
opacity: 0;
.name {
margin-left: 0.5rem;
}
.last-icon {
margin-left: auto;
}
}
}
.videos:hover .GroupCallParticipantVideo {
.info {
opacity: 1;
}
.back-button {
opacity: 1;
}
}

View File

@ -0,0 +1,86 @@
import { getUserStreams, GroupCallParticipant as TypeGroupCallParticipant, THRESHOLD } from '../../../lib/secret-sauce';
import React, { FC, memo, useCallback } from '../../../lib/teact/teact';
import { withGlobal } from '../../../lib/teact/teactn';
import { ApiChat, ApiUser } from '../../../api/types';
import buildClassName from '../../../util/buildClassName';
import { selectChat, selectUser } from '../../../modules/selectors';
import useLang from '../../../hooks/useLang';
import { ENABLE_THUMBNAIL_VIDEO } from '../../../config';
import Avatar from '../../common/Avatar';
import './GroupCallParticipantVideo.scss';
type OwnProps = {
participant: TypeGroupCallParticipant;
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> = ({
type,
onClick,
user,
chat,
isActive,
isFullscreen,
}) => {
const lang = useLang();
const handleClick = useCallback(() => {
if (onClick) {
onClick(user?.id || chat!.id, type);
}
}, [chat, onClick, type, user?.id]);
if (!user && !chat) return undefined;
const streams = getUserStreams(user?.id || chat!.id);
return (
<div
className={buildClassName('GroupCallParticipantVideo', isActive && 'active')}
onClick={handleClick}
>
{isFullscreen && (
<button className="back-button">
<i className="icon-arrow-left" />
{lang('Back')}
</button>
)}
<Avatar user={user} chat={chat} className="thumbnail-avatar" />
{ENABLE_THUMBNAIL_VIDEO && (
<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-microphone-alt" />
<span className="name">{user?.firstName || chat?.title}</span>
{type === 'presentation' && <i className="last-icon icon-active-sessions" />}
</div>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { participant }): StateProps => {
return {
currentUserId: global.currentUserId,
user: participant.isUser ? selectUser(global, participant.id) : undefined,
chat: !participant.isUser ? selectChat(global, participant.id) : undefined,
isActive: (participant.amplitude || 0) > THRESHOLD,
};
},
)(GroupCallParticipantVideo));

View File

@ -0,0 +1,89 @@
.GroupCallTopPane {
position: absolute;
top: 100%;
left: 0;
right: 0;
height: 2.875rem;
overflow: hidden;
box-shadow: 0 2px 2px var(--color-light-shadow);
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0.5rem 0.375rem 0.75rem;
background: var(--color-background);
z-index: -1;
cursor: pointer;
&::before {
content: "";
display: block;
position: absolute;
top: -.1875rem;
left: 0;
right: 0;
height: .125rem;
box-shadow: 0 .125rem .125rem var(--color-light-shadow);
}
&.is-hidden {
display: none;
}
@media (max-width: 600px) {
&.has-pinned-offset {
top: calc(100% + 2.875rem);
}
}
.info {
display: flex;
flex-direction: column;
.title {
font-size: 0.875rem;
color: var(--color-text);
}
.participants {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
}
.avatars {
display: flex;
flex-direction: row;
align-items: center;
.Avatar {
margin: 0 0 0 -0.75rem;
&:first-child {
width: 2rem;
height: 2rem;
}
&:not(:first-child) {
width: 2.25rem;
height: 2.25rem;
border: 0.125rem solid var(--color-background);
}
}
}
.join {
height: 1.5rem;
border-radius: 1.5rem;
font-weight: 500;
padding: 1rem 1rem;
width: auto;
}
}
@media (min-width: 1440px) {
#Main.right-column-open .MiddleHeader .GroupCallTopPane {
width: calc(100% - var(--right-column-width));
}
}

View File

@ -0,0 +1,139 @@
import React, {
FC, memo, useCallback, useEffect, useMemo,
} from '../../../lib/teact/teact';
import { withGlobal } from '../../../lib/teact/teactn';
import { GlobalActions } from '../../../global/types';
import { ApiChat, ApiGroupCall, ApiUser } from '../../../api/types';
import { pick } from '../../../util/iteratees';
import { selectChatGroupCall } from '../../../modules/selectors/calls';
import buildClassName from '../../../util/buildClassName';
import { selectChat } from '../../../modules/selectors';
import useLang from '../../../hooks/useLang';
import Button from '../../ui/Button';
import Avatar from '../../common/Avatar';
import './GroupCallTopPane.scss';
type OwnProps = {
chatId: string;
hasPinnedOffset: boolean;
};
type StateProps = {
groupCall?: ApiGroupCall;
isActive: boolean;
usersById: Record<string, ApiUser>;
chatsById: Record<string, ApiChat>;
};
type DispatchProps = Pick<GlobalActions, 'joinGroupCall' | 'subscribeToGroupCallUpdates'>;
const GroupCallTopPane: FC<OwnProps & StateProps & DispatchProps> = ({
chatId,
isActive,
groupCall,
hasPinnedOffset,
joinGroupCall,
subscribeToGroupCallUpdates,
usersById,
chatsById,
}) => {
const lang = useLang();
const handleJoinGroupCall = useCallback(() => {
joinGroupCall({
chatId,
});
}, [joinGroupCall, chatId]);
const participants = groupCall?.participants;
const fetchedParticipants = useMemo(() => {
if (participants) {
return Object.values(participants).filter((_, i) => i < 3).map(({ id, isUser }) => {
if (isUser) {
if (!usersById[id]) {
return undefined;
}
return { user: usersById[id] };
} else {
if (!chatsById[id]) {
return undefined;
}
return { chat: chatsById[id] };
}
}).filter(Boolean);
} else return [];
}, [chatsById, participants, usersById]);
useEffect(() => {
if (!groupCall?.id) return undefined;
if (!isActive && groupCall.isLoaded) return undefined;
subscribeToGroupCallUpdates({
id: groupCall.id,
subscribed: true,
});
return () => {
subscribeToGroupCallUpdates({
id: groupCall.id,
subscribed: false,
});
};
}, [groupCall?.id, groupCall?.isLoaded, isActive, subscribeToGroupCallUpdates]);
if (!groupCall) return undefined;
return (
<div
className={buildClassName(
'GroupCallTopPane',
hasPinnedOffset && 'has-pinned-offset',
!isActive && 'is-hidden',
)}
onClick={handleJoinGroupCall}
>
<div className="info">
<span className="title">{lang('VoipGroupVoiceChat')}</span>
<span className="participants">{lang('Participants', groupCall.participantsCount || 0, 'i')}</span>
</div>
<div className="avatars">
{fetchedParticipants.map((p) => {
if (!p) return undefined;
if (p.user) {
return <Avatar key={p.user.id} user={p.user} />;
} else {
return <Avatar key={p.chat.id} chat={p.chat} />;
}
})}
</div>
<Button round className="join">
{lang('VoipChatJoin')}
</Button>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { chatId }) => {
const chat = selectChat(global, chatId)!;
const groupCall = selectChatGroupCall(global, chatId);
return {
groupCall,
usersById: global.users.byId,
chatsById: global.chats.byId,
activeGroupCallId: global.groupCalls.activeGroupCallId,
isActive: ((!groupCall ? (chat && chat.isCallNotEmpty && chat.isCallActive)
: (groupCall.participantsCount > 0 && groupCall.isLoaded)))
&& (global.groupCalls.activeGroupCallId !== groupCall?.id),
};
},
(setGlobal, actions) => pick(actions, [
'joinGroupCall',
'subscribeToGroupCallUpdates',
]),
)(GroupCallTopPane));

View 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;
}
}
}

View File

@ -0,0 +1,187 @@
import { GroupCallConnectionState } from '../../../lib/secret-sauce';
import React, {
FC, memo, useEffect, useMemo, useRef, useState,
} from '../../../lib/teact/teact';
import { withGlobal } from '../../../lib/teact/teactn';
import { GlobalActions } from '../../../global/types';
import buildClassName from '../../../util/buildClassName';
import { vibrateShort } from '../../../util/vibrate';
import { pick } from '../../../util/iteratees';
import usePrevious from '../../../hooks/usePrevious';
import { selectActiveGroupCall, selectGroupCallParticipant } from '../../../modules/selectors/calls';
import useLang from '../../../hooks/useLang';
import AnimatedIcon from '../../common/AnimatedIcon';
import './MicrophoneButton.scss';
const CONNECTION_STATE_DEFAULT = 'discarded';
type StateProps = {
connectionState?: GroupCallConnectionState;
hasRequestedToSpeak: boolean;
isMuted?: boolean;
canSelfUnmute?: boolean;
noAudioStream: boolean;
};
type DispatchProps = Pick<GlobalActions, 'toggleGroupCallMute' | 'requestToSpeak' | 'playGroupCallSound'>;
const REQUEST_TO_SPEAK_THROTTLE = 3000;
const HOLD_TO_SPEAK_TIME = 200;
const ICON_SIZE = 48;
const MicrophoneButton: FC<StateProps & DispatchProps> = ({
noAudioStream,
canSelfUnmute,
isMuted,
hasRequestedToSpeak,
connectionState,
toggleGroupCallMute,
requestToSpeak,
playGroupCallSound,
}) => {
const lang = useLang();
const muteMouseDownState = useRef('up');
const [isRequestingToSpeak, setIsRequestingToSpeak] = useState(false);
const isConnecting = connectionState !== 'connected';
const shouldRaiseHand = !canSelfUnmute && isMuted;
const prevShouldRaiseHand = usePrevious(shouldRaiseHand);
useEffect(() => {
if (prevShouldRaiseHand && !shouldRaiseHand) {
playGroupCallSound('allowTalk');
}
}, [playGroupCallSound, prevShouldRaiseHand, shouldRaiseHand]);
// Voice mini
// unmuted -> muted [69, 99]
// muted -> unmuted [36, 69]
// raise -> muted [0, 36]
// muted -> raise [99, 136]
// unmuted -> raise [136, 172]
// TODO should probably move to other component
const playSegment: [number, number] = useMemo(() => {
if (isRequestingToSpeak) {
const r = Math.floor(Math.random() * 100);
return (r < 32 ? [0, 120]
: (r < 64 ? [120, 240]
: (r < 97 ? [240, 420]
: [420, 540]
)
)
);
}
if (!prevShouldRaiseHand && shouldRaiseHand) {
return noAudioStream ? [99, 135] : [136, 172];
}
if (prevShouldRaiseHand && !shouldRaiseHand) {
return [0, 36];
}
if (!shouldRaiseHand) {
return noAudioStream ? [69, 99] : [36, 69];
}
return [0, 0];
}, [prevShouldRaiseHand, isRequestingToSpeak, noAudioStream, shouldRaiseHand]);
const animatedIconName = isRequestingToSpeak ? 'HandFilled' : 'VoiceMini';
const toggleMute = () => {
vibrateShort();
toggleGroupCallMute();
};
const handleMouseDownMute = () => {
if (shouldRaiseHand) {
if (isRequestingToSpeak) return;
vibrateShort();
requestToSpeak();
setIsRequestingToSpeak(true);
setTimeout(() => {
setIsRequestingToSpeak(false);
}, REQUEST_TO_SPEAK_THROTTLE);
return;
}
muteMouseDownState.current = 'down';
if (noAudioStream) {
setTimeout(() => {
if (muteMouseDownState.current === 'down') {
muteMouseDownState.current = 'hold';
toggleMute();
}
}, HOLD_TO_SPEAK_TIME);
}
};
const handleMouseUpMute = () => {
if (shouldRaiseHand) {
return;
}
toggleMute();
muteMouseDownState.current = 'up';
};
const buttonText = useMemo(() => {
return lang(
hasRequestedToSpeak ? 'VoipMutedTapedForSpeak' : (
shouldRaiseHand ? 'VoipMutedByAdmin' : (
noAudioStream ? 'VoipUnmute' : 'VoipTapToMute'
)
),
);
}, [hasRequestedToSpeak, noAudioStream, lang, shouldRaiseHand]);
return (
<div className="button-wrapper microphone-wrapper">
<button
className={buildClassName(
'MicrophoneButton',
noAudioStream && 'crossed',
canSelfUnmute && 'can-self-unmute',
isConnecting && 'is-connecting',
shouldRaiseHand && 'muted-by-admin',
)}
onMouseDown={handleMouseDownMute}
onMouseUp={handleMouseUpMute}
>
<AnimatedIcon
name={animatedIconName}
size={ICON_SIZE}
playSegment={playSegment}
/>
</button>
<div className="button-text">
{buttonText}
</div>
</div>
);
};
export default memo(withGlobal(
(global): StateProps => {
const groupCall = selectActiveGroupCall(global);
const { connectionState } = groupCall || {};
const meParticipant = groupCall && selectGroupCallParticipant(global, groupCall.id, global.currentUserId!);
const {
raiseHandRating, hasAudioStream, canSelfUnmute, isMuted,
} = meParticipant || {};
return {
connectionState: connectionState || CONNECTION_STATE_DEFAULT,
hasRequestedToSpeak: Boolean(raiseHandRating),
noAudioStream: !hasAudioStream,
canSelfUnmute,
isMuted,
};
},
(setGlobal, actions): DispatchProps => pick(actions, [
'toggleGroupCallMute',
'requestToSpeak',
'playGroupCallSound',
]),
)(MicrophoneButton));

View File

@ -0,0 +1,68 @@
import { GroupCallParticipant, THRESHOLD } from '../../../lib/secret-sauce';
import React, { FC, memo, useMemo } from '../../../lib/teact/teact';
import AnimatedIcon from '../../common/AnimatedIcon';
import usePrevious from '../../../hooks/usePrevious';
type OwnProps = {
participant: GroupCallParticipant;
noColor?: boolean;
};
const OutlinedMicrophoneIcon: FC<OwnProps> = ({
participant,
noColor,
}) => {
const { isMuted, isMutedByMe } = participant;
const isSpeaking = (participant.amplitude || 0) > THRESHOLD;
const isRaiseHand = Boolean(participant.raiseHandRating);
const prevIsRaiseHand = usePrevious(isRaiseHand);
const canSelfUnmute = !!participant?.canSelfUnmute;
const shouldRaiseHand = !canSelfUnmute && isMuted;
const prevIsMuted = usePrevious(isMuted);
const playSegment: [number, number] = useMemo(() => {
if (isMuted && !prevIsMuted) {
return [43, 64];
}
if (!isMuted && prevIsMuted) {
return [22, 42];
}
if (isRaiseHand && !prevIsRaiseHand) {
return [65, 84];
}
if (!shouldRaiseHand && prevIsRaiseHand) {
return [0, 21];
}
// TODO cancel request to speak should play in reverse
// if (!isRaiseHand && prevIsRaiseHand) {
// return [84, 65];
// }
return isMuted ? [22, 23] : [43, 44];
// eslint-disable-next-line
}, [isMuted, shouldRaiseHand, isRaiseHand]);
const microphoneColor: [number, number, number] | undefined = useMemo(() => {
return noColor ? [0xff, 0xff, 0xff] : (
isRaiseHand ? [0x4d, 0xa6, 0xe0]
: (shouldRaiseHand || isMutedByMe ? [0xFF, 0x70, 0x6F] : (
isSpeaking ? [0x57, 0xBC, 0x6C] : [0x84, 0x8D, 0x94]
))
);
}, [noColor, isRaiseHand, shouldRaiseHand, isMutedByMe, isSpeaking]);
return (
<AnimatedIcon
name="VoiceOutlined"
playSegment={playSegment}
size={28}
color={microphoneColor}
/>
);
};
export default memo(OutlinedMicrophoneIcon);

View File

@ -0,0 +1,42 @@
import React, {
FC, memo, useEffect, useState,
} from '../../lib/teact/teact';
import getAnimationData, { ANIMATED_STICKERS_PATHS } from './helpers/animatedAssets';
import AnimatedSticker from './AnimatedSticker';
type OwnProps = {
name: keyof typeof ANIMATED_STICKERS_PATHS;
size: number;
playSegment?: [number, number];
color?: [number, number, number];
};
const AnimatedIcon: FC<OwnProps> = ({
size,
name,
playSegment,
color,
}) => {
const [iconData, setIconData] = useState<Record<string, any>>();
useEffect(() => {
getAnimationData(name).then(setIconData);
}, [name]);
return (
<AnimatedSticker
id={name}
play
noLoop
playSegment={playSegment}
size={size}
speed={1}
animationData={iconData}
color={color}
/>
);
};
export default memo(AnimatedIcon);

View File

@ -19,6 +19,7 @@ type OwnProps = {
quality?: number;
isLowPriority?: boolean;
onLoad?: NoneToVoidFunction;
color?: [number, number, number];
};
type RLottieClass = typeof import('../../lib/rlottie/RLottie').default;
@ -52,6 +53,7 @@ const AnimatedSticker: FC<OwnProps> = ({
quality,
isLowPriority,
onLoad,
color,
}) => {
const [animation, setAnimation] = useState<RLottieInstance>();
// eslint-disable-next-line no-null/no-null
@ -85,6 +87,7 @@ const AnimatedSticker: FC<OwnProps> = ({
isLowPriority,
},
onLoad,
color,
);
if (speed) {
@ -105,7 +108,13 @@ const AnimatedSticker: FC<OwnProps> = ({
});
});
}
}, [animation, animationData, id, isLowPriority, noLoop, onLoad, quality, size, speed]);
}, [color, animation, animationData, id, isLowPriority, noLoop, onLoad, quality, size, speed]);
useEffect(() => {
if (!animation) return;
animation.setColor(color);
}, [color, animation]);
useEffect(() => {
return () => {
@ -183,6 +192,13 @@ const AnimatedSticker: FC<OwnProps> = ({
}
}, [animation, play, playSegment, noLoop, playAnimation, pauseAnimation]);
useEffect(() => {
if (animation) {
animation.changeData(animationData);
playAnimation();
}
}, [playAnimation, animation, animationData]);
useHeavyAnimationCheck(freezeAnimation, unfreezeAnimation);
// Pausing frame may not happen in background
// so we need to make sure it happens right after focusing,

View File

@ -0,0 +1,41 @@
import React, { FC, useCallback } from '../../lib/teact/teact';
import { withGlobal } from '../../lib/teact/teactn';
import { GlobalActions } from '../../global/types';
import { ApiGroupCall } from '../../api/types';
import { pick } from '../../util/iteratees';
import buildClassName from '../../util/buildClassName';
import Link from '../ui/Link';
type OwnProps = {
className?: string;
groupCall?: Partial<ApiGroupCall>;
children: any;
};
type DispatchProps = Pick<GlobalActions, 'joinGroupCall'>;
const GroupCallLink: FC<OwnProps & DispatchProps> = ({
className, groupCall, joinGroupCall, children,
}) => {
const handleClick = useCallback(() => {
if (groupCall) {
joinGroupCall({ id: groupCall.id, accessHash: groupCall.accessHash });
}
}, [groupCall, joinGroupCall]);
if (!groupCall) {
return children;
}
return (
<Link className={buildClassName('GroupCallLink', className)} onClick={handleClick}>{children}</Link>
);
};
export default withGlobal<OwnProps>(
undefined,
(setGlobal, actions): DispatchProps => pick(actions, ['joinGroupCall']),
)(GroupCallLink);

View File

@ -16,6 +16,22 @@ import FoldersAll from '../../../assets/FoldersAll.tgs';
import FoldersNew from '../../../assets/FoldersNew.tgs';
// @ts-ignore
import DiscussionGroups from '../../../assets/DiscussionGroupsDucks.tgs';
// @ts-ignore
import CameraFlip from '../../../assets/animatedIcons/CameraFlip.tgs';
// @ts-ignore
import HandFilled from '../../../assets/animatedIcons/HandFilled.tgs';
// @ts-ignore
import HandOutline from '../../../assets/animatedIcons/HandOutline.tgs';
// @ts-ignore
import Speaker from '../../../assets/animatedIcons/Speaker.tgs';
// @ts-ignore
import VoiceAllowTalk from '../../../assets/animatedIcons/VoiceAllowTalk.tgs';
// @ts-ignore
import VoiceMini from '../../../assets/animatedIcons/VoiceMini.tgs';
// @ts-ignore
import VoiceMuted from '../../../assets/animatedIcons/VoiceMuted.tgs';
// @ts-ignore
import VoiceOutlined from '../../../assets/animatedIcons/VoiceOutlined.tgs';
export const ANIMATED_STICKERS_PATHS = {
MonkeyIdle,
@ -25,6 +41,14 @@ export const ANIMATED_STICKERS_PATHS = {
FoldersAll,
FoldersNew,
DiscussionGroups,
CameraFlip,
HandFilled,
HandOutline,
Speaker,
VoiceAllowTalk,
VoiceMini,
VoiceMuted,
VoiceOutlined,
};
export default function getAnimationData(name: keyof typeof ANIMATED_STICKERS_PATHS) {

View File

@ -1,6 +1,8 @@
import React from '../../../lib/teact/teact';
import { ApiChat, ApiMessage, ApiUser } from '../../../api/types';
import {
ApiChat, ApiMessage, ApiUser, ApiGroupCall,
} from '../../../api/types';
import { LangFn } from '../../../hooks/useLang';
import {
getChatTitle,
@ -17,6 +19,7 @@ import renderText from './renderText';
import UserLink from '../UserLink';
import MessageLink from '../MessageLink';
import ChatLink from '../ChatLink';
import GroupCallLink from '../GroupCallLink';
interface ActionMessageTextOptions {
maxTextLength?: number;
@ -39,7 +42,7 @@ export function renderActionMessageText(
return [];
}
const {
text, translationValues, amount, currency,
text, translationValues, amount, currency, call,
} = message.content.action;
const content: TextPart[] = [];
const textOptions: ActionMessageTextOptions = { ...options, maxTextLength: 32 };
@ -115,6 +118,10 @@ export function renderActionMessageText(
return content.join('').trim();
}
if (call) {
return renderGroupCallContent(call, content);
}
return content;
}
@ -172,6 +179,14 @@ function renderOriginContent(lang: LangFn, origin: ApiUser | ApiChat, asPlain?:
: renderChatContent(lang, origin as ApiChat, asPlain);
}
function renderGroupCallContent(groupCall: Partial<ApiGroupCall>, text: TextPart[]): string | TextPart | undefined {
return (
<GroupCallLink groupCall={groupCall}>
{text}
</GroupCallLink>
);
}
function renderUserContent(sender: ApiUser, asPlain?: boolean): string | TextPart | undefined {
const text = trimText(getUserFullName(sender));

View File

@ -76,6 +76,7 @@
}
.status {
position: relative;
flex-shrink: 0;
}

View File

@ -51,6 +51,7 @@ import DeleteChatModal from '../../common/DeleteChatModal';
import ListItem from '../../ui/ListItem';
import Badge from './Badge';
import ChatFolderModal from '../ChatFolderModal.async';
import ChatCallStatus from './ChatCallStatus';
import './Chat.scss';
@ -285,6 +286,9 @@ const Chat: FC<OwnProps & StateProps & DispatchProps> = ({
isSavedMessages={privateChatUser?.isSelf}
lastSyncTime={lastSyncTime}
/>
{chat.isCallActive && chat.isCallNotEmpty && (
<ChatCallStatus isSelected={isSelected} isActive={animationLevel !== 0} />
)}
</div>
<div className="info">
<div className="title">

View File

@ -1,6 +1,8 @@
import React, { FC, memo } from '../../../lib/teact/teact';
import buildClassName from '../../../util/buildClassName';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment';
import './ChatCallStatus.scss';
type OwnProps = {
@ -13,7 +15,12 @@ const ChatCallStatus: FC<OwnProps> = ({
isActive,
}) => {
return (
<div className={buildClassName('ChatCallStatus', isActive && 'active', isSelected && 'selected')}>
<div className={buildClassName(
'ChatCallStatus',
isActive && 'active',
isSelected && !IS_SINGLE_COLUMN_LAYOUT && 'selected',
)}
>
<div className="indicator">
<div />
<div />

View File

@ -18,6 +18,14 @@
}
}
.has-group-call-header {
--group-call-header-height: 2rem;
#LeftColumn, #MiddleColumn, #RightColumn-wrapper {
height: calc(100% - 2rem);
margin-top: 2rem;
}
}
#LeftColumn {
min-width: 12rem;
width: 33vw;

View File

@ -44,6 +44,8 @@ import ForwardPicker from './ForwardPicker.async';
import SafeLinkModal from './SafeLinkModal.async';
import HistoryCalendar from './HistoryCalendar.async';
import StickerSetModal from '../common/StickerSetModal.async';
import GroupCall from '../calls/group/GroupCall.async';
import ActiveCallHeader from '../calls/ActiveCallHeader.async';
import './Main.scss';
@ -60,6 +62,7 @@ type StateProps = {
isHistoryCalendarOpen: boolean;
shouldSkipHistoryAnimations?: boolean;
openedStickerSetShortName?: string;
activeGroupCallId?: string;
isServiceChatReady?: boolean;
animationLevel: number;
language?: LangCode;
@ -88,6 +91,7 @@ const Main: FC<StateProps & DispatchProps> = ({
hasNotifications,
hasDialogs,
audioMessage,
activeGroupCallId,
safeLinkModalUrl,
isHistoryCalendarOpen,
shouldSkipHistoryAnimations,
@ -271,6 +275,12 @@ const Main: FC<StateProps & DispatchProps> = ({
onClose={handleStickerSetModalClose}
stickerSetShortName={openedStickerSetShortName}
/>
{activeGroupCallId && (
<>
<GroupCall groupCallId={activeGroupCallId} />
<ActiveCallHeader groupCallId={activeGroupCallId} />
</>
)}
<DownloadManager />
</div>
);
@ -319,6 +329,7 @@ export default memo(withGlobal(
shouldSkipHistoryAnimations: global.shouldSkipHistoryAnimations,
openedStickerSetShortName: global.openedStickerSetShortName,
isServiceChatReady: selectIsServiceChatReady(global),
activeGroupCallId: global.groupCalls.activeGroupCallId,
animationLevel,
language,
wasTimeFormatSetManually,

View File

@ -11,9 +11,9 @@ import { GlobalActions, MessageListType } from '../../global/types';
import { MAIN_THREAD_ID } from '../../api/types';
import { IAnchorPosition } from '../../types';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
import { ARE_CALLS_SUPPORTED, IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
import { pick } from '../../util/iteratees';
import { isChatChannel, isChatSuperGroup } from '../../modules/helpers';
import { isChatBasicGroup, isChatChannel, isChatSuperGroup } from '../../modules/helpers';
import {
selectChat,
selectChatBot,
@ -45,6 +45,8 @@ interface StateProps {
canSearch?: boolean;
canMute?: boolean;
canLeave?: boolean;
canEnterVoiceChat?: boolean;
canCreateVoiceChat?: boolean;
}
type DispatchProps = Pick<GlobalActions, 'joinChannel' | 'sendBotCommand' | 'openLocalTextSearch' | 'restartBot'>;
@ -63,6 +65,8 @@ const HeaderActions: FC<OwnProps & StateProps & DispatchProps> = ({
canSearch,
canMute,
canLeave,
canEnterVoiceChat,
canCreateVoiceChat,
isRightColumnShown,
canExpandActions,
joinChannel,
@ -191,6 +195,8 @@ const HeaderActions: FC<OwnProps & StateProps & DispatchProps> = ({
canSearch={canSearch}
canMute={canMute}
canLeave={canLeave}
canEnterVoiceChat={canEnterVoiceChat}
canCreateVoiceChat={canCreateVoiceChat}
onSubscribeChannel={handleSubscribeClick}
onSearchClick={handleSearchClick}
onClose={handleHeaderMenuClose}
@ -226,6 +232,9 @@ export default memo(withGlobal<OwnProps>(
const canSearch = isMainThread || isDiscussionThread;
const canMute = isMainThread && !isChatWithSelf && !canSubscribe;
const canLeave = isMainThread && !canSubscribe;
const canEnterVoiceChat = ARE_CALLS_SUPPORTED && chat && chat.isCallActive;
const canCreateVoiceChat = ARE_CALLS_SUPPORTED && chat && !chat.isCallActive
&& (chat.adminRights?.manageCall || (chat.isCreator && isChatBasicGroup(chat)));
return {
noMenu: false,
@ -237,6 +246,8 @@ export default memo(withGlobal<OwnProps>(
canSearch,
canMute,
canLeave,
canEnterVoiceChat,
canCreateVoiceChat,
};
},
(setGlobal, actions): DispatchProps => pick(actions, [

View File

@ -27,7 +27,8 @@ import DeleteChatModal from '../common/DeleteChatModal';
import './HeaderMenuContainer.scss';
type DispatchProps = Pick<GlobalActions, (
'updateChatMutedState' | 'enterMessageSelectMode' | 'sendBotCommand' | 'restartBot' | 'openLinkedChat' | 'addContact'
'updateChatMutedState' | 'enterMessageSelectMode' | 'sendBotCommand' | 'restartBot' | 'openLinkedChat' |
'joinGroupCall' | 'createGroupCall' | 'addContact'
)>;
export type OwnProps = {
@ -43,6 +44,8 @@ export type OwnProps = {
canSearch?: boolean;
canMute?: boolean;
canLeave?: boolean;
canEnterVoiceChat?: boolean;
canCreateVoiceChat?: boolean;
onSubscribeChannel: () => void;
onSearchClick: () => void;
onClose: () => void;
@ -70,6 +73,8 @@ const HeaderMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
canSearch,
canMute,
canLeave,
canEnterVoiceChat,
canCreateVoiceChat,
chat,
isPrivate,
isMuted,
@ -84,6 +89,8 @@ const HeaderMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
enterMessageSelectMode,
sendBotCommand,
restartBot,
joinGroupCall,
createGroupCall,
openLinkedChat,
addContact,
}) => {
@ -121,6 +128,20 @@ const HeaderMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
closeMenu();
}, [chatId, closeMenu, isMuted, updateChatMutedState]);
const handleEnterVoiceChatClick = useCallback(() => {
if (canCreateVoiceChat) {
// TODO show popup to schedule
createGroupCall({
chatId,
});
} else {
joinGroupCall({
chatId,
});
}
closeMenu();
}, [closeMenu, canCreateVoiceChat, chatId, joinGroupCall, createGroupCall]);
const handleLinkedChatClick = useCallback(() => {
openLinkedChat({ id: chatId });
closeMenu();
@ -211,6 +232,14 @@ const HeaderMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
{lang(isMuted ? 'ChatsUnmute' : 'ChatsMute')}
</MenuItem>
)}
{(canEnterVoiceChat || canCreateVoiceChat) && (
<MenuItem
icon="voice-chat"
onClick={handleEnterVoiceChatClick}
>
{lang(canCreateVoiceChat ? 'StartVoipChat' : 'VoipGroupJoinCall')}
</MenuItem>
)}
{hasLinkedChat && (
<MenuItem
icon={isChannel ? 'comments' : 'channel'}
@ -273,6 +302,8 @@ export default memo(withGlobal<OwnProps>(
'enterMessageSelectMode',
'sendBotCommand',
'restartBot',
'joinGroupCall',
'createGroupCall',
'openLinkedChat',
'addContact',
]),

View File

@ -302,8 +302,9 @@
.Avatar {
margin-right: .625rem;
width: 2.5rem;
height: 2.5rem;
// TODO For some reason webpack imports `Audio.scss` second time when loading calls bundle
width: 2.5rem !important;
height: 2.5rem !important;
font-size: 1.0625rem;
}

View File

@ -61,6 +61,7 @@ import Button from '../ui/Button';
import HeaderActions from './HeaderActions';
import HeaderPinnedMessage from './HeaderPinnedMessage';
import AudioPlayer from './AudioPlayer';
import GroupCallTopPane from '../calls/group/GroupCallTopPane';
import './MiddleHeader.scss';
@ -404,6 +405,14 @@ const MiddleHeader: FC<OwnProps & StateProps & DispatchProps> = ({
{renderInfo}
</Transition>
<GroupCallTopPane
hasPinnedOffset={
(shouldRenderPinnedMessage && !!renderingPinnedMessage)
|| (shouldRenderAudioPlayer && !!renderingAudioMessage)
}
chatId={chatId}
/>
{shouldRenderPinnedMessage && renderingPinnedMessage && (
<HeaderPinnedMessage
key={chatId}

View File

@ -270,6 +270,16 @@ const ManageGroupAdminRights: FC<OwnProps & StateProps & DispatchProps> = ({
onChange={handlePermissionChange}
/>
</div>
<div className="ListItem no-selection">
<Checkbox
name="manageCall"
checked={!!permissions.manageCall}
label={lang('StartVoipChatPermission')}
blocking
disabled={getControlIsDisabled('manageCall')}
onChange={handlePermissionChange}
/>
</div>
{!isChannel && (
<div className="ListItem no-selection">
<Checkbox

View File

@ -2,7 +2,7 @@
position: absolute;
right: 1rem;
bottom: 1rem;
transform: translateY(5rem);
transform: translateY(calc(5rem - var(--group-call-header-height, 0rem)));
transition: transform .25s cubic-bezier(0.34, 1.56, 0.64, 1);
body.animation-level-0 & {
@ -10,6 +10,6 @@
}
&.revealed {
transform: translateY(0);
transform: translateY(calc(0rem - var(--group-call-header-height, 0rem)));
}
}

View File

@ -3,5 +3,9 @@
&:hover {
color: inherit;
&.GroupCallLink {
text-decoration: none;
}
}
}

View File

@ -1,3 +1,4 @@
import { RefObject } from 'react';
import React, {
FC, useEffect, useRef,
} from '../../lib/teact/teact';
@ -31,6 +32,7 @@ type OwnProps = {
onClose: () => void;
onCloseAnimationEnd?: () => void;
onEnter?: () => void;
dialogRef?: RefObject<HTMLDivElement>;
};
type StateProps = {
@ -38,6 +40,7 @@ type StateProps = {
};
const Modal: FC<OwnProps & StateProps> = ({
dialogRef,
title,
className,
isOpen,
@ -78,7 +81,6 @@ const Modal: FC<OwnProps & StateProps> = ({
useEffectWithPrevDeps(([prevIsOpen]) => {
document.body.classList.toggle('has-open-dialog', isOpen);
if (isOpen || (!isOpen && prevIsOpen !== undefined)) {
dispatchHeavyAnimationEvent(ANIMATION_DURATION);
}
@ -138,7 +140,7 @@ const Modal: FC<OwnProps & StateProps> = ({
>
<div className="modal-container">
<div className="modal-backdrop" onClick={onClose} />
<div className="modal-dialog">
<div className="modal-dialog" ref={dialogRef}>
{renderHeader()}
<div className="modal-content custom-scroll">
{children}

View File

@ -1,3 +1,5 @@
@import '../../styles/mixins';
@mixin thumb-styles() {
background: var(--slider-color);
border: none;
@ -72,43 +74,7 @@
}
// Reset range input browser styles
input[type="range"] {
-webkit-appearance: none;
display: block;
width: 100%;
height: 0.75rem;
margin-bottom: 0.5rem;
background: transparent;
&:focus {
outline: none;
}
&::-ms-track {
width: 100%;
cursor: pointer;
background: transparent;
border-color: transparent;
color: transparent;
}
&::-webkit-slider-thumb {
-webkit-appearance: none;
}
&::-moz-slider-thumb {
-moz-appearance: none;
}
&::-webkit-slider-runnable-track {
cursor: pointer;
}
&::-moz-range-track, &::-moz-range-progress {
cursor: pointer;
}
}
@include reset-range();
// Apply custom styles
input[type="range"] {

View File

@ -58,6 +58,7 @@ export const BLOCKED_LIST_LIMIT = 100;
export const PROFILE_PHOTOS_LIMIT = 40;
export const PROFILE_SENSITIVE_AREA = 500;
export const COMMON_CHATS_LIMIT = 100;
export const GROUP_CALL_PARTICIPANTS_LIMIT = 100;
export const TOP_CHAT_MESSAGES_PRELOAD_LIMIT = 20;
export const ALL_CHATS_PRELOAD_DISABLED = false;
@ -170,3 +171,8 @@ export const LIGHT_THEME_BG_COLOR = '#A2AF8E';
export const DARK_THEME_BG_COLOR = '#0F0F0F';
export const DARK_THEME_PATTERN_COLOR = '#0a0a0a8c';
export const DEFAULT_PATTERN_COLOR = 'rgba(90, 110, 70, 0.6)';
// Group calls
export const GROUP_CALL_VOLUME_MULTIPLIER = 100;
export const GROUP_CALL_DEFAULT_VOLUME = 100 * GROUP_CALL_VOLUME_MULTIPLIER;
export const ENABLE_THUMBNAIL_VIDEO = false;

View File

@ -189,6 +189,10 @@ function migrateCache(cached: GlobalState, initialState: GlobalState) {
if (cached.audioPlayer.playbackRate === undefined) {
cached.audioPlayer.playbackRate = DEFAULT_PLAYBACK_RATE;
}
if (cached.groupCalls === undefined) {
cached.groupCalls = initialState.groupCalls;
}
}
function updateCache() {

View File

@ -44,6 +44,10 @@ export const INITIAL_STATE: GlobalState = {
messageLists: [],
},
groupCalls: {
byId: {},
},
scheduledMessages: {
byChatId: {},
},

View File

@ -21,6 +21,7 @@ import {
ApiInviteInfo,
ApiCountryCode,
ApiCountry,
ApiGroupCall,
} from '../api/types';
import {
FocusDirection,
@ -163,6 +164,12 @@ export type GlobalState = {
};
};
groupCalls: {
byId: Record<string, ApiGroupCall>;
activeGroupCallId?: string;
isGroupCallPanelHidden?: boolean;
};
scheduledMessages: {
byChatId: Record<string, {
byId: Record<number, ApiMessage>;
@ -539,7 +546,12 @@ export type ActionTypes = (
// payment
'openPaymentModal' | 'closePaymentModal' | 'addPaymentError' |
'validateRequestedInfo' | 'setPaymentStep' | 'sendPaymentForm' | 'getPaymentForm' | 'getReceipt' |
'sendCredentialsInfo' | 'setInvoiceMessageInfo' | 'clearPaymentError' | 'clearReceipt'
'sendCredentialsInfo' | 'setInvoiceMessageInfo' | 'clearPaymentError' | 'clearReceipt' |
// calls
'joinGroupCall' | 'toggleGroupCallMute' | 'toggleGroupCallPresentation' | 'leaveGroupCall' |
'toggleGroupCallVideo' | 'requestToSpeak' | 'setGroupCallParticipantVolume' | 'toggleGroupCallPanel' |
'createGroupCall' | 'joinVoiceChatByLink' | 'subscribeToGroupCallUpdates' | 'createGroupCallInviteLink' |
'loadMoreGroupCallParticipants' | 'connectToActiveGroupCall' | 'playGroupCallSound'
);
export type GlobalActions = Record<ActionTypes, (...args: any[]) => void>;

View File

@ -1100,4 +1100,15 @@ langpack.getLangPack#f2f2330a lang_pack:string lang_code:string = LangPackDiffer
langpack.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector<string> = Vector<LangPackString>;
langpack.getLanguages#42c6978f lang_pack:string = Vector<LangPackLanguage>;
folders.editPeerFolders#6847d0ab folder_peers:Vector<InputFolderPeer> = Updates;
phone.createGroupCall#48cdc6d8 flags:# peer:InputPeer random_id:int title:flags.0?string schedule_date:flags.1?int = Updates;
phone.joinGroupCall#b132ff7b flags:# muted:flags.0?true video_stopped:flags.2?true call:InputGroupCall join_as:InputPeer invite_hash:flags.1?string params:DataJSON = Updates;
phone.leaveGroupCall#500377f9 call:InputGroupCall source:int = Updates;
phone.discardGroupCall#7a777135 call:InputGroupCall = Updates;
phone.getGroupCall#41845db call:InputGroupCall limit:int = phone.GroupCall;
phone.getGroupParticipants#c558d8ab call:InputGroupCall ids:Vector<InputPeer> sources:Vector<int> offset:string limit:int = phone.GroupParticipants;
phone.editGroupCallParticipant#a5273abf flags:# call:InputGroupCall participant:InputPeer muted:flags.0?Bool volume:flags.1?int raise_hand:flags.2?Bool video_stopped:flags.3?Bool video_paused:flags.4?Bool presentation_paused:flags.5?Bool = Updates;
phone.exportGroupCallInvite#e6aa647f flags:# can_self_unmute:flags.0?true call:InputGroupCall = phone.ExportedGroupCallInvite;
phone.toggleGroupCallStartSubscription#219c34e6 call:InputGroupCall subscribed:Bool = Updates;
phone.joinGroupCallPresentation#cbea6bc4 call:InputGroupCall params:DataJSON = Updates;
phone.leaveGroupCallPresentation#1c50d144 call:InputGroupCall = Updates;
`;

View File

@ -1101,4 +1101,15 @@ langpack.getLangPack#f2f2330a lang_pack:string lang_code:string = LangPackDiffer
langpack.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector<string> = Vector<LangPackString>;
langpack.getLanguages#42c6978f lang_pack:string = Vector<LangPackLanguage>;
folders.editPeerFolders#6847d0ab folder_peers:Vector<InputFolderPeer> = Updates;
phone.createGroupCall#48cdc6d8 flags:# peer:InputPeer random_id:int title:flags.0?string schedule_date:flags.1?int = Updates;
phone.joinGroupCall#b132ff7b flags:# muted:flags.0?true video_stopped:flags.2?true call:InputGroupCall join_as:InputPeer invite_hash:flags.1?string params:DataJSON = Updates;
phone.leaveGroupCall#500377f9 call:InputGroupCall source:int = Updates;
phone.discardGroupCall#7a777135 call:InputGroupCall = Updates;
phone.getGroupCall#41845db call:InputGroupCall limit:int = phone.GroupCall;
phone.getGroupParticipants#c558d8ab call:InputGroupCall ids:Vector<InputPeer> sources:Vector<int> offset:string limit:int = phone.GroupParticipants;
phone.editGroupCallParticipant#a5273abf flags:# call:InputGroupCall participant:InputPeer muted:flags.0?Bool volume:flags.1?int raise_hand:flags.2?Bool video_stopped:flags.3?Bool video_paused:flags.4?Bool presentation_paused:flags.5?Bool = Updates;
phone.exportGroupCallInvite#e6aa647f flags:# can_self_unmute:flags.0?true call:InputGroupCall = phone.ExportedGroupCallInvite;
phone.toggleGroupCallStartSubscription#219c34e6 call:InputGroupCall subscribed:Bool = Updates;
phone.joinGroupCallPresentation#cbea6bc4 call:InputGroupCall params:DataJSON = Updates;
phone.leaveGroupCallPresentation#1c50d144 call:InputGroupCall = Updates;
// LAYER 133

View File

@ -89,6 +89,7 @@ class RLottie {
private animationData: AnyLiteral,
private params: Params = {},
private onLoad?: () => void,
private customColor?: [number, number, number],
) {
this.initContainer();
this.initConfig();
@ -199,6 +200,46 @@ class RLottie {
this.canvas.remove();
}
private onChangeData(framesCount: number) {
this.isWaiting = false;
this.framesCount = framesCount;
this.chunksCount = Math.ceil(framesCount / this.chunkSize);
this.isAnimating = false;
this.doPlay();
}
setColor(newColor: [number, number, number] | undefined) {
this.customColor = newColor;
if (this.customColor) {
const imageData = this.ctx.getImageData(0, 0, this.imgSize, this.imgSize);
const arr = imageData.data;
for (let i = 0; i < arr.length; i += 4) {
/* eslint-disable prefer-destructuring */
arr[i] = this.customColor[0];
arr[i + 1] = this.customColor[1];
arr[i + 2] = this.customColor[2];
/* eslint-enable prefer-destructuring */
}
this.ctx.putImageData(imageData, 0, 0);
}
}
changeData(animationData: AnyLiteral) {
this.pause();
this.animationData = animationData;
this.initConfig();
workers[this.workerIndex].request({
name: 'changeData',
args: [
this.key,
this.animationData,
this.onChangeData.bind(this),
],
});
}
private initRenderer() {
this.workerIndex = cycleRestrict(MAX_WORKERS, ++lastWorkerIndex);
@ -264,7 +305,8 @@ class RLottie {
const frameIndex = Math.round(this.approxFrameIndex);
const chunkIndex = this.getChunkIndex(frameIndex);
const chunk = this.chunks[chunkIndex];
if (!chunk) {
if (!chunk || chunk.length === 0) {
this.requestChunk(chunkIndex);
this.isAnimating = false;
this.isWaiting = true;
@ -285,7 +327,17 @@ class RLottie {
return false;
}
const imageData = new ImageData(new Uint8ClampedArray(frame), this.imgSize, this.imgSize);
const arr = new Uint8ClampedArray(frame);
if (this.customColor) {
for (let i = 0; i < arr.length; i += 4) {
/* eslint-disable prefer-destructuring */
arr[i] = this.customColor[0];
arr[i + 1] = this.customColor[1];
arr[i + 2] = this.customColor[2];
/* eslint-enable prefer-destructuring */
}
}
const imageData = new ImageData(arr, this.imgSize, this.imgSize);
this.ctx.putImageData(imageData, 0, 0);
if (this.onLoad && !this.isOnLoadFired) {
@ -373,7 +425,7 @@ class RLottie {
}
private requestChunk(chunkIndex: number) {
if (this.chunks[chunkIndex]) {
if (this.chunks[chunkIndex] && this.chunks[chunkIndex]?.length !== 0) {
return;
}

View File

@ -56,6 +56,23 @@ async function init(
onInit(Math.ceil(framesCount / reduceFactor));
}
async function changeData(
key: string,
animationData: AnyLiteral,
onInit: CancellableCallback,
) {
if (!rLottieApi) {
await rLottieApiPromise;
}
const json = JSON.stringify(animationData);
const { reduceFactor, handle } = renderers.get(key)!;
const stringOnWasmHeap = allocate(intArrayFromString(json), 'i8', 0);
const framesCount = rLottieApi.loadFromData(handle, stringOnWasmHeap);
onInit(Math.ceil(framesCount / reduceFactor));
}
async function renderFrames(
key: string, fromIndex: number, toIndex: number, onProgress: CancellableCallback,
) {
@ -86,6 +103,7 @@ function destroy(key: string) {
createWorkerInterface({
init,
changeData,
renderFrames,
destroy,
});

View File

@ -0,0 +1,5 @@
export declare const silence: (ctx: AudioContext) => MediaStream;
export declare const black: ({ width, height }?: {
width?: number | undefined;
height?: number | undefined;
}) => MediaStream;

21
src/lib/secret-sauce/buildSdp.d.ts vendored Normal file
View File

@ -0,0 +1,21 @@
import { GroupCallTransport, PayloadType, RTPExtension, SsrcGroup } from './types';
export declare type Conference = {
sessionId: number;
audioExtensions: RTPExtension[];
videoExtensions: RTPExtension[];
audioPayloadTypes: PayloadType[];
videoPayloadTypes: PayloadType[];
ssrcs: Ssrc[];
transport: GroupCallTransport;
};
export declare type Ssrc = {
userId: string;
endpoint: string;
isMain: boolean;
isRemoved?: boolean;
isVideo: boolean;
isPresentation?: boolean;
sourceGroups: SsrcGroup[];
};
declare const _default: (conference: Conference, isAnswer?: boolean, isPresentation?: boolean) => string;
export default _default;

36
src/lib/secret-sauce/colibriClass.d.ts vendored Normal file
View File

@ -0,0 +1,36 @@
export declare type EndpointConnectivityStatusChangeEvent = {
colibriClass: 'EndpointConnectivityStatusChangeEvent';
endpoint: string;
active: boolean;
};
export declare type DominantSpeakerEndpointChangeEvent = {
colibriClass: 'DominantSpeakerEndpointChangeEvent';
dominantSpeakerEndpoint: string;
previousSpeakers: string[];
};
export declare type SenderVideoConstraints = {
colibriClass: 'SenderVideoConstraints';
videoConstraints: {
idealHeight: number;
};
};
export declare type DebugMessage = {
colibriClass: 'DebugMessage';
message: string;
};
export declare type LastNEndpointsChangeEvent = {
colibriClass: 'LastNEndpointsChangeEvent';
lastNEndpoints: string[];
};
export declare type ReceiverVideoConstraints = {
colibriClass: 'ReceiverVideoConstraints';
defaultConstraints: {
maxHeight: number;
};
constraints: Record<string, {
minHeight: number;
maxHeight: number;
}>;
onStageEndpoints: string[];
};
export declare type ColibriClass = (LastNEndpointsChangeEvent | DebugMessage | EndpointConnectivityStatusChangeEvent | SenderVideoConstraints | DominantSpeakerEndpointChangeEvent | ReceiverVideoConstraints);

3
src/lib/secret-sauce/index.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
export { handleUpdateGroupCallConnection, startSharingScreen, joinGroupCall, getDevices, getUserStreams, setVolume, isStreamEnabled, toggleStream, leaveGroupCall, handleUpdateGroupCallParticipants, switchCameraInput, toggleSpeaker, toggleNoiseSuppression, } from './secretsauce';
export { IS_SCREENSHARE_SUPPORTED, THRESHOLD, } from './utils';
export * from './types';

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,39 @@
/*! ./blacksilence */
/*! ./buildSdp */
/*! ./parseSdp */
/*! ./secretsauce */
/*! ./types */
/*! ./utils */
/*!**********************!*\
!*** ./src/index.ts ***!
\**********************/
/*!**********************!*\
!*** ./src/types.ts ***!
\**********************/
/*!**********************!*\
!*** ./src/utils.ts ***!
\**********************/
/*!*************************!*\
!*** ./src/buildSdp.ts ***!
\*************************/
/*!*************************!*\
!*** ./src/parseSdp.ts ***!
\*************************/
/*!****************************!*\
!*** ./src/secretsauce.ts ***!
\****************************/
/*!*****************************!*\
!*** ./src/blacksilence.ts ***!
\*****************************/

3
src/lib/secret-sauce/parseSdp.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import { JoinGroupCallPayload } from './types';
declare const _default: (sessionDescription: RTCSessionDescriptionInit) => JoinGroupCallPayload;
export default _default;

20
src/lib/secret-sauce/secretsauce.d.ts vendored Normal file
View File

@ -0,0 +1,20 @@
import { GroupCallConnectionData, GroupCallParticipant, JoinGroupCallPayload } from './types';
declare type StreamType = 'audio' | 'video' | 'presentation';
export declare function getDevices(streamType: StreamType, isInput?: boolean): Promise<MediaDeviceInfo[]>;
export declare function toggleSpeaker(): void;
export declare function toggleNoiseSuppression(): void;
export declare function getUserStreams(userId: string): {
audio?: MediaStream | undefined;
video?: MediaStream | undefined;
presentation?: MediaStream | undefined;
} | undefined;
export declare function setVolume(userId: string, volume: number): void;
export declare function isStreamEnabled(streamType: StreamType, userId?: string): boolean;
export declare function switchCameraInput(): Promise<void>;
export declare function toggleStream(streamType: StreamType, value?: boolean | undefined): Promise<void>;
export declare function leaveGroupCall(): void;
export declare function handleUpdateGroupCallParticipants(updatedParticipants: GroupCallParticipant[]): Promise<void>;
export declare function handleUpdateGroupCallConnection(data: GroupCallConnectionData, isPresentation: boolean): Promise<void>;
export declare function startSharingScreen(): Promise<JoinGroupCallPayload | undefined>;
export declare function joinGroupCall(myId: string, audioContext: AudioContext, audioElement: HTMLAudioElement, onUpdate: (...args: any[]) => void): Promise<JoinGroupCallPayload>;
export {};

100
src/lib/secret-sauce/types.d.ts vendored Normal file
View File

@ -0,0 +1,100 @@
export interface GroupCallParticipant {
isSelf?: boolean;
isMuted?: boolean;
isLeft?: boolean;
isUser?: boolean;
canSelfUnmute?: boolean;
hasJustJoined?: boolean;
isVideoJoined?: boolean;
isMutedByMe?: boolean;
isVolumeByAdmin?: boolean;
isMin?: boolean;
isVersioned?: boolean;
source: number;
date: Date;
id: string;
volume?: number;
about?: string;
video?: GroupCallParticipantVideo;
presentation?: GroupCallParticipantVideo;
raiseHandRating?: string;
hasAudioStream?: boolean;
hasVideoStream?: boolean;
hasPresentationStream?: boolean;
amplitude?: number;
}
export declare type GroupCallConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting' | 'discarded';
export interface GroupCallParticipantVideo {
endpoint: string;
isPaused?: boolean;
sourceGroups: SsrcGroup[];
audioSource?: number;
}
export declare type Fingerprint = {
hash: string;
setup: string;
fingerprint: string;
};
export declare type SsrcGroup = {
semantics: string;
sources: number[];
};
export declare type Candidate = {
generation: string;
component: string;
protocol: string;
port: string;
ip: string;
foundation: string;
id: string;
priority: string;
type: string;
network: string;
'rel-addr': string;
'rel-port': string;
};
export declare type JoinGroupCallPayload = {
ufrag: string;
pwd: string;
fingerprints: Fingerprint[];
ssrc?: number;
'ssrc-groups'?: SsrcGroup[];
};
export interface RTPExtension {
id: number;
uri: string;
}
export interface RTCPFeedbackParam {
type: string;
subtype?: string;
}
export interface PayloadType {
id: number;
name: string;
clockrate: number;
channels: number;
parameters?: Record<string, string | number>;
'rtcp-fbs'?: RTCPFeedbackParam[];
}
export interface GroupCallTransport {
candidates: Candidate[];
pwd: string;
ufrag: string;
fingerprints: Fingerprint[];
'rtcp-mux': boolean;
xmlns: string;
}
export interface GroupCallConnectionData {
transport: GroupCallTransport;
audio: {
'payload-types': PayloadType[];
'rtp-hdrexts': RTPExtension[];
};
video: {
endpoint: string;
'payload-types': PayloadType[];
'rtp-hdrexts': RTPExtension[];
server_sources: number[];
};
stream?: boolean;
}

11
src/lib/secret-sauce/utils.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
export declare function toTelegramSource(source: number): number;
export declare function fromTelegramSource(source: number): number;
export declare function getAmplitude(array: Uint8Array, scale?: number): number;
export declare function getPlatform(): "Windows" | "macOS" | "iOS" | "Android" | "Linux" | undefined;
export declare const THRESHOLD = 0.1;
export declare const PLATFORM_ENV: "Windows" | "macOS" | "iOS" | "Android" | "Linux" | undefined;
export declare const IS_MAC_OS: boolean;
export declare const IS_IOS: boolean;
export declare const IS_SCREENSHARE_SUPPORTED: boolean;
export declare const IS_ECHO_CANCELLATION_SUPPORTED: boolean | undefined;
export declare const IS_NOISE_SUPPRESSION_SUPPORTED: any;

View File

@ -8,6 +8,7 @@ import './ui/users';
import './ui/settings';
import './ui/misc';
import './ui/payments';
import './ui/calls';
import './api/initial';
import './api/chats';
@ -31,3 +32,4 @@ import './apiUpdaters/symbols';
import './apiUpdaters/misc';
import './apiUpdaters/settings';
import './apiUpdaters/twoFaSettings';
import './apiUpdaters/calls';

View File

@ -0,0 +1,283 @@
import {
joinGroupCall,
startSharingScreen,
leaveGroupCall,
toggleStream,
isStreamEnabled,
setVolume,
handleUpdateGroupCallParticipants, handleUpdateGroupCallConnection,
} from '../../../lib/secret-sauce';
import { addReducer, getGlobal, setGlobal } from '../../../lib/teact/teactn';
import { callApi } from '../../../api/gramjs';
import { selectChat, selectUser } from '../../selectors';
import {
selectActiveGroupCall,
selectGroupCallParticipant,
} from '../../selectors/calls';
import {
removeGroupCall,
updateActiveGroupCall,
updateGroupCall,
updateGroupCallParticipant,
} from '../../reducers/calls';
import { ApiUpdate } from '../../../api/types';
import { GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';
import { omit } from '../../../util/iteratees';
import { getGroupCallAudioContext, getGroupCallAudioElement, removeGroupCallAudioElement } from '../ui/calls';
import { loadFullChat } from './chats';
addReducer('apiUpdate', (global, actions, update: ApiUpdate) => {
const { activeGroupCallId } = global.groupCalls;
switch (update['@type']) {
case 'updateGroupCallLeavePresentation': {
actions.toggleGroupCallPresentation({ value: false });
break;
}
case 'updateGroupCallStreams': {
if (!update.userId || !activeGroupCallId) break;
if (!selectGroupCallParticipant(global, activeGroupCallId, update.userId)) break;
return updateGroupCallParticipant(global, activeGroupCallId, update.userId, omit(update, ['@type', 'userId']));
}
case 'updateGroupCallConnectionState': {
if (!activeGroupCallId) break;
if (update.connectionState === 'disconnected') {
actions.leaveGroupCall({ isFromLibrary: true });
break;
}
return updateGroupCall(global, activeGroupCallId, {
connectionState: update.connectionState,
isSpeakerDisabled: update.isSpeakerDisabled,
});
}
case 'updateGroupCallParticipants': {
const { groupCallId, participants } = update;
if (activeGroupCallId === groupCallId) {
void handleUpdateGroupCallParticipants(participants);
}
break;
}
case 'updateGroupCallConnection': {
if (update.data.stream) {
actions.showNotification({ message: 'Big live streams are not yet supported' });
actions.leaveGroupCall();
break;
}
void handleUpdateGroupCallConnection(update.data, update.presentation);
const groupCall = selectActiveGroupCall(global);
if (groupCall?.participants && Object.keys(groupCall.participants).length > 0) {
void handleUpdateGroupCallParticipants(Object.values(groupCall.participants));
}
break;
}
}
return undefined;
});
addReducer('leaveGroupCall', (global, actions, payload) => {
const {
isFromLibrary, shouldDiscard, shouldRemove, rejoin,
} = payload || {};
const groupCall = selectActiveGroupCall(global);
if (!groupCall) {
return;
}
global = updateActiveGroupCall(global, {
connectionState: 'disconnected',
}, groupCall.participantsCount - 1);
(async () => {
await callApi('leaveGroupCall', {
call: groupCall,
});
if (shouldDiscard) {
await callApi('discardGroupCall', {
call: groupCall,
});
}
global = getGlobal();
if (shouldRemove) {
global = removeGroupCall(global, groupCall.id);
}
removeGroupCallAudioElement();
setGlobal({
...global,
groupCalls: {
...global.groupCalls,
isGroupCallPanelHidden: true,
activeGroupCallId: undefined,
},
});
if (!isFromLibrary) {
leaveGroupCall();
}
if (rejoin) {
actions.joinGroupCall(rejoin);
}
})();
});
addReducer('toggleGroupCallVideo', (global) => {
const groupCall = selectActiveGroupCall(global);
const user = selectUser(global, global.currentUserId!);
if (!user || !groupCall) {
return;
}
(async () => {
await toggleStream('video');
await callApi('editGroupCallParticipant', {
call: groupCall,
videoStopped: !isStreamEnabled('video'),
participant: user,
});
})();
});
addReducer('requestToSpeak', (global, actions, payload) => {
const { value } = payload || { value: true };
const groupCall = selectActiveGroupCall(global);
const user = selectUser(global, global.currentUserId!);
if (!user || !groupCall) {
return;
}
void callApi('editGroupCallParticipant', {
call: groupCall,
raiseHand: value,
participant: user,
});
});
addReducer('setGroupCallParticipantVolume', (global, actions, payload) => {
const { participantId, volume } = payload!;
const groupCall = selectActiveGroupCall(global);
const user = selectUser(global, participantId);
if (!user || !groupCall) {
return;
}
setVolume(participantId, Math.floor(volume / GROUP_CALL_VOLUME_MULTIPLIER) / 100);
void callApi('editGroupCallParticipant', {
call: groupCall,
volume: Number(volume),
participant: user,
});
});
addReducer('toggleGroupCallMute', (global, actions, payload) => {
const { participantId, value } = payload || {};
const groupCall = selectActiveGroupCall(global);
const user = selectUser(global, participantId || global.currentUserId!);
if (!user || !groupCall) {
return;
}
(async () => {
const muted = value === undefined ? isStreamEnabled('audio', user.id) : value;
if (!participantId) {
await toggleStream('audio');
} else {
setVolume(participantId, muted ? 0 : 1);
}
await callApi('editGroupCallParticipant', {
call: groupCall,
muted,
participant: user,
});
})();
});
addReducer('toggleGroupCallPresentation', (global, actions, payload) => {
const groupCall = selectActiveGroupCall(global);
const user = selectUser(global, global.currentUserId!);
if (!user || !groupCall) {
return;
}
(async () => {
const value = payload?.value !== undefined ? payload?.value : !isStreamEnabled('presentation');
if (value) {
const params = await startSharingScreen();
if (!params) {
return;
}
await callApi('joinGroupCallPresentation', {
call: groupCall,
params,
});
} else {
await toggleStream('presentation', false);
await callApi('leaveGroupCallPresentation', {
call: groupCall,
});
}
await callApi('editGroupCallParticipant', {
call: groupCall,
presentationPaused: !isStreamEnabled('presentation'),
participant: user,
});
})();
});
addReducer('connectToActiveGroupCall', (global, actions) => {
const groupCall = selectActiveGroupCall(global);
if (!groupCall) return;
if (groupCall.connectionState === 'discarded') {
actions.showNotification({ message: 'This voice chat is not active' });
return;
}
const audioElement = getGroupCallAudioElement();
const audioContext = getGroupCallAudioContext();
if (!audioElement || !audioContext) {
return;
}
const {
currentUserId,
} = global;
if (!currentUserId) return;
(async () => {
const params = await joinGroupCall(currentUserId, audioContext, audioElement, actions.apiUpdate);
const result = await callApi('joinGroupCall', {
call: groupCall,
params,
inviteHash: groupCall.inviteHash,
});
if (!result) return;
actions.loadMoreGroupCallParticipants();
if (groupCall.chatId) {
const chat = selectChat(getGlobal(), groupCall.chatId);
if (!chat) return;
await loadFullChat(chat);
}
})();
});

View File

@ -42,12 +42,14 @@ import {
selectCurrentMessageList,
selectThreadInfo, selectCurrentChat, selectLastServiceNotification,
} from '../../selectors';
import { buildCollectionByKey } from '../../../util/iteratees';
import { buildCollectionByKey, omit } from '../../../util/iteratees';
import { debounce, pause, throttle } from '../../../util/schedulers';
import {
isChatSummaryOnly, isChatArchived, prepareChatList, isChatBasicGroup,
} from '../../helpers';
import { processDeepLink } from '../../../util/deeplink';
import { updateGroupCall } from '../../reducers/calls';
import { selectGroupCall } from '../../selectors/calls';
const TOP_CHAT_MESSAGES_PRELOAD_INTERVAL = 100;
const CHATS_PRELOAD_INTERVAL = 300;
@ -567,7 +569,13 @@ addReducer('openTelegramLink', (global, actions, payload) => {
const chatOrChannelPostId = part2 ? Number(part2) : undefined;
const messageId = part3 ? Number(part3) : undefined;
const commentId = params.comment ? Number(params.comment) : undefined;
if (part1 === 'c' && chatOrChannelPostId && messageId) {
if (params.hasOwnProperty('voicechat') || params.hasOwnProperty('livestream')) {
actions.joinVoiceChatByLink({
username: part1,
inviteHash: params.voicechat || params.livestream,
});
} else if (part1 === 'c' && chatOrChannelPostId && messageId) {
actions.focusMessage({
chatId: -chatOrChannelPostId,
messageId,
@ -869,7 +877,7 @@ addReducer('linkDiscussionGroup', (global, actions, payload) => {
fullInfo = fullChat.fullInfo;
}
if (fullInfo.isPreHistoryHidden) {
if (fullInfo!.isPreHistoryHidden) {
await callApi('togglePreHistoryHidden', { chat, isEnabled: false });
}
@ -1031,21 +1039,35 @@ async function loadChats(listType: 'active' | 'archived', offsetId?: string, off
setGlobal(global);
}
async function loadFullChat(chat: ApiChat) {
export async function loadFullChat(chat: ApiChat) {
const result = await callApi('fetchFullChat', chat);
if (!result) {
return;
return undefined;
}
const { users, fullInfo } = result;
const { users, fullInfo, groupCall } = result;
let global = getGlobal();
if (users) {
global = addUsers(global, buildCollectionByKey(users, 'id'));
}
if (groupCall) {
const existingGroupCall = selectGroupCall(global, groupCall.id!);
global = updateGroupCall(
global,
groupCall.id!,
omit(groupCall, ['connectionState']),
undefined,
existingGroupCall ? undefined : groupCall.participantsCount,
);
}
global = updateChat(global, chat.id, { fullInfo });
setGlobal(global);
return result;
}
async function createChannel(title: string, users: ApiUser[], about?: string, photo?: File) {
@ -1203,7 +1225,7 @@ async function deleteChatFolder(id: number) {
await callApi('deleteChatFolder', id);
}
async function fetchChatByUsername(
export async function fetchChatByUsername(
username: string,
) {
const global = getGlobal();

Some files were not shown because too many files have changed in this diff Show More