Introduce Group Calls (#1520)
This commit is contained in:
parent
40930e07dc
commit
41f2c3e26b
@ -74,6 +74,7 @@
|
|||||||
},
|
},
|
||||||
"ignorePatterns": [
|
"ignorePatterns": [
|
||||||
"webpack.config.js",
|
"webpack.config.js",
|
||||||
"jest.config.js"
|
"jest.config.js",
|
||||||
|
"src/lib/secret-sauce"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
14
package-lock.json
generated
14
package-lock.json
generated
@ -8633,11 +8633,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ext": {
|
"ext": {
|
||||||
"version": "1.4.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ext/-/ext-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/ext/-/ext-1.5.0.tgz",
|
||||||
"integrity": "sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A==",
|
"integrity": "sha512-+ONcYoWj/SoQwUofMr94aGu05Ou4FepKi7N7b+O8T4jVfyIsZQV1/xeS8jpaBzF0csAk0KLXoHCxU7cKYZjo1Q==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"type": "^2.0.0"
|
"type": "^2.5.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"type": {
|
"type": {
|
||||||
@ -15127,9 +15127,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node-gyp-build": {
|
"node-gyp-build": {
|
||||||
"version": "4.2.3",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.3.0.tgz",
|
||||||
"integrity": "sha512-MN6ZpzmfNCRM+3t57PTJHgHyw/h4OWnZ6mR8P5j/uZtqQr46RRuDE/P+g3n0YR/AiYXeWixZZzaip77gdICfRg=="
|
"integrity": "sha512-iWjXZvmboq0ja1pUGULQBexmxq8CV4xBhX7VDOTbL7ZR4FOowwY/VOtRxBN/yKxmdGoIp4j5ysNT4u3S2pDQ3Q=="
|
||||||
},
|
},
|
||||||
"node-int64": {
|
"node-int64": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
|
|||||||
BIN
public/voicechat_connecting.mp3
Normal file
BIN
public/voicechat_connecting.mp3
Normal file
Binary file not shown.
BIN
public/voicechat_join.mp3
Normal file
BIN
public/voicechat_join.mp3
Normal file
Binary file not shown.
BIN
public/voicechat_leave.mp3
Normal file
BIN
public/voicechat_leave.mp3
Normal file
Binary file not shown.
BIN
public/voicechat_onallowtalk.mp3
Normal file
BIN
public/voicechat_onallowtalk.mp3
Normal file
Binary file not shown.
BIN
public/voicechat_recordstart.mp3
Normal file
BIN
public/voicechat_recordstart.mp3
Normal file
Binary file not shown.
4
src/@types/global.d.ts
vendored
4
src/@types/global.d.ts
vendored
@ -14,6 +14,10 @@ declare namespace React {
|
|||||||
interface ImgHTMLAttributes<T> extends HTMLAttributes<T> {
|
interface ImgHTMLAttributes<T> extends HTMLAttributes<T> {
|
||||||
loading?: 'auto' | 'eager' | 'lazy';
|
loading?: 'auto' | 'eager' | 'lazy';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface VideoHTMLAttributes {
|
||||||
|
srcObject?: MediaStream;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnyLiteral = Record<string, any>;
|
type AnyLiteral = Record<string, any>;
|
||||||
|
|||||||
98
src/api/gramjs/apiBuilders/calls.ts
Normal file
98
src/api/gramjs/apiBuilders/calls.ts
Normal 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();
|
||||||
|
}
|
||||||
@ -21,6 +21,7 @@ import {
|
|||||||
ApiChat,
|
ApiChat,
|
||||||
ApiThreadInfo,
|
ApiThreadInfo,
|
||||||
ApiInvoice,
|
ApiInvoice,
|
||||||
|
ApiGroupCall,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -593,6 +594,7 @@ function buildAction(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let call: Partial<ApiGroupCall> | undefined;
|
||||||
let amount: number | undefined;
|
let amount: number | undefined;
|
||||||
let currency: string | undefined;
|
let currency: string | undefined;
|
||||||
let text: string;
|
let text: string;
|
||||||
@ -677,6 +679,13 @@ function buildAction(
|
|||||||
const mins = Math.max(Math.round(action.duration! / 60), 1);
|
const mins = Math.max(Math.round(action.duration! / 60), 1);
|
||||||
translationValues.push(`${mins} min${mins > 1 ? 's' : ''}`);
|
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) {
|
} else if (action instanceof GramJs.MessageActionContactSignUp) {
|
||||||
text = 'Notification.Joined';
|
text = 'Notification.Joined';
|
||||||
translationValues.push('%action_origin%');
|
translationValues.push('%action_origin%');
|
||||||
@ -696,6 +705,10 @@ function buildAction(
|
|||||||
translationValues.push(`${mins} min${mins > 1 ? 's' : ''}`);
|
translationValues.push(`${mins} min${mins > 1 ? 's' : ''}`);
|
||||||
} else {
|
} else {
|
||||||
text = 'Notification.VoiceChatStartedChannel';
|
text = 'Notification.VoiceChatStartedChannel';
|
||||||
|
call = {
|
||||||
|
id: action.call.id.toString(),
|
||||||
|
accessHash: action.call.accessHash.toString(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} else if (action instanceof GramJs.MessageActionBotAllowed) {
|
} else if (action instanceof GramJs.MessageActionBotAllowed) {
|
||||||
text = 'Chat.Service.BotPermissionAllowed';
|
text = 'Chat.Service.BotPermissionAllowed';
|
||||||
@ -718,6 +731,7 @@ function buildAction(
|
|||||||
amount,
|
amount,
|
||||||
currency,
|
currency,
|
||||||
translationValues,
|
translationValues,
|
||||||
|
call,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,15 +5,16 @@ import { ApiPrivacyKey } from '../../../types';
|
|||||||
|
|
||||||
import { generateRandomBytes, readBigIntFromBuffer } from '../../../lib/gramjs/Helpers';
|
import { generateRandomBytes, readBigIntFromBuffer } from '../../../lib/gramjs/Helpers';
|
||||||
import {
|
import {
|
||||||
ApiSticker,
|
ApiChatAdminRights,
|
||||||
ApiVideo,
|
ApiChatBannedRights,
|
||||||
ApiNewPoll,
|
ApiChatFolder,
|
||||||
|
ApiGroupCall,
|
||||||
ApiMessageEntity,
|
ApiMessageEntity,
|
||||||
ApiMessageEntityTypes,
|
ApiMessageEntityTypes,
|
||||||
ApiChatFolder,
|
ApiNewPoll,
|
||||||
ApiChatBannedRights,
|
|
||||||
ApiChatAdminRights,
|
|
||||||
ApiReportReason,
|
ApiReportReason,
|
||||||
|
ApiSticker,
|
||||||
|
ApiVideo,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
import localDb from '../localDb';
|
import localDb from '../localDb';
|
||||||
import { pick } from '../../../util/iteratees';
|
import { pick } from '../../../util/iteratees';
|
||||||
@ -237,6 +238,10 @@ export function generateRandomBigInt() {
|
|||||||
return readBigIntFromBuffer(generateRandomBytes(8), true, true);
|
return readBigIntFromBuffer(generateRandomBytes(8), true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function generateRandomInt() {
|
||||||
|
return readBigIntFromBuffer(generateRandomBytes(4), true, true).toJSNumber();
|
||||||
|
}
|
||||||
|
|
||||||
export function buildMessageFromUpdate(
|
export function buildMessageFromUpdate(
|
||||||
id: number,
|
id: number,
|
||||||
chatId: string,
|
chatId: string,
|
||||||
@ -424,3 +429,10 @@ export function buildMtpPeerId(id: string, type: 'user' | 'chat' | 'channel') {
|
|||||||
|
|
||||||
return type === 'user' ? BigInt(id) : BigInt(id.slice(1));
|
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!),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -47,3 +47,7 @@ export function addChatToLocalDb(chat: GramJs.TypeChat) {
|
|||||||
localDb.chats[buildApiPeerId(chat.id, chat instanceof GramJs.Chat ? 'chat' : 'channel')] = chat;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { buildInputPeer, generateRandomBigInt } from '../gramjsBuilders';
|
|||||||
import { buildApiUser } from '../apiBuilders/users';
|
import { buildApiUser } from '../apiBuilders/users';
|
||||||
import { buildApiBotInlineMediaResult, buildApiBotInlineResult, buildBotSwitchPm } from '../apiBuilders/bots';
|
import { buildApiBotInlineMediaResult, buildApiBotInlineResult, buildBotSwitchPm } from '../apiBuilders/bots';
|
||||||
import { buildApiChatFromPreview } from '../apiBuilders/chats';
|
import { buildApiChatFromPreview } from '../apiBuilders/chats';
|
||||||
import { buildApiPeerId } from '../apiBuilders/peers';
|
import { addUserToLocalDb } from '../helpers';
|
||||||
|
|
||||||
export function init() {
|
export function init() {
|
||||||
}
|
}
|
||||||
@ -158,10 +158,6 @@ function getInlineBotResultsNextOffset(username: string, nextOffset?: string) {
|
|||||||
return username === 'gif' && nextOffset === '0' ? '' : nextOffset;
|
return username === 'gif' && nextOffset === '0' ? '' : nextOffset;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addUserToLocalDb(user: GramJs.User) {
|
|
||||||
localDb.users[buildApiPeerId(user.id, 'user')] = user;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addDocumentToLocalDb(document: GramJs.Document) {
|
function addDocumentToLocalDb(document: GramJs.Document) {
|
||||||
localDb.documents[String(document.id)] = document;
|
localDb.documents[String(document.id)] = document;
|
||||||
}
|
}
|
||||||
|
|||||||
236
src/api/gramjs/methods/calls.ts
Normal file
236
src/api/gramjs/methods/calls.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import {
|
|||||||
ApiChatFolder,
|
ApiChatFolder,
|
||||||
ApiChatBannedRights,
|
ApiChatBannedRights,
|
||||||
ApiChatAdminRights,
|
ApiChatAdminRights,
|
||||||
|
ApiGroupCall,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -323,6 +324,7 @@ export function clearDraft(chat: ApiChat) {
|
|||||||
async function getFullChatInfo(chatId: string): Promise<{
|
async function getFullChatInfo(chatId: string): Promise<{
|
||||||
fullInfo: ApiChatFullInfo;
|
fullInfo: ApiChatFullInfo;
|
||||||
users?: ApiUser[];
|
users?: ApiUser[];
|
||||||
|
groupCall?: Partial<ApiGroupCall>;
|
||||||
} | undefined> {
|
} | undefined> {
|
||||||
const result = await invokeRequest(new GramJs.messages.GetFullChat({
|
const result = await invokeRequest(new GramJs.messages.GetFullChat({
|
||||||
chatId: buildInputEntity(chatId) as BigInt.BigInteger,
|
chatId: buildInputEntity(chatId) as BigInt.BigInteger,
|
||||||
@ -339,6 +341,7 @@ async function getFullChatInfo(chatId: string): Promise<{
|
|||||||
participants,
|
participants,
|
||||||
exportedInvite,
|
exportedInvite,
|
||||||
botInfo,
|
botInfo,
|
||||||
|
call,
|
||||||
} = result.fullChat;
|
} = result.fullChat;
|
||||||
|
|
||||||
const members = buildChatMembers(participants);
|
const members = buildChatMembers(participants);
|
||||||
@ -355,8 +358,19 @@ async function getFullChatInfo(chatId: string): Promise<{
|
|||||||
...(exportedInvite && {
|
...(exportedInvite && {
|
||||||
inviteLink: exportedInvite.link,
|
inviteLink: exportedInvite.link,
|
||||||
}),
|
}),
|
||||||
|
groupCallId: call?.id.toString(),
|
||||||
},
|
},
|
||||||
users: result.users.map(buildApiUser).filter<ApiUser>(Boolean as any),
|
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,
|
id: string,
|
||||||
accessHash: string,
|
accessHash: string,
|
||||||
adminRights?: ApiChatAdminRights,
|
adminRights?: ApiChatAdminRights,
|
||||||
) {
|
): Promise<{
|
||||||
|
fullInfo: ApiChatFullInfo;
|
||||||
|
users?: ApiUser[];
|
||||||
|
groupCall?: Partial<ApiGroupCall>;
|
||||||
|
} | undefined> {
|
||||||
const result = await invokeRequest(new GramJs.channels.GetFullChannel({
|
const result = await invokeRequest(new GramJs.channels.GetFullChannel({
|
||||||
channel: buildInputEntity(id, accessHash) as GramJs.InputChannel,
|
channel: buildInputEntity(id, accessHash) as GramJs.InputChannel,
|
||||||
}));
|
}));
|
||||||
@ -438,6 +456,16 @@ async function getFullChannelInfo(
|
|||||||
botCommands,
|
botCommands,
|
||||||
},
|
},
|
||||||
users: [...(users || []), ...(bannedUsers || []), ...(adminUsers || [])],
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -60,3 +60,9 @@ export {
|
|||||||
export {
|
export {
|
||||||
validateRequestedInfo, sendPaymentForm, getPaymentForm, getReceipt,
|
validateRequestedInfo, sendPaymentForm, getPaymentForm, getReceipt,
|
||||||
} from './payments';
|
} from './payments';
|
||||||
|
|
||||||
|
export {
|
||||||
|
getGroupCall, joinGroupCall, discardGroupCall, createGroupCall,
|
||||||
|
editGroupCallTitle, editGroupCallParticipant, exportGroupCallInvite, fetchGroupCallParticipants,
|
||||||
|
joinGroupCallPresentation, leaveGroupCall, leaveGroupCallPresentation, toggleGroupCallStartSubscription,
|
||||||
|
} from './calls';
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import { init as initClient } from './methods/client';
|
|||||||
import { init as initStickers } from './methods/symbols';
|
import { init as initStickers } from './methods/symbols';
|
||||||
import { init as initManagement } from './methods/management';
|
import { init as initManagement } from './methods/management';
|
||||||
import { init as initTwoFaSettings } from './methods/twoFaSettings';
|
import { init as initTwoFaSettings } from './methods/twoFaSettings';
|
||||||
|
import { init as initCalls } from './methods/calls';
|
||||||
import * as methods from './methods';
|
import * as methods from './methods';
|
||||||
|
|
||||||
let onUpdate: OnApiUpdate;
|
let onUpdate: OnApiUpdate;
|
||||||
@ -32,6 +33,7 @@ export async function initApi(_onUpdate: OnApiUpdate, initialArgs: ApiInitialArg
|
|||||||
initStickers(handleUpdate);
|
initStickers(handleUpdate);
|
||||||
initManagement(handleUpdate);
|
initManagement(handleUpdate);
|
||||||
initTwoFaSettings(handleUpdate);
|
initTwoFaSettings(handleUpdate);
|
||||||
|
initCalls(handleUpdate);
|
||||||
|
|
||||||
await initClient(handleUpdate, initialArgs);
|
await initClient(handleUpdate, initialArgs);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { GroupCallConnectionData } from '../../lib/secret-sauce';
|
||||||
import { Api as GramJs, connection } from '../../lib/gramjs';
|
import { Api as GramJs, connection } from '../../lib/gramjs';
|
||||||
import { ApiMessage, ApiUpdateConnectionStateType, OnApiUpdate } from '../types';
|
import { ApiMessage, ApiUpdateConnectionStateType, OnApiUpdate } from '../types';
|
||||||
|
|
||||||
@ -33,6 +34,11 @@ import { DEBUG } from '../../config';
|
|||||||
import { addMessageToLocalDb, addPhotoToLocalDb, resolveMessageApiChatId } from './helpers';
|
import { addMessageToLocalDb, addPhotoToLocalDb, resolveMessageApiChatId } from './helpers';
|
||||||
import { buildApiNotifyException, buildPrivacyKey, buildPrivacyRules } from './apiBuilders/misc';
|
import { buildApiNotifyException, buildPrivacyKey, buildPrivacyRules } from './apiBuilders/misc';
|
||||||
import { buildApiPhoto } from './apiBuilders/common';
|
import { buildApiPhoto } from './apiBuilders/common';
|
||||||
|
import {
|
||||||
|
buildApiGroupCall,
|
||||||
|
buildApiGroupCallParticipant,
|
||||||
|
getGroupCallId,
|
||||||
|
} from './apiBuilders/calls';
|
||||||
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './apiBuilders/peers';
|
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './apiBuilders/peers';
|
||||||
|
|
||||||
type Update = (
|
type Update = (
|
||||||
@ -50,6 +56,39 @@ export function init(_onUpdate: OnApiUpdate) {
|
|||||||
const sentMessageIds = new Set();
|
const sentMessageIds = new Set();
|
||||||
let serverTimeOffset = 0;
|
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) {
|
export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
|
||||||
if (update instanceof connection.UpdateServerTimeOffset) {
|
if (update instanceof connection.UpdateServerTimeOffset) {
|
||||||
serverTimeOffset = update.timeOffset;
|
serverTimeOffset = update.timeOffset;
|
||||||
@ -111,37 +150,7 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-underscore-dangle
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
const entities = update._entities;
|
addEntities(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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (update instanceof GramJs.UpdateNewScheduledMessage) {
|
if (update instanceof GramJs.UpdateNewScheduledMessage) {
|
||||||
onUpdate({
|
onUpdate({
|
||||||
@ -232,6 +241,17 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
|
|||||||
id: message.chatId,
|
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 (
|
} else if (
|
||||||
@ -785,6 +805,26 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
|
|||||||
onUpdate({ '@type': 'updateResetContactList' });
|
onUpdate({ '@type': 'updateResetContactList' });
|
||||||
} else if (update instanceof GramJs.UpdateFavedStickers) {
|
} else if (update instanceof GramJs.UpdateFavedStickers) {
|
||||||
onUpdate({ '@type': 'updateFavoriteStickers' });
|
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) {
|
} else if (DEBUG) {
|
||||||
const params = typeof update === 'object' && 'className' in update ? update.className : update;
|
const params = typeof update === 'object' && 'className' in update ? update.className : update;
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
|
|||||||
26
src/api/types/calls.ts
Normal file
26
src/api/types/calls.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -108,6 +108,7 @@ export interface ApiChatAdminRights {
|
|||||||
pinMessages?: true;
|
pinMessages?: true;
|
||||||
addAdmins?: true;
|
addAdmins?: true;
|
||||||
anonymous?: true;
|
anonymous?: true;
|
||||||
|
manageCall?: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiChatBannedRights {
|
export interface ApiChatBannedRights {
|
||||||
|
|||||||
@ -7,3 +7,4 @@ export * from './payments';
|
|||||||
export * from './settings';
|
export * from './settings';
|
||||||
export * from './bots';
|
export * from './bots';
|
||||||
export * from './misc';
|
export * from './misc';
|
||||||
|
export * from './calls';
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { ApiGroupCall } from './calls';
|
||||||
|
|
||||||
export interface ApiDimensions {
|
export interface ApiDimensions {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
@ -155,6 +157,7 @@ export interface ApiAction {
|
|||||||
amount?: number;
|
amount?: number;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
translationValues: string[];
|
translationValues: string[];
|
||||||
|
call?: Partial<ApiGroupCall>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiWebPage {
|
export interface ApiWebPage {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { GroupCallConnectionData, GroupCallParticipant, GroupCallConnectionState } from '../../lib/secret-sauce';
|
||||||
import {
|
import {
|
||||||
ApiChat,
|
ApiChat,
|
||||||
ApiChatFullInfo,
|
ApiChatFullInfo,
|
||||||
@ -12,6 +13,9 @@ import { ApiUser, ApiUserFullInfo, ApiUserStatus } from './users';
|
|||||||
import {
|
import {
|
||||||
ApiError, ApiInviteInfo, ApiNotifyException, ApiSessionData,
|
ApiError, ApiInviteInfo, ApiNotifyException, ApiSessionData,
|
||||||
} from './misc';
|
} from './misc';
|
||||||
|
import {
|
||||||
|
ApiGroupCall,
|
||||||
|
} from './calls';
|
||||||
|
|
||||||
export type ApiUpdateReady = {
|
export type ApiUpdateReady = {
|
||||||
'@type': 'updateApiReady';
|
'@type': 'updateApiReady';
|
||||||
@ -378,6 +382,48 @@ export type ApiUpdateServerTimeOffset = {
|
|||||||
serverTimeOffset: number;
|
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 = (
|
export type ApiUpdate = (
|
||||||
ApiUpdateReady | ApiUpdateSession |
|
ApiUpdateReady | ApiUpdateSession |
|
||||||
ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser |
|
ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser |
|
||||||
@ -395,7 +441,9 @@ export type ApiUpdate = (
|
|||||||
ApiUpdateDeleteScheduledMessages | ApiUpdateResetMessages |
|
ApiUpdateDeleteScheduledMessages | ApiUpdateResetMessages |
|
||||||
ApiUpdateTwoFaError | ApiUpdateTwoFaStateWaitCode |
|
ApiUpdateTwoFaError | ApiUpdateTwoFaStateWaitCode |
|
||||||
ApiUpdateNotifySettings | ApiUpdateNotifyExceptions | ApiUpdatePeerBlocked | ApiUpdatePrivacy |
|
ApiUpdateNotifySettings | ApiUpdateNotifyExceptions | ApiUpdatePeerBlocked | ApiUpdatePrivacy |
|
||||||
ApiUpdateServerTimeOffset | ApiUpdateShowInvite
|
ApiUpdateServerTimeOffset | ApiUpdateShowInvite |
|
||||||
|
ApiUpdateGroupCallParticipants | ApiUpdateGroupCallConnection | ApiUpdateGroupCall | ApiUpdateGroupCallStreams |
|
||||||
|
ApiUpdateGroupCallConnectionState | ApiUpdateGroupCallLeavePresentation | ApiUpdateGroupCallChatId
|
||||||
);
|
);
|
||||||
|
|
||||||
export type OnApiUpdate = (update: ApiUpdate) => void;
|
export type OnApiUpdate = (update: ApiUpdate) => void;
|
||||||
|
|||||||
BIN
src/assets/animatedIcons/CallSchedule.tgs
Normal file
BIN
src/assets/animatedIcons/CallSchedule.tgs
Normal file
Binary file not shown.
BIN
src/assets/animatedIcons/CameraFlip.tgs
Normal file
BIN
src/assets/animatedIcons/CameraFlip.tgs
Normal file
Binary file not shown.
BIN
src/assets/animatedIcons/HandFilled.tgs
Normal file
BIN
src/assets/animatedIcons/HandFilled.tgs
Normal file
Binary file not shown.
BIN
src/assets/animatedIcons/HandOutline.tgs
Normal file
BIN
src/assets/animatedIcons/HandOutline.tgs
Normal file
Binary file not shown.
BIN
src/assets/animatedIcons/Speaker.tgs
Normal file
BIN
src/assets/animatedIcons/Speaker.tgs
Normal file
Binary file not shown.
BIN
src/assets/animatedIcons/VoiceAllowTalk.tgs
Normal file
BIN
src/assets/animatedIcons/VoiceAllowTalk.tgs
Normal file
Binary file not shown.
BIN
src/assets/animatedIcons/VoiceMini.tgs
Normal file
BIN
src/assets/animatedIcons/VoiceMini.tgs
Normal file
Binary file not shown.
BIN
src/assets/animatedIcons/VoiceMuted.tgs
Normal file
BIN
src/assets/animatedIcons/VoiceMuted.tgs
Normal file
Binary file not shown.
BIN
src/assets/animatedIcons/VoiceOutlined.tgs
Normal file
BIN
src/assets/animatedIcons/VoiceOutlined.tgs
Normal file
Binary file not shown.
BIN
src/assets/animatedIcons/VoipGroupRemoved.tgs
Normal file
BIN
src/assets/animatedIcons/VoipGroupRemoved.tgs
Normal file
Binary file not shown.
BIN
src/assets/animatedIcons/VoipInvite.tgs
Normal file
BIN
src/assets/animatedIcons/VoipInvite.tgs
Normal file
Binary file not shown.
BIN
src/assets/animatedIcons/VoipMuted.tgs
Normal file
BIN
src/assets/animatedIcons/VoipMuted.tgs
Normal file
Binary file not shown.
BIN
src/assets/animatedIcons/VoipRecordSave.tgs
Normal file
BIN
src/assets/animatedIcons/VoipRecordSave.tgs
Normal file
Binary file not shown.
BIN
src/assets/animatedIcons/VoipRecordStart.tgs
Normal file
BIN
src/assets/animatedIcons/VoipRecordStart.tgs
Normal file
Binary file not shown.
BIN
src/assets/animatedIcons/VoipUnmuted.tgs
Normal file
BIN
src/assets/animatedIcons/VoipUnmuted.tgs
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
2
src/bundles/calls.ts
Normal file
2
src/bundles/calls.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { default as GroupCall } from '../components/calls/group/GroupCall';
|
||||||
|
export { default as ActiveCallHeader } from '../components/calls/ActiveCallHeader';
|
||||||
16
src/components/calls/ActiveCallHeader.async.tsx
Normal file
16
src/components/calls/ActiveCallHeader.async.tsx
Normal 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);
|
||||||
22
src/components/calls/ActiveCallHeader.scss
Normal file
22
src/components/calls/ActiveCallHeader.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/components/calls/ActiveCallHeader.tsx
Normal file
63
src/components/calls/ActiveCallHeader.tsx
Normal 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));
|
||||||
14
src/components/calls/group/GroupCall.async.tsx
Normal file
14
src/components/calls/group/GroupCall.async.tsx
Normal 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);
|
||||||
348
src/components/calls/group/GroupCall.scss
Normal file
348
src/components/calls/group/GroupCall.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
412
src/components/calls/group/GroupCall.tsx
Normal file
412
src/components/calls/group/GroupCall.tsx
Normal 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));
|
||||||
78
src/components/calls/group/GroupCallParticipant.scss
Normal file
78
src/components/calls/group/GroupCallParticipant.scss
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
.GroupCallParticipant {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
transition: .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;
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/components/calls/group/GroupCallParticipant.tsx
Normal file
101
src/components/calls/group/GroupCallParticipant.tsx
Normal 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));
|
||||||
95
src/components/calls/group/GroupCallParticipantList.tsx
Normal file
95
src/components/calls/group/GroupCallParticipantList.tsx
Normal 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));
|
||||||
98
src/components/calls/group/GroupCallParticipantMenu.scss
Normal file
98
src/components/calls/group/GroupCallParticipantMenu.scss
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
239
src/components/calls/group/GroupCallParticipantMenu.tsx
Normal file
239
src/components/calls/group/GroupCallParticipantMenu.tsx
Normal 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));
|
||||||
105
src/components/calls/group/GroupCallParticipantStreams.tsx
Normal file
105
src/components/calls/group/GroupCallParticipantStreams.tsx
Normal 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));
|
||||||
125
src/components/calls/group/GroupCallParticipantVideo.scss
Normal file
125
src/components/calls/group/GroupCallParticipantVideo.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/components/calls/group/GroupCallParticipantVideo.tsx
Normal file
86
src/components/calls/group/GroupCallParticipantVideo.tsx
Normal 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));
|
||||||
89
src/components/calls/group/GroupCallTopPane.scss
Normal file
89
src/components/calls/group/GroupCallTopPane.scss
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/components/calls/group/GroupCallTopPane.tsx
Normal file
139
src/components/calls/group/GroupCallTopPane.tsx
Normal 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));
|
||||||
58
src/components/calls/group/MicrophoneButton.scss
Normal file
58
src/components/calls/group/MicrophoneButton.scss
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
.MicrophoneButton {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
outline: none !important;
|
||||||
|
position: relative;
|
||||||
|
width: 6rem;
|
||||||
|
height: 6rem;
|
||||||
|
border: 0;
|
||||||
|
background: radial-gradient(100% 100% at 100% 0%, #00a0b9 0%, #33c659 55%, #33c659 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #fff;
|
||||||
|
transition: 0.25s ease-out filter;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
width: 8rem;
|
||||||
|
height: 8rem;
|
||||||
|
background: #64C166;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(10px);
|
||||||
|
opacity: 0.2;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
body.is-ios & {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
filter: brightness(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.crossed {
|
||||||
|
background: radial-gradient(100% 100% at 100% 0%, #00AFFE 0%, #00AFFE 55%, #007FFF 100%);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
background: #00AFFE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.muted-by-admin {
|
||||||
|
background: radial-gradient(85.5% 103.5% at 87.5% 20.65%, #CE4D74 0%, #3D52DF 100%);
|
||||||
|
&::before {
|
||||||
|
background: #3D52DF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-connecting, &.is-connecting:hover {
|
||||||
|
background: #222B34;
|
||||||
|
&::before {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
187
src/components/calls/group/MicrophoneButton.tsx
Normal file
187
src/components/calls/group/MicrophoneButton.tsx
Normal 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));
|
||||||
68
src/components/calls/group/OutlinedMicrophoneIcon.tsx
Normal file
68
src/components/calls/group/OutlinedMicrophoneIcon.tsx
Normal 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);
|
||||||
42
src/components/common/AnimatedIcon.tsx
Normal file
42
src/components/common/AnimatedIcon.tsx
Normal 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);
|
||||||
@ -19,6 +19,7 @@ type OwnProps = {
|
|||||||
quality?: number;
|
quality?: number;
|
||||||
isLowPriority?: boolean;
|
isLowPriority?: boolean;
|
||||||
onLoad?: NoneToVoidFunction;
|
onLoad?: NoneToVoidFunction;
|
||||||
|
color?: [number, number, number];
|
||||||
};
|
};
|
||||||
|
|
||||||
type RLottieClass = typeof import('../../lib/rlottie/RLottie').default;
|
type RLottieClass = typeof import('../../lib/rlottie/RLottie').default;
|
||||||
@ -52,6 +53,7 @@ const AnimatedSticker: FC<OwnProps> = ({
|
|||||||
quality,
|
quality,
|
||||||
isLowPriority,
|
isLowPriority,
|
||||||
onLoad,
|
onLoad,
|
||||||
|
color,
|
||||||
}) => {
|
}) => {
|
||||||
const [animation, setAnimation] = useState<RLottieInstance>();
|
const [animation, setAnimation] = useState<RLottieInstance>();
|
||||||
// eslint-disable-next-line no-null/no-null
|
// eslint-disable-next-line no-null/no-null
|
||||||
@ -85,6 +87,7 @@ const AnimatedSticker: FC<OwnProps> = ({
|
|||||||
isLowPriority,
|
isLowPriority,
|
||||||
},
|
},
|
||||||
onLoad,
|
onLoad,
|
||||||
|
color,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (speed) {
|
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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@ -183,6 +192,13 @@ const AnimatedSticker: FC<OwnProps> = ({
|
|||||||
}
|
}
|
||||||
}, [animation, play, playSegment, noLoop, playAnimation, pauseAnimation]);
|
}, [animation, play, playSegment, noLoop, playAnimation, pauseAnimation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (animation) {
|
||||||
|
animation.changeData(animationData);
|
||||||
|
playAnimation();
|
||||||
|
}
|
||||||
|
}, [playAnimation, animation, animationData]);
|
||||||
|
|
||||||
useHeavyAnimationCheck(freezeAnimation, unfreezeAnimation);
|
useHeavyAnimationCheck(freezeAnimation, unfreezeAnimation);
|
||||||
// Pausing frame may not happen in background
|
// Pausing frame may not happen in background
|
||||||
// so we need to make sure it happens right after focusing,
|
// so we need to make sure it happens right after focusing,
|
||||||
|
|||||||
41
src/components/common/GroupCallLink.tsx
Normal file
41
src/components/common/GroupCallLink.tsx
Normal 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);
|
||||||
@ -16,6 +16,22 @@ import FoldersAll from '../../../assets/FoldersAll.tgs';
|
|||||||
import FoldersNew from '../../../assets/FoldersNew.tgs';
|
import FoldersNew from '../../../assets/FoldersNew.tgs';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import DiscussionGroups from '../../../assets/DiscussionGroupsDucks.tgs';
|
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 = {
|
export const ANIMATED_STICKERS_PATHS = {
|
||||||
MonkeyIdle,
|
MonkeyIdle,
|
||||||
@ -25,6 +41,14 @@ export const ANIMATED_STICKERS_PATHS = {
|
|||||||
FoldersAll,
|
FoldersAll,
|
||||||
FoldersNew,
|
FoldersNew,
|
||||||
DiscussionGroups,
|
DiscussionGroups,
|
||||||
|
CameraFlip,
|
||||||
|
HandFilled,
|
||||||
|
HandOutline,
|
||||||
|
Speaker,
|
||||||
|
VoiceAllowTalk,
|
||||||
|
VoiceMini,
|
||||||
|
VoiceMuted,
|
||||||
|
VoiceOutlined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function getAnimationData(name: keyof typeof ANIMATED_STICKERS_PATHS) {
|
export default function getAnimationData(name: keyof typeof ANIMATED_STICKERS_PATHS) {
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import React from '../../../lib/teact/teact';
|
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 { LangFn } from '../../../hooks/useLang';
|
||||||
import {
|
import {
|
||||||
getChatTitle,
|
getChatTitle,
|
||||||
@ -17,6 +19,7 @@ import renderText from './renderText';
|
|||||||
import UserLink from '../UserLink';
|
import UserLink from '../UserLink';
|
||||||
import MessageLink from '../MessageLink';
|
import MessageLink from '../MessageLink';
|
||||||
import ChatLink from '../ChatLink';
|
import ChatLink from '../ChatLink';
|
||||||
|
import GroupCallLink from '../GroupCallLink';
|
||||||
|
|
||||||
interface ActionMessageTextOptions {
|
interface ActionMessageTextOptions {
|
||||||
maxTextLength?: number;
|
maxTextLength?: number;
|
||||||
@ -39,7 +42,7 @@ export function renderActionMessageText(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const {
|
const {
|
||||||
text, translationValues, amount, currency,
|
text, translationValues, amount, currency, call,
|
||||||
} = message.content.action;
|
} = message.content.action;
|
||||||
const content: TextPart[] = [];
|
const content: TextPart[] = [];
|
||||||
const textOptions: ActionMessageTextOptions = { ...options, maxTextLength: 32 };
|
const textOptions: ActionMessageTextOptions = { ...options, maxTextLength: 32 };
|
||||||
@ -115,6 +118,10 @@ export function renderActionMessageText(
|
|||||||
return content.join('').trim();
|
return content.join('').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (call) {
|
||||||
|
return renderGroupCallContent(call, content);
|
||||||
|
}
|
||||||
|
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -172,6 +179,14 @@ function renderOriginContent(lang: LangFn, origin: ApiUser | ApiChat, asPlain?:
|
|||||||
: renderChatContent(lang, origin as 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 {
|
function renderUserContent(sender: ApiUser, asPlain?: boolean): string | TextPart | undefined {
|
||||||
const text = trimText(getUserFullName(sender));
|
const text = trimText(getUserFullName(sender));
|
||||||
|
|
||||||
|
|||||||
@ -76,6 +76,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -51,6 +51,7 @@ import DeleteChatModal from '../../common/DeleteChatModal';
|
|||||||
import ListItem from '../../ui/ListItem';
|
import ListItem from '../../ui/ListItem';
|
||||||
import Badge from './Badge';
|
import Badge from './Badge';
|
||||||
import ChatFolderModal from '../ChatFolderModal.async';
|
import ChatFolderModal from '../ChatFolderModal.async';
|
||||||
|
import ChatCallStatus from './ChatCallStatus';
|
||||||
|
|
||||||
import './Chat.scss';
|
import './Chat.scss';
|
||||||
|
|
||||||
@ -285,6 +286,9 @@ const Chat: FC<OwnProps & StateProps & DispatchProps> = ({
|
|||||||
isSavedMessages={privateChatUser?.isSelf}
|
isSavedMessages={privateChatUser?.isSelf}
|
||||||
lastSyncTime={lastSyncTime}
|
lastSyncTime={lastSyncTime}
|
||||||
/>
|
/>
|
||||||
|
{chat.isCallActive && chat.isCallNotEmpty && (
|
||||||
|
<ChatCallStatus isSelected={isSelected} isActive={animationLevel !== 0} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="info">
|
<div className="info">
|
||||||
<div className="title">
|
<div className="title">
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import React, { FC, memo } from '../../../lib/teact/teact';
|
import React, { FC, memo } from '../../../lib/teact/teact';
|
||||||
import buildClassName from '../../../util/buildClassName';
|
import buildClassName from '../../../util/buildClassName';
|
||||||
|
|
||||||
|
import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment';
|
||||||
|
|
||||||
import './ChatCallStatus.scss';
|
import './ChatCallStatus.scss';
|
||||||
|
|
||||||
type OwnProps = {
|
type OwnProps = {
|
||||||
@ -13,7 +15,12 @@ const ChatCallStatus: FC<OwnProps> = ({
|
|||||||
isActive,
|
isActive,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
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 className="indicator">
|
||||||
<div />
|
<div />
|
||||||
<div />
|
<div />
|
||||||
|
|||||||
@ -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 {
|
#LeftColumn {
|
||||||
min-width: 12rem;
|
min-width: 12rem;
|
||||||
width: 33vw;
|
width: 33vw;
|
||||||
|
|||||||
@ -44,6 +44,8 @@ import ForwardPicker from './ForwardPicker.async';
|
|||||||
import SafeLinkModal from './SafeLinkModal.async';
|
import SafeLinkModal from './SafeLinkModal.async';
|
||||||
import HistoryCalendar from './HistoryCalendar.async';
|
import HistoryCalendar from './HistoryCalendar.async';
|
||||||
import StickerSetModal from '../common/StickerSetModal.async';
|
import StickerSetModal from '../common/StickerSetModal.async';
|
||||||
|
import GroupCall from '../calls/group/GroupCall.async';
|
||||||
|
import ActiveCallHeader from '../calls/ActiveCallHeader.async';
|
||||||
|
|
||||||
import './Main.scss';
|
import './Main.scss';
|
||||||
|
|
||||||
@ -60,6 +62,7 @@ type StateProps = {
|
|||||||
isHistoryCalendarOpen: boolean;
|
isHistoryCalendarOpen: boolean;
|
||||||
shouldSkipHistoryAnimations?: boolean;
|
shouldSkipHistoryAnimations?: boolean;
|
||||||
openedStickerSetShortName?: string;
|
openedStickerSetShortName?: string;
|
||||||
|
activeGroupCallId?: string;
|
||||||
isServiceChatReady?: boolean;
|
isServiceChatReady?: boolean;
|
||||||
animationLevel: number;
|
animationLevel: number;
|
||||||
language?: LangCode;
|
language?: LangCode;
|
||||||
@ -88,6 +91,7 @@ const Main: FC<StateProps & DispatchProps> = ({
|
|||||||
hasNotifications,
|
hasNotifications,
|
||||||
hasDialogs,
|
hasDialogs,
|
||||||
audioMessage,
|
audioMessage,
|
||||||
|
activeGroupCallId,
|
||||||
safeLinkModalUrl,
|
safeLinkModalUrl,
|
||||||
isHistoryCalendarOpen,
|
isHistoryCalendarOpen,
|
||||||
shouldSkipHistoryAnimations,
|
shouldSkipHistoryAnimations,
|
||||||
@ -271,6 +275,12 @@ const Main: FC<StateProps & DispatchProps> = ({
|
|||||||
onClose={handleStickerSetModalClose}
|
onClose={handleStickerSetModalClose}
|
||||||
stickerSetShortName={openedStickerSetShortName}
|
stickerSetShortName={openedStickerSetShortName}
|
||||||
/>
|
/>
|
||||||
|
{activeGroupCallId && (
|
||||||
|
<>
|
||||||
|
<GroupCall groupCallId={activeGroupCallId} />
|
||||||
|
<ActiveCallHeader groupCallId={activeGroupCallId} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<DownloadManager />
|
<DownloadManager />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -319,6 +329,7 @@ export default memo(withGlobal(
|
|||||||
shouldSkipHistoryAnimations: global.shouldSkipHistoryAnimations,
|
shouldSkipHistoryAnimations: global.shouldSkipHistoryAnimations,
|
||||||
openedStickerSetShortName: global.openedStickerSetShortName,
|
openedStickerSetShortName: global.openedStickerSetShortName,
|
||||||
isServiceChatReady: selectIsServiceChatReady(global),
|
isServiceChatReady: selectIsServiceChatReady(global),
|
||||||
|
activeGroupCallId: global.groupCalls.activeGroupCallId,
|
||||||
animationLevel,
|
animationLevel,
|
||||||
language,
|
language,
|
||||||
wasTimeFormatSetManually,
|
wasTimeFormatSetManually,
|
||||||
|
|||||||
@ -11,9 +11,9 @@ import { GlobalActions, MessageListType } from '../../global/types';
|
|||||||
import { MAIN_THREAD_ID } from '../../api/types';
|
import { MAIN_THREAD_ID } from '../../api/types';
|
||||||
import { IAnchorPosition } from '../../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 { pick } from '../../util/iteratees';
|
||||||
import { isChatChannel, isChatSuperGroup } from '../../modules/helpers';
|
import { isChatBasicGroup, isChatChannel, isChatSuperGroup } from '../../modules/helpers';
|
||||||
import {
|
import {
|
||||||
selectChat,
|
selectChat,
|
||||||
selectChatBot,
|
selectChatBot,
|
||||||
@ -45,6 +45,8 @@ interface StateProps {
|
|||||||
canSearch?: boolean;
|
canSearch?: boolean;
|
||||||
canMute?: boolean;
|
canMute?: boolean;
|
||||||
canLeave?: boolean;
|
canLeave?: boolean;
|
||||||
|
canEnterVoiceChat?: boolean;
|
||||||
|
canCreateVoiceChat?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DispatchProps = Pick<GlobalActions, 'joinChannel' | 'sendBotCommand' | 'openLocalTextSearch' | 'restartBot'>;
|
type DispatchProps = Pick<GlobalActions, 'joinChannel' | 'sendBotCommand' | 'openLocalTextSearch' | 'restartBot'>;
|
||||||
@ -63,6 +65,8 @@ const HeaderActions: FC<OwnProps & StateProps & DispatchProps> = ({
|
|||||||
canSearch,
|
canSearch,
|
||||||
canMute,
|
canMute,
|
||||||
canLeave,
|
canLeave,
|
||||||
|
canEnterVoiceChat,
|
||||||
|
canCreateVoiceChat,
|
||||||
isRightColumnShown,
|
isRightColumnShown,
|
||||||
canExpandActions,
|
canExpandActions,
|
||||||
joinChannel,
|
joinChannel,
|
||||||
@ -191,6 +195,8 @@ const HeaderActions: FC<OwnProps & StateProps & DispatchProps> = ({
|
|||||||
canSearch={canSearch}
|
canSearch={canSearch}
|
||||||
canMute={canMute}
|
canMute={canMute}
|
||||||
canLeave={canLeave}
|
canLeave={canLeave}
|
||||||
|
canEnterVoiceChat={canEnterVoiceChat}
|
||||||
|
canCreateVoiceChat={canCreateVoiceChat}
|
||||||
onSubscribeChannel={handleSubscribeClick}
|
onSubscribeChannel={handleSubscribeClick}
|
||||||
onSearchClick={handleSearchClick}
|
onSearchClick={handleSearchClick}
|
||||||
onClose={handleHeaderMenuClose}
|
onClose={handleHeaderMenuClose}
|
||||||
@ -226,6 +232,9 @@ export default memo(withGlobal<OwnProps>(
|
|||||||
const canSearch = isMainThread || isDiscussionThread;
|
const canSearch = isMainThread || isDiscussionThread;
|
||||||
const canMute = isMainThread && !isChatWithSelf && !canSubscribe;
|
const canMute = isMainThread && !isChatWithSelf && !canSubscribe;
|
||||||
const canLeave = isMainThread && !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 {
|
return {
|
||||||
noMenu: false,
|
noMenu: false,
|
||||||
@ -237,6 +246,8 @@ export default memo(withGlobal<OwnProps>(
|
|||||||
canSearch,
|
canSearch,
|
||||||
canMute,
|
canMute,
|
||||||
canLeave,
|
canLeave,
|
||||||
|
canEnterVoiceChat,
|
||||||
|
canCreateVoiceChat,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
(setGlobal, actions): DispatchProps => pick(actions, [
|
(setGlobal, actions): DispatchProps => pick(actions, [
|
||||||
|
|||||||
@ -27,7 +27,8 @@ import DeleteChatModal from '../common/DeleteChatModal';
|
|||||||
import './HeaderMenuContainer.scss';
|
import './HeaderMenuContainer.scss';
|
||||||
|
|
||||||
type DispatchProps = Pick<GlobalActions, (
|
type DispatchProps = Pick<GlobalActions, (
|
||||||
'updateChatMutedState' | 'enterMessageSelectMode' | 'sendBotCommand' | 'restartBot' | 'openLinkedChat' | 'addContact'
|
'updateChatMutedState' | 'enterMessageSelectMode' | 'sendBotCommand' | 'restartBot' | 'openLinkedChat' |
|
||||||
|
'joinGroupCall' | 'createGroupCall' | 'addContact'
|
||||||
)>;
|
)>;
|
||||||
|
|
||||||
export type OwnProps = {
|
export type OwnProps = {
|
||||||
@ -43,6 +44,8 @@ export type OwnProps = {
|
|||||||
canSearch?: boolean;
|
canSearch?: boolean;
|
||||||
canMute?: boolean;
|
canMute?: boolean;
|
||||||
canLeave?: boolean;
|
canLeave?: boolean;
|
||||||
|
canEnterVoiceChat?: boolean;
|
||||||
|
canCreateVoiceChat?: boolean;
|
||||||
onSubscribeChannel: () => void;
|
onSubscribeChannel: () => void;
|
||||||
onSearchClick: () => void;
|
onSearchClick: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@ -70,6 +73,8 @@ const HeaderMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
|
|||||||
canSearch,
|
canSearch,
|
||||||
canMute,
|
canMute,
|
||||||
canLeave,
|
canLeave,
|
||||||
|
canEnterVoiceChat,
|
||||||
|
canCreateVoiceChat,
|
||||||
chat,
|
chat,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
isMuted,
|
isMuted,
|
||||||
@ -84,6 +89,8 @@ const HeaderMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
|
|||||||
enterMessageSelectMode,
|
enterMessageSelectMode,
|
||||||
sendBotCommand,
|
sendBotCommand,
|
||||||
restartBot,
|
restartBot,
|
||||||
|
joinGroupCall,
|
||||||
|
createGroupCall,
|
||||||
openLinkedChat,
|
openLinkedChat,
|
||||||
addContact,
|
addContact,
|
||||||
}) => {
|
}) => {
|
||||||
@ -121,6 +128,20 @@ const HeaderMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
|
|||||||
closeMenu();
|
closeMenu();
|
||||||
}, [chatId, closeMenu, isMuted, updateChatMutedState]);
|
}, [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(() => {
|
const handleLinkedChatClick = useCallback(() => {
|
||||||
openLinkedChat({ id: chatId });
|
openLinkedChat({ id: chatId });
|
||||||
closeMenu();
|
closeMenu();
|
||||||
@ -211,6 +232,14 @@ const HeaderMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
|
|||||||
{lang(isMuted ? 'ChatsUnmute' : 'ChatsMute')}
|
{lang(isMuted ? 'ChatsUnmute' : 'ChatsMute')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
|
{(canEnterVoiceChat || canCreateVoiceChat) && (
|
||||||
|
<MenuItem
|
||||||
|
icon="voice-chat"
|
||||||
|
onClick={handleEnterVoiceChatClick}
|
||||||
|
>
|
||||||
|
{lang(canCreateVoiceChat ? 'StartVoipChat' : 'VoipGroupJoinCall')}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
{hasLinkedChat && (
|
{hasLinkedChat && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={isChannel ? 'comments' : 'channel'}
|
icon={isChannel ? 'comments' : 'channel'}
|
||||||
@ -273,6 +302,8 @@ export default memo(withGlobal<OwnProps>(
|
|||||||
'enterMessageSelectMode',
|
'enterMessageSelectMode',
|
||||||
'sendBotCommand',
|
'sendBotCommand',
|
||||||
'restartBot',
|
'restartBot',
|
||||||
|
'joinGroupCall',
|
||||||
|
'createGroupCall',
|
||||||
'openLinkedChat',
|
'openLinkedChat',
|
||||||
'addContact',
|
'addContact',
|
||||||
]),
|
]),
|
||||||
|
|||||||
@ -302,8 +302,9 @@
|
|||||||
|
|
||||||
.Avatar {
|
.Avatar {
|
||||||
margin-right: .625rem;
|
margin-right: .625rem;
|
||||||
width: 2.5rem;
|
// TODO For some reason webpack imports `Audio.scss` second time when loading calls bundle
|
||||||
height: 2.5rem;
|
width: 2.5rem !important;
|
||||||
|
height: 2.5rem !important;
|
||||||
font-size: 1.0625rem;
|
font-size: 1.0625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -61,6 +61,7 @@ import Button from '../ui/Button';
|
|||||||
import HeaderActions from './HeaderActions';
|
import HeaderActions from './HeaderActions';
|
||||||
import HeaderPinnedMessage from './HeaderPinnedMessage';
|
import HeaderPinnedMessage from './HeaderPinnedMessage';
|
||||||
import AudioPlayer from './AudioPlayer';
|
import AudioPlayer from './AudioPlayer';
|
||||||
|
import GroupCallTopPane from '../calls/group/GroupCallTopPane';
|
||||||
|
|
||||||
import './MiddleHeader.scss';
|
import './MiddleHeader.scss';
|
||||||
|
|
||||||
@ -404,6 +405,14 @@ const MiddleHeader: FC<OwnProps & StateProps & DispatchProps> = ({
|
|||||||
{renderInfo}
|
{renderInfo}
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
|
<GroupCallTopPane
|
||||||
|
hasPinnedOffset={
|
||||||
|
(shouldRenderPinnedMessage && !!renderingPinnedMessage)
|
||||||
|
|| (shouldRenderAudioPlayer && !!renderingAudioMessage)
|
||||||
|
}
|
||||||
|
chatId={chatId}
|
||||||
|
/>
|
||||||
|
|
||||||
{shouldRenderPinnedMessage && renderingPinnedMessage && (
|
{shouldRenderPinnedMessage && renderingPinnedMessage && (
|
||||||
<HeaderPinnedMessage
|
<HeaderPinnedMessage
|
||||||
key={chatId}
|
key={chatId}
|
||||||
|
|||||||
@ -270,6 +270,16 @@ const ManageGroupAdminRights: FC<OwnProps & StateProps & DispatchProps> = ({
|
|||||||
onChange={handlePermissionChange}
|
onChange={handlePermissionChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="ListItem no-selection">
|
||||||
|
<Checkbox
|
||||||
|
name="manageCall"
|
||||||
|
checked={!!permissions.manageCall}
|
||||||
|
label={lang('StartVoipChatPermission')}
|
||||||
|
blocking
|
||||||
|
disabled={getControlIsDisabled('manageCall')}
|
||||||
|
onChange={handlePermissionChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{!isChannel && (
|
{!isChannel && (
|
||||||
<div className="ListItem no-selection">
|
<div className="ListItem no-selection">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
bottom: 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);
|
transition: transform .25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
|
||||||
body.animation-level-0 & {
|
body.animation-level-0 & {
|
||||||
@ -10,6 +10,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.revealed {
|
&.revealed {
|
||||||
transform: translateY(0);
|
transform: translateY(calc(0rem - var(--group-call-header-height, 0rem)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,5 +3,9 @@
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
|
||||||
|
&.GroupCallLink {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { RefObject } from 'react';
|
||||||
import React, {
|
import React, {
|
||||||
FC, useEffect, useRef,
|
FC, useEffect, useRef,
|
||||||
} from '../../lib/teact/teact';
|
} from '../../lib/teact/teact';
|
||||||
@ -31,6 +32,7 @@ type OwnProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCloseAnimationEnd?: () => void;
|
onCloseAnimationEnd?: () => void;
|
||||||
onEnter?: () => void;
|
onEnter?: () => void;
|
||||||
|
dialogRef?: RefObject<HTMLDivElement>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StateProps = {
|
type StateProps = {
|
||||||
@ -38,6 +40,7 @@ type StateProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Modal: FC<OwnProps & StateProps> = ({
|
const Modal: FC<OwnProps & StateProps> = ({
|
||||||
|
dialogRef,
|
||||||
title,
|
title,
|
||||||
className,
|
className,
|
||||||
isOpen,
|
isOpen,
|
||||||
@ -78,7 +81,6 @@ const Modal: FC<OwnProps & StateProps> = ({
|
|||||||
|
|
||||||
useEffectWithPrevDeps(([prevIsOpen]) => {
|
useEffectWithPrevDeps(([prevIsOpen]) => {
|
||||||
document.body.classList.toggle('has-open-dialog', isOpen);
|
document.body.classList.toggle('has-open-dialog', isOpen);
|
||||||
|
|
||||||
if (isOpen || (!isOpen && prevIsOpen !== undefined)) {
|
if (isOpen || (!isOpen && prevIsOpen !== undefined)) {
|
||||||
dispatchHeavyAnimationEvent(ANIMATION_DURATION);
|
dispatchHeavyAnimationEvent(ANIMATION_DURATION);
|
||||||
}
|
}
|
||||||
@ -138,7 +140,7 @@ const Modal: FC<OwnProps & StateProps> = ({
|
|||||||
>
|
>
|
||||||
<div className="modal-container">
|
<div className="modal-container">
|
||||||
<div className="modal-backdrop" onClick={onClose} />
|
<div className="modal-backdrop" onClick={onClose} />
|
||||||
<div className="modal-dialog">
|
<div className="modal-dialog" ref={dialogRef}>
|
||||||
{renderHeader()}
|
{renderHeader()}
|
||||||
<div className="modal-content custom-scroll">
|
<div className="modal-content custom-scroll">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
@import '../../styles/mixins';
|
||||||
|
|
||||||
@mixin thumb-styles() {
|
@mixin thumb-styles() {
|
||||||
background: var(--slider-color);
|
background: var(--slider-color);
|
||||||
border: none;
|
border: none;
|
||||||
@ -72,43 +74,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reset range input browser styles
|
// Reset range input browser styles
|
||||||
input[type="range"] {
|
@include reset-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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply custom styles
|
// Apply custom styles
|
||||||
input[type="range"] {
|
input[type="range"] {
|
||||||
|
|||||||
@ -58,6 +58,7 @@ export const BLOCKED_LIST_LIMIT = 100;
|
|||||||
export const PROFILE_PHOTOS_LIMIT = 40;
|
export const PROFILE_PHOTOS_LIMIT = 40;
|
||||||
export const PROFILE_SENSITIVE_AREA = 500;
|
export const PROFILE_SENSITIVE_AREA = 500;
|
||||||
export const COMMON_CHATS_LIMIT = 100;
|
export const COMMON_CHATS_LIMIT = 100;
|
||||||
|
export const GROUP_CALL_PARTICIPANTS_LIMIT = 100;
|
||||||
|
|
||||||
export const TOP_CHAT_MESSAGES_PRELOAD_LIMIT = 20;
|
export const TOP_CHAT_MESSAGES_PRELOAD_LIMIT = 20;
|
||||||
export const ALL_CHATS_PRELOAD_DISABLED = false;
|
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_BG_COLOR = '#0F0F0F';
|
||||||
export const DARK_THEME_PATTERN_COLOR = '#0a0a0a8c';
|
export const DARK_THEME_PATTERN_COLOR = '#0a0a0a8c';
|
||||||
export const DEFAULT_PATTERN_COLOR = 'rgba(90, 110, 70, 0.6)';
|
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;
|
||||||
|
|||||||
@ -189,6 +189,10 @@ function migrateCache(cached: GlobalState, initialState: GlobalState) {
|
|||||||
if (cached.audioPlayer.playbackRate === undefined) {
|
if (cached.audioPlayer.playbackRate === undefined) {
|
||||||
cached.audioPlayer.playbackRate = DEFAULT_PLAYBACK_RATE;
|
cached.audioPlayer.playbackRate = DEFAULT_PLAYBACK_RATE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cached.groupCalls === undefined) {
|
||||||
|
cached.groupCalls = initialState.groupCalls;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCache() {
|
function updateCache() {
|
||||||
|
|||||||
@ -44,6 +44,10 @@ export const INITIAL_STATE: GlobalState = {
|
|||||||
messageLists: [],
|
messageLists: [],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
groupCalls: {
|
||||||
|
byId: {},
|
||||||
|
},
|
||||||
|
|
||||||
scheduledMessages: {
|
scheduledMessages: {
|
||||||
byChatId: {},
|
byChatId: {},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import {
|
|||||||
ApiInviteInfo,
|
ApiInviteInfo,
|
||||||
ApiCountryCode,
|
ApiCountryCode,
|
||||||
ApiCountry,
|
ApiCountry,
|
||||||
|
ApiGroupCall,
|
||||||
} from '../api/types';
|
} from '../api/types';
|
||||||
import {
|
import {
|
||||||
FocusDirection,
|
FocusDirection,
|
||||||
@ -163,6 +164,12 @@ export type GlobalState = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
groupCalls: {
|
||||||
|
byId: Record<string, ApiGroupCall>;
|
||||||
|
activeGroupCallId?: string;
|
||||||
|
isGroupCallPanelHidden?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
scheduledMessages: {
|
scheduledMessages: {
|
||||||
byChatId: Record<string, {
|
byChatId: Record<string, {
|
||||||
byId: Record<number, ApiMessage>;
|
byId: Record<number, ApiMessage>;
|
||||||
@ -539,7 +546,12 @@ export type ActionTypes = (
|
|||||||
// payment
|
// payment
|
||||||
'openPaymentModal' | 'closePaymentModal' | 'addPaymentError' |
|
'openPaymentModal' | 'closePaymentModal' | 'addPaymentError' |
|
||||||
'validateRequestedInfo' | 'setPaymentStep' | 'sendPaymentForm' | 'getPaymentForm' | 'getReceipt' |
|
'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>;
|
export type GlobalActions = Record<ActionTypes, (...args: any[]) => void>;
|
||||||
|
|||||||
@ -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.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector<string> = Vector<LangPackString>;
|
||||||
langpack.getLanguages#42c6978f lang_pack:string = Vector<LangPackLanguage>;
|
langpack.getLanguages#42c6978f lang_pack:string = Vector<LangPackLanguage>;
|
||||||
folders.editPeerFolders#6847d0ab folder_peers:Vector<InputFolderPeer> = Updates;
|
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;
|
||||||
`;
|
`;
|
||||||
@ -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.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector<string> = Vector<LangPackString>;
|
||||||
langpack.getLanguages#42c6978f lang_pack:string = Vector<LangPackLanguage>;
|
langpack.getLanguages#42c6978f lang_pack:string = Vector<LangPackLanguage>;
|
||||||
folders.editPeerFolders#6847d0ab folder_peers:Vector<InputFolderPeer> = Updates;
|
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
|
// LAYER 133
|
||||||
|
|||||||
@ -89,6 +89,7 @@ class RLottie {
|
|||||||
private animationData: AnyLiteral,
|
private animationData: AnyLiteral,
|
||||||
private params: Params = {},
|
private params: Params = {},
|
||||||
private onLoad?: () => void,
|
private onLoad?: () => void,
|
||||||
|
private customColor?: [number, number, number],
|
||||||
) {
|
) {
|
||||||
this.initContainer();
|
this.initContainer();
|
||||||
this.initConfig();
|
this.initConfig();
|
||||||
@ -199,6 +200,46 @@ class RLottie {
|
|||||||
this.canvas.remove();
|
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() {
|
private initRenderer() {
|
||||||
this.workerIndex = cycleRestrict(MAX_WORKERS, ++lastWorkerIndex);
|
this.workerIndex = cycleRestrict(MAX_WORKERS, ++lastWorkerIndex);
|
||||||
|
|
||||||
@ -264,7 +305,8 @@ class RLottie {
|
|||||||
const frameIndex = Math.round(this.approxFrameIndex);
|
const frameIndex = Math.round(this.approxFrameIndex);
|
||||||
const chunkIndex = this.getChunkIndex(frameIndex);
|
const chunkIndex = this.getChunkIndex(frameIndex);
|
||||||
const chunk = this.chunks[chunkIndex];
|
const chunk = this.chunks[chunkIndex];
|
||||||
if (!chunk) {
|
|
||||||
|
if (!chunk || chunk.length === 0) {
|
||||||
this.requestChunk(chunkIndex);
|
this.requestChunk(chunkIndex);
|
||||||
this.isAnimating = false;
|
this.isAnimating = false;
|
||||||
this.isWaiting = true;
|
this.isWaiting = true;
|
||||||
@ -285,7 +327,17 @@ class RLottie {
|
|||||||
return false;
|
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);
|
this.ctx.putImageData(imageData, 0, 0);
|
||||||
|
|
||||||
if (this.onLoad && !this.isOnLoadFired) {
|
if (this.onLoad && !this.isOnLoadFired) {
|
||||||
@ -373,7 +425,7 @@ class RLottie {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private requestChunk(chunkIndex: number) {
|
private requestChunk(chunkIndex: number) {
|
||||||
if (this.chunks[chunkIndex]) {
|
if (this.chunks[chunkIndex] && this.chunks[chunkIndex]?.length !== 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -56,6 +56,23 @@ async function init(
|
|||||||
onInit(Math.ceil(framesCount / reduceFactor));
|
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(
|
async function renderFrames(
|
||||||
key: string, fromIndex: number, toIndex: number, onProgress: CancellableCallback,
|
key: string, fromIndex: number, toIndex: number, onProgress: CancellableCallback,
|
||||||
) {
|
) {
|
||||||
@ -86,6 +103,7 @@ function destroy(key: string) {
|
|||||||
|
|
||||||
createWorkerInterface({
|
createWorkerInterface({
|
||||||
init,
|
init,
|
||||||
|
changeData,
|
||||||
renderFrames,
|
renderFrames,
|
||||||
destroy,
|
destroy,
|
||||||
});
|
});
|
||||||
|
|||||||
5
src/lib/secret-sauce/blacksilence.d.ts
vendored
Normal file
5
src/lib/secret-sauce/blacksilence.d.ts
vendored
Normal 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
21
src/lib/secret-sauce/buildSdp.d.ts
vendored
Normal 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
36
src/lib/secret-sauce/colibriClass.d.ts
vendored
Normal 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
3
src/lib/secret-sauce/index.d.ts
vendored
Normal 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';
|
||||||
2
src/lib/secret-sauce/index.js
Normal file
2
src/lib/secret-sauce/index.js
Normal file
File diff suppressed because one or more lines are too long
39
src/lib/secret-sauce/index.js.LICENSE.txt
Normal file
39
src/lib/secret-sauce/index.js.LICENSE.txt
Normal 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
3
src/lib/secret-sauce/parseSdp.d.ts
vendored
Normal 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
20
src/lib/secret-sauce/secretsauce.d.ts
vendored
Normal 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
100
src/lib/secret-sauce/types.d.ts
vendored
Normal 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
11
src/lib/secret-sauce/utils.d.ts
vendored
Normal 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;
|
||||||
@ -8,6 +8,7 @@ import './ui/users';
|
|||||||
import './ui/settings';
|
import './ui/settings';
|
||||||
import './ui/misc';
|
import './ui/misc';
|
||||||
import './ui/payments';
|
import './ui/payments';
|
||||||
|
import './ui/calls';
|
||||||
|
|
||||||
import './api/initial';
|
import './api/initial';
|
||||||
import './api/chats';
|
import './api/chats';
|
||||||
@ -31,3 +32,4 @@ import './apiUpdaters/symbols';
|
|||||||
import './apiUpdaters/misc';
|
import './apiUpdaters/misc';
|
||||||
import './apiUpdaters/settings';
|
import './apiUpdaters/settings';
|
||||||
import './apiUpdaters/twoFaSettings';
|
import './apiUpdaters/twoFaSettings';
|
||||||
|
import './apiUpdaters/calls';
|
||||||
|
|||||||
283
src/modules/actions/api/calls.async.ts
Normal file
283
src/modules/actions/api/calls.async.ts
Normal 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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
@ -42,12 +42,14 @@ import {
|
|||||||
selectCurrentMessageList,
|
selectCurrentMessageList,
|
||||||
selectThreadInfo, selectCurrentChat, selectLastServiceNotification,
|
selectThreadInfo, selectCurrentChat, selectLastServiceNotification,
|
||||||
} from '../../selectors';
|
} from '../../selectors';
|
||||||
import { buildCollectionByKey } from '../../../util/iteratees';
|
import { buildCollectionByKey, omit } from '../../../util/iteratees';
|
||||||
import { debounce, pause, throttle } from '../../../util/schedulers';
|
import { debounce, pause, throttle } from '../../../util/schedulers';
|
||||||
import {
|
import {
|
||||||
isChatSummaryOnly, isChatArchived, prepareChatList, isChatBasicGroup,
|
isChatSummaryOnly, isChatArchived, prepareChatList, isChatBasicGroup,
|
||||||
} from '../../helpers';
|
} from '../../helpers';
|
||||||
import { processDeepLink } from '../../../util/deeplink';
|
import { processDeepLink } from '../../../util/deeplink';
|
||||||
|
import { updateGroupCall } from '../../reducers/calls';
|
||||||
|
import { selectGroupCall } from '../../selectors/calls';
|
||||||
|
|
||||||
const TOP_CHAT_MESSAGES_PRELOAD_INTERVAL = 100;
|
const TOP_CHAT_MESSAGES_PRELOAD_INTERVAL = 100;
|
||||||
const CHATS_PRELOAD_INTERVAL = 300;
|
const CHATS_PRELOAD_INTERVAL = 300;
|
||||||
@ -567,7 +569,13 @@ addReducer('openTelegramLink', (global, actions, payload) => {
|
|||||||
const chatOrChannelPostId = part2 ? Number(part2) : undefined;
|
const chatOrChannelPostId = part2 ? Number(part2) : undefined;
|
||||||
const messageId = part3 ? Number(part3) : undefined;
|
const messageId = part3 ? Number(part3) : undefined;
|
||||||
const commentId = params.comment ? Number(params.comment) : 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({
|
actions.focusMessage({
|
||||||
chatId: -chatOrChannelPostId,
|
chatId: -chatOrChannelPostId,
|
||||||
messageId,
|
messageId,
|
||||||
@ -869,7 +877,7 @@ addReducer('linkDiscussionGroup', (global, actions, payload) => {
|
|||||||
fullInfo = fullChat.fullInfo;
|
fullInfo = fullChat.fullInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fullInfo.isPreHistoryHidden) {
|
if (fullInfo!.isPreHistoryHidden) {
|
||||||
await callApi('togglePreHistoryHidden', { chat, isEnabled: false });
|
await callApi('togglePreHistoryHidden', { chat, isEnabled: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1031,21 +1039,35 @@ async function loadChats(listType: 'active' | 'archived', offsetId?: string, off
|
|||||||
setGlobal(global);
|
setGlobal(global);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFullChat(chat: ApiChat) {
|
export async function loadFullChat(chat: ApiChat) {
|
||||||
const result = await callApi('fetchFullChat', chat);
|
const result = await callApi('fetchFullChat', chat);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { users, fullInfo } = result;
|
const { users, fullInfo, groupCall } = result;
|
||||||
|
|
||||||
let global = getGlobal();
|
let global = getGlobal();
|
||||||
if (users) {
|
if (users) {
|
||||||
global = addUsers(global, buildCollectionByKey(users, 'id'));
|
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 });
|
global = updateChat(global, chat.id, { fullInfo });
|
||||||
|
|
||||||
setGlobal(global);
|
setGlobal(global);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createChannel(title: string, users: ApiUser[], about?: string, photo?: File) {
|
async function createChannel(title: string, users: ApiUser[], about?: string, photo?: File) {
|
||||||
@ -1203,7 +1225,7 @@ async function deleteChatFolder(id: number) {
|
|||||||
await callApi('deleteChatFolder', id);
|
await callApi('deleteChatFolder', id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchChatByUsername(
|
export async function fetchChatByUsername(
|
||||||
username: string,
|
username: string,
|
||||||
) {
|
) {
|
||||||
const global = getGlobal();
|
const global = getGlobal();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user