Introduce Group Calls (#1520)
This commit is contained in:
parent
40930e07dc
commit
41f2c3e26b
@ -74,6 +74,7 @@
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"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": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ext/-/ext-1.4.0.tgz",
|
||||
"integrity": "sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ext/-/ext-1.5.0.tgz",
|
||||
"integrity": "sha512-+ONcYoWj/SoQwUofMr94aGu05Ou4FepKi7N7b+O8T4jVfyIsZQV1/xeS8jpaBzF0csAk0KLXoHCxU7cKYZjo1Q==",
|
||||
"requires": {
|
||||
"type": "^2.0.0"
|
||||
"type": "^2.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"type": {
|
||||
@ -15127,9 +15127,9 @@
|
||||
}
|
||||
},
|
||||
"node-gyp-build": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.2.3.tgz",
|
||||
"integrity": "sha512-MN6ZpzmfNCRM+3t57PTJHgHyw/h4OWnZ6mR8P5j/uZtqQr46RRuDE/P+g3n0YR/AiYXeWixZZzaip77gdICfRg=="
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.3.0.tgz",
|
||||
"integrity": "sha512-iWjXZvmboq0ja1pUGULQBexmxq8CV4xBhX7VDOTbL7ZR4FOowwY/VOtRxBN/yKxmdGoIp4j5ysNT4u3S2pDQ3Q=="
|
||||
},
|
||||
"node-int64": {
|
||||
"version": "0.4.0",
|
||||
|
||||
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> {
|
||||
loading?: 'auto' | 'eager' | 'lazy';
|
||||
}
|
||||
|
||||
interface VideoHTMLAttributes {
|
||||
srcObject?: MediaStream;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
ApiThreadInfo,
|
||||
ApiInvoice,
|
||||
ApiGroupCall,
|
||||
} from '../../types';
|
||||
|
||||
import {
|
||||
@ -593,6 +594,7 @@ function buildAction(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let call: Partial<ApiGroupCall> | undefined;
|
||||
let amount: number | undefined;
|
||||
let currency: string | undefined;
|
||||
let text: string;
|
||||
@ -677,6 +679,13 @@ function buildAction(
|
||||
const mins = Math.max(Math.round(action.duration! / 60), 1);
|
||||
translationValues.push(`${mins} min${mins > 1 ? 's' : ''}`);
|
||||
}
|
||||
} else if (action instanceof GramJs.MessageActionInviteToGroupCall) {
|
||||
text = 'Notification.VoiceChatInvitation';
|
||||
call = {
|
||||
id: action.call.id.toString(),
|
||||
accessHash: action.call.accessHash.toString(),
|
||||
};
|
||||
translationValues.push('%action_origin%', '%target_user%');
|
||||
} else if (action instanceof GramJs.MessageActionContactSignUp) {
|
||||
text = 'Notification.Joined';
|
||||
translationValues.push('%action_origin%');
|
||||
@ -696,6 +705,10 @@ function buildAction(
|
||||
translationValues.push(`${mins} min${mins > 1 ? 's' : ''}`);
|
||||
} else {
|
||||
text = 'Notification.VoiceChatStartedChannel';
|
||||
call = {
|
||||
id: action.call.id.toString(),
|
||||
accessHash: action.call.accessHash.toString(),
|
||||
};
|
||||
}
|
||||
} else if (action instanceof GramJs.MessageActionBotAllowed) {
|
||||
text = 'Chat.Service.BotPermissionAllowed';
|
||||
@ -718,6 +731,7 @@ function buildAction(
|
||||
amount,
|
||||
currency,
|
||||
translationValues,
|
||||
call,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -5,15 +5,16 @@ import { ApiPrivacyKey } from '../../../types';
|
||||
|
||||
import { generateRandomBytes, readBigIntFromBuffer } from '../../../lib/gramjs/Helpers';
|
||||
import {
|
||||
ApiSticker,
|
||||
ApiVideo,
|
||||
ApiNewPoll,
|
||||
ApiChatAdminRights,
|
||||
ApiChatBannedRights,
|
||||
ApiChatFolder,
|
||||
ApiGroupCall,
|
||||
ApiMessageEntity,
|
||||
ApiMessageEntityTypes,
|
||||
ApiChatFolder,
|
||||
ApiChatBannedRights,
|
||||
ApiChatAdminRights,
|
||||
ApiNewPoll,
|
||||
ApiReportReason,
|
||||
ApiSticker,
|
||||
ApiVideo,
|
||||
} from '../../types';
|
||||
import localDb from '../localDb';
|
||||
import { pick } from '../../../util/iteratees';
|
||||
@ -237,6 +238,10 @@ export function generateRandomBigInt() {
|
||||
return readBigIntFromBuffer(generateRandomBytes(8), true, true);
|
||||
}
|
||||
|
||||
export function generateRandomInt() {
|
||||
return readBigIntFromBuffer(generateRandomBytes(4), true, true).toJSNumber();
|
||||
}
|
||||
|
||||
export function buildMessageFromUpdate(
|
||||
id: number,
|
||||
chatId: string,
|
||||
@ -424,3 +429,10 @@ export function buildMtpPeerId(id: string, type: 'user' | 'chat' | 'channel') {
|
||||
|
||||
return type === 'user' ? BigInt(id) : BigInt(id.slice(1));
|
||||
}
|
||||
|
||||
export function buildInputGroupCall(groupCall: Partial<ApiGroupCall>) {
|
||||
return new GramJs.InputGroupCall({
|
||||
id: BigInt(groupCall.id!),
|
||||
accessHash: BigInt(groupCall.accessHash!),
|
||||
});
|
||||
}
|
||||
|
||||
@ -47,3 +47,7 @@ export function addChatToLocalDb(chat: GramJs.TypeChat) {
|
||||
localDb.chats[buildApiPeerId(chat.id, chat instanceof GramJs.Chat ? 'chat' : 'channel')] = chat;
|
||||
}
|
||||
}
|
||||
|
||||
export function addUserToLocalDb(user: GramJs.User) {
|
||||
localDb.users[buildApiPeerId(user.id, 'user')] = user;
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ import { buildInputPeer, generateRandomBigInt } from '../gramjsBuilders';
|
||||
import { buildApiUser } from '../apiBuilders/users';
|
||||
import { buildApiBotInlineMediaResult, buildApiBotInlineResult, buildBotSwitchPm } from '../apiBuilders/bots';
|
||||
import { buildApiChatFromPreview } from '../apiBuilders/chats';
|
||||
import { buildApiPeerId } from '../apiBuilders/peers';
|
||||
import { addUserToLocalDb } from '../helpers';
|
||||
|
||||
export function init() {
|
||||
}
|
||||
@ -158,10 +158,6 @@ function getInlineBotResultsNextOffset(username: string, nextOffset?: string) {
|
||||
return username === 'gif' && nextOffset === '0' ? '' : nextOffset;
|
||||
}
|
||||
|
||||
function addUserToLocalDb(user: GramJs.User) {
|
||||
localDb.users[buildApiPeerId(user.id, 'user')] = user;
|
||||
}
|
||||
|
||||
function addDocumentToLocalDb(document: GramJs.Document) {
|
||||
localDb.documents[String(document.id)] = document;
|
||||
}
|
||||
|
||||
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,
|
||||
ApiChatBannedRights,
|
||||
ApiChatAdminRights,
|
||||
ApiGroupCall,
|
||||
} from '../../types';
|
||||
|
||||
import {
|
||||
@ -323,6 +324,7 @@ export function clearDraft(chat: ApiChat) {
|
||||
async function getFullChatInfo(chatId: string): Promise<{
|
||||
fullInfo: ApiChatFullInfo;
|
||||
users?: ApiUser[];
|
||||
groupCall?: Partial<ApiGroupCall>;
|
||||
} | undefined> {
|
||||
const result = await invokeRequest(new GramJs.messages.GetFullChat({
|
||||
chatId: buildInputEntity(chatId) as BigInt.BigInteger,
|
||||
@ -339,6 +341,7 @@ async function getFullChatInfo(chatId: string): Promise<{
|
||||
participants,
|
||||
exportedInvite,
|
||||
botInfo,
|
||||
call,
|
||||
} = result.fullChat;
|
||||
|
||||
const members = buildChatMembers(participants);
|
||||
@ -355,8 +358,19 @@ async function getFullChatInfo(chatId: string): Promise<{
|
||||
...(exportedInvite && {
|
||||
inviteLink: exportedInvite.link,
|
||||
}),
|
||||
groupCallId: call?.id.toString(),
|
||||
},
|
||||
users: result.users.map(buildApiUser).filter<ApiUser>(Boolean as any),
|
||||
groupCall: call ? {
|
||||
chatId,
|
||||
isLoaded: false,
|
||||
id: call.id.toString(),
|
||||
accessHash: call.accessHash.toString(),
|
||||
connectionState: 'disconnected',
|
||||
participantsCount: 0,
|
||||
version: 0,
|
||||
participants: {},
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@ -364,7 +378,11 @@ async function getFullChannelInfo(
|
||||
id: string,
|
||||
accessHash: string,
|
||||
adminRights?: ApiChatAdminRights,
|
||||
) {
|
||||
): Promise<{
|
||||
fullInfo: ApiChatFullInfo;
|
||||
users?: ApiUser[];
|
||||
groupCall?: Partial<ApiGroupCall>;
|
||||
} | undefined> {
|
||||
const result = await invokeRequest(new GramJs.channels.GetFullChannel({
|
||||
channel: buildInputEntity(id, accessHash) as GramJs.InputChannel,
|
||||
}));
|
||||
@ -438,6 +456,16 @@ async function getFullChannelInfo(
|
||||
botCommands,
|
||||
},
|
||||
users: [...(users || []), ...(bannedUsers || []), ...(adminUsers || [])],
|
||||
groupCall: call ? {
|
||||
chatId: id,
|
||||
isLoaded: false,
|
||||
id: call.id.toString(),
|
||||
accessHash: call?.accessHash.toString(),
|
||||
participants: {},
|
||||
version: 0,
|
||||
participantsCount: 0,
|
||||
connectionState: 'disconnected',
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -60,3 +60,9 @@ export {
|
||||
export {
|
||||
validateRequestedInfo, sendPaymentForm, getPaymentForm, getReceipt,
|
||||
} from './payments';
|
||||
|
||||
export {
|
||||
getGroupCall, joinGroupCall, discardGroupCall, createGroupCall,
|
||||
editGroupCallTitle, editGroupCallParticipant, exportGroupCallInvite, fetchGroupCallParticipants,
|
||||
joinGroupCallPresentation, leaveGroupCall, leaveGroupCallPresentation, toggleGroupCallStartSubscription,
|
||||
} from './calls';
|
||||
|
||||
@ -17,6 +17,7 @@ import { init as initClient } from './methods/client';
|
||||
import { init as initStickers } from './methods/symbols';
|
||||
import { init as initManagement } from './methods/management';
|
||||
import { init as initTwoFaSettings } from './methods/twoFaSettings';
|
||||
import { init as initCalls } from './methods/calls';
|
||||
import * as methods from './methods';
|
||||
|
||||
let onUpdate: OnApiUpdate;
|
||||
@ -32,6 +33,7 @@ export async function initApi(_onUpdate: OnApiUpdate, initialArgs: ApiInitialArg
|
||||
initStickers(handleUpdate);
|
||||
initManagement(handleUpdate);
|
||||
initTwoFaSettings(handleUpdate);
|
||||
initCalls(handleUpdate);
|
||||
|
||||
await initClient(handleUpdate, initialArgs);
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { GroupCallConnectionData } from '../../lib/secret-sauce';
|
||||
import { Api as GramJs, connection } from '../../lib/gramjs';
|
||||
import { ApiMessage, ApiUpdateConnectionStateType, OnApiUpdate } from '../types';
|
||||
|
||||
@ -33,6 +34,11 @@ import { DEBUG } from '../../config';
|
||||
import { addMessageToLocalDb, addPhotoToLocalDb, resolveMessageApiChatId } from './helpers';
|
||||
import { buildApiNotifyException, buildPrivacyKey, buildPrivacyRules } from './apiBuilders/misc';
|
||||
import { buildApiPhoto } from './apiBuilders/common';
|
||||
import {
|
||||
buildApiGroupCall,
|
||||
buildApiGroupCallParticipant,
|
||||
getGroupCallId,
|
||||
} from './apiBuilders/calls';
|
||||
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './apiBuilders/peers';
|
||||
|
||||
type Update = (
|
||||
@ -50,6 +56,39 @@ export function init(_onUpdate: OnApiUpdate) {
|
||||
const sentMessageIds = new Set();
|
||||
let serverTimeOffset = 0;
|
||||
|
||||
function addEntities(entities: (GramJs.TypeUser | GramJs.TypeChat)[] | undefined) {
|
||||
if (entities?.length) {
|
||||
entities
|
||||
.filter((e) => e instanceof GramJs.User)
|
||||
.map(buildApiUser)
|
||||
.forEach((user) => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdate({
|
||||
'@type': 'updateUser',
|
||||
id: user.id,
|
||||
user,
|
||||
});
|
||||
});
|
||||
entities
|
||||
.filter((e) => e instanceof GramJs.Chat || e instanceof GramJs.Channel)
|
||||
.map((e) => buildApiChatFromPreview(e))
|
||||
.forEach((chat) => {
|
||||
if (!chat) {
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdate({
|
||||
'@type': 'updateChat',
|
||||
id: chat.id,
|
||||
chat,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
|
||||
if (update instanceof connection.UpdateServerTimeOffset) {
|
||||
serverTimeOffset = update.timeOffset;
|
||||
@ -111,37 +150,7 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
const entities = update._entities;
|
||||
if (entities?.length) {
|
||||
entities
|
||||
.filter((e) => e instanceof GramJs.User)
|
||||
.map(buildApiUser)
|
||||
.forEach((user) => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdate({
|
||||
'@type': 'updateUser',
|
||||
id: user.id,
|
||||
user,
|
||||
});
|
||||
});
|
||||
entities
|
||||
.filter((e) => e instanceof GramJs.Chat || e instanceof GramJs.Channel)
|
||||
.map((e) => buildApiChatFromPreview(e))
|
||||
.forEach((chat) => {
|
||||
if (!chat) {
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdate({
|
||||
'@type': 'updateChat',
|
||||
id: chat.id,
|
||||
chat,
|
||||
});
|
||||
});
|
||||
}
|
||||
addEntities(update._entities);
|
||||
|
||||
if (update instanceof GramJs.UpdateNewScheduledMessage) {
|
||||
onUpdate({
|
||||
@ -232,6 +241,17 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
|
||||
id: message.chatId,
|
||||
});
|
||||
}
|
||||
} else if (action instanceof GramJs.MessageActionGroupCall) {
|
||||
if (!action.duration && action.call) {
|
||||
onUpdate({
|
||||
'@type': 'updateGroupCallChatId',
|
||||
chatId: message.chatId,
|
||||
call: {
|
||||
id: action.call.id.toString(),
|
||||
accessHash: action.call.accessHash.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
@ -785,6 +805,26 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
|
||||
onUpdate({ '@type': 'updateResetContactList' });
|
||||
} else if (update instanceof GramJs.UpdateFavedStickers) {
|
||||
onUpdate({ '@type': 'updateFavoriteStickers' });
|
||||
} else if (update instanceof GramJs.UpdateGroupCall) {
|
||||
onUpdate({
|
||||
'@type': 'updateGroupCall',
|
||||
call: buildApiGroupCall(update.call),
|
||||
});
|
||||
} else if (update instanceof GramJs.UpdateGroupCallConnection) {
|
||||
onUpdate({
|
||||
'@type': 'updateGroupCallConnection',
|
||||
data: JSON.parse(update.params.data) as GroupCallConnectionData,
|
||||
presentation: Boolean(update.presentation),
|
||||
});
|
||||
} else if (update instanceof GramJs.UpdateGroupCallParticipants) {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
addEntities(update._entities);
|
||||
|
||||
onUpdate({
|
||||
'@type': 'updateGroupCallParticipants',
|
||||
groupCallId: getGroupCallId(update.call),
|
||||
participants: update.participants.map(buildApiGroupCallParticipant),
|
||||
});
|
||||
} else if (DEBUG) {
|
||||
const params = typeof update === 'object' && 'className' in update ? update.className : update;
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
26
src/api/types/calls.ts
Normal file
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;
|
||||
addAdmins?: true;
|
||||
anonymous?: true;
|
||||
manageCall?: true;
|
||||
}
|
||||
|
||||
export interface ApiChatBannedRights {
|
||||
|
||||
@ -7,3 +7,4 @@ export * from './payments';
|
||||
export * from './settings';
|
||||
export * from './bots';
|
||||
export * from './misc';
|
||||
export * from './calls';
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { ApiGroupCall } from './calls';
|
||||
|
||||
export interface ApiDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
@ -155,6 +157,7 @@ export interface ApiAction {
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
translationValues: string[];
|
||||
call?: Partial<ApiGroupCall>;
|
||||
}
|
||||
|
||||
export interface ApiWebPage {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { GroupCallConnectionData, GroupCallParticipant, GroupCallConnectionState } from '../../lib/secret-sauce';
|
||||
import {
|
||||
ApiChat,
|
||||
ApiChatFullInfo,
|
||||
@ -12,6 +13,9 @@ import { ApiUser, ApiUserFullInfo, ApiUserStatus } from './users';
|
||||
import {
|
||||
ApiError, ApiInviteInfo, ApiNotifyException, ApiSessionData,
|
||||
} from './misc';
|
||||
import {
|
||||
ApiGroupCall,
|
||||
} from './calls';
|
||||
|
||||
export type ApiUpdateReady = {
|
||||
'@type': 'updateApiReady';
|
||||
@ -378,6 +382,48 @@ export type ApiUpdateServerTimeOffset = {
|
||||
serverTimeOffset: number;
|
||||
};
|
||||
|
||||
export type ApiUpdateGroupCall = {
|
||||
'@type': 'updateGroupCall';
|
||||
call: ApiGroupCall;
|
||||
};
|
||||
|
||||
export type ApiUpdateGroupCallChatId = {
|
||||
'@type': 'updateGroupCallChatId';
|
||||
call: Partial<ApiGroupCall>;
|
||||
chatId: string;
|
||||
};
|
||||
|
||||
export type ApiUpdateGroupCallLeavePresentation = {
|
||||
'@type': 'updateGroupCallLeavePresentation';
|
||||
};
|
||||
|
||||
export type ApiUpdateGroupCallParticipants = {
|
||||
'@type': 'updateGroupCallParticipants';
|
||||
groupCallId: string;
|
||||
participants: GroupCallParticipant[];
|
||||
nextOffset?: string;
|
||||
};
|
||||
|
||||
export type ApiUpdateGroupCallConnection = {
|
||||
'@type': 'updateGroupCallConnection';
|
||||
data: GroupCallConnectionData;
|
||||
presentation: boolean;
|
||||
};
|
||||
|
||||
export type ApiUpdateGroupCallStreams = {
|
||||
'@type': 'updateGroupCallStreams';
|
||||
userId: string;
|
||||
hasAudioStream: boolean;
|
||||
hasVideoStream: boolean;
|
||||
hasPresentationStream: boolean;
|
||||
};
|
||||
|
||||
export type ApiUpdateGroupCallConnectionState = {
|
||||
'@type': 'updateGroupCallConnectionState';
|
||||
connectionState: GroupCallConnectionState;
|
||||
isSpeakerDisabled?: boolean;
|
||||
};
|
||||
|
||||
export type ApiUpdate = (
|
||||
ApiUpdateReady | ApiUpdateSession |
|
||||
ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser |
|
||||
@ -395,7 +441,9 @@ export type ApiUpdate = (
|
||||
ApiUpdateDeleteScheduledMessages | ApiUpdateResetMessages |
|
||||
ApiUpdateTwoFaError | ApiUpdateTwoFaStateWaitCode |
|
||||
ApiUpdateNotifySettings | ApiUpdateNotifyExceptions | ApiUpdatePeerBlocked | ApiUpdatePrivacy |
|
||||
ApiUpdateServerTimeOffset | ApiUpdateShowInvite
|
||||
ApiUpdateServerTimeOffset | ApiUpdateShowInvite |
|
||||
ApiUpdateGroupCallParticipants | ApiUpdateGroupCallConnection | ApiUpdateGroupCall | ApiUpdateGroupCallStreams |
|
||||
ApiUpdateGroupCallConnectionState | ApiUpdateGroupCallLeavePresentation | ApiUpdateGroupCallChatId
|
||||
);
|
||||
|
||||
export type OnApiUpdate = (update: ApiUpdate) => void;
|
||||
|
||||
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;
|
||||
isLowPriority?: boolean;
|
||||
onLoad?: NoneToVoidFunction;
|
||||
color?: [number, number, number];
|
||||
};
|
||||
|
||||
type RLottieClass = typeof import('../../lib/rlottie/RLottie').default;
|
||||
@ -52,6 +53,7 @@ const AnimatedSticker: FC<OwnProps> = ({
|
||||
quality,
|
||||
isLowPriority,
|
||||
onLoad,
|
||||
color,
|
||||
}) => {
|
||||
const [animation, setAnimation] = useState<RLottieInstance>();
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
@ -85,6 +87,7 @@ const AnimatedSticker: FC<OwnProps> = ({
|
||||
isLowPriority,
|
||||
},
|
||||
onLoad,
|
||||
color,
|
||||
);
|
||||
|
||||
if (speed) {
|
||||
@ -105,7 +108,13 @@ const AnimatedSticker: FC<OwnProps> = ({
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [animation, animationData, id, isLowPriority, noLoop, onLoad, quality, size, speed]);
|
||||
}, [color, animation, animationData, id, isLowPriority, noLoop, onLoad, quality, size, speed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!animation) return;
|
||||
|
||||
animation.setColor(color);
|
||||
}, [color, animation]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@ -183,6 +192,13 @@ const AnimatedSticker: FC<OwnProps> = ({
|
||||
}
|
||||
}, [animation, play, playSegment, noLoop, playAnimation, pauseAnimation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (animation) {
|
||||
animation.changeData(animationData);
|
||||
playAnimation();
|
||||
}
|
||||
}, [playAnimation, animation, animationData]);
|
||||
|
||||
useHeavyAnimationCheck(freezeAnimation, unfreezeAnimation);
|
||||
// Pausing frame may not happen in background
|
||||
// so we need to make sure it happens right after focusing,
|
||||
|
||||
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';
|
||||
// @ts-ignore
|
||||
import DiscussionGroups from '../../../assets/DiscussionGroupsDucks.tgs';
|
||||
// @ts-ignore
|
||||
import CameraFlip from '../../../assets/animatedIcons/CameraFlip.tgs';
|
||||
// @ts-ignore
|
||||
import HandFilled from '../../../assets/animatedIcons/HandFilled.tgs';
|
||||
// @ts-ignore
|
||||
import HandOutline from '../../../assets/animatedIcons/HandOutline.tgs';
|
||||
// @ts-ignore
|
||||
import Speaker from '../../../assets/animatedIcons/Speaker.tgs';
|
||||
// @ts-ignore
|
||||
import VoiceAllowTalk from '../../../assets/animatedIcons/VoiceAllowTalk.tgs';
|
||||
// @ts-ignore
|
||||
import VoiceMini from '../../../assets/animatedIcons/VoiceMini.tgs';
|
||||
// @ts-ignore
|
||||
import VoiceMuted from '../../../assets/animatedIcons/VoiceMuted.tgs';
|
||||
// @ts-ignore
|
||||
import VoiceOutlined from '../../../assets/animatedIcons/VoiceOutlined.tgs';
|
||||
|
||||
export const ANIMATED_STICKERS_PATHS = {
|
||||
MonkeyIdle,
|
||||
@ -25,6 +41,14 @@ export const ANIMATED_STICKERS_PATHS = {
|
||||
FoldersAll,
|
||||
FoldersNew,
|
||||
DiscussionGroups,
|
||||
CameraFlip,
|
||||
HandFilled,
|
||||
HandOutline,
|
||||
Speaker,
|
||||
VoiceAllowTalk,
|
||||
VoiceMini,
|
||||
VoiceMuted,
|
||||
VoiceOutlined,
|
||||
};
|
||||
|
||||
export default function getAnimationData(name: keyof typeof ANIMATED_STICKERS_PATHS) {
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import React from '../../../lib/teact/teact';
|
||||
|
||||
import { ApiChat, ApiMessage, ApiUser } from '../../../api/types';
|
||||
import {
|
||||
ApiChat, ApiMessage, ApiUser, ApiGroupCall,
|
||||
} from '../../../api/types';
|
||||
import { LangFn } from '../../../hooks/useLang';
|
||||
import {
|
||||
getChatTitle,
|
||||
@ -17,6 +19,7 @@ import renderText from './renderText';
|
||||
import UserLink from '../UserLink';
|
||||
import MessageLink from '../MessageLink';
|
||||
import ChatLink from '../ChatLink';
|
||||
import GroupCallLink from '../GroupCallLink';
|
||||
|
||||
interface ActionMessageTextOptions {
|
||||
maxTextLength?: number;
|
||||
@ -39,7 +42,7 @@ export function renderActionMessageText(
|
||||
return [];
|
||||
}
|
||||
const {
|
||||
text, translationValues, amount, currency,
|
||||
text, translationValues, amount, currency, call,
|
||||
} = message.content.action;
|
||||
const content: TextPart[] = [];
|
||||
const textOptions: ActionMessageTextOptions = { ...options, maxTextLength: 32 };
|
||||
@ -115,6 +118,10 @@ export function renderActionMessageText(
|
||||
return content.join('').trim();
|
||||
}
|
||||
|
||||
if (call) {
|
||||
return renderGroupCallContent(call, content);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
@ -172,6 +179,14 @@ function renderOriginContent(lang: LangFn, origin: ApiUser | ApiChat, asPlain?:
|
||||
: renderChatContent(lang, origin as ApiChat, asPlain);
|
||||
}
|
||||
|
||||
function renderGroupCallContent(groupCall: Partial<ApiGroupCall>, text: TextPart[]): string | TextPart | undefined {
|
||||
return (
|
||||
<GroupCallLink groupCall={groupCall}>
|
||||
{text}
|
||||
</GroupCallLink>
|
||||
);
|
||||
}
|
||||
|
||||
function renderUserContent(sender: ApiUser, asPlain?: boolean): string | TextPart | undefined {
|
||||
const text = trimText(getUserFullName(sender));
|
||||
|
||||
|
||||
@ -76,6 +76,7 @@
|
||||
}
|
||||
|
||||
.status {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
@ -51,6 +51,7 @@ import DeleteChatModal from '../../common/DeleteChatModal';
|
||||
import ListItem from '../../ui/ListItem';
|
||||
import Badge from './Badge';
|
||||
import ChatFolderModal from '../ChatFolderModal.async';
|
||||
import ChatCallStatus from './ChatCallStatus';
|
||||
|
||||
import './Chat.scss';
|
||||
|
||||
@ -285,6 +286,9 @@ const Chat: FC<OwnProps & StateProps & DispatchProps> = ({
|
||||
isSavedMessages={privateChatUser?.isSelf}
|
||||
lastSyncTime={lastSyncTime}
|
||||
/>
|
||||
{chat.isCallActive && chat.isCallNotEmpty && (
|
||||
<ChatCallStatus isSelected={isSelected} isActive={animationLevel !== 0} />
|
||||
)}
|
||||
</div>
|
||||
<div className="info">
|
||||
<div className="title">
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import React, { FC, memo } from '../../../lib/teact/teact';
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
|
||||
import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment';
|
||||
|
||||
import './ChatCallStatus.scss';
|
||||
|
||||
type OwnProps = {
|
||||
@ -13,7 +15,12 @@ const ChatCallStatus: FC<OwnProps> = ({
|
||||
isActive,
|
||||
}) => {
|
||||
return (
|
||||
<div className={buildClassName('ChatCallStatus', isActive && 'active', isSelected && 'selected')}>
|
||||
<div className={buildClassName(
|
||||
'ChatCallStatus',
|
||||
isActive && 'active',
|
||||
isSelected && !IS_SINGLE_COLUMN_LAYOUT && 'selected',
|
||||
)}
|
||||
>
|
||||
<div className="indicator">
|
||||
<div />
|
||||
<div />
|
||||
|
||||
@ -18,6 +18,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.has-group-call-header {
|
||||
--group-call-header-height: 2rem;
|
||||
#LeftColumn, #MiddleColumn, #RightColumn-wrapper {
|
||||
height: calc(100% - 2rem);
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
#LeftColumn {
|
||||
min-width: 12rem;
|
||||
width: 33vw;
|
||||
|
||||
@ -44,6 +44,8 @@ import ForwardPicker from './ForwardPicker.async';
|
||||
import SafeLinkModal from './SafeLinkModal.async';
|
||||
import HistoryCalendar from './HistoryCalendar.async';
|
||||
import StickerSetModal from '../common/StickerSetModal.async';
|
||||
import GroupCall from '../calls/group/GroupCall.async';
|
||||
import ActiveCallHeader from '../calls/ActiveCallHeader.async';
|
||||
|
||||
import './Main.scss';
|
||||
|
||||
@ -60,6 +62,7 @@ type StateProps = {
|
||||
isHistoryCalendarOpen: boolean;
|
||||
shouldSkipHistoryAnimations?: boolean;
|
||||
openedStickerSetShortName?: string;
|
||||
activeGroupCallId?: string;
|
||||
isServiceChatReady?: boolean;
|
||||
animationLevel: number;
|
||||
language?: LangCode;
|
||||
@ -88,6 +91,7 @@ const Main: FC<StateProps & DispatchProps> = ({
|
||||
hasNotifications,
|
||||
hasDialogs,
|
||||
audioMessage,
|
||||
activeGroupCallId,
|
||||
safeLinkModalUrl,
|
||||
isHistoryCalendarOpen,
|
||||
shouldSkipHistoryAnimations,
|
||||
@ -271,6 +275,12 @@ const Main: FC<StateProps & DispatchProps> = ({
|
||||
onClose={handleStickerSetModalClose}
|
||||
stickerSetShortName={openedStickerSetShortName}
|
||||
/>
|
||||
{activeGroupCallId && (
|
||||
<>
|
||||
<GroupCall groupCallId={activeGroupCallId} />
|
||||
<ActiveCallHeader groupCallId={activeGroupCallId} />
|
||||
</>
|
||||
)}
|
||||
<DownloadManager />
|
||||
</div>
|
||||
);
|
||||
@ -319,6 +329,7 @@ export default memo(withGlobal(
|
||||
shouldSkipHistoryAnimations: global.shouldSkipHistoryAnimations,
|
||||
openedStickerSetShortName: global.openedStickerSetShortName,
|
||||
isServiceChatReady: selectIsServiceChatReady(global),
|
||||
activeGroupCallId: global.groupCalls.activeGroupCallId,
|
||||
animationLevel,
|
||||
language,
|
||||
wasTimeFormatSetManually,
|
||||
|
||||
@ -11,9 +11,9 @@ import { GlobalActions, MessageListType } from '../../global/types';
|
||||
import { MAIN_THREAD_ID } from '../../api/types';
|
||||
import { IAnchorPosition } from '../../types';
|
||||
|
||||
import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
|
||||
import { ARE_CALLS_SUPPORTED, IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
|
||||
import { pick } from '../../util/iteratees';
|
||||
import { isChatChannel, isChatSuperGroup } from '../../modules/helpers';
|
||||
import { isChatBasicGroup, isChatChannel, isChatSuperGroup } from '../../modules/helpers';
|
||||
import {
|
||||
selectChat,
|
||||
selectChatBot,
|
||||
@ -45,6 +45,8 @@ interface StateProps {
|
||||
canSearch?: boolean;
|
||||
canMute?: boolean;
|
||||
canLeave?: boolean;
|
||||
canEnterVoiceChat?: boolean;
|
||||
canCreateVoiceChat?: boolean;
|
||||
}
|
||||
|
||||
type DispatchProps = Pick<GlobalActions, 'joinChannel' | 'sendBotCommand' | 'openLocalTextSearch' | 'restartBot'>;
|
||||
@ -63,6 +65,8 @@ const HeaderActions: FC<OwnProps & StateProps & DispatchProps> = ({
|
||||
canSearch,
|
||||
canMute,
|
||||
canLeave,
|
||||
canEnterVoiceChat,
|
||||
canCreateVoiceChat,
|
||||
isRightColumnShown,
|
||||
canExpandActions,
|
||||
joinChannel,
|
||||
@ -191,6 +195,8 @@ const HeaderActions: FC<OwnProps & StateProps & DispatchProps> = ({
|
||||
canSearch={canSearch}
|
||||
canMute={canMute}
|
||||
canLeave={canLeave}
|
||||
canEnterVoiceChat={canEnterVoiceChat}
|
||||
canCreateVoiceChat={canCreateVoiceChat}
|
||||
onSubscribeChannel={handleSubscribeClick}
|
||||
onSearchClick={handleSearchClick}
|
||||
onClose={handleHeaderMenuClose}
|
||||
@ -226,6 +232,9 @@ export default memo(withGlobal<OwnProps>(
|
||||
const canSearch = isMainThread || isDiscussionThread;
|
||||
const canMute = isMainThread && !isChatWithSelf && !canSubscribe;
|
||||
const canLeave = isMainThread && !canSubscribe;
|
||||
const canEnterVoiceChat = ARE_CALLS_SUPPORTED && chat && chat.isCallActive;
|
||||
const canCreateVoiceChat = ARE_CALLS_SUPPORTED && chat && !chat.isCallActive
|
||||
&& (chat.adminRights?.manageCall || (chat.isCreator && isChatBasicGroup(chat)));
|
||||
|
||||
return {
|
||||
noMenu: false,
|
||||
@ -237,6 +246,8 @@ export default memo(withGlobal<OwnProps>(
|
||||
canSearch,
|
||||
canMute,
|
||||
canLeave,
|
||||
canEnterVoiceChat,
|
||||
canCreateVoiceChat,
|
||||
};
|
||||
},
|
||||
(setGlobal, actions): DispatchProps => pick(actions, [
|
||||
|
||||
@ -27,7 +27,8 @@ import DeleteChatModal from '../common/DeleteChatModal';
|
||||
import './HeaderMenuContainer.scss';
|
||||
|
||||
type DispatchProps = Pick<GlobalActions, (
|
||||
'updateChatMutedState' | 'enterMessageSelectMode' | 'sendBotCommand' | 'restartBot' | 'openLinkedChat' | 'addContact'
|
||||
'updateChatMutedState' | 'enterMessageSelectMode' | 'sendBotCommand' | 'restartBot' | 'openLinkedChat' |
|
||||
'joinGroupCall' | 'createGroupCall' | 'addContact'
|
||||
)>;
|
||||
|
||||
export type OwnProps = {
|
||||
@ -43,6 +44,8 @@ export type OwnProps = {
|
||||
canSearch?: boolean;
|
||||
canMute?: boolean;
|
||||
canLeave?: boolean;
|
||||
canEnterVoiceChat?: boolean;
|
||||
canCreateVoiceChat?: boolean;
|
||||
onSubscribeChannel: () => void;
|
||||
onSearchClick: () => void;
|
||||
onClose: () => void;
|
||||
@ -70,6 +73,8 @@ const HeaderMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
|
||||
canSearch,
|
||||
canMute,
|
||||
canLeave,
|
||||
canEnterVoiceChat,
|
||||
canCreateVoiceChat,
|
||||
chat,
|
||||
isPrivate,
|
||||
isMuted,
|
||||
@ -84,6 +89,8 @@ const HeaderMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
|
||||
enterMessageSelectMode,
|
||||
sendBotCommand,
|
||||
restartBot,
|
||||
joinGroupCall,
|
||||
createGroupCall,
|
||||
openLinkedChat,
|
||||
addContact,
|
||||
}) => {
|
||||
@ -121,6 +128,20 @@ const HeaderMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
|
||||
closeMenu();
|
||||
}, [chatId, closeMenu, isMuted, updateChatMutedState]);
|
||||
|
||||
const handleEnterVoiceChatClick = useCallback(() => {
|
||||
if (canCreateVoiceChat) {
|
||||
// TODO show popup to schedule
|
||||
createGroupCall({
|
||||
chatId,
|
||||
});
|
||||
} else {
|
||||
joinGroupCall({
|
||||
chatId,
|
||||
});
|
||||
}
|
||||
closeMenu();
|
||||
}, [closeMenu, canCreateVoiceChat, chatId, joinGroupCall, createGroupCall]);
|
||||
|
||||
const handleLinkedChatClick = useCallback(() => {
|
||||
openLinkedChat({ id: chatId });
|
||||
closeMenu();
|
||||
@ -211,6 +232,14 @@ const HeaderMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
|
||||
{lang(isMuted ? 'ChatsUnmute' : 'ChatsMute')}
|
||||
</MenuItem>
|
||||
)}
|
||||
{(canEnterVoiceChat || canCreateVoiceChat) && (
|
||||
<MenuItem
|
||||
icon="voice-chat"
|
||||
onClick={handleEnterVoiceChatClick}
|
||||
>
|
||||
{lang(canCreateVoiceChat ? 'StartVoipChat' : 'VoipGroupJoinCall')}
|
||||
</MenuItem>
|
||||
)}
|
||||
{hasLinkedChat && (
|
||||
<MenuItem
|
||||
icon={isChannel ? 'comments' : 'channel'}
|
||||
@ -273,6 +302,8 @@ export default memo(withGlobal<OwnProps>(
|
||||
'enterMessageSelectMode',
|
||||
'sendBotCommand',
|
||||
'restartBot',
|
||||
'joinGroupCall',
|
||||
'createGroupCall',
|
||||
'openLinkedChat',
|
||||
'addContact',
|
||||
]),
|
||||
|
||||
@ -302,8 +302,9 @@
|
||||
|
||||
.Avatar {
|
||||
margin-right: .625rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
// TODO For some reason webpack imports `Audio.scss` second time when loading calls bundle
|
||||
width: 2.5rem !important;
|
||||
height: 2.5rem !important;
|
||||
font-size: 1.0625rem;
|
||||
}
|
||||
|
||||
|
||||
@ -61,6 +61,7 @@ import Button from '../ui/Button';
|
||||
import HeaderActions from './HeaderActions';
|
||||
import HeaderPinnedMessage from './HeaderPinnedMessage';
|
||||
import AudioPlayer from './AudioPlayer';
|
||||
import GroupCallTopPane from '../calls/group/GroupCallTopPane';
|
||||
|
||||
import './MiddleHeader.scss';
|
||||
|
||||
@ -404,6 +405,14 @@ const MiddleHeader: FC<OwnProps & StateProps & DispatchProps> = ({
|
||||
{renderInfo}
|
||||
</Transition>
|
||||
|
||||
<GroupCallTopPane
|
||||
hasPinnedOffset={
|
||||
(shouldRenderPinnedMessage && !!renderingPinnedMessage)
|
||||
|| (shouldRenderAudioPlayer && !!renderingAudioMessage)
|
||||
}
|
||||
chatId={chatId}
|
||||
/>
|
||||
|
||||
{shouldRenderPinnedMessage && renderingPinnedMessage && (
|
||||
<HeaderPinnedMessage
|
||||
key={chatId}
|
||||
|
||||
@ -270,6 +270,16 @@ const ManageGroupAdminRights: FC<OwnProps & StateProps & DispatchProps> = ({
|
||||
onChange={handlePermissionChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="ListItem no-selection">
|
||||
<Checkbox
|
||||
name="manageCall"
|
||||
checked={!!permissions.manageCall}
|
||||
label={lang('StartVoipChatPermission')}
|
||||
blocking
|
||||
disabled={getControlIsDisabled('manageCall')}
|
||||
onChange={handlePermissionChange}
|
||||
/>
|
||||
</div>
|
||||
{!isChannel && (
|
||||
<div className="ListItem no-selection">
|
||||
<Checkbox
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
transform: translateY(5rem);
|
||||
transform: translateY(calc(5rem - var(--group-call-header-height, 0rem)));
|
||||
transition: transform .25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
body.animation-level-0 & {
|
||||
@ -10,6 +10,6 @@
|
||||
}
|
||||
|
||||
&.revealed {
|
||||
transform: translateY(0);
|
||||
transform: translateY(calc(0rem - var(--group-call-header-height, 0rem)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,5 +3,9 @@
|
||||
|
||||
&:hover {
|
||||
color: inherit;
|
||||
|
||||
&.GroupCallLink {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { RefObject } from 'react';
|
||||
import React, {
|
||||
FC, useEffect, useRef,
|
||||
} from '../../lib/teact/teact';
|
||||
@ -31,6 +32,7 @@ type OwnProps = {
|
||||
onClose: () => void;
|
||||
onCloseAnimationEnd?: () => void;
|
||||
onEnter?: () => void;
|
||||
dialogRef?: RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
type StateProps = {
|
||||
@ -38,6 +40,7 @@ type StateProps = {
|
||||
};
|
||||
|
||||
const Modal: FC<OwnProps & StateProps> = ({
|
||||
dialogRef,
|
||||
title,
|
||||
className,
|
||||
isOpen,
|
||||
@ -78,7 +81,6 @@ const Modal: FC<OwnProps & StateProps> = ({
|
||||
|
||||
useEffectWithPrevDeps(([prevIsOpen]) => {
|
||||
document.body.classList.toggle('has-open-dialog', isOpen);
|
||||
|
||||
if (isOpen || (!isOpen && prevIsOpen !== undefined)) {
|
||||
dispatchHeavyAnimationEvent(ANIMATION_DURATION);
|
||||
}
|
||||
@ -138,7 +140,7 @@ const Modal: FC<OwnProps & StateProps> = ({
|
||||
>
|
||||
<div className="modal-container">
|
||||
<div className="modal-backdrop" onClick={onClose} />
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-dialog" ref={dialogRef}>
|
||||
{renderHeader()}
|
||||
<div className="modal-content custom-scroll">
|
||||
{children}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
@import '../../styles/mixins';
|
||||
|
||||
@mixin thumb-styles() {
|
||||
background: var(--slider-color);
|
||||
border: none;
|
||||
@ -72,43 +74,7 @@
|
||||
}
|
||||
|
||||
// Reset range input browser styles
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: transparent;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::-ms-track {
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
&::-moz-slider-thumb {
|
||||
-moz-appearance: none;
|
||||
}
|
||||
|
||||
&::-webkit-slider-runnable-track {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&::-moz-range-track, &::-moz-range-progress {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
@include reset-range();
|
||||
|
||||
// Apply custom styles
|
||||
input[type="range"] {
|
||||
|
||||
@ -58,6 +58,7 @@ export const BLOCKED_LIST_LIMIT = 100;
|
||||
export const PROFILE_PHOTOS_LIMIT = 40;
|
||||
export const PROFILE_SENSITIVE_AREA = 500;
|
||||
export const COMMON_CHATS_LIMIT = 100;
|
||||
export const GROUP_CALL_PARTICIPANTS_LIMIT = 100;
|
||||
|
||||
export const TOP_CHAT_MESSAGES_PRELOAD_LIMIT = 20;
|
||||
export const ALL_CHATS_PRELOAD_DISABLED = false;
|
||||
@ -170,3 +171,8 @@ export const LIGHT_THEME_BG_COLOR = '#A2AF8E';
|
||||
export const DARK_THEME_BG_COLOR = '#0F0F0F';
|
||||
export const DARK_THEME_PATTERN_COLOR = '#0a0a0a8c';
|
||||
export const DEFAULT_PATTERN_COLOR = 'rgba(90, 110, 70, 0.6)';
|
||||
|
||||
// Group calls
|
||||
export const GROUP_CALL_VOLUME_MULTIPLIER = 100;
|
||||
export const GROUP_CALL_DEFAULT_VOLUME = 100 * GROUP_CALL_VOLUME_MULTIPLIER;
|
||||
export const ENABLE_THUMBNAIL_VIDEO = false;
|
||||
|
||||
@ -189,6 +189,10 @@ function migrateCache(cached: GlobalState, initialState: GlobalState) {
|
||||
if (cached.audioPlayer.playbackRate === undefined) {
|
||||
cached.audioPlayer.playbackRate = DEFAULT_PLAYBACK_RATE;
|
||||
}
|
||||
|
||||
if (cached.groupCalls === undefined) {
|
||||
cached.groupCalls = initialState.groupCalls;
|
||||
}
|
||||
}
|
||||
|
||||
function updateCache() {
|
||||
|
||||
@ -44,6 +44,10 @@ export const INITIAL_STATE: GlobalState = {
|
||||
messageLists: [],
|
||||
},
|
||||
|
||||
groupCalls: {
|
||||
byId: {},
|
||||
},
|
||||
|
||||
scheduledMessages: {
|
||||
byChatId: {},
|
||||
},
|
||||
|
||||
@ -21,6 +21,7 @@ import {
|
||||
ApiInviteInfo,
|
||||
ApiCountryCode,
|
||||
ApiCountry,
|
||||
ApiGroupCall,
|
||||
} from '../api/types';
|
||||
import {
|
||||
FocusDirection,
|
||||
@ -163,6 +164,12 @@ export type GlobalState = {
|
||||
};
|
||||
};
|
||||
|
||||
groupCalls: {
|
||||
byId: Record<string, ApiGroupCall>;
|
||||
activeGroupCallId?: string;
|
||||
isGroupCallPanelHidden?: boolean;
|
||||
};
|
||||
|
||||
scheduledMessages: {
|
||||
byChatId: Record<string, {
|
||||
byId: Record<number, ApiMessage>;
|
||||
@ -539,7 +546,12 @@ export type ActionTypes = (
|
||||
// payment
|
||||
'openPaymentModal' | 'closePaymentModal' | 'addPaymentError' |
|
||||
'validateRequestedInfo' | 'setPaymentStep' | 'sendPaymentForm' | 'getPaymentForm' | 'getReceipt' |
|
||||
'sendCredentialsInfo' | 'setInvoiceMessageInfo' | 'clearPaymentError' | 'clearReceipt'
|
||||
'sendCredentialsInfo' | 'setInvoiceMessageInfo' | 'clearPaymentError' | 'clearReceipt' |
|
||||
// calls
|
||||
'joinGroupCall' | 'toggleGroupCallMute' | 'toggleGroupCallPresentation' | 'leaveGroupCall' |
|
||||
'toggleGroupCallVideo' | 'requestToSpeak' | 'setGroupCallParticipantVolume' | 'toggleGroupCallPanel' |
|
||||
'createGroupCall' | 'joinVoiceChatByLink' | 'subscribeToGroupCallUpdates' | 'createGroupCallInviteLink' |
|
||||
'loadMoreGroupCallParticipants' | 'connectToActiveGroupCall' | 'playGroupCallSound'
|
||||
);
|
||||
|
||||
export type GlobalActions = Record<ActionTypes, (...args: any[]) => void>;
|
||||
|
||||
@ -1100,4 +1100,15 @@ langpack.getLangPack#f2f2330a lang_pack:string lang_code:string = LangPackDiffer
|
||||
langpack.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector<string> = Vector<LangPackString>;
|
||||
langpack.getLanguages#42c6978f lang_pack:string = Vector<LangPackLanguage>;
|
||||
folders.editPeerFolders#6847d0ab folder_peers:Vector<InputFolderPeer> = Updates;
|
||||
phone.createGroupCall#48cdc6d8 flags:# peer:InputPeer random_id:int title:flags.0?string schedule_date:flags.1?int = Updates;
|
||||
phone.joinGroupCall#b132ff7b flags:# muted:flags.0?true video_stopped:flags.2?true call:InputGroupCall join_as:InputPeer invite_hash:flags.1?string params:DataJSON = Updates;
|
||||
phone.leaveGroupCall#500377f9 call:InputGroupCall source:int = Updates;
|
||||
phone.discardGroupCall#7a777135 call:InputGroupCall = Updates;
|
||||
phone.getGroupCall#41845db call:InputGroupCall limit:int = phone.GroupCall;
|
||||
phone.getGroupParticipants#c558d8ab call:InputGroupCall ids:Vector<InputPeer> sources:Vector<int> offset:string limit:int = phone.GroupParticipants;
|
||||
phone.editGroupCallParticipant#a5273abf flags:# call:InputGroupCall participant:InputPeer muted:flags.0?Bool volume:flags.1?int raise_hand:flags.2?Bool video_stopped:flags.3?Bool video_paused:flags.4?Bool presentation_paused:flags.5?Bool = Updates;
|
||||
phone.exportGroupCallInvite#e6aa647f flags:# can_self_unmute:flags.0?true call:InputGroupCall = phone.ExportedGroupCallInvite;
|
||||
phone.toggleGroupCallStartSubscription#219c34e6 call:InputGroupCall subscribed:Bool = Updates;
|
||||
phone.joinGroupCallPresentation#cbea6bc4 call:InputGroupCall params:DataJSON = Updates;
|
||||
phone.leaveGroupCallPresentation#1c50d144 call:InputGroupCall = Updates;
|
||||
`;
|
||||
@ -1101,4 +1101,15 @@ langpack.getLangPack#f2f2330a lang_pack:string lang_code:string = LangPackDiffer
|
||||
langpack.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector<string> = Vector<LangPackString>;
|
||||
langpack.getLanguages#42c6978f lang_pack:string = Vector<LangPackLanguage>;
|
||||
folders.editPeerFolders#6847d0ab folder_peers:Vector<InputFolderPeer> = Updates;
|
||||
phone.createGroupCall#48cdc6d8 flags:# peer:InputPeer random_id:int title:flags.0?string schedule_date:flags.1?int = Updates;
|
||||
phone.joinGroupCall#b132ff7b flags:# muted:flags.0?true video_stopped:flags.2?true call:InputGroupCall join_as:InputPeer invite_hash:flags.1?string params:DataJSON = Updates;
|
||||
phone.leaveGroupCall#500377f9 call:InputGroupCall source:int = Updates;
|
||||
phone.discardGroupCall#7a777135 call:InputGroupCall = Updates;
|
||||
phone.getGroupCall#41845db call:InputGroupCall limit:int = phone.GroupCall;
|
||||
phone.getGroupParticipants#c558d8ab call:InputGroupCall ids:Vector<InputPeer> sources:Vector<int> offset:string limit:int = phone.GroupParticipants;
|
||||
phone.editGroupCallParticipant#a5273abf flags:# call:InputGroupCall participant:InputPeer muted:flags.0?Bool volume:flags.1?int raise_hand:flags.2?Bool video_stopped:flags.3?Bool video_paused:flags.4?Bool presentation_paused:flags.5?Bool = Updates;
|
||||
phone.exportGroupCallInvite#e6aa647f flags:# can_self_unmute:flags.0?true call:InputGroupCall = phone.ExportedGroupCallInvite;
|
||||
phone.toggleGroupCallStartSubscription#219c34e6 call:InputGroupCall subscribed:Bool = Updates;
|
||||
phone.joinGroupCallPresentation#cbea6bc4 call:InputGroupCall params:DataJSON = Updates;
|
||||
phone.leaveGroupCallPresentation#1c50d144 call:InputGroupCall = Updates;
|
||||
// LAYER 133
|
||||
|
||||
@ -89,6 +89,7 @@ class RLottie {
|
||||
private animationData: AnyLiteral,
|
||||
private params: Params = {},
|
||||
private onLoad?: () => void,
|
||||
private customColor?: [number, number, number],
|
||||
) {
|
||||
this.initContainer();
|
||||
this.initConfig();
|
||||
@ -199,6 +200,46 @@ class RLottie {
|
||||
this.canvas.remove();
|
||||
}
|
||||
|
||||
private onChangeData(framesCount: number) {
|
||||
this.isWaiting = false;
|
||||
this.framesCount = framesCount;
|
||||
this.chunksCount = Math.ceil(framesCount / this.chunkSize);
|
||||
this.isAnimating = false;
|
||||
|
||||
this.doPlay();
|
||||
}
|
||||
|
||||
setColor(newColor: [number, number, number] | undefined) {
|
||||
this.customColor = newColor;
|
||||
if (this.customColor) {
|
||||
const imageData = this.ctx.getImageData(0, 0, this.imgSize, this.imgSize);
|
||||
const arr = imageData.data;
|
||||
for (let i = 0; i < arr.length; i += 4) {
|
||||
/* eslint-disable prefer-destructuring */
|
||||
arr[i] = this.customColor[0];
|
||||
arr[i + 1] = this.customColor[1];
|
||||
arr[i + 2] = this.customColor[2];
|
||||
/* eslint-enable prefer-destructuring */
|
||||
}
|
||||
this.ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
changeData(animationData: AnyLiteral) {
|
||||
this.pause();
|
||||
this.animationData = animationData;
|
||||
this.initConfig();
|
||||
|
||||
workers[this.workerIndex].request({
|
||||
name: 'changeData',
|
||||
args: [
|
||||
this.key,
|
||||
this.animationData,
|
||||
this.onChangeData.bind(this),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private initRenderer() {
|
||||
this.workerIndex = cycleRestrict(MAX_WORKERS, ++lastWorkerIndex);
|
||||
|
||||
@ -264,7 +305,8 @@ class RLottie {
|
||||
const frameIndex = Math.round(this.approxFrameIndex);
|
||||
const chunkIndex = this.getChunkIndex(frameIndex);
|
||||
const chunk = this.chunks[chunkIndex];
|
||||
if (!chunk) {
|
||||
|
||||
if (!chunk || chunk.length === 0) {
|
||||
this.requestChunk(chunkIndex);
|
||||
this.isAnimating = false;
|
||||
this.isWaiting = true;
|
||||
@ -285,7 +327,17 @@ class RLottie {
|
||||
return false;
|
||||
}
|
||||
|
||||
const imageData = new ImageData(new Uint8ClampedArray(frame), this.imgSize, this.imgSize);
|
||||
const arr = new Uint8ClampedArray(frame);
|
||||
if (this.customColor) {
|
||||
for (let i = 0; i < arr.length; i += 4) {
|
||||
/* eslint-disable prefer-destructuring */
|
||||
arr[i] = this.customColor[0];
|
||||
arr[i + 1] = this.customColor[1];
|
||||
arr[i + 2] = this.customColor[2];
|
||||
/* eslint-enable prefer-destructuring */
|
||||
}
|
||||
}
|
||||
const imageData = new ImageData(arr, this.imgSize, this.imgSize);
|
||||
this.ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
if (this.onLoad && !this.isOnLoadFired) {
|
||||
@ -373,7 +425,7 @@ class RLottie {
|
||||
}
|
||||
|
||||
private requestChunk(chunkIndex: number) {
|
||||
if (this.chunks[chunkIndex]) {
|
||||
if (this.chunks[chunkIndex] && this.chunks[chunkIndex]?.length !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -56,6 +56,23 @@ async function init(
|
||||
onInit(Math.ceil(framesCount / reduceFactor));
|
||||
}
|
||||
|
||||
async function changeData(
|
||||
key: string,
|
||||
animationData: AnyLiteral,
|
||||
onInit: CancellableCallback,
|
||||
) {
|
||||
if (!rLottieApi) {
|
||||
await rLottieApiPromise;
|
||||
}
|
||||
|
||||
const json = JSON.stringify(animationData);
|
||||
const { reduceFactor, handle } = renderers.get(key)!;
|
||||
|
||||
const stringOnWasmHeap = allocate(intArrayFromString(json), 'i8', 0);
|
||||
const framesCount = rLottieApi.loadFromData(handle, stringOnWasmHeap);
|
||||
onInit(Math.ceil(framesCount / reduceFactor));
|
||||
}
|
||||
|
||||
async function renderFrames(
|
||||
key: string, fromIndex: number, toIndex: number, onProgress: CancellableCallback,
|
||||
) {
|
||||
@ -86,6 +103,7 @@ function destroy(key: string) {
|
||||
|
||||
createWorkerInterface({
|
||||
init,
|
||||
changeData,
|
||||
renderFrames,
|
||||
destroy,
|
||||
});
|
||||
|
||||
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/misc';
|
||||
import './ui/payments';
|
||||
import './ui/calls';
|
||||
|
||||
import './api/initial';
|
||||
import './api/chats';
|
||||
@ -31,3 +32,4 @@ import './apiUpdaters/symbols';
|
||||
import './apiUpdaters/misc';
|
||||
import './apiUpdaters/settings';
|
||||
import './apiUpdaters/twoFaSettings';
|
||||
import './apiUpdaters/calls';
|
||||
|
||||
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,
|
||||
selectThreadInfo, selectCurrentChat, selectLastServiceNotification,
|
||||
} from '../../selectors';
|
||||
import { buildCollectionByKey } from '../../../util/iteratees';
|
||||
import { buildCollectionByKey, omit } from '../../../util/iteratees';
|
||||
import { debounce, pause, throttle } from '../../../util/schedulers';
|
||||
import {
|
||||
isChatSummaryOnly, isChatArchived, prepareChatList, isChatBasicGroup,
|
||||
} from '../../helpers';
|
||||
import { processDeepLink } from '../../../util/deeplink';
|
||||
import { updateGroupCall } from '../../reducers/calls';
|
||||
import { selectGroupCall } from '../../selectors/calls';
|
||||
|
||||
const TOP_CHAT_MESSAGES_PRELOAD_INTERVAL = 100;
|
||||
const CHATS_PRELOAD_INTERVAL = 300;
|
||||
@ -567,7 +569,13 @@ addReducer('openTelegramLink', (global, actions, payload) => {
|
||||
const chatOrChannelPostId = part2 ? Number(part2) : undefined;
|
||||
const messageId = part3 ? Number(part3) : undefined;
|
||||
const commentId = params.comment ? Number(params.comment) : undefined;
|
||||
if (part1 === 'c' && chatOrChannelPostId && messageId) {
|
||||
|
||||
if (params.hasOwnProperty('voicechat') || params.hasOwnProperty('livestream')) {
|
||||
actions.joinVoiceChatByLink({
|
||||
username: part1,
|
||||
inviteHash: params.voicechat || params.livestream,
|
||||
});
|
||||
} else if (part1 === 'c' && chatOrChannelPostId && messageId) {
|
||||
actions.focusMessage({
|
||||
chatId: -chatOrChannelPostId,
|
||||
messageId,
|
||||
@ -869,7 +877,7 @@ addReducer('linkDiscussionGroup', (global, actions, payload) => {
|
||||
fullInfo = fullChat.fullInfo;
|
||||
}
|
||||
|
||||
if (fullInfo.isPreHistoryHidden) {
|
||||
if (fullInfo!.isPreHistoryHidden) {
|
||||
await callApi('togglePreHistoryHidden', { chat, isEnabled: false });
|
||||
}
|
||||
|
||||
@ -1031,21 +1039,35 @@ async function loadChats(listType: 'active' | 'archived', offsetId?: string, off
|
||||
setGlobal(global);
|
||||
}
|
||||
|
||||
async function loadFullChat(chat: ApiChat) {
|
||||
export async function loadFullChat(chat: ApiChat) {
|
||||
const result = await callApi('fetchFullChat', chat);
|
||||
if (!result) {
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { users, fullInfo } = result;
|
||||
const { users, fullInfo, groupCall } = result;
|
||||
|
||||
let global = getGlobal();
|
||||
if (users) {
|
||||
global = addUsers(global, buildCollectionByKey(users, 'id'));
|
||||
}
|
||||
|
||||
if (groupCall) {
|
||||
const existingGroupCall = selectGroupCall(global, groupCall.id!);
|
||||
global = updateGroupCall(
|
||||
global,
|
||||
groupCall.id!,
|
||||
omit(groupCall, ['connectionState']),
|
||||
undefined,
|
||||
existingGroupCall ? undefined : groupCall.participantsCount,
|
||||
);
|
||||
}
|
||||
|
||||
global = updateChat(global, chat.id, { fullInfo });
|
||||
|
||||
setGlobal(global);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function createChannel(title: string, users: ApiUser[], about?: string, photo?: File) {
|
||||
@ -1203,7 +1225,7 @@ async function deleteChatFolder(id: number) {
|
||||
await callApi('deleteChatFolder', id);
|
||||
}
|
||||
|
||||
async function fetchChatByUsername(
|
||||
export async function fetchChatByUsername(
|
||||
username: string,
|
||||
) {
|
||||
const global = getGlobal();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user