Calls: Introduce peer-to-peer calls (#1791)
This commit is contained in:
parent
075893c37e
commit
1da41443ca
@ -24,6 +24,12 @@
|
||||
}
|
||||
],
|
||||
"plugin/stylelint-group-selectors": [true, { "severity": "warning" }],
|
||||
"plugin/whole-pixel": [true, { "ignoreList": ["letter-spacing"] }]
|
||||
"plugin/whole-pixel": [true, { "ignoreList": ["letter-spacing"] }],
|
||||
"selector-pseudo-class-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignorePseudoClasses": ["global"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/call_busy.mp3
Normal file
BIN
public/call_busy.mp3
Normal file
Binary file not shown.
BIN
public/call_connect.mp3
Normal file
BIN
public/call_connect.mp3
Normal file
Binary file not shown.
BIN
public/call_end.mp3
Normal file
BIN
public/call_end.mp3
Normal file
Binary file not shown.
BIN
public/call_incoming.mp3
Normal file
BIN
public/call_incoming.mp3
Normal file
Binary file not shown.
BIN
public/call_ringing.mp3
Normal file
BIN
public/call_ringing.mp3
Normal file
Binary file not shown.
2
src/@types/global.d.ts
vendored
2
src/@types/global.d.ts
vendored
@ -1,5 +1,7 @@
|
||||
declare const process: NodeJS.Process;
|
||||
|
||||
declare module '*.module.scss';
|
||||
|
||||
declare const APP_REVISION: string;
|
||||
|
||||
declare namespace React {
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import { GroupCallParticipant, GroupCallParticipantVideo, SsrcGroup } from '../../../lib/secret-sauce';
|
||||
import type {
|
||||
ApiCallProtocol,
|
||||
ApiPhoneCallConnection,
|
||||
GroupCallParticipant,
|
||||
GroupCallParticipantVideo,
|
||||
SsrcGroup,
|
||||
} from '../../../lib/secret-sauce';
|
||||
import { Api as GramJs } from '../../../lib/gramjs';
|
||||
import { ApiGroupCall } from '../../types';
|
||||
import { ApiGroupCall, ApiPhoneCall } from '../../types';
|
||||
import { getApiChatIdFromMtpPeer, isPeerUser } from './peers';
|
||||
|
||||
export function buildApiGroupCallParticipant(participant: GramJs.GroupCallParticipant): GroupCallParticipant {
|
||||
@ -96,3 +102,139 @@ export function buildApiGroupCall(groupCall: GramJs.TypeGroupCall): ApiGroupCall
|
||||
export function getGroupCallId(groupCall: GramJs.TypeInputGroupCall) {
|
||||
return groupCall.id.toString();
|
||||
}
|
||||
|
||||
export function buildPhoneCall(call: GramJs.TypePhoneCall): ApiPhoneCall {
|
||||
const { id } = call;
|
||||
|
||||
let phoneCall: ApiPhoneCall = {
|
||||
id: id.toString(),
|
||||
};
|
||||
|
||||
if (call instanceof GramJs.PhoneCallAccepted
|
||||
|| call instanceof GramJs.PhoneCallWaiting
|
||||
|| call instanceof GramJs.PhoneCall
|
||||
|| call instanceof GramJs.PhoneCallRequested) {
|
||||
const {
|
||||
accessHash, adminId, date, video, participantId, protocol,
|
||||
} = call;
|
||||
|
||||
phoneCall = {
|
||||
...phoneCall,
|
||||
accessHash: accessHash.toString(),
|
||||
adminId: adminId.toString(),
|
||||
participantId: participantId.toString(),
|
||||
date,
|
||||
isVideo: video,
|
||||
protocol: buildApiCallProtocol(protocol),
|
||||
};
|
||||
}
|
||||
|
||||
if (call instanceof GramJs.PhoneCall) {
|
||||
const {
|
||||
p2pAllowed, gAOrB, keyFingerprint, connections, startDate,
|
||||
} = call;
|
||||
|
||||
phoneCall = {
|
||||
...phoneCall,
|
||||
state: 'active',
|
||||
gAOrB: Array.from(gAOrB),
|
||||
keyFingerprint: keyFingerprint.toString(),
|
||||
startDate,
|
||||
p2pAllowed,
|
||||
connections: connections.map(buildApiCallConnection).filter(Boolean) as ApiPhoneCallConnection[],
|
||||
};
|
||||
}
|
||||
|
||||
if (call instanceof GramJs.PhoneCallDiscarded) {
|
||||
phoneCall = {
|
||||
...phoneCall,
|
||||
state: 'discarded',
|
||||
duration: call.duration,
|
||||
reason: buildApiCallDiscardReason(call.reason),
|
||||
needRating: call.needRating,
|
||||
needDebug: call.needDebug,
|
||||
};
|
||||
}
|
||||
|
||||
if (call instanceof GramJs.PhoneCallWaiting) {
|
||||
phoneCall = {
|
||||
...phoneCall,
|
||||
state: 'waiting',
|
||||
receiveDate: call.receiveDate,
|
||||
};
|
||||
}
|
||||
|
||||
if (call instanceof GramJs.PhoneCallAccepted) {
|
||||
phoneCall = {
|
||||
...phoneCall,
|
||||
state: 'accepted',
|
||||
gB: Array.from(call.gB),
|
||||
};
|
||||
}
|
||||
|
||||
if (call instanceof GramJs.PhoneCallRequested) {
|
||||
phoneCall = {
|
||||
...phoneCall,
|
||||
state: 'requested',
|
||||
gAHash: Array.from(call.gAHash),
|
||||
};
|
||||
}
|
||||
|
||||
return phoneCall;
|
||||
}
|
||||
|
||||
export function buildApiCallDiscardReason(discardReason?: GramJs.TypePhoneCallDiscardReason) {
|
||||
if (discardReason instanceof GramJs.PhoneCallDiscardReasonMissed) {
|
||||
return 'missed';
|
||||
} else if (discardReason instanceof GramJs.PhoneCallDiscardReasonBusy) {
|
||||
return 'busy';
|
||||
} else if (discardReason instanceof GramJs.PhoneCallDiscardReasonHangup) {
|
||||
return 'hangup';
|
||||
} else {
|
||||
return 'disconnect';
|
||||
}
|
||||
}
|
||||
|
||||
function buildApiCallConnection(connection: GramJs.TypePhoneConnection): ApiPhoneCallConnection | undefined {
|
||||
if (connection instanceof GramJs.PhoneConnectionWebrtc) {
|
||||
const {
|
||||
username, password, turn, stun, ip, ipv6, port,
|
||||
} = connection;
|
||||
|
||||
return {
|
||||
username,
|
||||
password,
|
||||
isTurn: turn,
|
||||
isStun: stun,
|
||||
ip,
|
||||
ipv6,
|
||||
port,
|
||||
};
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildApiCallProtocol(protocol: GramJs.PhoneCallProtocol): ApiCallProtocol {
|
||||
const {
|
||||
libraryVersions, minLayer, maxLayer, udpP2p, udpReflector,
|
||||
} = protocol;
|
||||
|
||||
return {
|
||||
libraryVersions,
|
||||
minLayer,
|
||||
maxLayer,
|
||||
isUdpP2p: udpP2p,
|
||||
isUdpReflector: udpReflector,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCallProtocol() {
|
||||
return new GramJs.PhoneCallProtocol({
|
||||
libraryVersions: ['4.0.0'],
|
||||
minLayer: 92,
|
||||
maxLayer: 92,
|
||||
udpReflector: true,
|
||||
udpP2p: true,
|
||||
});
|
||||
}
|
||||
|
||||
@ -30,6 +30,7 @@ import {
|
||||
ApiUser,
|
||||
ApiLocation,
|
||||
ApiGame,
|
||||
PhoneCallAction,
|
||||
} from '../../types';
|
||||
|
||||
import {
|
||||
@ -50,6 +51,7 @@ import { interpolateArray } from '../../../util/waveform';
|
||||
import { buildPeer } from '../gramjsBuilders';
|
||||
import { addPhotoToLocalDb, resolveMessageApiChatId, serializeBytes } from '../helpers';
|
||||
import { buildApiPeerId, getApiChatIdFromMtpPeer, isPeerUser } from './peers';
|
||||
import { buildApiCallDiscardReason } from './calls';
|
||||
|
||||
const LOCAL_MEDIA_UPLOADING_TEMP_ID = 'temp';
|
||||
const INPUT_WAVEFORM_LENGTH = 63;
|
||||
@ -785,6 +787,7 @@ function buildAction(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let phoneCall: PhoneCallAction | undefined;
|
||||
let call: Partial<ApiGroupCall> | undefined;
|
||||
let amount: number | undefined;
|
||||
let currency: string | undefined;
|
||||
@ -871,6 +874,13 @@ function buildAction(
|
||||
const mins = Math.max(Math.round(action.duration! / 60), 1);
|
||||
translationValues.push(`${mins} min${mins > 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
phoneCall = {
|
||||
isOutgoing,
|
||||
isVideo: action.video,
|
||||
duration: action.duration,
|
||||
reason: buildApiCallDiscardReason(action.reason),
|
||||
};
|
||||
} else if (action instanceof GramJs.MessageActionInviteToGroupCall) {
|
||||
text = 'Notification.VoiceChatInvitation';
|
||||
call = {
|
||||
@ -933,6 +943,7 @@ function buildAction(
|
||||
currency,
|
||||
translationValues,
|
||||
call,
|
||||
phoneCall,
|
||||
score,
|
||||
};
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
ApiGroupCall,
|
||||
ApiMessageEntity,
|
||||
ApiMessageEntityTypes,
|
||||
ApiNewPoll,
|
||||
ApiNewPoll, ApiPhoneCall,
|
||||
ApiReportReason,
|
||||
ApiSendMessageAction,
|
||||
ApiSticker,
|
||||
@ -465,3 +465,10 @@ export function buildInputGroupCall(groupCall: Partial<ApiGroupCall>) {
|
||||
accessHash: BigInt(groupCall.accessHash!),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildInputPhoneCall({ id, accessHash }: ApiPhoneCall) {
|
||||
return new GramJs.InputPhoneCall({
|
||||
id: BigInt(id),
|
||||
accessHash: BigInt(accessHash!),
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
import { JoinGroupCallPayload } from '../../../lib/secret-sauce';
|
||||
import BigInt from 'big-integer';
|
||||
import type { JoinGroupCallPayload } from '../../../lib/secret-sauce';
|
||||
import {
|
||||
ApiChat, ApiUser, OnApiUpdate, ApiGroupCall,
|
||||
ApiChat, ApiUser, OnApiUpdate, ApiGroupCall, ApiPhoneCall,
|
||||
} from '../../types';
|
||||
import { Api as GramJs } from '../../../lib/gramjs';
|
||||
|
||||
import { invokeRequest } from './client';
|
||||
import { buildInputGroupCall, buildInputPeer, generateRandomInt } from '../gramjsBuilders';
|
||||
import {
|
||||
buildInputGroupCall, buildInputPeer, buildInputPhoneCall, generateRandomInt,
|
||||
} from '../gramjsBuilders';
|
||||
import {
|
||||
buildCallProtocol,
|
||||
buildApiGroupCall,
|
||||
buildApiGroupCallParticipant,
|
||||
buildApiGroupCallParticipant, buildPhoneCall,
|
||||
|
||||
} from '../apiBuilders/calls';
|
||||
import { buildApiUser } from '../apiBuilders/users';
|
||||
@ -234,3 +238,131 @@ export function leaveGroupCallPresentation({
|
||||
call: buildInputGroupCall(call),
|
||||
}), true);
|
||||
}
|
||||
|
||||
export async function getDhConfig() {
|
||||
const dhConfig = await invokeRequest(new GramJs.messages.GetDhConfig({}));
|
||||
|
||||
if (!dhConfig || dhConfig instanceof GramJs.messages.DhConfigNotModified) return undefined;
|
||||
|
||||
return {
|
||||
g: dhConfig.g,
|
||||
p: Array.from(dhConfig.p),
|
||||
random: Array.from(dhConfig.random),
|
||||
};
|
||||
}
|
||||
|
||||
export function discardCall({
|
||||
call, isBusy,
|
||||
}: {
|
||||
call: ApiPhoneCall; isBusy?: boolean;
|
||||
}) {
|
||||
return invokeRequest(new GramJs.phone.DiscardCall({
|
||||
peer: buildInputPhoneCall(call),
|
||||
reason: isBusy ? new GramJs.PhoneCallDiscardReasonBusy() : new GramJs.PhoneCallDiscardReasonHangup(),
|
||||
}), true);
|
||||
}
|
||||
|
||||
export async function requestCall({
|
||||
user, gAHash, isVideo,
|
||||
}: {
|
||||
user: ApiUser; gAHash: number[]; isVideo?: boolean;
|
||||
}) {
|
||||
const result = await invokeRequest(new GramJs.phone.RequestCall({
|
||||
randomId: generateRandomInt(),
|
||||
userId: buildInputPeer(user.id, user.accessHash),
|
||||
gAHash: Buffer.from(gAHash),
|
||||
...(isVideo && { video: true }),
|
||||
protocol: buildCallProtocol(),
|
||||
}));
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const call = buildPhoneCall(result.phoneCall);
|
||||
|
||||
onUpdate({
|
||||
'@type': 'updatePhoneCall',
|
||||
call,
|
||||
});
|
||||
}
|
||||
|
||||
export function setCallRating({
|
||||
call, rating, comment,
|
||||
}: {
|
||||
call: ApiPhoneCall; rating: number; comment: string;
|
||||
}) {
|
||||
return invokeRequest(new GramJs.phone.SetCallRating({
|
||||
rating,
|
||||
peer: buildInputPhoneCall(call),
|
||||
comment,
|
||||
}), true);
|
||||
}
|
||||
|
||||
export function receivedCall({
|
||||
call,
|
||||
}: {
|
||||
call: ApiPhoneCall;
|
||||
}) {
|
||||
return invokeRequest(new GramJs.phone.ReceivedCall({
|
||||
peer: buildInputPhoneCall(call),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function acceptCall({
|
||||
call, gB,
|
||||
}: {
|
||||
call: ApiPhoneCall; gB: number[];
|
||||
}) {
|
||||
const result = await invokeRequest(new GramJs.phone.AcceptCall({
|
||||
peer: buildInputPhoneCall(call),
|
||||
gB: Buffer.from(gB),
|
||||
protocol: buildCallProtocol(),
|
||||
}));
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
call = buildPhoneCall(result.phoneCall);
|
||||
|
||||
onUpdate({
|
||||
'@type': 'updatePhoneCall',
|
||||
call,
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirmCall({
|
||||
call, gA, keyFingerprint,
|
||||
}: {
|
||||
call: ApiPhoneCall; gA: number[]; keyFingerprint: string;
|
||||
}) {
|
||||
const result = await invokeRequest(new GramJs.phone.ConfirmCall({
|
||||
peer: buildInputPhoneCall(call),
|
||||
gA: Buffer.from(gA),
|
||||
keyFingerprint: BigInt(keyFingerprint),
|
||||
protocol: buildCallProtocol(),
|
||||
}));
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
call = buildPhoneCall(result.phoneCall);
|
||||
|
||||
onUpdate({
|
||||
'@type': 'updatePhoneCall',
|
||||
call,
|
||||
});
|
||||
}
|
||||
|
||||
export function sendSignalingData({
|
||||
data, call,
|
||||
}: {
|
||||
data: number[]; call: ApiPhoneCall;
|
||||
}) {
|
||||
return invokeRequest(new GramJs.phone.SendSignalingData({
|
||||
data: Buffer.from(data),
|
||||
peer: buildInputPhoneCall(call),
|
||||
}));
|
||||
}
|
||||
|
||||
@ -70,6 +70,7 @@ export {
|
||||
getGroupCall, joinGroupCall, discardGroupCall, createGroupCall,
|
||||
editGroupCallTitle, editGroupCallParticipant, exportGroupCallInvite, fetchGroupCallParticipants,
|
||||
joinGroupCallPresentation, leaveGroupCall, leaveGroupCallPresentation, toggleGroupCallStartSubscription,
|
||||
requestCall, getDhConfig, confirmCall, sendSignalingData, acceptCall, discardCall, setCallRating, receivedCall,
|
||||
} from './calls';
|
||||
|
||||
export {
|
||||
@ -78,3 +79,8 @@ export {
|
||||
} from './reactions';
|
||||
|
||||
export { fetchChannelStatistics, fetchGroupStatistics, fetchStatisticsAsyncGraph } from './statistics';
|
||||
|
||||
export {
|
||||
acceptPhoneCall, confirmPhoneCall, requestPhoneCall, decodePhoneCallData, createPhoneCallState,
|
||||
destroyPhoneCallState, encodePhoneCallData,
|
||||
} from './phoneCallState';
|
||||
|
||||
184
src/api/gramjs/methods/phoneCallState.ts
Normal file
184
src/api/gramjs/methods/phoneCallState.ts
Normal file
@ -0,0 +1,184 @@
|
||||
import BigInt from 'big-integer';
|
||||
import type bigInt from 'big-integer';
|
||||
import MTProtoState from '../../../lib/gramjs/network/MTProtoState';
|
||||
import Logger from '../../../lib/gramjs/extensions/Logger';
|
||||
import Helpers from '../../../lib/gramjs/Helpers';
|
||||
import AuthKey from '../../../lib/gramjs/crypto/AuthKey';
|
||||
|
||||
type DhConfig = {
|
||||
p: number[];
|
||||
g: number;
|
||||
random: number[];
|
||||
};
|
||||
|
||||
let currentPhoneCallState: PhoneCallState | undefined;
|
||||
|
||||
class PhoneCallState {
|
||||
private state?: MTProtoState;
|
||||
|
||||
private seq = 0;
|
||||
|
||||
private gA?: bigInt.BigInteger;
|
||||
|
||||
private gB: any;
|
||||
|
||||
private p?: bigInt.BigInteger;
|
||||
|
||||
private random?: bigInt.BigInteger;
|
||||
|
||||
private waitForState: Promise<void>;
|
||||
|
||||
private resolveState?: VoidFunction;
|
||||
|
||||
constructor(
|
||||
private isOutgoing: boolean,
|
||||
) {
|
||||
this.waitForState = new Promise<void>((resolve) => {
|
||||
this.resolveState = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
async requestCall({ p, g, random }: DhConfig) {
|
||||
const pBN = Helpers.readBigIntFromBuffer(Buffer.from(p), false);
|
||||
const randomBN = Helpers.readBigIntFromBuffer(Buffer.from(random), false);
|
||||
|
||||
const gA = Helpers.modExp(BigInt(g), randomBN, pBN);
|
||||
|
||||
this.gA = gA;
|
||||
this.p = pBN;
|
||||
this.random = randomBN;
|
||||
|
||||
const gAHash: Buffer = await Helpers.sha256(Helpers.getByteArray(gA));
|
||||
return Array.from(gAHash);
|
||||
}
|
||||
|
||||
acceptCall({ p, g, random }: DhConfig) {
|
||||
const pLast = Helpers.readBigIntFromBuffer(p, false);
|
||||
const randomLast = Helpers.readBigIntFromBuffer(random, false);
|
||||
|
||||
const gB = Helpers.modExp(BigInt(g), randomLast, pLast);
|
||||
this.gB = gB;
|
||||
this.p = pLast;
|
||||
this.random = randomLast;
|
||||
|
||||
return Array.from(Helpers.getByteArray(gB));
|
||||
}
|
||||
|
||||
async confirmCall(gAOrB: number[], emojiData: Uint16Array, emojiOffsets: number[]) {
|
||||
if (this.isOutgoing) {
|
||||
this.gB = Helpers.readBigIntFromBuffer(Buffer.from(gAOrB), false);
|
||||
} else {
|
||||
this.gA = Helpers.readBigIntFromBuffer(Buffer.from(gAOrB), false);
|
||||
}
|
||||
const authKey = Helpers.modExp(
|
||||
!this.isOutgoing ? this.gA : this.gB,
|
||||
this.random,
|
||||
this.p,
|
||||
);
|
||||
const fingerprint: Buffer = await Helpers.sha1(Helpers.getByteArray(authKey));
|
||||
const keyFingerprint = Helpers.readBigIntFromBuffer(fingerprint.slice(-8).reverse(), false);
|
||||
|
||||
const emojis = await generateEmojiFingerprint(
|
||||
Helpers.getByteArray(authKey),
|
||||
Helpers.getByteArray(this.gA),
|
||||
emojiData,
|
||||
emojiOffsets,
|
||||
);
|
||||
|
||||
const key = new AuthKey();
|
||||
await key.setKey(Helpers.getByteArray(authKey));
|
||||
this.state = new MTProtoState(key, new Logger(), true, this.isOutgoing);
|
||||
this.resolveState!();
|
||||
|
||||
return { gA: Array.from(Helpers.getByteArray(this.gA)), keyFingerprint: keyFingerprint.toString(), emojis };
|
||||
}
|
||||
|
||||
async encode(data: string) {
|
||||
if (!this.state) return undefined;
|
||||
|
||||
const seqArray = new Uint32Array(1);
|
||||
seqArray[0] = this.seq++;
|
||||
const encodedData = await this.state.encryptMessageData(
|
||||
Buffer.concat([Helpers.convertToLittle(seqArray), Buffer.from(data)]),
|
||||
);
|
||||
return Array.from(encodedData);
|
||||
}
|
||||
|
||||
async decode(data: number[]): Promise<any> {
|
||||
if (!this.state) {
|
||||
return this.waitForState.then(() => {
|
||||
return this.decode(data);
|
||||
});
|
||||
}
|
||||
|
||||
const message = await this.state.decryptMessageData(Buffer.from(data));
|
||||
|
||||
return JSON.parse(message.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/TelegramV/App/blob/ead52320975362139cabad18cf8346f98c349a22/src/js/MTProto/Calls/Internal.js#L72
|
||||
function computeEmojiIndex(bytes: Uint8Array) {
|
||||
return ((BigInt(bytes[0]).and(0x7F)).shiftLeft(56))
|
||||
.or((BigInt(bytes[1]).shiftLeft(48)))
|
||||
.or((BigInt(bytes[2]).shiftLeft(40)))
|
||||
.or((BigInt(bytes[3]).shiftLeft(32)))
|
||||
.or((BigInt(bytes[4]).shiftLeft(24)))
|
||||
.or((BigInt(bytes[5]).shiftLeft(16)))
|
||||
.or((BigInt(bytes[6]).shiftLeft(8)))
|
||||
.or((BigInt(bytes[7])));
|
||||
}
|
||||
|
||||
export async function generateEmojiFingerprint(
|
||||
authKey: Uint8Array, gA: Uint8Array, emojiData: Uint16Array, emojiOffsets: number[],
|
||||
) {
|
||||
const hash = await Helpers.sha256(Buffer.concat([new Uint8Array(authKey), new Uint8Array(gA)]));
|
||||
const result = [];
|
||||
const emojiCount = emojiOffsets.length - 1;
|
||||
const kPartSize = 8;
|
||||
for (let partOffset = 0; partOffset !== hash.byteLength; partOffset += kPartSize) {
|
||||
const value = computeEmojiIndex(hash.subarray(partOffset, partOffset + kPartSize));
|
||||
const index = value.modPow(1, emojiCount).toJSNumber();
|
||||
const offset = emojiOffsets[index];
|
||||
const size = emojiOffsets[index + 1] - offset;
|
||||
result.push(String.fromCharCode(...emojiData.subarray(offset, offset + size)));
|
||||
}
|
||||
return result.join('');
|
||||
}
|
||||
|
||||
export function createPhoneCallState(params: ConstructorParameters<typeof PhoneCallState>) {
|
||||
currentPhoneCallState = new PhoneCallState(...params);
|
||||
}
|
||||
|
||||
export function destroyPhoneCallState() {
|
||||
currentPhoneCallState = undefined;
|
||||
}
|
||||
|
||||
type FunctionPropertyOf<T> = {
|
||||
[P in keyof T]: T[P] extends Function
|
||||
? P
|
||||
: never
|
||||
}[keyof T];
|
||||
|
||||
type ParamsOf<T extends FunctionPropertyOf<PhoneCallState>> = Parameters<PhoneCallState[T]>;
|
||||
type ReturnTypeOf<T extends FunctionPropertyOf<PhoneCallState>> = ReturnType<PhoneCallState[T]>;
|
||||
|
||||
export function encodePhoneCallData(params: ParamsOf<'encode'>): ReturnTypeOf<'encode'> {
|
||||
return currentPhoneCallState!.encode(...params);
|
||||
}
|
||||
|
||||
export function decodePhoneCallData(params: ParamsOf<'decode'>): ReturnTypeOf<'decode'> {
|
||||
return currentPhoneCallState!.decode(...params);
|
||||
}
|
||||
|
||||
export function confirmPhoneCall(params: ParamsOf<'confirmCall'>): ReturnTypeOf<'confirmCall'> {
|
||||
return currentPhoneCallState!.confirmCall(...params);
|
||||
}
|
||||
|
||||
export function acceptPhoneCall(params: ParamsOf<'acceptCall'>): ReturnTypeOf<'acceptCall'> {
|
||||
return currentPhoneCallState!.acceptCall(...params);
|
||||
}
|
||||
|
||||
export function requestPhoneCall(params: ParamsOf<'requestCall'>): ReturnTypeOf<'requestCall'> {
|
||||
return currentPhoneCallState!.requestCall(...params);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { GroupCallConnectionData } from '../../lib/secret-sauce';
|
||||
import type { GroupCallConnectionData } from '../../lib/secret-sauce';
|
||||
import { Api as GramJs, connection } from '../../lib/gramjs';
|
||||
import { ApiMessage, ApiUpdateConnectionStateType, OnApiUpdate } from '../types';
|
||||
|
||||
@ -44,7 +44,7 @@ import { buildApiNotifyException, buildPrivacyKey, buildPrivacyRules } from './a
|
||||
import { buildApiPhoto } from './apiBuilders/common';
|
||||
import {
|
||||
buildApiGroupCall,
|
||||
buildApiGroupCallParticipant,
|
||||
buildApiGroupCallParticipant, buildPhoneCall,
|
||||
getGroupCallId,
|
||||
} from './apiBuilders/calls';
|
||||
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './apiBuilders/peers';
|
||||
@ -897,6 +897,24 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
|
||||
recentRequesterIds: update.recentRequesters.map((id) => buildApiPeerId(id, 'user')),
|
||||
requestsPending: update.requestsPending,
|
||||
});
|
||||
} else if (update instanceof GramJs.UpdatePhoneCall) {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
const entities = update._entities;
|
||||
if (entities) {
|
||||
addEntitiesWithPhotosToLocalDb(entities);
|
||||
dispatchUserAndChatUpdates(entities);
|
||||
}
|
||||
|
||||
onUpdate({
|
||||
'@type': 'updatePhoneCall',
|
||||
call: buildPhoneCall(update.phoneCall),
|
||||
});
|
||||
} else if (update instanceof GramJs.UpdatePhoneCallSignalingData) {
|
||||
onUpdate({
|
||||
'@type': 'updatePhoneCallSignalingData',
|
||||
callId: update.phoneCallId.toString(),
|
||||
data: Array.from(update.data),
|
||||
});
|
||||
} else if (DEBUG) {
|
||||
const params = typeof update === 'object' && 'className' in update ? update.className : update;
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { GroupCallParticipant, GroupCallConnectionState } from '../../lib/secret-sauce';
|
||||
import type {
|
||||
GroupCallParticipant,
|
||||
GroupCallConnectionState,
|
||||
ApiPhoneCallConnection,
|
||||
ApiCallProtocol, VideoState, VideoRotation,
|
||||
} from '../../lib/secret-sauce';
|
||||
|
||||
export interface ApiGroupCall {
|
||||
chatId?: string;
|
||||
@ -24,3 +29,45 @@ export interface ApiGroupCall {
|
||||
connectionState: GroupCallConnectionState;
|
||||
isSpeakerDisabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PhoneCallAction {
|
||||
isOutgoing: boolean;
|
||||
isVideo?: boolean;
|
||||
duration?: number;
|
||||
reason?: 'missed' | 'disconnect' | 'hangup' | 'busy';
|
||||
}
|
||||
|
||||
export interface ApiPhoneCall {
|
||||
state?: 'active' | 'waiting' | 'discarded' | 'requested' | 'accepted' | 'requesting';
|
||||
isConnected?: boolean;
|
||||
id: string;
|
||||
accessHash?: string;
|
||||
adminId?: string;
|
||||
participantId?: string;
|
||||
isVideo?: boolean;
|
||||
date?: number;
|
||||
startDate?: number;
|
||||
receiveDate?: number;
|
||||
p2pAllowed?: boolean;
|
||||
connections?: ApiPhoneCallConnection[];
|
||||
protocol?: ApiCallProtocol;
|
||||
needRating?: boolean;
|
||||
needDebug?: boolean;
|
||||
reason?: 'missed' | 'disconnect' | 'hangup' | 'busy';
|
||||
duration?: number;
|
||||
|
||||
emojis?: string;
|
||||
gA?: number[];
|
||||
gB?: number[];
|
||||
pLast?: number[];
|
||||
randomLast?: number[];
|
||||
gAOrB?: number[];
|
||||
gAHash?: number[];
|
||||
keyFingerprint?: string;
|
||||
|
||||
isMuted?: boolean;
|
||||
videoState?: VideoState;
|
||||
videoRotation?: VideoRotation;
|
||||
screencastState?: VideoState;
|
||||
isBatteryLow?: boolean;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { ApiGroupCall } from './calls';
|
||||
import { ApiGroupCall, PhoneCallAction } from './calls';
|
||||
|
||||
export interface ApiDimensions {
|
||||
width: number;
|
||||
@ -203,6 +203,7 @@ export interface ApiAction {
|
||||
currency?: string;
|
||||
translationValues: string[];
|
||||
call?: Partial<ApiGroupCall>;
|
||||
phoneCall?: PhoneCallAction;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { GroupCallConnectionData, GroupCallParticipant, GroupCallConnectionState } from '../../lib/secret-sauce';
|
||||
import type {
|
||||
GroupCallConnectionData,
|
||||
GroupCallParticipant,
|
||||
GroupCallConnectionState,
|
||||
VideoState, VideoRotation,
|
||||
} from '../../lib/secret-sauce';
|
||||
import {
|
||||
ApiChat,
|
||||
ApiChatFullInfo,
|
||||
@ -15,7 +20,7 @@ import {
|
||||
ApiError, ApiInviteInfo, ApiNotifyException, ApiSessionData,
|
||||
} from './misc';
|
||||
import {
|
||||
ApiGroupCall,
|
||||
ApiGroupCall, ApiPhoneCall,
|
||||
} from './calls';
|
||||
|
||||
export type ApiUpdateReady = {
|
||||
@ -456,6 +461,31 @@ export type ApiUpdateGroupCallConnectionState = {
|
||||
isSpeakerDisabled?: boolean;
|
||||
};
|
||||
|
||||
export type ApiUpdatePhoneCall = {
|
||||
'@type': 'updatePhoneCall';
|
||||
call: ApiPhoneCall;
|
||||
};
|
||||
|
||||
export type ApiUpdatePhoneCallSignalingData = {
|
||||
'@type': 'updatePhoneCallSignalingData';
|
||||
callId: string;
|
||||
data: number[];
|
||||
};
|
||||
|
||||
export type ApiUpdatePhoneCallMediaState = {
|
||||
'@type': 'updatePhoneCallMediaState';
|
||||
isMuted: boolean;
|
||||
videoState: VideoState;
|
||||
videoRotation: VideoRotation;
|
||||
screencastState: VideoState;
|
||||
isBatteryLow: boolean;
|
||||
};
|
||||
|
||||
export type ApiUpdatePhoneCallConnectionState = {
|
||||
'@type': 'updatePhoneCallConnectionState';
|
||||
connectionState: RTCPeerConnectionState;
|
||||
};
|
||||
|
||||
export type ApiUpdate = (
|
||||
ApiUpdateReady | ApiUpdateSession |
|
||||
ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser |
|
||||
@ -476,7 +506,9 @@ export type ApiUpdate = (
|
||||
ApiUpdateServerTimeOffset | ApiUpdateShowInvite | ApiUpdateMessageReactions |
|
||||
ApiUpdateGroupCallParticipants | ApiUpdateGroupCallConnection | ApiUpdateGroupCall | ApiUpdateGroupCallStreams |
|
||||
ApiUpdateGroupCallConnectionState | ApiUpdateGroupCallLeavePresentation | ApiUpdateGroupCallChatId |
|
||||
ApiUpdatePendingJoinRequests | ApiUpdatePaymentVerificationNeeded | ApiUpdatePaymentStateCompleted
|
||||
ApiUpdatePendingJoinRequests | ApiUpdatePaymentVerificationNeeded | ApiUpdatePaymentStateCompleted |
|
||||
ApiUpdatePhoneCall | ApiUpdatePhoneCallSignalingData | ApiUpdatePhoneCallMediaState |
|
||||
ApiUpdatePhoneCallConnectionState
|
||||
);
|
||||
|
||||
export type OnApiUpdate = (update: ApiUpdate) => void;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB |
Binary file not shown.
Binary file not shown.
@ -1,3 +1,4 @@
|
||||
export { default as GroupCall } from '../components/calls/group/GroupCall';
|
||||
export { default as ActiveCallHeader } from '../components/calls/ActiveCallHeader';
|
||||
export { default as CallFallbackConfirm } from '../components/calls/CallFallbackConfirm';
|
||||
export { default as PhoneCall } from '../components/calls/phone/PhoneCall';
|
||||
export { default as RatePhoneCallModal } from '../components/calls/phone/RatePhoneCallModal';
|
||||
|
||||
@ -3,12 +3,12 @@ import useModuleLoader from '../../hooks/useModuleLoader';
|
||||
import { Bundles } from '../../util/moduleLoader';
|
||||
|
||||
type OwnProps = {
|
||||
groupCallId?: string;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
const ActiveCallHeaderAsync: FC<OwnProps> = (props) => {
|
||||
const { groupCallId } = props;
|
||||
const ActiveCallHeader = useModuleLoader(Bundles.Calls, 'ActiveCallHeader', !groupCallId);
|
||||
const { isActive } = props;
|
||||
const ActiveCallHeader = useModuleLoader(Bundles.Calls, 'ActiveCallHeader', !isActive);
|
||||
|
||||
return ActiveCallHeader ? <ActiveCallHeader /> : undefined;
|
||||
};
|
||||
|
||||
@ -1,51 +1,50 @@
|
||||
import { GroupCallParticipant } from '../../lib/secret-sauce';
|
||||
import React, {
|
||||
FC, memo, useEffect,
|
||||
} from '../../lib/teact/teact';
|
||||
import { getActions, withGlobal } from '../../global';
|
||||
|
||||
import { ApiGroupCall } from '../../api/types';
|
||||
import { ApiGroupCall, ApiUser } from '../../api/types';
|
||||
|
||||
import { selectActiveGroupCall, selectGroupCallParticipant } from '../../global/selectors/calls';
|
||||
import { selectActiveGroupCall, selectPhoneCallUser } from '../../global/selectors/calls';
|
||||
import buildClassName from '../../util/buildClassName';
|
||||
import useLang from '../../hooks/useLang';
|
||||
|
||||
import './ActiveCallHeader.scss';
|
||||
|
||||
type StateProps = {
|
||||
isGroupCallPanelHidden?: boolean;
|
||||
meParticipant: GroupCallParticipant;
|
||||
isCallPanelVisible?: boolean;
|
||||
groupCall?: ApiGroupCall;
|
||||
phoneCallUser?: ApiUser;
|
||||
};
|
||||
|
||||
const ActiveCallHeader: FC<StateProps> = ({
|
||||
groupCall,
|
||||
meParticipant,
|
||||
isGroupCallPanelHidden,
|
||||
phoneCallUser,
|
||||
isCallPanelVisible,
|
||||
}) => {
|
||||
const { toggleGroupCallPanel } = getActions();
|
||||
|
||||
const lang = useLang();
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle('has-group-call-header', isGroupCallPanelHidden);
|
||||
document.body.classList.toggle('has-call-header', Boolean(isCallPanelVisible));
|
||||
|
||||
return () => {
|
||||
document.body.classList.toggle('has-group-call-header', false);
|
||||
document.body.classList.toggle('has-call-header', false);
|
||||
};
|
||||
}, [isGroupCallPanelHidden]);
|
||||
}, [isCallPanelVisible]);
|
||||
|
||||
if (!groupCall || !meParticipant) return undefined;
|
||||
if (!groupCall && !phoneCallUser) return undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={buildClassName(
|
||||
'ActiveCallHeader',
|
||||
isGroupCallPanelHidden && 'open',
|
||||
isCallPanelVisible && 'open',
|
||||
)}
|
||||
onClick={toggleGroupCallPanel}
|
||||
>
|
||||
<span className="title">{groupCall.title || lang('VoipGroupVoiceChat')}</span>
|
||||
<span className="title">{phoneCallUser?.firstName || groupCall?.title || lang('VoipGroupVoiceChat')}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -54,8 +53,8 @@ export default memo(withGlobal(
|
||||
(global): StateProps => {
|
||||
return {
|
||||
groupCall: selectActiveGroupCall(global),
|
||||
isGroupCallPanelHidden: global.groupCalls.isGroupCallPanelHidden,
|
||||
meParticipant: selectGroupCallParticipant(global, global.groupCalls.activeGroupCallId!, global.currentUserId!),
|
||||
isCallPanelVisible: global.isCallPanelVisible,
|
||||
phoneCallUser: selectPhoneCallUser(global),
|
||||
};
|
||||
},
|
||||
)(ActiveCallHeader));
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
import React, { FC, memo } from '../../lib/teact/teact';
|
||||
import useModuleLoader from '../../hooks/useModuleLoader';
|
||||
import { Bundles } from '../../util/moduleLoader';
|
||||
|
||||
type OwnProps = {
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
const CallFallbackConfirmAsync: FC<OwnProps> = ({ isOpen }) => {
|
||||
const CallFallbackConfirm = useModuleLoader(Bundles.Calls, 'CallFallbackConfirm', !isOpen);
|
||||
|
||||
return CallFallbackConfirm ? <CallFallbackConfirm isOpen={isOpen} /> : undefined;
|
||||
};
|
||||
|
||||
export default memo(CallFallbackConfirmAsync);
|
||||
@ -1,66 +0,0 @@
|
||||
import React, {
|
||||
FC, memo, useCallback, useState,
|
||||
} from '../../lib/teact/teact';
|
||||
import { getActions, withGlobal } from '../../global';
|
||||
|
||||
import ConfirmDialog from '../ui/ConfirmDialog';
|
||||
import Checkbox from '../ui/Checkbox';
|
||||
import { selectCallFallbackChannelTitle } from '../../global/selectors/calls';
|
||||
import { getUserFullName } from '../../global/helpers';
|
||||
import { selectCurrentMessageList, selectUser } from '../../global/selectors';
|
||||
import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
|
||||
|
||||
export type OwnProps = {
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
interface StateProps {
|
||||
userFullName?: string;
|
||||
channelTitle: string;
|
||||
}
|
||||
|
||||
const CallFallbackConfirm: FC<OwnProps & StateProps> = ({
|
||||
isOpen,
|
||||
channelTitle,
|
||||
userFullName,
|
||||
}) => {
|
||||
const {
|
||||
closeCallFallbackConfirm,
|
||||
inviteToCallFallback,
|
||||
} = getActions();
|
||||
|
||||
const [shouldRemove, setShouldRemove] = useState(true);
|
||||
const renderingUserFullName = useCurrentOrPrev(userFullName, true);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
inviteToCallFallback({ shouldRemove });
|
||||
}, [inviteToCallFallback, shouldRemove]);
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
title="Start Call"
|
||||
isOpen={isOpen}
|
||||
confirmHandler={handleConfirm}
|
||||
onClose={closeCallFallbackConfirm}
|
||||
>
|
||||
<p>The call will be started in a private channel <b>{channelTitle}</b>.</p>
|
||||
<Checkbox
|
||||
label={`Remove ${renderingUserFullName} from this channel after the call`}
|
||||
checked={shouldRemove}
|
||||
onCheck={setShouldRemove}
|
||||
/>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(withGlobal<OwnProps>(
|
||||
(global): StateProps => {
|
||||
const { chatId } = selectCurrentMessageList(global) || {};
|
||||
const user = chatId ? selectUser(global, chatId) : undefined;
|
||||
|
||||
return {
|
||||
userFullName: user ? getUserFullName(user) : undefined,
|
||||
channelTitle: selectCallFallbackChannelTitle(global),
|
||||
};
|
||||
},
|
||||
)(CallFallbackConfirm));
|
||||
@ -47,7 +47,7 @@ export type OwnProps = {
|
||||
};
|
||||
|
||||
type StateProps = {
|
||||
isGroupCallPanelHidden: boolean;
|
||||
isCallPanelVisible: boolean;
|
||||
connectionState: GroupCallConnectionState;
|
||||
title?: string;
|
||||
meParticipant?: TypeGroupCallParticipant;
|
||||
@ -59,7 +59,7 @@ type StateProps = {
|
||||
|
||||
const GroupCall: FC<OwnProps & StateProps> = ({
|
||||
groupCallId,
|
||||
isGroupCallPanelHidden,
|
||||
isCallPanelVisible,
|
||||
connectionState,
|
||||
isSpeakerEnabled,
|
||||
title,
|
||||
@ -246,7 +246,7 @@ const GroupCall: FC<OwnProps & StateProps> = ({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={!isGroupCallPanelHidden && !isLeaving}
|
||||
isOpen={!isCallPanelVisible && !isLeaving}
|
||||
onClose={toggleGroupCallPanel}
|
||||
className={buildClassName(
|
||||
'GroupCall',
|
||||
@ -287,7 +287,7 @@ const GroupCall: FC<OwnProps & StateProps> = ({
|
||||
>
|
||||
{IS_SCREENSHARE_SUPPORTED && !shouldRaiseHand && (
|
||||
<MenuItem
|
||||
icon="share-screen"
|
||||
icon="share-screen-outlined"
|
||||
onClick={toggleGroupCallPresentation}
|
||||
>
|
||||
{lang(hasPresentation ? 'VoipChatStopScreenCapture' : 'VoipChatStartScreenCapture')}
|
||||
@ -405,7 +405,7 @@ export default memo(withGlobal<OwnProps>(
|
||||
isSpeakerEnabled: !isSpeakerDisabled,
|
||||
participantsCount,
|
||||
meParticipant: selectGroupCallParticipant(global, groupCallId, global.currentUserId!),
|
||||
isGroupCallPanelHidden: Boolean(global.groupCalls.isGroupCallPanelHidden),
|
||||
isCallPanelVisible: Boolean(global.isCallPanelVisible),
|
||||
isAdmin: selectIsAdminInActiveGroupCall(global),
|
||||
participants,
|
||||
};
|
||||
|
||||
@ -51,7 +51,7 @@ const MicrophoneButton: FC<StateProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (prevShouldRaiseHand && !shouldRaiseHand) {
|
||||
playGroupCallSound('allowTalk');
|
||||
playGroupCallSound({ sound: 'allowTalk' });
|
||||
}
|
||||
}, [playGroupCallSound, prevShouldRaiseHand, shouldRaiseHand]);
|
||||
|
||||
|
||||
16
src/components/calls/phone/PhoneCall.async.tsx
Normal file
16
src/components/calls/phone/PhoneCall.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 = {
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
const PhoneCallAsync: FC<OwnProps> = (props) => {
|
||||
const { isActive } = props;
|
||||
const PhoneCall = useModuleLoader(Bundles.Calls, 'PhoneCall', !isActive);
|
||||
|
||||
return PhoneCall ? <PhoneCall /> : undefined;
|
||||
};
|
||||
|
||||
export default memo(PhoneCallAsync);
|
||||
179
src/components/calls/phone/PhoneCall.module.scss
Normal file
179
src/components/calls/phone/PhoneCall.module.scss
Normal file
@ -0,0 +1,179 @@
|
||||
.root {
|
||||
:global(.modal-dialog) {
|
||||
overflow: hidden;
|
||||
}
|
||||
:global(.modal-content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 80vh;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(.Avatar) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
z-index: -1;
|
||||
transform: scale(1.1);
|
||||
|
||||
:global(.Avatar__img) {
|
||||
border-radius: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&.blurred :global(.Avatar__img) {
|
||||
filter: blur(10px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.single-column {
|
||||
:global(.modal-dialog) {
|
||||
max-width: 100% !important;
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:global(.modal-content) {
|
||||
height: calc(var(--vh) * 100);
|
||||
max-height: calc(var(--vh) * 100);
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #fff;
|
||||
position: absolute;
|
||||
padding: 0.5rem;
|
||||
|
||||
:global(.Button) {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.close-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.emojis-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
transition: 0.25s ease-in-out background-color;
|
||||
z-index: 2;
|
||||
|
||||
&.open {
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
.emojis {
|
||||
user-select: none;
|
||||
pointer-events: all;
|
||||
cursor: pointer;
|
||||
margin-top: 1rem;
|
||||
height: 3rem;
|
||||
transition: 0.25s ease-in-out transform;
|
||||
top: 0;
|
||||
font-size: 1.5rem;
|
||||
|
||||
&.open {
|
||||
transform: scale(2) translateY(3rem);
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-tooltip {
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
margin-top: 10rem;
|
||||
color: white;
|
||||
max-width: 20rem;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
opacity: 0;
|
||||
transition: 0.25s ease-in-out opacity;
|
||||
|
||||
&.open {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.user-info {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: 0;
|
||||
padding-top: 4rem;
|
||||
padding-bottom: 2rem;
|
||||
margin-bottom: auto;
|
||||
color: #fff;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.2) 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.leave {
|
||||
background: #ff595a !important;
|
||||
|
||||
&:hover {
|
||||
background-color: #d24646 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.accept {
|
||||
background: #5CC85E !important;
|
||||
|
||||
&:hover {
|
||||
background-color: #4eab50 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.accept-icon {
|
||||
transform: rotate(-135deg);
|
||||
}
|
||||
|
||||
.main-video {
|
||||
z-index: -1;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.second-video {
|
||||
z-index: -1;
|
||||
position: absolute;
|
||||
width: 9rem;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
transform: translateY(calc(100% + 1rem));
|
||||
transition: 0.25s ease-in-out transform;
|
||||
|
||||
&.visible {
|
||||
transform: translateY(-5.5rem);
|
||||
}
|
||||
|
||||
&.fullscreen {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
362
src/components/calls/phone/PhoneCall.tsx
Normal file
362
src/components/calls/phone/PhoneCall.tsx
Normal file
@ -0,0 +1,362 @@
|
||||
import React, {
|
||||
FC, memo, useCallback, useEffect, useMemo, useRef,
|
||||
} from '../../../lib/teact/teact';
|
||||
import { getActions, withGlobal } from '../../../global';
|
||||
import '../../../global/actions/calls';
|
||||
|
||||
import { ApiPhoneCall, ApiUser } from '../../../api/types';
|
||||
|
||||
import {
|
||||
IS_ANDROID,
|
||||
IS_IOS,
|
||||
IS_REQUEST_FULLSCREEN_SUPPORTED,
|
||||
IS_SINGLE_COLUMN_LAYOUT,
|
||||
} from '../../../util/environment';
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
import { selectPhoneCallUser } from '../../../global/selectors/calls';
|
||||
import useLang from '../../../hooks/useLang';
|
||||
import renderText from '../../common/helpers/renderText';
|
||||
import useFlag from '../../../hooks/useFlag';
|
||||
import { formatMediaDuration } from '../../../util/dateFormat';
|
||||
import {
|
||||
getStreams, IS_SCREENSHARE_SUPPORTED, switchCameraInputP2p, toggleStreamP2p,
|
||||
} from '../../../lib/secret-sauce';
|
||||
import useInterval from '../../../hooks/useInterval';
|
||||
import useForceUpdate from '../../../hooks/useForceUpdate';
|
||||
|
||||
import Modal from '../../ui/Modal';
|
||||
import Avatar from '../../common/Avatar';
|
||||
import Button from '../../ui/Button';
|
||||
import PhoneCallButton from './PhoneCallButton';
|
||||
import AnimatedIcon from '../../common/AnimatedIcon';
|
||||
|
||||
import styles from './PhoneCall.module.scss';
|
||||
|
||||
type StateProps = {
|
||||
user?: ApiUser;
|
||||
phoneCall?: ApiPhoneCall;
|
||||
isOutgoing: boolean;
|
||||
isCallPanelVisible?: boolean;
|
||||
};
|
||||
|
||||
const PhoneCall: FC<StateProps> = ({
|
||||
user,
|
||||
isOutgoing,
|
||||
phoneCall,
|
||||
isCallPanelVisible,
|
||||
}) => {
|
||||
const lang = useLang();
|
||||
const {
|
||||
hangUp, acceptCall, playGroupCallSound, toggleGroupCallPanel, connectToActivePhoneCall,
|
||||
} = getActions();
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isFullscreen, openFullscreen, closeFullscreen] = useFlag();
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (isFullscreen) {
|
||||
closeFullscreen();
|
||||
} else {
|
||||
openFullscreen();
|
||||
}
|
||||
}, [closeFullscreen, isFullscreen, openFullscreen]);
|
||||
|
||||
const handleToggleFullscreen = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
if (isFullscreen) {
|
||||
document.exitFullscreen().then(closeFullscreen);
|
||||
} else {
|
||||
containerRef.current.requestFullscreen().then(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 handleClose = useCallback(() => {
|
||||
toggleGroupCallPanel();
|
||||
if (isFullscreen) {
|
||||
closeFullscreen();
|
||||
}
|
||||
}, [closeFullscreen, isFullscreen, toggleGroupCallPanel]);
|
||||
|
||||
const isDiscarded = phoneCall?.state === 'discarded';
|
||||
const isBusy = phoneCall?.reason === 'busy';
|
||||
|
||||
const isIncomingRequested = phoneCall?.state === 'requested' && !isOutgoing;
|
||||
const isOutgoingRequested = (phoneCall?.state === 'requested' || phoneCall?.state === 'waiting') && isOutgoing;
|
||||
const isActive = phoneCall?.state === 'active';
|
||||
const isConnected = phoneCall?.isConnected;
|
||||
|
||||
useEffect(() => {
|
||||
if (isIncomingRequested) {
|
||||
playGroupCallSound({ sound: 'incoming' });
|
||||
} else if (isBusy) {
|
||||
playGroupCallSound({ sound: 'busy' });
|
||||
} else if (isDiscarded) {
|
||||
playGroupCallSound({ sound: 'end' });
|
||||
} else if (isOutgoingRequested) {
|
||||
playGroupCallSound({ sound: 'ringing' });
|
||||
} else if (isConnected) {
|
||||
playGroupCallSound({ sound: 'connect' });
|
||||
}
|
||||
}, [isBusy, isDiscarded, isIncomingRequested, isOutgoingRequested, isConnected, playGroupCallSound]);
|
||||
|
||||
const [isHangingUp, startHangingUp, stopHangingUp] = useFlag();
|
||||
const handleHangUp = useCallback(() => {
|
||||
startHangingUp();
|
||||
hangUp();
|
||||
}, [hangUp, startHangingUp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phoneCall?.id) {
|
||||
stopHangingUp();
|
||||
} else {
|
||||
connectToActivePhoneCall();
|
||||
}
|
||||
}, [connectToActivePhoneCall, phoneCall?.id, stopHangingUp]);
|
||||
|
||||
const forceUpdate = useForceUpdate();
|
||||
|
||||
useInterval(() => {
|
||||
forceUpdate();
|
||||
}, isConnected ? 1000 : undefined);
|
||||
|
||||
const callStatus = useMemo(() => {
|
||||
const state = phoneCall?.state;
|
||||
if (isHangingUp) {
|
||||
return lang('lng_call_status_hanging');
|
||||
}
|
||||
if (isBusy) return 'busy';
|
||||
if (state === 'requesting') {
|
||||
return lang('lng_call_status_requesting');
|
||||
} else if (state === 'requested') {
|
||||
return isOutgoing ? lang('lng_call_status_ringing') : lang('lng_call_status_incoming');
|
||||
} else if (state === 'waiting') {
|
||||
return lang('lng_call_status_waiting');
|
||||
} else if (state === 'active' && isConnected) {
|
||||
return undefined;
|
||||
} else {
|
||||
return lang('lng_call_status_exchanging');
|
||||
}
|
||||
}, [isBusy, isConnected, isHangingUp, isOutgoing, lang, phoneCall?.state]);
|
||||
|
||||
const hasVideo = phoneCall?.videoState === 'active';
|
||||
const hasPresentation = phoneCall?.screencastState === 'active';
|
||||
|
||||
const streams = getStreams();
|
||||
const hasOwnAudio = streams?.ownAudio?.getTracks()[0].enabled;
|
||||
const hasOwnPresentation = streams?.ownPresentation?.getTracks()[0].enabled;
|
||||
const hasOwnVideo = streams?.ownVideo?.getTracks()[0].enabled;
|
||||
|
||||
const [isHidingPresentation, startHidingPresentation, stopHidingPresentation] = useFlag();
|
||||
const [isHidingVideo, startHidingVideo, stopHidingVideo] = useFlag();
|
||||
|
||||
const handleTogglePresentation = useCallback(() => {
|
||||
if (hasOwnPresentation) {
|
||||
startHidingPresentation();
|
||||
}
|
||||
if (hasOwnVideo) {
|
||||
startHidingVideo();
|
||||
}
|
||||
setTimeout(async () => {
|
||||
await toggleStreamP2p('presentation');
|
||||
stopHidingPresentation();
|
||||
stopHidingVideo();
|
||||
}, 250);
|
||||
}, [
|
||||
hasOwnPresentation, hasOwnVideo, startHidingPresentation, startHidingVideo, stopHidingPresentation, stopHidingVideo,
|
||||
]);
|
||||
|
||||
const handleToggleVideo = useCallback(() => {
|
||||
if (hasOwnVideo) {
|
||||
startHidingVideo();
|
||||
}
|
||||
if (hasOwnPresentation) {
|
||||
startHidingPresentation();
|
||||
}
|
||||
setTimeout(async () => {
|
||||
await toggleStreamP2p('video');
|
||||
stopHidingPresentation();
|
||||
stopHidingVideo();
|
||||
}, 250);
|
||||
}, [
|
||||
hasOwnPresentation, hasOwnVideo, startHidingPresentation, startHidingVideo, stopHidingPresentation, stopHidingVideo,
|
||||
]);
|
||||
|
||||
const handleToggleAudio = useCallback(() => {
|
||||
void toggleStreamP2p('audio');
|
||||
}, []);
|
||||
|
||||
const [isEmojiOpen, openEmoji, closeEmoji] = useFlag();
|
||||
|
||||
const [isFlipping, startFlipping, stopFlipping] = useFlag();
|
||||
|
||||
const handleFlipCamera = useCallback(() => {
|
||||
startFlipping();
|
||||
switchCameraInputP2p();
|
||||
setTimeout(stopFlipping, 250);
|
||||
}, [startFlipping, stopFlipping]);
|
||||
|
||||
const timeElapsed = phoneCall?.startDate && (Number(new Date()) / 1000 - phoneCall.startDate);
|
||||
|
||||
useEffect(() => {
|
||||
if (phoneCall?.state === 'discarded') {
|
||||
setTimeout(hangUp, 250);
|
||||
}
|
||||
}, [hangUp, phoneCall?.reason, phoneCall?.state]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={phoneCall && phoneCall?.state !== 'discarded' && !isCallPanelVisible}
|
||||
onClose={handleClose}
|
||||
className={buildClassName(
|
||||
styles.root,
|
||||
IS_SINGLE_COLUMN_LAYOUT && styles.singleColumn,
|
||||
)}
|
||||
dialogRef={containerRef}
|
||||
>
|
||||
<Avatar user={user} size="jumbo" className={hasVideo || hasPresentation ? styles.blurred : ''} />
|
||||
{phoneCall?.screencastState === 'active' && streams?.presentation
|
||||
&& <video className={styles.mainVideo} muted autoPlay playsInline srcObject={streams.presentation} />}
|
||||
{phoneCall?.videoState === 'active' && streams?.video
|
||||
&& <video className={styles.mainVideo} muted autoPlay playsInline srcObject={streams.video} />}
|
||||
<video
|
||||
className={buildClassName(
|
||||
styles.secondVideo,
|
||||
!isHidingPresentation && hasOwnPresentation && styles.visible,
|
||||
isFullscreen && styles.fullscreen,
|
||||
)}
|
||||
muted
|
||||
autoPlay
|
||||
playsInline
|
||||
srcObject={streams?.ownPresentation}
|
||||
/>
|
||||
<video
|
||||
className={buildClassName(
|
||||
styles.secondVideo,
|
||||
!isHidingVideo && hasOwnVideo && styles.visible,
|
||||
isFullscreen && styles.fullscreen,
|
||||
)}
|
||||
muted
|
||||
autoPlay
|
||||
playsInline
|
||||
srcObject={streams?.ownVideo}
|
||||
/>
|
||||
<div className={styles.header}>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<Button
|
||||
round
|
||||
size="smaller"
|
||||
color="translucent"
|
||||
onClick={handleClose}
|
||||
className={styles.closeButton}
|
||||
>
|
||||
<i className="icon-close" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={buildClassName(styles.emojisBackdrop, isEmojiOpen && styles.open)}
|
||||
onClick={!isEmojiOpen ? openEmoji : closeEmoji}
|
||||
>
|
||||
<div className={buildClassName(styles.emojis, isEmojiOpen && styles.open)}>
|
||||
{phoneCall?.isConnected && phoneCall?.emojis && renderText(phoneCall.emojis, ['emoji'])}
|
||||
</div>
|
||||
<div className={buildClassName(styles.emojiTooltip, isEmojiOpen && styles.open)}>
|
||||
{lang('CallEmojiKeyTooltip', user?.firstName).replace('%%', '%')}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.userInfo}>
|
||||
<h1>{user?.firstName}</h1>
|
||||
<span className={styles.status}>{callStatus || formatMediaDuration(timeElapsed || 0)}</span>
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
<PhoneCallButton
|
||||
onClick={handleToggleAudio}
|
||||
icon="microphone"
|
||||
isDisabled={!isActive}
|
||||
isActive={hasOwnAudio}
|
||||
label={lang(hasOwnAudio ? 'lng_call_mute_audio' : 'lng_call_unmute_audio')}
|
||||
/>
|
||||
<PhoneCallButton
|
||||
onClick={handleToggleVideo}
|
||||
icon="video"
|
||||
isDisabled={!isActive}
|
||||
isActive={hasOwnVideo}
|
||||
label={lang(hasOwnVideo ? 'lng_call_stop_video' : 'lng_call_start_video')}
|
||||
/>
|
||||
{hasOwnVideo && (IS_ANDROID || IS_IOS) && (
|
||||
<PhoneCallButton
|
||||
onClick={handleFlipCamera}
|
||||
customIcon={
|
||||
<AnimatedIcon name="CameraFlip" playSegment={!isFlipping ? [0, 1] : [0, 10]} size={32} />
|
||||
}
|
||||
isDisabled={!isActive}
|
||||
label={lang('VoipFlip')}
|
||||
/>
|
||||
)}
|
||||
{IS_SCREENSHARE_SUPPORTED && (
|
||||
<PhoneCallButton
|
||||
onClick={handleTogglePresentation}
|
||||
icon="share-screen"
|
||||
isDisabled={!isActive}
|
||||
isActive={hasOwnPresentation}
|
||||
label={lang('lng_call_screencast')}
|
||||
/>
|
||||
)}
|
||||
{isIncomingRequested && (
|
||||
<PhoneCallButton
|
||||
onClick={acceptCall}
|
||||
icon="phone-discard"
|
||||
isDisabled={isDiscarded}
|
||||
label={lang('lng_call_accept')}
|
||||
className={styles.accept}
|
||||
iconClassName={styles.acceptIcon}
|
||||
/>
|
||||
)}
|
||||
<PhoneCallButton
|
||||
onClick={handleHangUp}
|
||||
icon="phone-discard"
|
||||
isDisabled={isDiscarded}
|
||||
label={lang(isIncomingRequested ? 'lng_call_decline' : 'lng_call_end_call')}
|
||||
className={styles.leave}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(withGlobal(
|
||||
(global): StateProps => {
|
||||
const { phoneCall, currentUserId } = global;
|
||||
|
||||
return {
|
||||
isCallPanelVisible: Boolean(global.isCallPanelVisible),
|
||||
user: selectPhoneCallUser(global),
|
||||
isOutgoing: phoneCall?.adminId === currentUserId,
|
||||
phoneCall,
|
||||
};
|
||||
},
|
||||
)(PhoneCall));
|
||||
36
src/components/calls/phone/PhoneCallButton.module.scss
Normal file
36
src/components/calls/phone/PhoneCallButton.module.scss
Normal file
@ -0,0 +1,36 @@
|
||||
.root {
|
||||
width: 5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
background-color: rgba(0, 0, 0, 0.1) !important;
|
||||
color: #fff !important;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.2) !important;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #fff !important;
|
||||
color: var(--color-text-secondary) !important;
|
||||
|
||||
&:hover {
|
||||
background-color: #ddd !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.button-text {
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
text-transform: lowercase;
|
||||
margin-top: 0.25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
45
src/components/calls/phone/PhoneCallButton.tsx
Normal file
45
src/components/calls/phone/PhoneCallButton.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import React, { FC, memo } from '../../../lib/teact/teact';
|
||||
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
|
||||
import Button from '../../ui/Button';
|
||||
|
||||
import styles from './PhoneCallButton.module.scss';
|
||||
|
||||
type OwnProps = {
|
||||
onClick: VoidFunction;
|
||||
label: string;
|
||||
icon?: string;
|
||||
iconClassName?: string;
|
||||
customIcon?: React.ReactNode;
|
||||
className?: string;
|
||||
isDisabled?: boolean;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
const PhoneCallButton: FC<OwnProps> = ({
|
||||
onClick,
|
||||
label,
|
||||
customIcon,
|
||||
icon,
|
||||
iconClassName,
|
||||
className,
|
||||
isDisabled,
|
||||
isActive,
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Button
|
||||
round
|
||||
className={buildClassName(className, styles.button, isActive && styles.active)}
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{customIcon || <i className={buildClassName(iconClassName, `icon-${icon}`)} />}
|
||||
</Button>
|
||||
<div className={styles.buttonText}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(PhoneCallButton);
|
||||
16
src/components/calls/phone/RatePhoneCallModal.async.tsx
Normal file
16
src/components/calls/phone/RatePhoneCallModal.async.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import React, { FC, memo } from '../../../lib/teact/teact';
|
||||
|
||||
import { OwnProps } from './RatePhoneCallModal';
|
||||
import { Bundles } from '../../../util/moduleLoader';
|
||||
|
||||
import useModuleLoader from '../../../hooks/useModuleLoader';
|
||||
|
||||
const RatePhoneCallModalAsync: FC<OwnProps> = (props) => {
|
||||
const { isOpen } = props;
|
||||
const RatePhoneCallModal = useModuleLoader(Bundles.Calls, 'RatePhoneCallModal', !isOpen);
|
||||
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
return RatePhoneCallModal ? <RatePhoneCallModal {...props} /> : undefined;
|
||||
};
|
||||
|
||||
export default memo(RatePhoneCallModalAsync);
|
||||
28
src/components/calls/phone/RatePhoneCallModal.module.scss
Normal file
28
src/components/calls/phone/RatePhoneCallModal.module.scss
Normal file
@ -0,0 +1,28 @@
|
||||
.stars {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.star {
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
&.isFilled {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.comment {
|
||||
margin-top: 1rem;
|
||||
overflow: hidden;
|
||||
|
||||
&:not(.visible) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
77
src/components/calls/phone/RatePhoneCallModal.tsx
Normal file
77
src/components/calls/phone/RatePhoneCallModal.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import React, {
|
||||
FC, memo, useRef, useState,
|
||||
} from '../../../lib/teact/teact';
|
||||
import { getActions } from '../../../global';
|
||||
|
||||
import useLang from '../../../hooks/useLang';
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
|
||||
import Modal from '../../ui/Modal';
|
||||
import Button from '../../ui/Button';
|
||||
import InputText from '../../ui/InputText';
|
||||
|
||||
import styles from './RatePhoneCallModal.module.scss';
|
||||
|
||||
export type OwnProps = {
|
||||
isOpen?: boolean;
|
||||
};
|
||||
|
||||
const RatePhoneCallModal: FC<OwnProps> = ({
|
||||
isOpen,
|
||||
}) => {
|
||||
const { closeCallRatingModal, setCallRating } = getActions();
|
||||
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const lang = useLang();
|
||||
const [rating, setRating] = useState<number | undefined>();
|
||||
|
||||
function handleSend() {
|
||||
if (!rating) {
|
||||
closeCallRatingModal();
|
||||
return;
|
||||
}
|
||||
setCallRating({
|
||||
rating: rating + 1,
|
||||
comment: inputRef.current?.value || '',
|
||||
});
|
||||
}
|
||||
|
||||
function handleClickStar(index: number) {
|
||||
return () => setRating(rating === index ? undefined : index);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={lang('lng_call_rate_label')} className="narrow" onClose={closeCallRatingModal} isOpen={isOpen}>
|
||||
<div className={styles.stars}>
|
||||
{new Array(5).fill(undefined).map((_, i) => {
|
||||
const isFilled = rating !== undefined && rating >= i;
|
||||
return (
|
||||
<i
|
||||
className={buildClassName(
|
||||
isFilled ? 'icon-favorite-filled' : 'icon-favorite',
|
||||
isFilled && styles.isFilled,
|
||||
styles.star,
|
||||
)}
|
||||
onClick={handleClickStar(i)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<InputText
|
||||
ref={inputRef}
|
||||
placeholder={lang('lng_call_rate_comment')}
|
||||
className={buildClassName(styles.comment, rating !== 4 && rating !== undefined && styles.visible)}
|
||||
/>
|
||||
|
||||
{/* eslint-disable-next-line react/jsx-no-bind */}
|
||||
<Button className="confirm-dialog-button" isText onClick={handleSend}>
|
||||
{lang('Send')}
|
||||
</Button>
|
||||
<Button className="confirm-dialog-button" isText onClick={closeCallRatingModal}>{lang('Cancel')}</Button>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(RatePhoneCallModal);
|
||||
@ -58,11 +58,12 @@ const Avatar: FC<OwnProps> = ({
|
||||
const isReplies = user && isChatWithRepliesBot(user.id);
|
||||
let imageHash: string | undefined;
|
||||
|
||||
const shouldFetchBig = size === 'jumbo';
|
||||
if (!isSavedMessages && !isDeleted) {
|
||||
if (user) {
|
||||
imageHash = getChatAvatarHash(user);
|
||||
imageHash = getChatAvatarHash(user, shouldFetchBig ? 'big' : undefined);
|
||||
} else if (chat) {
|
||||
imageHash = getChatAvatarHash(chat);
|
||||
imageHash = getChatAvatarHash(chat, shouldFetchBig ? 'big' : undefined);
|
||||
} else if (photo) {
|
||||
imageHash = `photo${photo.id}?size=m`;
|
||||
}
|
||||
|
||||
@ -44,7 +44,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
|
||||
}, [lastSyncTime, profileId, loadProfilePhotos]);
|
||||
|
||||
useHistoryBack(isActive, onReset, onScreenSelect, SettingsScreens.Main);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (lastSyncTime) {
|
||||
loadAuthorizations();
|
||||
|
||||
@ -18,8 +18,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.has-group-call-header {
|
||||
--group-call-header-height: 2rem;
|
||||
.has-call-header {
|
||||
--call-header-height: 2rem;
|
||||
#LeftColumn, #MiddleColumn, #RightColumn-wrapper {
|
||||
height: calc(100% - 2rem);
|
||||
margin-top: 2rem;
|
||||
|
||||
@ -49,8 +49,9 @@ import SafeLinkModal from './SafeLinkModal.async';
|
||||
import HistoryCalendar from './HistoryCalendar.async';
|
||||
import GroupCall from '../calls/group/GroupCall.async';
|
||||
import ActiveCallHeader from '../calls/ActiveCallHeader.async';
|
||||
import CallFallbackConfirm from '../calls/CallFallbackConfirm.async';
|
||||
import PhoneCall from '../calls/phone/PhoneCall.async';
|
||||
import NewContactModal from './NewContactModal.async';
|
||||
import RatePhoneCallModal from '../calls/phone/RatePhoneCallModal.async';
|
||||
|
||||
import './Main.scss';
|
||||
|
||||
@ -74,12 +75,13 @@ type StateProps = {
|
||||
animationLevel: number;
|
||||
language?: LangCode;
|
||||
wasTimeFormatSetManually?: boolean;
|
||||
isCallFallbackConfirmOpen: boolean;
|
||||
isPhoneCallActive?: boolean;
|
||||
addedSetIds?: string[];
|
||||
newContactUserId?: string;
|
||||
newContactByPhoneNumber?: boolean;
|
||||
openedGame?: GlobalState['openedGame'];
|
||||
gameTitle?: string;
|
||||
isRatePhoneCallModalOpen?: boolean;
|
||||
};
|
||||
|
||||
const NOTIFICATION_INTERVAL = 1000;
|
||||
@ -109,12 +111,13 @@ const Main: FC<StateProps> = ({
|
||||
animationLevel,
|
||||
language,
|
||||
wasTimeFormatSetManually,
|
||||
isCallFallbackConfirmOpen,
|
||||
addedSetIds,
|
||||
isPhoneCallActive,
|
||||
newContactUserId,
|
||||
newContactByPhoneNumber,
|
||||
openedGame,
|
||||
gameTitle,
|
||||
isRatePhoneCallModalOpen,
|
||||
}) => {
|
||||
const {
|
||||
sync,
|
||||
@ -335,12 +338,8 @@ const Main: FC<StateProps> = ({
|
||||
onClose={handleStickerSetModalClose}
|
||||
stickerSetShortName={openedStickerSetShortName}
|
||||
/>
|
||||
{activeGroupCallId && (
|
||||
<>
|
||||
<GroupCall groupCallId={activeGroupCallId} />
|
||||
<ActiveCallHeader groupCallId={activeGroupCallId} />
|
||||
</>
|
||||
)}
|
||||
{activeGroupCallId && <GroupCall groupCallId={activeGroupCallId} />}
|
||||
<ActiveCallHeader isActive={Boolean(activeGroupCallId || isPhoneCallActive)} />
|
||||
<NewContactModal
|
||||
isOpen={Boolean(newContactUserId || newContactByPhoneNumber)}
|
||||
userId={newContactUserId}
|
||||
@ -348,8 +347,9 @@ const Main: FC<StateProps> = ({
|
||||
/>
|
||||
<GameModal openedGame={openedGame} gameTitle={gameTitle} />
|
||||
<DownloadManager />
|
||||
<CallFallbackConfirm isOpen={isCallFallbackConfirmOpen} />
|
||||
<PhoneCall isActive={isPhoneCallActive} />
|
||||
<UnreadCount isForAppBadge />
|
||||
<RatePhoneCallModal isOpen={isRatePhoneCallModalOpen} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -406,12 +406,13 @@ export default memo(withGlobal(
|
||||
animationLevel,
|
||||
language,
|
||||
wasTimeFormatSetManually,
|
||||
isCallFallbackConfirmOpen: Boolean(global.groupCalls.isFallbackConfirmOpen),
|
||||
isPhoneCallActive: Boolean(global.phoneCall),
|
||||
addedSetIds: global.stickers.added.setIds,
|
||||
newContactUserId: global.newContact?.userId,
|
||||
newContactByPhoneNumber: global.newContact?.isByPhoneNumber,
|
||||
openedGame,
|
||||
gameTitle,
|
||||
isRatePhoneCallModalOpen: Boolean(global.ratingPhoneCall),
|
||||
};
|
||||
},
|
||||
)(Main));
|
||||
|
||||
@ -84,10 +84,9 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
|
||||
sendBotCommand,
|
||||
openLocalTextSearch,
|
||||
restartBot,
|
||||
openCallFallbackConfirm,
|
||||
requestCall,
|
||||
requestNextManagementScreen,
|
||||
} = getActions();
|
||||
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
@ -140,6 +139,10 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
|
||||
}
|
||||
}, [openLocalTextSearch]);
|
||||
|
||||
function handleRequestCall() {
|
||||
requestCall({ userId: chatId });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!canSearch) {
|
||||
return undefined;
|
||||
@ -214,7 +217,8 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
|
||||
round
|
||||
color="translucent"
|
||||
size="smaller"
|
||||
onClick={openCallFallbackConfirm}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={handleRequestCall}
|
||||
ariaLabel="Call"
|
||||
>
|
||||
<i className="icon-phone" />
|
||||
|
||||
@ -110,10 +110,9 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
createGroupCall,
|
||||
openLinkedChat,
|
||||
openAddContactDialog,
|
||||
openCallFallbackConfirm,
|
||||
requestCall,
|
||||
toggleStatistics,
|
||||
} = getActions();
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(true);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const { x, y } = anchor;
|
||||
@ -177,10 +176,15 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
closeMenu();
|
||||
}, [closeMenu, onSubscribeChannel]);
|
||||
|
||||
const handleCall = useCallback(() => {
|
||||
openCallFallbackConfirm();
|
||||
const handleVideoCall = useCallback(() => {
|
||||
requestCall({ userId: chatId, isVideo: true });
|
||||
closeMenu();
|
||||
}, [closeMenu, openCallFallbackConfirm]);
|
||||
}, [chatId, closeMenu, requestCall]);
|
||||
|
||||
const handleCall = useCallback(() => {
|
||||
requestCall({ userId: chatId });
|
||||
closeMenu();
|
||||
}, [chatId, closeMenu, requestCall]);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
onSearchClick();
|
||||
@ -276,6 +280,14 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
{lang('Call')}
|
||||
</MenuItem>
|
||||
)}
|
||||
{canCall && (
|
||||
<MenuItem
|
||||
icon="video-outlined"
|
||||
onClick={handleVideoCall}
|
||||
>
|
||||
{lang('VideoCall')}
|
||||
</MenuItem>
|
||||
)}
|
||||
{IS_SINGLE_COLUMN_LAYOUT && canSearch && (
|
||||
<MenuItem
|
||||
icon="search"
|
||||
|
||||
@ -122,7 +122,12 @@ const MessageListContent: FC<OwnProps> = ({
|
||||
senderGroupIndex,
|
||||
senderGroupsArray,
|
||||
) => {
|
||||
if (senderGroup.length === 1 && !isAlbum(senderGroup[0]) && isActionMessage(senderGroup[0])) {
|
||||
if (
|
||||
senderGroup.length === 1
|
||||
&& !isAlbum(senderGroup[0])
|
||||
&& isActionMessage(senderGroup[0])
|
||||
&& !senderGroup[0].content.action?.phoneCall
|
||||
) {
|
||||
const message = senderGroup[0];
|
||||
const isLastInList = (
|
||||
senderGroupIndex === senderGroupsArray.length - 1
|
||||
|
||||
@ -114,6 +114,7 @@ import CommentButton from './CommentButton';
|
||||
import Reactions from './Reactions';
|
||||
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
|
||||
import LocalAnimatedEmoji from '../../common/LocalAnimatedEmoji';
|
||||
import MessagePhoneCall from './MessagePhoneCall';
|
||||
|
||||
import './Message.scss';
|
||||
|
||||
@ -354,9 +355,6 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
&& forwardInfo.fromMessageId
|
||||
));
|
||||
|
||||
const withCommentButton = threadInfo && !isInDocumentGroupNotLast && messageListType === 'thread' && !noComments;
|
||||
const withQuickReactionButton = !IS_TOUCH_ENV && !isInSelectMode && defaultReaction && !isInDocumentGroupNotLast;
|
||||
|
||||
const selectMessage = useCallback((e?: React.MouseEvent<HTMLDivElement, MouseEvent>, groupedId?: string) => {
|
||||
toggleMessageSelection({
|
||||
messageId,
|
||||
@ -462,9 +460,15 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
);
|
||||
|
||||
const {
|
||||
text, photo, video, audio, voice, document, sticker, contact, poll, webPage, invoice, location, game,
|
||||
text, photo, video, audio, voice, document, sticker, contact, poll, webPage, invoice, location, action, game,
|
||||
} = getMessageContent(message);
|
||||
|
||||
const { phoneCall } = action || {};
|
||||
|
||||
const withCommentButton = threadInfo && !isInDocumentGroupNotLast && messageListType === 'thread' && !noComments;
|
||||
const withQuickReactionButton = !IS_TOUCH_ENV && !phoneCall && !isInSelectMode && defaultReaction
|
||||
&& !isInDocumentGroupNotLast;
|
||||
|
||||
const contentClassName = buildContentClassName(message, {
|
||||
hasReply,
|
||||
customShape,
|
||||
@ -482,7 +486,9 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
const textParts = renderMessageText(message, highlight, isEmojiOnlyMessage(customShape));
|
||||
|
||||
let metaPosition!: MetaPosition;
|
||||
if (isInDocumentGroupNotLast) {
|
||||
if (phoneCall) {
|
||||
metaPosition = 'none';
|
||||
} else if (isInDocumentGroupNotLast) {
|
||||
metaPosition = 'none';
|
||||
} else if (textParts && !hasAnimatedEmoji && !webPage) {
|
||||
metaPosition = 'in-text';
|
||||
@ -680,6 +686,13 @@ const Message: FC<OwnProps & StateProps> = ({
|
||||
onMediaClick={handleAlbumMediaClick}
|
||||
/>
|
||||
)}
|
||||
{phoneCall && (
|
||||
<MessagePhoneCall
|
||||
message={message}
|
||||
phoneCall={phoneCall}
|
||||
chatId={chatId}
|
||||
/>
|
||||
)}
|
||||
{!isAlbum && photo && (
|
||||
<Photo
|
||||
message={message}
|
||||
|
||||
49
src/components/middle/message/MessagePhoneCall.module.scss
Normal file
49
src/components/middle/message/MessagePhoneCall.module.scss
Normal file
@ -0,0 +1,49 @@
|
||||
.root {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin-inline-end: 0.5rem;
|
||||
color: var(--color-text) !important;
|
||||
}
|
||||
|
||||
.info {
|
||||
margin-inline-end: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.reason {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 1.125rem;
|
||||
display: inline-block;
|
||||
transform: rotateZ(-45deg);
|
||||
color: #4fae4e;
|
||||
|
||||
&.incoming {
|
||||
transform: rotateZ(135deg);
|
||||
|
||||
&.missed {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.duration {
|
||||
margin-inline-start: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary)
|
||||
}
|
||||
|
||||
:global(.own) .duration {
|
||||
color: var(--color-message-meta-own);
|
||||
}
|
||||
89
src/components/middle/message/MessagePhoneCall.tsx
Normal file
89
src/components/middle/message/MessagePhoneCall.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import React, {
|
||||
FC, memo, useCallback, useMemo,
|
||||
} from '../../../lib/teact/teact';
|
||||
import { getActions } from '../../../global';
|
||||
|
||||
import { ApiMessage, PhoneCallAction } from '../../../api/types';
|
||||
|
||||
import useLang from '../../../hooks/useLang';
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
import { formatTimeDuration, formatTime } from '../../../util/dateFormat';
|
||||
import { ARE_CALLS_SUPPORTED } from '../../../util/environment';
|
||||
|
||||
import Button from '../../ui/Button';
|
||||
|
||||
import styles from './MessagePhoneCall.module.scss';
|
||||
|
||||
type OwnProps = {
|
||||
phoneCall: PhoneCallAction;
|
||||
message: ApiMessage;
|
||||
chatId: string;
|
||||
};
|
||||
|
||||
const MessagePhoneCall: FC<OwnProps> = ({
|
||||
phoneCall,
|
||||
message,
|
||||
chatId,
|
||||
}) => {
|
||||
const { requestCall } = getActions();
|
||||
|
||||
const lang = useLang();
|
||||
const { isOutgoing, isVideo, reason } = phoneCall;
|
||||
const isMissed = reason === 'missed';
|
||||
const isCancelled = reason === 'busy' && !isOutgoing;
|
||||
|
||||
const handleCall = useCallback(() => {
|
||||
requestCall({ isVideo, userId: chatId });
|
||||
}, [chatId, isVideo, requestCall]);
|
||||
|
||||
const reasonText = useMemo(() => {
|
||||
if (isVideo) {
|
||||
if (isCancelled) return 'CallMessageVideoIncomingDeclined';
|
||||
if (isMissed) return isOutgoing ? 'CallMessageVideoOutgoingMissed' : 'CallMessageVideoIncomingMissed';
|
||||
|
||||
return isOutgoing ? 'CallMessageVideoOutgoing' : 'CallMessageVideoIncoming';
|
||||
} else {
|
||||
if (isCancelled) return 'CallMessageIncomingDeclined';
|
||||
if (isMissed) return isOutgoing ? 'CallMessageOutgoingMissed' : 'CallMessageIncomingMissed';
|
||||
|
||||
return isOutgoing ? 'CallMessageOutgoing' : 'CallMessageIncoming';
|
||||
}
|
||||
}, [isCancelled, isMissed, isOutgoing, isVideo]);
|
||||
|
||||
const duration = useMemo(() => {
|
||||
return phoneCall.duration ? formatTimeDuration(lang, phoneCall.duration) : undefined;
|
||||
}, [lang, phoneCall.duration]);
|
||||
|
||||
const timeFormatted = formatTime(lang, message.date * 1000);
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Button
|
||||
size="smaller"
|
||||
color="translucent"
|
||||
round
|
||||
ripple
|
||||
onClick={handleCall}
|
||||
className={styles.button}
|
||||
disabled={!ARE_CALLS_SUPPORTED}
|
||||
ariaLabel={lang(isOutgoing ? 'CallAgain' : 'CallBack')}
|
||||
>
|
||||
<i className={isVideo ? 'icon-video-outlined' : 'icon-phone'} />
|
||||
</Button>
|
||||
<div className={styles.info}>
|
||||
<div className={styles.reason}>{lang(reasonText)}</div>
|
||||
<div className={styles.meta}>
|
||||
<i
|
||||
className={buildClassName(
|
||||
'icon-arrow-right', styles.arrow, isMissed && styles.missed, !isOutgoing && styles.incoming,
|
||||
)}
|
||||
/>
|
||||
<span className={styles.duration}>
|
||||
{duration ? lang('CallMessageWithDuration', [timeFormatted, duration]) : timeFormatted}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(MessagePhoneCall);
|
||||
@ -2,7 +2,7 @@
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
transform: translateY(calc(5rem - var(--group-call-header-height, 0rem)));
|
||||
transform: translateY(calc(5rem - var(--call-header-height, 0rem)));
|
||||
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
body.animation-level-0 & {
|
||||
@ -10,6 +10,6 @@
|
||||
}
|
||||
|
||||
&.revealed {
|
||||
transform: translateY(calc(0rem - var(--group-call-header-height, 0rem)));
|
||||
transform: translateY(calc(0rem - var(--call-header-height, 0rem)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,87 +5,22 @@ import {
|
||||
leaveGroupCall,
|
||||
toggleStream,
|
||||
isStreamEnabled,
|
||||
setVolume,
|
||||
handleUpdateGroupCallParticipants, handleUpdateGroupCallConnection,
|
||||
setVolume, stopPhoneCall,
|
||||
} from '../../../lib/secret-sauce';
|
||||
|
||||
import { GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';
|
||||
import { callApi } from '../../../api/gramjs';
|
||||
import { selectChat, selectCurrentMessageList, selectUser } from '../../selectors';
|
||||
import { selectChat, selectUser } from '../../selectors';
|
||||
import {
|
||||
selectActiveGroupCall,
|
||||
selectCallFallbackChannelTitle,
|
||||
selectGroupCallParticipant,
|
||||
selectActiveGroupCall, selectPhoneCallUser,
|
||||
} from '../../selectors/calls';
|
||||
import {
|
||||
removeGroupCall,
|
||||
updateActiveGroupCall,
|
||||
updateGroupCall,
|
||||
updateGroupCallParticipant,
|
||||
} from '../../reducers/calls';
|
||||
import { omit } from '../../../util/iteratees';
|
||||
import { getServerTime } from '../../../util/serverTime';
|
||||
import { fetchFile } from '../../../util/files';
|
||||
import { getGroupCallAudioContext, getGroupCallAudioElement, removeGroupCallAudioElement } from '../ui/calls';
|
||||
import { loadFullChat } from './chats';
|
||||
|
||||
import callFallbackAvatarPath from '../../../assets/call-fallback-avatar.png';
|
||||
|
||||
const FALLBACK_INVITE_EXPIRE_SECONDS = 1800; // 30 min
|
||||
|
||||
addActionHandler('apiUpdate', (global, actions, update) => {
|
||||
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;
|
||||
});
|
||||
|
||||
addActionHandler('leaveGroupCall', async (global, actions, payload) => {
|
||||
const {
|
||||
isFromLibrary, shouldDiscard, shouldRemove, rejoin,
|
||||
@ -101,18 +36,7 @@ addActionHandler('leaveGroupCall', async (global, actions, payload) => {
|
||||
call: groupCall,
|
||||
});
|
||||
|
||||
let shouldResetFallbackState = false;
|
||||
if (shouldDiscard) {
|
||||
global = getGlobal();
|
||||
|
||||
if (global.groupCalls.fallbackChatId === groupCall.chatId) {
|
||||
shouldResetFallbackState = true;
|
||||
|
||||
global.groupCalls.fallbackUserIdsToRemove?.forEach((userId) => {
|
||||
actions.deleteChatMember({ chatId: global.groupCalls.fallbackChatId, userId });
|
||||
});
|
||||
}
|
||||
|
||||
await callApi('discardGroupCall', {
|
||||
call: groupCall,
|
||||
});
|
||||
@ -129,13 +53,9 @@ addActionHandler('leaveGroupCall', async (global, actions, payload) => {
|
||||
...global,
|
||||
groupCalls: {
|
||||
...global.groupCalls,
|
||||
isGroupCallPanelHidden: true,
|
||||
activeGroupCallId: undefined,
|
||||
...(shouldResetFallbackState && {
|
||||
fallbackChatId: undefined,
|
||||
fallbackUserIdsToRemove: undefined,
|
||||
}),
|
||||
},
|
||||
isCallPanelVisible: undefined,
|
||||
});
|
||||
|
||||
if (!isFromLibrary) {
|
||||
@ -292,79 +212,106 @@ addActionHandler('connectToActiveGroupCall', async (global, actions) => {
|
||||
}
|
||||
});
|
||||
|
||||
addActionHandler('inviteToCallFallback', async (global, actions, payload) => {
|
||||
const { chatId } = selectCurrentMessageList(global) || {};
|
||||
if (!chatId) {
|
||||
return;
|
||||
}
|
||||
addActionHandler('connectToActivePhoneCall', async (global) => {
|
||||
const { phoneCall } = global;
|
||||
|
||||
const user = selectUser(global, chatId);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
if (!phoneCall) return;
|
||||
|
||||
const { shouldRemove } = payload;
|
||||
const user = selectPhoneCallUser(global);
|
||||
|
||||
const fallbackChannelTitle = selectCallFallbackChannelTitle(global);
|
||||
if (!user) return;
|
||||
|
||||
let fallbackChannel = Object.values(global.chats.byId).find((channel) => {
|
||||
return (
|
||||
channel.title === fallbackChannelTitle
|
||||
&& channel.isCreator
|
||||
&& !channel.isRestricted
|
||||
&& !channel.isForbidden
|
||||
);
|
||||
});
|
||||
if (!fallbackChannel) {
|
||||
fallbackChannel = await callApi('createChannel', {
|
||||
title: fallbackChannelTitle,
|
||||
users: [user],
|
||||
});
|
||||
const dhConfig = await callApi('getDhConfig');
|
||||
|
||||
if (!fallbackChannel) {
|
||||
return;
|
||||
}
|
||||
if (!dhConfig) return;
|
||||
|
||||
const photo = await fetchFile(callFallbackAvatarPath, 'avatar.png');
|
||||
void callApi('editChatPhoto', {
|
||||
chatId: fallbackChannel.id,
|
||||
accessHash: fallbackChannel.accessHash,
|
||||
photo,
|
||||
});
|
||||
} else {
|
||||
actions.updateChatMemberBannedRights({
|
||||
chatId: fallbackChannel.id,
|
||||
userId: chatId,
|
||||
bannedRights: {},
|
||||
});
|
||||
await callApi('createPhoneCallState', [true]);
|
||||
|
||||
void callApi('addChatMembers', fallbackChannel, [user], true);
|
||||
}
|
||||
const gAHash = await callApi('requestPhoneCall', [dhConfig])!;
|
||||
|
||||
const inviteLink = await callApi('updatePrivateLink', {
|
||||
chat: fallbackChannel,
|
||||
usageLimit: 1,
|
||||
expireDate: getServerTime(global.serverTimeOffset) + FALLBACK_INVITE_EXPIRE_SECONDS,
|
||||
});
|
||||
if (!inviteLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldRemove) {
|
||||
global = getGlobal();
|
||||
const fallbackUserIdsToRemove = global.groupCalls.fallbackUserIdsToRemove || [];
|
||||
setGlobal({
|
||||
...global,
|
||||
groupCalls: {
|
||||
...global.groupCalls,
|
||||
fallbackChatId: fallbackChannel.id,
|
||||
fallbackUserIdsToRemove: [...fallbackUserIdsToRemove, chatId],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
actions.sendMessage({ text: `Join a call: ${inviteLink}` });
|
||||
actions.openChat({ id: fallbackChannel.id });
|
||||
actions.createGroupCall({ chatId: fallbackChannel.id });
|
||||
actions.closeCallFallbackConfirm();
|
||||
await callApi('requestCall', { user, gAHash, isVideo: phoneCall.isVideo });
|
||||
});
|
||||
|
||||
addActionHandler('acceptCall', async (global) => {
|
||||
const { phoneCall } = global;
|
||||
|
||||
if (!phoneCall) return;
|
||||
|
||||
const dhConfig = await callApi('getDhConfig');
|
||||
if (!dhConfig) return;
|
||||
|
||||
await callApi('createPhoneCallState', [false]);
|
||||
|
||||
const gB = await callApi('acceptPhoneCall', [dhConfig])!;
|
||||
callApi('acceptCall', { call: phoneCall, gB });
|
||||
});
|
||||
|
||||
addActionHandler('sendSignalingData', (global, actions, payload) => {
|
||||
const { phoneCall } = global;
|
||||
if (!phoneCall) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.stringify(payload);
|
||||
|
||||
(async () => {
|
||||
const encodedData = await callApi('encodePhoneCallData', [data]);
|
||||
|
||||
if (!encodedData) return;
|
||||
|
||||
callApi('sendSignalingData', { data: encodedData, call: phoneCall });
|
||||
})();
|
||||
});
|
||||
|
||||
addActionHandler('closeCallRatingModal', (global) => {
|
||||
return {
|
||||
...global,
|
||||
ratingPhoneCall: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
addActionHandler('setCallRating', (global, actions, payload) => {
|
||||
const { ratingPhoneCall } = global;
|
||||
if (!ratingPhoneCall) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { rating, comment } = payload;
|
||||
|
||||
callApi('setCallRating', { call: ratingPhoneCall, rating, comment });
|
||||
|
||||
return {
|
||||
...global,
|
||||
ratingPhoneCall: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
addActionHandler('hangUp', (global) => {
|
||||
const { phoneCall } = global;
|
||||
|
||||
if (!phoneCall) return undefined;
|
||||
|
||||
if (phoneCall.state === 'discarded') {
|
||||
callApi('destroyPhoneCallState');
|
||||
stopPhoneCall();
|
||||
return {
|
||||
...global,
|
||||
phoneCall: undefined,
|
||||
isCallPanelVisible: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
callApi('destroyPhoneCallState');
|
||||
stopPhoneCall();
|
||||
callApi('discardCall', { call: phoneCall });
|
||||
|
||||
if (phoneCall.state === 'requesting') {
|
||||
return {
|
||||
...global,
|
||||
phoneCall: undefined,
|
||||
isCallPanelVisible: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
202
src/global/actions/apiUpdaters/calls.async.ts
Normal file
202
src/global/actions/apiUpdaters/calls.async.ts
Normal file
@ -0,0 +1,202 @@
|
||||
import { addActionHandler, getGlobal, setGlobal } from '../../index';
|
||||
import { selectActiveGroupCall, selectGroupCallParticipant, selectPhoneCallUser } from '../../selectors/calls';
|
||||
import { updateGroupCall, updateGroupCallParticipant } from '../../reducers/calls';
|
||||
import { omit } from '../../../util/iteratees';
|
||||
import {
|
||||
ApiCallProtocol,
|
||||
handleUpdateGroupCallConnection,
|
||||
handleUpdateGroupCallParticipants,
|
||||
joinPhoneCall, processSignalingMessage,
|
||||
} from '../../../lib/secret-sauce';
|
||||
import { ApiPhoneCall } from '../../../api/types';
|
||||
import { ARE_CALLS_SUPPORTED } from '../../../util/environment';
|
||||
import { callApi } from '../../../api/gramjs';
|
||||
import * as langProvider from '../../../util/langProvider';
|
||||
import { EMOJI_DATA, EMOJI_OFFSETS } from '../../../util/phoneCallEmojiConstants';
|
||||
|
||||
addActionHandler('apiUpdate', (global, actions, update) => {
|
||||
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;
|
||||
}
|
||||
case 'updatePhoneCallMediaState':
|
||||
return {
|
||||
...global,
|
||||
phoneCall: {
|
||||
...global.phoneCall,
|
||||
...omit(update, ['@type']),
|
||||
} as ApiPhoneCall,
|
||||
};
|
||||
case 'updatePhoneCall': {
|
||||
if (!ARE_CALLS_SUPPORTED) return undefined;
|
||||
const { phoneCall, currentUserId } = global;
|
||||
|
||||
const call: ApiPhoneCall = {
|
||||
...phoneCall,
|
||||
...update.call,
|
||||
};
|
||||
|
||||
const isOutgoing = phoneCall?.adminId === currentUserId;
|
||||
|
||||
global = {
|
||||
...global,
|
||||
phoneCall: call,
|
||||
};
|
||||
|
||||
if (phoneCall && phoneCall.id && call.id !== phoneCall.id) {
|
||||
if (call.state !== 'discarded') {
|
||||
callApi('discardCall', {
|
||||
call,
|
||||
isBusy: true,
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const {
|
||||
accessHash, state, connections, gB,
|
||||
} = call;
|
||||
|
||||
if (state === 'active' || state === 'accepted') {
|
||||
if (!verifyPhoneCallProtocol(call.protocol)) {
|
||||
const user = selectPhoneCallUser(global);
|
||||
actions.hangUp();
|
||||
actions.showNotification({ message: langProvider.getTranslation('VoipPeerIncompatible', user?.firstName) });
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (state === 'discarded') {
|
||||
// Discarded from other device
|
||||
if (!phoneCall) return undefined;
|
||||
|
||||
return {
|
||||
...global,
|
||||
...(call.needRating && { ratingPhoneCall: call }),
|
||||
isCallPanelVisible: undefined,
|
||||
};
|
||||
} else if (state === 'accepted' && accessHash && gB) {
|
||||
(async () => {
|
||||
const { gA, keyFingerprint, emojis } = await callApi('confirmPhoneCall', [gB, EMOJI_DATA, EMOJI_OFFSETS])!;
|
||||
|
||||
global = getGlobal();
|
||||
const newCall = {
|
||||
...global.phoneCall,
|
||||
emojis,
|
||||
} as ApiPhoneCall;
|
||||
|
||||
setGlobal({
|
||||
...global,
|
||||
phoneCall: newCall,
|
||||
});
|
||||
|
||||
callApi('confirmCall', {
|
||||
call, gA, keyFingerprint,
|
||||
});
|
||||
})();
|
||||
} else if (state === 'active' && connections && phoneCall?.state !== 'active') {
|
||||
if (!isOutgoing) {
|
||||
callApi('receivedCall', { call });
|
||||
(async () => {
|
||||
const { emojis } = await callApi('confirmPhoneCall', [call!.gAOrB!, EMOJI_DATA, EMOJI_OFFSETS])!;
|
||||
|
||||
global = getGlobal();
|
||||
const newCall = {
|
||||
...global.phoneCall,
|
||||
emojis,
|
||||
} as ApiPhoneCall;
|
||||
|
||||
setGlobal({
|
||||
...global,
|
||||
phoneCall: newCall,
|
||||
});
|
||||
})();
|
||||
}
|
||||
void joinPhoneCall(
|
||||
connections, actions.sendSignalingData, isOutgoing, Boolean(call?.isVideo), actions.apiUpdate,
|
||||
);
|
||||
}
|
||||
|
||||
return global;
|
||||
}
|
||||
case 'updatePhoneCallConnectionState': {
|
||||
const { connectionState } = update;
|
||||
|
||||
if (!global.phoneCall) return global;
|
||||
|
||||
if (connectionState === 'closed' || connectionState === 'disconnected' || connectionState === 'failed') {
|
||||
actions.hangUp();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...global,
|
||||
phoneCall: {
|
||||
...global.phoneCall,
|
||||
isConnected: connectionState === 'connected',
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'updatePhoneCallSignalingData': {
|
||||
const { phoneCall } = global;
|
||||
|
||||
if (!phoneCall) {
|
||||
break;
|
||||
}
|
||||
|
||||
callApi('decodePhoneCallData', [update.data])?.then(processSignalingMessage);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
function verifyPhoneCallProtocol(protocol?: ApiCallProtocol) {
|
||||
return protocol?.libraryVersions.some((version) => {
|
||||
return version === '4.0.0' || version === '4.0.1';
|
||||
});
|
||||
}
|
||||
@ -3,6 +3,10 @@ import { removeGroupCall, updateGroupCall, updateGroupCallParticipant } from '..
|
||||
import { omit } from '../../../util/iteratees';
|
||||
import { selectChat } from '../../selectors';
|
||||
import { updateChat } from '../../reducers';
|
||||
import { ARE_CALLS_SUPPORTED } from '../../../util/environment';
|
||||
import { notifyAboutCall } from '../../../util/notifications';
|
||||
import { selectPhoneCallUser } from '../../selectors/calls';
|
||||
import { initializeSoundsForSafari } from '../ui/calls';
|
||||
|
||||
addActionHandler('apiUpdate', (global, actions, update) => {
|
||||
switch (update['@type']) {
|
||||
@ -54,6 +58,32 @@ addActionHandler('apiUpdate', (global, actions, update) => {
|
||||
}
|
||||
return global;
|
||||
}
|
||||
case 'updatePhoneCall': {
|
||||
if (!ARE_CALLS_SUPPORTED) return undefined;
|
||||
|
||||
const {
|
||||
phoneCall,
|
||||
currentUserId,
|
||||
} = global;
|
||||
|
||||
if (phoneCall) return undefined;
|
||||
|
||||
const { call } = update;
|
||||
const isOutgoing = call?.adminId === currentUserId;
|
||||
|
||||
if (!isOutgoing && call.state === 'requested') {
|
||||
notifyAboutCall({
|
||||
call,
|
||||
user: selectPhoneCallUser(global)!,
|
||||
});
|
||||
void initializeSoundsForSafari();
|
||||
return {
|
||||
...global,
|
||||
phoneCall: call,
|
||||
isCallPanelVisible: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@ -1 +1,2 @@
|
||||
import './api/calls.async';
|
||||
import './apiUpdaters/calls.async';
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { addActionHandler, getGlobal, setGlobal } from '../../index';
|
||||
import { selectActiveGroupCall, selectChatGroupCall, selectGroupCall } from '../../selectors/calls';
|
||||
import { callApi } from '../../../api/gramjs';
|
||||
import { selectChat } from '../../selectors';
|
||||
import { selectChat, selectUser } from '../../selectors';
|
||||
import { copyTextToClipboard } from '../../../util/clipboard';
|
||||
import { ApiGroupCall } from '../../../api/types';
|
||||
import { updateGroupCall } from '../../reducers/calls';
|
||||
@ -11,29 +11,43 @@ import { fetchChatByUsername, loadFullChat } from '../api/chats';
|
||||
import safePlay from '../../../util/safePlay';
|
||||
import { ARE_CALLS_SUPPORTED } from '../../../util/environment';
|
||||
import * as langProvider from '../../../util/langProvider';
|
||||
import { CallSound } from '../../types';
|
||||
|
||||
// Workaround for Safari not playing audio without user interaction
|
||||
let audioElement: HTMLAudioElement | undefined;
|
||||
let audioContext: AudioContext | undefined;
|
||||
|
||||
const joinAudio = new Audio('./voicechat_join.mp3');
|
||||
const connectingAudio = new Audio('./voicechat_connecting.mp3');
|
||||
connectingAudio.loop = true;
|
||||
const leaveAudio = new Audio('./voicechat_leave.mp3');
|
||||
const allowTalkAudio = new Audio('./voicechat_onallowtalk.mp3');
|
||||
|
||||
const sounds: Record<string, HTMLAudioElement> = {
|
||||
join: joinAudio,
|
||||
allowTalk: allowTalkAudio,
|
||||
leave: leaveAudio,
|
||||
connecting: connectingAudio,
|
||||
};
|
||||
|
||||
let sounds: Record<CallSound, HTMLAudioElement>;
|
||||
let initializationPromise: Promise<void> | undefined = Promise.resolve();
|
||||
|
||||
const initializeSoundsForSafari = () => {
|
||||
export const initializeSoundsForSafari = () => {
|
||||
if (!initializationPromise) return Promise.resolve();
|
||||
|
||||
const joinAudio = new Audio('./voicechat_join.mp3');
|
||||
const connectingAudio = new Audio('./voicechat_connecting.mp3');
|
||||
connectingAudio.loop = true;
|
||||
const leaveAudio = new Audio('./voicechat_leave.mp3');
|
||||
const allowTalkAudio = new Audio('./voicechat_onallowtalk.mp3');
|
||||
const busyAudio = new Audio('./call_busy.mp3');
|
||||
const connectAudio = new Audio('./call_connect.mp3');
|
||||
const endAudio = new Audio('./call_end.mp3');
|
||||
const incomingAudio = new Audio('./call_incoming.mp3');
|
||||
incomingAudio.loop = true;
|
||||
const ringingAudio = new Audio('./call_ringing.mp3');
|
||||
ringingAudio.loop = true;
|
||||
|
||||
sounds = {
|
||||
join: joinAudio,
|
||||
allowTalk: allowTalkAudio,
|
||||
leave: leaveAudio,
|
||||
connecting: connectingAudio,
|
||||
incoming: incomingAudio,
|
||||
end: endAudio,
|
||||
connect: connectAudio,
|
||||
busy: busyAudio,
|
||||
ringing: ringingAudio,
|
||||
};
|
||||
|
||||
initializationPromise = Promise.all(Object.values(sounds).map((l) => {
|
||||
l.muted = true;
|
||||
l.volume = 0.0001;
|
||||
@ -95,10 +109,7 @@ async function fetchGroupCallParticipants(groupCall: Partial<ApiGroupCall>, next
|
||||
addActionHandler('toggleGroupCallPanel', (global) => {
|
||||
return {
|
||||
...global,
|
||||
groupCalls: {
|
||||
...global.groupCalls,
|
||||
isGroupCallPanelHidden: !global.groupCalls.isGroupCallPanelHidden,
|
||||
},
|
||||
isCallPanelVisible: !global.isCallPanelVisible,
|
||||
};
|
||||
});
|
||||
|
||||
@ -194,6 +205,11 @@ addActionHandler('joinVoiceChatByLink', async (global, actions, payload) => {
|
||||
addActionHandler('joinGroupCall', async (global, actions, payload) => {
|
||||
if (!ARE_CALLS_SUPPORTED) return undefined;
|
||||
|
||||
if (global.phoneCall) {
|
||||
actions.toggleGroupCallPanel();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const {
|
||||
chatId, id, accessHash, inviteHash,
|
||||
} = payload;
|
||||
@ -246,8 +262,8 @@ addActionHandler('joinGroupCall', async (global, actions, payload) => {
|
||||
groupCalls: {
|
||||
...global.groupCalls,
|
||||
activeGroupCallId: groupCall.id,
|
||||
isGroupCallPanelHidden: false,
|
||||
},
|
||||
isCallPanelVisible: false,
|
||||
};
|
||||
return global;
|
||||
});
|
||||
@ -259,15 +275,23 @@ addActionHandler('playGroupCallSound', (global, actions, payload) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (initializationPromise) {
|
||||
initializationPromise.then(() => {
|
||||
safePlay(sounds[sound]);
|
||||
});
|
||||
} else {
|
||||
const doPlay = () => {
|
||||
if (sound !== 'connecting') {
|
||||
sounds.connecting.pause();
|
||||
}
|
||||
if (sound !== 'incoming') {
|
||||
sounds.incoming.pause();
|
||||
}
|
||||
if (sound !== 'ringing') {
|
||||
sounds.ringing.pause();
|
||||
}
|
||||
safePlay(sounds[sound]);
|
||||
};
|
||||
|
||||
if (initializationPromise) {
|
||||
initializationPromise.then(doPlay);
|
||||
} else {
|
||||
doPlay();
|
||||
}
|
||||
});
|
||||
|
||||
@ -280,6 +304,35 @@ addActionHandler('loadMoreGroupCallParticipants', (global) => {
|
||||
void fetchGroupCallParticipants(groupCall, groupCall.nextOffset);
|
||||
});
|
||||
|
||||
addActionHandler('requestCall', async (global, actions, payload) => {
|
||||
const { userId, isVideo } = payload;
|
||||
|
||||
if (global.phoneCall) {
|
||||
actions.toggleGroupCallPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
const user = selectUser(global, userId);
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
await initializeSoundsForSafari();
|
||||
|
||||
setGlobal({
|
||||
...getGlobal(),
|
||||
phoneCall: {
|
||||
id: '',
|
||||
state: 'requesting',
|
||||
participantId: userId,
|
||||
isVideo,
|
||||
adminId: global.currentUserId,
|
||||
},
|
||||
isCallPanelVisible: false,
|
||||
});
|
||||
});
|
||||
|
||||
function createAudioContext() {
|
||||
return (new (window.AudioContext || (window as any).webkitAudioContext)());
|
||||
}
|
||||
@ -312,23 +365,3 @@ export function removeGroupCallAudioElement() {
|
||||
audioContext = undefined;
|
||||
audioElement = undefined;
|
||||
}
|
||||
|
||||
addActionHandler('openCallFallbackConfirm', (global) => {
|
||||
return {
|
||||
...global,
|
||||
groupCalls: {
|
||||
...global.groupCalls,
|
||||
isFallbackConfirmOpen: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
addActionHandler('closeCallFallbackConfirm', (global) => {
|
||||
return {
|
||||
...global,
|
||||
groupCalls: {
|
||||
...global.groupCalls,
|
||||
isFallbackConfirmOpen: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@ -276,6 +276,7 @@ function updateCache() {
|
||||
chatFolders: reduceChatFolders(global),
|
||||
groupCalls: reduceGroupCalls(global),
|
||||
availableReactions: reduceAvailableReactions(global),
|
||||
isCallPanelVisible: undefined,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(reducedGlobal);
|
||||
@ -389,8 +390,6 @@ function reduceGroupCalls(global: GlobalState): GlobalState['groupCalls'] {
|
||||
...global.groupCalls,
|
||||
byId: {},
|
||||
activeGroupCallId: undefined,
|
||||
isGroupCallPanelHidden: undefined,
|
||||
isFallbackConfirmOpen: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -44,7 +44,8 @@ export function getMessageOriginalId(message: ApiMessage) {
|
||||
|
||||
export function getMessageText(message: ApiMessage) {
|
||||
const {
|
||||
text, sticker, photo, video, audio, voice, document, poll, webPage, contact, invoice, location, game,
|
||||
text, sticker, photo, video, audio, voice, document, poll, webPage, contact, invoice, location,
|
||||
game, action,
|
||||
} = message.content;
|
||||
|
||||
if (text) {
|
||||
@ -52,7 +53,7 @@ export function getMessageText(message: ApiMessage) {
|
||||
}
|
||||
|
||||
if (sticker || photo || video || audio || voice || document
|
||||
|| contact || poll || webPage || invoice || location || game) {
|
||||
|| contact || poll || webPage || invoice || location || game || action?.phoneCall) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { GlobalState } from '../types';
|
||||
import { selectChat } from './chats';
|
||||
import { getUserFullName, isChatBasicGroup } from '../helpers';
|
||||
import { isChatBasicGroup } from '../helpers';
|
||||
import { selectUser } from './users';
|
||||
|
||||
export function selectChatGroupCall(global: GlobalState, chatId: string) {
|
||||
@ -38,8 +38,12 @@ export function selectActiveGroupCall(global: GlobalState) {
|
||||
return selectGroupCall(global, activeGroupCallId);
|
||||
}
|
||||
|
||||
export function selectCallFallbackChannelTitle(global: GlobalState) {
|
||||
const currentUser = selectUser(global, global.currentUserId!);
|
||||
export function selectPhoneCallUser(global: GlobalState) {
|
||||
const { phoneCall, currentUserId } = global;
|
||||
if (!phoneCall || !phoneCall.participantId || !phoneCall.adminId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `Calls: ${getUserFullName(currentUser!)}`;
|
||||
const id = phoneCall.adminId === currentUserId ? phoneCall.participantId : phoneCall.adminId;
|
||||
return selectUser(global, id);
|
||||
}
|
||||
|
||||
@ -31,6 +31,7 @@ import {
|
||||
ApiPaymentFormNativeParams,
|
||||
ApiUpdate,
|
||||
ApiKeyboardButton,
|
||||
ApiPhoneCall,
|
||||
} from '../api/types';
|
||||
import {
|
||||
FocusDirection,
|
||||
@ -58,6 +59,7 @@ import {
|
||||
ManagementState,
|
||||
} from '../types';
|
||||
import { typify } from '../lib/teact/teactn';
|
||||
import type { P2pMessage } from '../lib/secret-sauce';
|
||||
|
||||
export type MessageListType =
|
||||
'thread'
|
||||
@ -200,12 +202,12 @@ export type GlobalState = {
|
||||
groupCalls: {
|
||||
byId: Record<string, ApiGroupCall>;
|
||||
activeGroupCallId?: string;
|
||||
isGroupCallPanelHidden?: boolean;
|
||||
isFallbackConfirmOpen?: boolean;
|
||||
fallbackChatId?: string;
|
||||
fallbackUserIdsToRemove?: string[];
|
||||
};
|
||||
|
||||
isCallPanelVisible?: boolean;
|
||||
phoneCall?: ApiPhoneCall;
|
||||
ratingPhoneCall?: ApiPhoneCall;
|
||||
|
||||
scheduledMessages: {
|
||||
byChatId: Record<string, {
|
||||
byId: Record<number, ApiMessage>;
|
||||
@ -541,6 +543,10 @@ export type GlobalState = {
|
||||
};
|
||||
};
|
||||
|
||||
export type CallSound = (
|
||||
'join' | 'allowTalk' | 'leave' | 'connecting' | 'incoming' | 'end' | 'connect' | 'busy' | 'ringing'
|
||||
);
|
||||
|
||||
export interface ActionPayloads {
|
||||
// Initial
|
||||
signOut: { forceInitApi?: boolean } | undefined;
|
||||
@ -673,6 +679,24 @@ export interface ActionPayloads {
|
||||
isQuiz?: boolean;
|
||||
};
|
||||
closePollModal: {};
|
||||
|
||||
// Calls
|
||||
requestCall: {
|
||||
userId: string;
|
||||
isVideo?: boolean;
|
||||
};
|
||||
sendSignalingData: P2pMessage;
|
||||
hangUp: {};
|
||||
acceptCall: {};
|
||||
setCallRating: {
|
||||
rating: number;
|
||||
comment: string;
|
||||
};
|
||||
closeCallRatingModal: {};
|
||||
playGroupCallSound: {
|
||||
sound: CallSound;
|
||||
};
|
||||
connectToActivePhoneCall: {};
|
||||
}
|
||||
|
||||
export type NonTypedActionNames = (
|
||||
@ -769,8 +793,7 @@ export type NonTypedActionNames = (
|
||||
'joinGroupCall' | 'toggleGroupCallMute' | 'toggleGroupCallPresentation' | 'leaveGroupCall' |
|
||||
'toggleGroupCallVideo' | 'requestToSpeak' | 'setGroupCallParticipantVolume' | 'toggleGroupCallPanel' |
|
||||
'createGroupCall' | 'joinVoiceChatByLink' | 'subscribeToGroupCallUpdates' | 'createGroupCallInviteLink' |
|
||||
'loadMoreGroupCallParticipants' | 'connectToActiveGroupCall' | 'playGroupCallSound' |
|
||||
'openCallFallbackConfirm' | 'closeCallFallbackConfirm' | 'inviteToCallFallback' |
|
||||
'loadMoreGroupCallParticipants' | 'connectToActiveGroupCall' |
|
||||
// stats
|
||||
'loadStatistics' | 'loadStatisticsAsyncGraph'
|
||||
);
|
||||
|
||||
@ -38,7 +38,6 @@ function toSignedLittleBuffer(big, number = 8) {
|
||||
return Buffer.from(byteArray);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* converts a big int to a buffer
|
||||
* @param bigInt {bigInt.BigInteger}
|
||||
@ -205,7 +204,6 @@ function sha1(data) {
|
||||
return shaSum.digest();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Calculates the SHA256 digest for the given data
|
||||
* @param data
|
||||
@ -241,10 +239,9 @@ function modExp(a, b, n) {
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets the arbitrary-length byte array corresponding to the given integer
|
||||
* @param integer {number,BigInteger}
|
||||
* @param integer {any}
|
||||
* @param signed {boolean}
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
const BigInt = require('big-integer');
|
||||
const aes = require('@cryptography/aes');
|
||||
|
||||
const Helpers = require('../Helpers');
|
||||
const IGE = require('../crypto/IGE');
|
||||
@ -39,10 +40,14 @@ class MTProtoState {
|
||||
authentication process, at which point the `MTProtoPlainSender` is better
|
||||
* @param authKey
|
||||
* @param loggers
|
||||
* @param isCall
|
||||
* @param isOutgoing
|
||||
*/
|
||||
constructor(authKey, loggers) {
|
||||
constructor(authKey, loggers, isCall = false, isOutgoing = false) {
|
||||
this.authKey = authKey;
|
||||
this._log = loggers;
|
||||
this._isCall = isCall;
|
||||
this._isOutgoing = isOutgoing;
|
||||
this.timeOffset = 0;
|
||||
this.salt = 0;
|
||||
|
||||
@ -81,12 +86,20 @@ class MTProtoState {
|
||||
* @returns {{iv: Buffer, key: Buffer}}
|
||||
*/
|
||||
async _calcKey(authKey, msgKey, client) {
|
||||
const x = client === true ? 0 : 8;
|
||||
const x = (this._isCall ? 128 + ((this._isOutgoing ^ client) ? 8 : 0) : (client === true ? 0 : 8));
|
||||
const [sha256a, sha256b] = await Promise.all([
|
||||
Helpers.sha256(Buffer.concat([msgKey, authKey.slice(x, x + 36)])),
|
||||
Helpers.sha256(Buffer.concat([authKey.slice(x + 40, x + 76), msgKey])),
|
||||
]);
|
||||
const key = Buffer.concat([sha256a.slice(0, 8), sha256b.slice(8, 24), sha256a.slice(24, 32)]);
|
||||
if (this._isCall) {
|
||||
const iv = Buffer.concat([sha256b.slice(0, 4), sha256a.slice(8, 16), sha256b.slice(24, 28)]);
|
||||
|
||||
return {
|
||||
key,
|
||||
iv,
|
||||
};
|
||||
}
|
||||
const iv = Buffer.concat([sha256b.slice(0, 8), sha256a.slice(8, 24), sha256b.slice(24, 32)]);
|
||||
return {
|
||||
key,
|
||||
@ -133,24 +146,48 @@ class MTProtoState {
|
||||
*/
|
||||
async encryptMessageData(data) {
|
||||
await this.authKey.waitForKey();
|
||||
const s = toSignedLittleBuffer(this.salt, 8);
|
||||
const i = toSignedLittleBuffer(this.id, 8);
|
||||
data = Buffer.concat([Buffer.concat([s, i]), data]);
|
||||
const padding = Helpers.generateRandomBytes(Helpers.mod(-(data.length + 12), 16) + 12);
|
||||
// Being substr(what, offset, length); x = 0 for client
|
||||
// "msg_key_large = SHA256(substr(auth_key, 88+x, 32) + pt + padding)"
|
||||
const msgKeyLarge = await Helpers.sha256(Buffer.concat([this.authKey.getKey()
|
||||
.slice(88, 88 + 32), data, padding]));
|
||||
// "msg_key = substr (msg_key_large, 8, 16)"
|
||||
const msgKey = msgKeyLarge.slice(8, 24);
|
||||
if (this._isCall) {
|
||||
const x = 128 + (this._isOutgoing ? 0 : 8);
|
||||
const lengthStart = data.length;
|
||||
|
||||
const {
|
||||
iv,
|
||||
key,
|
||||
} = await this._calcKey(this.authKey.getKey(), msgKey, true);
|
||||
data = Buffer.from(data);
|
||||
if (lengthStart % 4 !== 0) {
|
||||
data = Buffer.concat([data, Buffer.from(new Array(4 - (lengthStart % 4)).fill(0x20))]);
|
||||
}
|
||||
|
||||
const keyId = Helpers.readBufferFromBigInt(this.authKey.keyId, 8);
|
||||
return Buffer.concat([keyId, msgKey, new IGE(key, iv).encryptIge(Buffer.concat([data, padding]))]);
|
||||
const msgKeyLarge = await Helpers.sha256(Buffer.concat([this.authKey.getKey()
|
||||
.slice(88 + x, 88 + x + 32), Buffer.from(data)]));
|
||||
|
||||
const msgKey = msgKeyLarge.slice(8, 24);
|
||||
|
||||
const {
|
||||
iv,
|
||||
key,
|
||||
} = await this._calcKey(this.authKey.getKey(), msgKey, true);
|
||||
|
||||
data = Helpers.convertToLittle(new aes.CTR(key, iv).encrypt(data));
|
||||
// data = data.slice(0, lengthStart)
|
||||
return Buffer.concat([msgKey, data]);
|
||||
} else {
|
||||
const s = toSignedLittleBuffer(this.salt, 8);
|
||||
const i = toSignedLittleBuffer(this.id, 8);
|
||||
data = Buffer.concat([Buffer.concat([s, i]), data]);
|
||||
const padding = Helpers.generateRandomBytes(Helpers.mod(-(data.length + 12), 16) + 12);
|
||||
// Being substr(what, offset, length); x = 0 for client
|
||||
// "msg_key_large = SHA256(substr(auth_key, 88+x, 32) + pt + padding)"
|
||||
const msgKeyLarge = await Helpers.sha256(Buffer.concat([this.authKey.getKey()
|
||||
.slice(88, 88 + 32), data, padding]));
|
||||
// "msg_key = substr (msg_key_large, 8, 16)"
|
||||
const msgKey = msgKeyLarge.slice(8, 24);
|
||||
|
||||
const {
|
||||
iv,
|
||||
key,
|
||||
} = await this._calcKey(this.authKey.getKey(), msgKey, true);
|
||||
|
||||
const keyId = Helpers.readBufferFromBigInt(this.authKey.keyId, 8);
|
||||
return Buffer.concat([keyId, msgKey, new IGE(key, iv).encryptIge(Buffer.concat([data, padding]))]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -164,65 +201,87 @@ class MTProtoState {
|
||||
if (body.length < 0) { // length needs to be positive
|
||||
throw new SecurityError('Server replied with negative length');
|
||||
}
|
||||
if (body.length % 4 !== 0) {
|
||||
if (body.length % 4 !== 0 && !this._isCall) {
|
||||
throw new SecurityError('Server replied with length not divisible by 4');
|
||||
}
|
||||
// TODO Check salt,sessionId, and sequenceNumber
|
||||
const keyId = Helpers.readBigIntFromBuffer(body.slice(0, 8));
|
||||
if (keyId.neq(this.authKey.keyId)) {
|
||||
throw new SecurityError('Server replied with an invalid auth key');
|
||||
}
|
||||
if (!this._isCall) {
|
||||
const keyId = Helpers.readBigIntFromBuffer(body.slice(0, 8));
|
||||
|
||||
const msgKey = body.slice(8, 24);
|
||||
if (keyId.neq(this.authKey.keyId)) {
|
||||
throw new SecurityError('Server replied with an invalid auth key');
|
||||
}
|
||||
}
|
||||
const msgKey = this._isCall ? body.slice(0, 16) : body.slice(8, 24);
|
||||
|
||||
const x = this._isCall ? 128 + (this.isOutgoing ? 8 : 0) : undefined;
|
||||
const {
|
||||
iv,
|
||||
key,
|
||||
} = await this._calcKey(this.authKey.getKey(), msgKey, false);
|
||||
body = new IGE(key, iv).decryptIge(body.slice(24));
|
||||
|
||||
if (this._isCall) {
|
||||
body = body.slice(16);
|
||||
const lengthStart = body.length;
|
||||
|
||||
body = Buffer.concat([body, Buffer.from(new Array(4 - (lengthStart % 4)).fill(0))]);
|
||||
|
||||
body = Helpers.convertToLittle(new aes.CTR(key, iv).decrypt(body));
|
||||
|
||||
body = body.slice(0, lengthStart);
|
||||
} else {
|
||||
body = new IGE(key, iv).decryptIge(this._isCall ? body.slice(16) : body.slice(24));
|
||||
}
|
||||
// https://core.telegram.org/mtproto/security_guidelines
|
||||
// Sections "checking sha256 hash" and "message length"
|
||||
|
||||
const ourKey = await Helpers.sha256(Buffer.concat([this.authKey.getKey()
|
||||
.slice(96, 96 + 32), body]));
|
||||
const ourKey = this._isCall
|
||||
? await Helpers.sha256(Buffer.concat([this.authKey.getKey()
|
||||
.slice(88 + x, 88 + x + 32), body]))
|
||||
: await Helpers.sha256(Buffer.concat([this.authKey.getKey()
|
||||
.slice(96, 96 + 32), body]));
|
||||
|
||||
if (!msgKey.equals(ourKey.slice(8, 24))) {
|
||||
if (!this._isCall && !msgKey.equals(ourKey.slice(8, 24))) {
|
||||
throw new SecurityError('Received msg_key doesn\'t match with expected one');
|
||||
}
|
||||
|
||||
const reader = new BinaryReader(body);
|
||||
reader.readLong(); // removeSalt
|
||||
const serverId = reader.readLong();
|
||||
if (!serverId.eq(this.id)) {
|
||||
throw new SecurityError('Server replied with a wrong session ID');
|
||||
}
|
||||
|
||||
const remoteMsgId = reader.readLong();
|
||||
// if we get a duplicate message id we should ignore it.
|
||||
if (this.msgIds.includes(remoteMsgId.toString())) {
|
||||
throw new SecurityError('Duplicate msgIds');
|
||||
}
|
||||
// we only store the latest 500 message ids from the server
|
||||
if (this.msgIds.length > 500) {
|
||||
this.msgIds.shift();
|
||||
}
|
||||
this.msgIds.push(remoteMsgId.toString());
|
||||
if (this._isCall) {
|
||||
// Seq
|
||||
reader.readInt(false);
|
||||
return reader.read(body.length - 4);
|
||||
} else {
|
||||
reader.readLong(); // removeSalt
|
||||
const serverId = reader.readLong();
|
||||
if (!serverId.eq(this.id)) {
|
||||
throw new SecurityError('Server replied with a wrong session ID');
|
||||
}
|
||||
|
||||
const remoteSequence = reader.readInt();
|
||||
const containerLen = reader.readInt(); // msgLen for the inner object, padding ignored
|
||||
const diff = body.length - containerLen;
|
||||
// We want to check if it's between 12 and 1024
|
||||
// https://core.telegram.org/mtproto/security_guidelines#checking-message-length
|
||||
if (diff < 12 || diff > 1024) {
|
||||
throw new SecurityError('Server replied with the wrong message padding');
|
||||
const remoteMsgId = reader.readLong();
|
||||
// if we get a duplicate message id we should ignore it.
|
||||
if (this.msgIds.includes(remoteMsgId.toString())) {
|
||||
throw new SecurityError('Duplicate msgIds');
|
||||
}
|
||||
// we only store the latest 500 message ids from the server
|
||||
if (this.msgIds.length > 500) {
|
||||
this.msgIds.shift();
|
||||
}
|
||||
this.msgIds.push(remoteMsgId.toString());const remoteSequence = reader.readInt();
|
||||
const containerLen = reader.readInt(); // msgLen for the inner object, padding ignored
|
||||
const diff = body.length - containerLen;
|
||||
// We want to check if it's between 12 and 1024
|
||||
// https://core.telegram.org/mtproto/security_guidelines#checking-message-length
|
||||
if (diff < 12 || diff > 1024) {
|
||||
throw new SecurityError('Server replied with the wrong message padding');
|
||||
}
|
||||
|
||||
// We could read msg_len bytes and use those in a new reader to read
|
||||
// the next TLObject without including the padding, but since the
|
||||
// reader isn't used for anything else after this, it's unnecessary.
|
||||
const obj = reader.tgReadObject();
|
||||
|
||||
return new TLMessage(remoteMsgId, remoteSequence, obj);
|
||||
}
|
||||
|
||||
// We could read msg_len bytes and use those in a new reader to read
|
||||
// the next TLObject without including the padding, but since the
|
||||
// reader isn't used for anything else after this, it's unnecessary.
|
||||
const obj = reader.tgReadObject();
|
||||
|
||||
return new TLMessage(remoteMsgId, remoteSequence, obj);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1154,6 +1154,14 @@ payments.getPaymentReceipt#2478d1cc peer:InputPeer msg_id:int = payments.Payment
|
||||
payments.validateRequestedInfo#db103170 flags:# save:flags.0?true peer:InputPeer msg_id:int info:PaymentRequestedInfo = payments.ValidatedRequestedInfo;
|
||||
payments.sendPaymentForm#30c3bc9d flags:# form_id:long peer:InputPeer msg_id:int requested_info_id:flags.0?string shipping_option_id:flags.1?string credentials:InputPaymentCredentials tip_amount:flags.2?long = payments.PaymentResult;
|
||||
payments.getSavedInfo#227d824b = payments.SavedInfo;
|
||||
phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall;
|
||||
phone.acceptCall#3bd2b4a0 peer:InputPhoneCall g_b:bytes protocol:PhoneCallProtocol = phone.PhoneCall;
|
||||
phone.confirmCall#2efe1722 peer:InputPhoneCall g_a:bytes key_fingerprint:long protocol:PhoneCallProtocol = phone.PhoneCall;
|
||||
phone.receivedCall#17d54f61 peer:InputPhoneCall = Bool;
|
||||
phone.discardCall#b2cbc1c0 flags:# video:flags.0?true peer:InputPhoneCall duration:int reason:PhoneCallDiscardReason connection_id:long = Updates;
|
||||
phone.setCallRating#59ead627 flags:# user_initiative:flags.0?true peer:InputPhoneCall rating:int comment:string = Updates;
|
||||
phone.saveCallDebug#277add7e peer:InputPhoneCall debug:DataJSON = Bool;
|
||||
phone.sendSignalingData#ff7a9383 peer:InputPhoneCall data:bytes = Bool;
|
||||
phone.createGroupCall#48cdc6d8 flags:# rtmp_stream:flags.2?true 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;
|
||||
|
||||
@ -203,6 +203,14 @@
|
||||
"phone.toggleGroupCallStartSubscription",
|
||||
"phone.joinGroupCallPresentation",
|
||||
"phone.leaveGroupCallPresentation",
|
||||
"phone.requestCall",
|
||||
"phone.acceptCall",
|
||||
"phone.confirmCall",
|
||||
"phone.receivedCall",
|
||||
"phone.discardCall",
|
||||
"phone.setCallRating",
|
||||
"phone.saveCallDebug",
|
||||
"phone.sendSignalingData",
|
||||
"messages.sendReaction",
|
||||
"messages.getMessagesReactions",
|
||||
"messages.getMessageReactionsList",
|
||||
|
||||
2
src/lib/secret-sauce/buildSdp.d.ts
vendored
2
src/lib/secret-sauce/buildSdp.d.ts
vendored
@ -17,5 +17,5 @@ export declare type Ssrc = {
|
||||
isPresentation?: boolean;
|
||||
sourceGroups: SsrcGroup[];
|
||||
};
|
||||
declare const _default: (conference: Conference, isAnswer?: boolean, isPresentation?: boolean) => string;
|
||||
declare const _default: (conference: Conference, isAnswer?: boolean, isPresentation?: boolean, isP2p?: boolean) => string;
|
||||
export default _default;
|
||||
|
||||
2
src/lib/secret-sauce/index.d.ts
vendored
2
src/lib/secret-sauce/index.d.ts
vendored
@ -1,3 +1,5 @@
|
||||
export { handleUpdateGroupCallConnection, startSharingScreen, joinGroupCall, getDevices, getUserStreams, setVolume, isStreamEnabled, toggleStream, leaveGroupCall, handleUpdateGroupCallParticipants, switchCameraInput, toggleSpeaker, toggleNoiseSuppression, } from './secretsauce';
|
||||
export { joinPhoneCall, processSignalingMessage, getStreams, toggleStreamP2p, stopPhoneCall, switchCameraInputP2p, } from './p2p';
|
||||
export * from './p2pMessage';
|
||||
export { IS_SCREENSHARE_SUPPORTED, THRESHOLD, } from './utils';
|
||||
export * from './types';
|
||||
|
||||
File diff suppressed because one or more lines are too long
16
src/lib/secret-sauce/p2p.d.ts
vendored
Normal file
16
src/lib/secret-sauce/p2p.d.ts
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
import { ApiPhoneCallConnection } from './types';
|
||||
import { P2pMessage } from './p2pMessage';
|
||||
import { StreamType } from './secretsauce';
|
||||
export declare function getStreams(): {
|
||||
video?: MediaStream | undefined;
|
||||
audio?: MediaStream | undefined;
|
||||
presentation?: MediaStream | undefined;
|
||||
ownAudio?: MediaStream | undefined;
|
||||
ownVideo?: MediaStream | undefined;
|
||||
ownPresentation?: MediaStream | undefined;
|
||||
} | undefined;
|
||||
export declare function switchCameraInputP2p(): Promise<void>;
|
||||
export declare function toggleStreamP2p(streamType: StreamType, value?: boolean | undefined): Promise<void>;
|
||||
export declare function joinPhoneCall(connections: ApiPhoneCallConnection[], emitSignalingData: (data: P2pMessage) => void, isOutgoing: boolean, shouldStartVideo: boolean, onUpdate: (...args: any[]) => void): Promise<void>;
|
||||
export declare function stopPhoneCall(): void;
|
||||
export declare function processSignalingMessage(message: P2pMessage): Promise<void>;
|
||||
47
src/lib/secret-sauce/p2pMessage.d.ts
vendored
Normal file
47
src/lib/secret-sauce/p2pMessage.d.ts
vendored
Normal file
@ -0,0 +1,47 @@
|
||||
import { Fingerprint, RTCPFeedbackParam, RTPExtension } from './types';
|
||||
export declare type VideoState = 'inactive' | 'active' | 'suspended';
|
||||
export declare type VideoRotation = 0 | 90 | 180 | 270;
|
||||
export declare type MediaStateMessage = {
|
||||
'@type': 'MediaState';
|
||||
isMuted: boolean;
|
||||
videoState: VideoState;
|
||||
videoRotation: VideoRotation;
|
||||
screencastState: VideoState;
|
||||
isBatteryLow: boolean;
|
||||
};
|
||||
declare type CandidatesMessage = {
|
||||
'@type': 'Candidates';
|
||||
candidates: P2pCandidate[];
|
||||
};
|
||||
export declare type InitialSetupMessage = {
|
||||
'@type': 'InitialSetup';
|
||||
ufrag: string;
|
||||
pwd: string;
|
||||
fingerprints: Fingerprint[];
|
||||
audio?: MediaContent;
|
||||
video?: MediaContent;
|
||||
screencast?: MediaContent;
|
||||
};
|
||||
export declare type MediaContent = {
|
||||
ssrc: string;
|
||||
ssrcGroups: P2pSsrcGroup[];
|
||||
payloadTypes: P2PPayloadType[];
|
||||
rtpExtensions: RTPExtension[];
|
||||
};
|
||||
export interface P2PPayloadType {
|
||||
id: number;
|
||||
name: string;
|
||||
clockrate: number;
|
||||
channels: number;
|
||||
parameters?: Record<string, string>;
|
||||
feedbackTypes?: RTCPFeedbackParam[];
|
||||
}
|
||||
declare type P2pSsrcGroup = {
|
||||
semantics: string;
|
||||
ssrcs: number[];
|
||||
};
|
||||
declare type P2pCandidate = {
|
||||
sdpString: string;
|
||||
};
|
||||
export declare type P2pMessage = CandidatesMessage | InitialSetupMessage | MediaStateMessage;
|
||||
export {};
|
||||
2
src/lib/secret-sauce/parseSdp.d.ts
vendored
2
src/lib/secret-sauce/parseSdp.d.ts
vendored
@ -1,3 +1,3 @@
|
||||
import { JoinGroupCallPayload } from './types';
|
||||
declare const _default: (sessionDescription: RTCSessionDescriptionInit) => JoinGroupCallPayload;
|
||||
declare const _default: (sessionDescription: RTCSessionDescriptionInit, isP2p?: boolean) => JoinGroupCallPayload;
|
||||
export default _default;
|
||||
|
||||
3
src/lib/secret-sauce/secretsauce.d.ts
vendored
3
src/lib/secret-sauce/secretsauce.d.ts
vendored
@ -1,5 +1,5 @@
|
||||
import { GroupCallConnectionData, GroupCallParticipant, JoinGroupCallPayload } from './types';
|
||||
declare type StreamType = 'audio' | 'video' | 'presentation';
|
||||
export 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;
|
||||
@ -17,4 +17,3 @@ export declare function handleUpdateGroupCallParticipants(updatedParticipants: G
|
||||
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 {};
|
||||
|
||||
26
src/lib/secret-sauce/types.d.ts
vendored
26
src/lib/secret-sauce/types.d.ts
vendored
@ -1,3 +1,4 @@
|
||||
import { P2PPayloadType } from './p2pMessage';
|
||||
export interface GroupCallParticipant {
|
||||
isSelf?: boolean;
|
||||
isMuted?: boolean;
|
||||
@ -52,6 +53,7 @@ export declare type Candidate = {
|
||||
network: string;
|
||||
'rel-addr': string;
|
||||
'rel-port': string;
|
||||
sdpString?: string;
|
||||
};
|
||||
export declare type JoinGroupCallPayload = {
|
||||
ufrag: string;
|
||||
@ -60,6 +62,14 @@ export declare type JoinGroupCallPayload = {
|
||||
ssrc?: number;
|
||||
'ssrc-groups'?: SsrcGroup[];
|
||||
};
|
||||
export declare type P2pParsedSdp = JoinGroupCallPayload & {
|
||||
audioExtmap: RTPExtension[];
|
||||
videoExtmap: RTPExtension[];
|
||||
screencastExtmap: RTPExtension[];
|
||||
audioPayloadTypes: P2PPayloadType[];
|
||||
videoPayloadTypes: P2PPayloadType[];
|
||||
screencastPayloadTypes: P2PPayloadType[];
|
||||
};
|
||||
export interface RTPExtension {
|
||||
id: number;
|
||||
uri: string;
|
||||
@ -98,3 +108,19 @@ export interface GroupCallConnectionData {
|
||||
};
|
||||
stream?: boolean;
|
||||
}
|
||||
export interface ApiPhoneCallConnection {
|
||||
username: string;
|
||||
password: string;
|
||||
isTurn?: boolean;
|
||||
isStun?: boolean;
|
||||
ip: string;
|
||||
ipv6: string;
|
||||
port: number;
|
||||
}
|
||||
export interface ApiCallProtocol {
|
||||
libraryVersions: string[];
|
||||
minLayer: number;
|
||||
maxLayer: number;
|
||||
isUdpP2p?: boolean;
|
||||
isUdpReflector?: boolean;
|
||||
}
|
||||
|
||||
7
src/lib/secret-sauce/utils.d.ts
vendored
7
src/lib/secret-sauce/utils.d.ts
vendored
@ -1,11 +1,10 @@
|
||||
import { P2PPayloadType } from './p2pMessage';
|
||||
import { PayloadType } from './types';
|
||||
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 function p2pPayloadTypeToConference(p: P2PPayloadType): PayloadType;
|
||||
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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -51,6 +51,15 @@
|
||||
.icon-volume-3:before {
|
||||
content: "\e991";
|
||||
}
|
||||
.icon-favorite-filled:before {
|
||||
content: "\e998";
|
||||
}
|
||||
.icon-share-screen:before {
|
||||
content: "\e97a";
|
||||
}
|
||||
.icon-video-outlined:before {
|
||||
content: "\e997";
|
||||
}
|
||||
.icon-stats:before {
|
||||
content: "\e996";
|
||||
}
|
||||
@ -84,7 +93,7 @@
|
||||
.icon-stop-raising-hand:before {
|
||||
content: "\e985";
|
||||
}
|
||||
.icon-share-screen:before {
|
||||
.icon-share-screen-outlined:before {
|
||||
content: "\e986";
|
||||
}
|
||||
.icon-voice-chat:before {
|
||||
|
||||
@ -121,6 +121,47 @@ export function formatLastUpdated(lang: LangFn, currentTime: number, lastUpdated
|
||||
}
|
||||
}
|
||||
|
||||
type DurationType = 'Seconds' | 'Minutes' | 'Hours' | 'Days' | 'Weeks';
|
||||
|
||||
export function formatTimeDuration(lang: LangFn, duration: number, showLast = 2) {
|
||||
if (!duration) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const durationRecords: { duration: number; type: DurationType }[] = [];
|
||||
const labels = [
|
||||
{ multiplier: 1, type: 'Seconds' },
|
||||
{ multiplier: 60, type: 'Minutes' },
|
||||
{ multiplier: 60, type: 'Hours' },
|
||||
{ multiplier: 24, type: 'Days' },
|
||||
{ multiplier: 7, type: 'Weeks' },
|
||||
] as Array<{ multiplier: number; type: DurationType }>;
|
||||
let t = 1;
|
||||
labels.forEach((label, idx) => {
|
||||
t *= label.multiplier;
|
||||
|
||||
if (duration < t) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modulus = labels[idx === (labels.length - 1) ? idx : idx + 1].multiplier!;
|
||||
durationRecords.push({
|
||||
duration: Math.floor((duration / t) % modulus),
|
||||
type: label.type,
|
||||
});
|
||||
});
|
||||
|
||||
const out = durationRecords.slice(-showLast).reverse();
|
||||
for (let i = out.length - 1; i >= 0; --i) {
|
||||
if (out[i].duration === 0) {
|
||||
out.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO In arabic we don't use "," as delimiter rather we use "and" each time
|
||||
return out.map((l) => lang(l.type, l.duration, 'i')).join(', ');
|
||||
}
|
||||
|
||||
export function formatHumanDate(
|
||||
lang: LangFn,
|
||||
datetime: number | Date,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { callApi } from '../api/gramjs';
|
||||
import {
|
||||
ApiChat, ApiMediaFormat, ApiMessage, ApiUser, ApiUserReaction,
|
||||
ApiChat, ApiMediaFormat, ApiMessage, ApiPhoneCall, ApiUser, ApiUserReaction,
|
||||
} from '../api/types';
|
||||
import { renderActionMessageText } from '../components/common/helpers/renderActionMessageText';
|
||||
import { DEBUG, IS_TEST } from '../config';
|
||||
@ -12,7 +12,7 @@ import {
|
||||
getMessageRecentReaction,
|
||||
getMessageSenderName,
|
||||
getMessageSummaryText,
|
||||
getPrivateChatUserId,
|
||||
getPrivateChatUserId, getUserFullName,
|
||||
isActionMessage,
|
||||
isChatChannel,
|
||||
selectIsChatMuted,
|
||||
@ -322,7 +322,7 @@ function getNotificationContent(chat: ApiChat, message: ApiMessage, reaction?: A
|
||||
};
|
||||
}
|
||||
|
||||
async function getAvatar(chat: ApiChat) {
|
||||
async function getAvatar(chat: ApiChat | ApiUser) {
|
||||
const imageHash = getChatAvatarHash(chat);
|
||||
if (!imageHash) return undefined;
|
||||
let mediaData = mediaLoader.getFromMemory(imageHash);
|
||||
@ -333,6 +333,39 @@ async function getAvatar(chat: ApiChat) {
|
||||
return mediaData;
|
||||
}
|
||||
|
||||
export async function notifyAboutCall({
|
||||
call, user,
|
||||
}: {
|
||||
call: ApiPhoneCall; user: ApiUser;
|
||||
}) {
|
||||
const { hasWebNotifications } = await loadNotificationSettings();
|
||||
if (document.hasFocus() || !hasWebNotifications) return;
|
||||
const areNotificationsSupported = checkIfNotificationsSupported();
|
||||
if (!areNotificationsSupported) return;
|
||||
|
||||
const icon = await getAvatar(user);
|
||||
|
||||
const options: NotificationOptions = {
|
||||
body: getUserFullName(user),
|
||||
icon,
|
||||
badge: icon,
|
||||
tag: `call_${call.id}`,
|
||||
};
|
||||
|
||||
if ('vibrate' in navigator) {
|
||||
options.vibrate = [200, 100, 200];
|
||||
}
|
||||
|
||||
const notification = new Notification(getTranslation('VoipIncoming'), options);
|
||||
|
||||
notification.onclick = () => {
|
||||
notification.close();
|
||||
if (window.focus) {
|
||||
window.focus();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function notifyAboutMessage({
|
||||
chat,
|
||||
message,
|
||||
|
||||
88
src/util/phoneCallEmojiConstants.ts
Normal file
88
src/util/phoneCallEmojiConstants.ts
Normal file
@ -0,0 +1,88 @@
|
||||
export const EMOJI_DATA = new Uint16Array([
|
||||
0xd83d, 0xde09, 0xd83d, 0xde0d, 0xd83d, 0xde1b, 0xd83d, 0xde2d, 0xd83d, 0xde31, 0xd83d, 0xde21,
|
||||
0xd83d, 0xde0e, 0xd83d, 0xde34, 0xd83d, 0xde35, 0xd83d, 0xde08, 0xd83d, 0xde2c, 0xd83d, 0xde07,
|
||||
0xd83d, 0xde0f, 0xd83d, 0xdc6e, 0xd83d, 0xdc77, 0xd83d, 0xdc82, 0xd83d, 0xdc76, 0xd83d, 0xdc68,
|
||||
0xd83d, 0xdc69, 0xd83d, 0xdc74, 0xd83d, 0xdc75, 0xd83d, 0xde3b, 0xd83d, 0xde3d, 0xd83d, 0xde40,
|
||||
0xd83d, 0xdc7a, 0xd83d, 0xde48, 0xd83d, 0xde49, 0xd83d, 0xde4a, 0xd83d, 0xdc80, 0xd83d, 0xdc7d,
|
||||
0xd83d, 0xdca9, 0xd83d, 0xdd25, 0xd83d, 0xdca5, 0xd83d, 0xdca4, 0xd83d, 0xdc42, 0xd83d, 0xdc40,
|
||||
0xd83d, 0xdc43, 0xd83d, 0xdc45, 0xd83d, 0xdc44, 0xd83d, 0xdc4d, 0xd83d, 0xdc4e, 0xd83d, 0xdc4c,
|
||||
0xd83d, 0xdc4a, 0x270c, 0x270b, 0xd83d, 0xdc50, 0xd83d, 0xdc46, 0xd83d, 0xdc47, 0xd83d, 0xdc49,
|
||||
0xd83d, 0xdc48, 0xd83d, 0xde4f, 0xd83d, 0xdc4f, 0xd83d, 0xdcaa, 0xd83d, 0xdeb6, 0xd83c, 0xdfc3,
|
||||
0xd83d, 0xdc83, 0xd83d, 0xdc6b, 0xd83d, 0xdc6a, 0xd83d, 0xdc6c, 0xd83d, 0xdc6d, 0xd83d, 0xdc85,
|
||||
0xd83c, 0xdfa9, 0xd83d, 0xdc51, 0xd83d, 0xdc52, 0xd83d, 0xdc5f, 0xd83d, 0xdc5e, 0xd83d, 0xdc60,
|
||||
0xd83d, 0xdc55, 0xd83d, 0xdc57, 0xd83d, 0xdc56, 0xd83d, 0xdc59, 0xd83d, 0xdc5c, 0xd83d, 0xdc53,
|
||||
0xd83c, 0xdf80, 0xd83d, 0xdc84, 0xd83d, 0xdc9b, 0xd83d, 0xdc99, 0xd83d, 0xdc9c, 0xd83d, 0xdc9a,
|
||||
0xd83d, 0xdc8d, 0xd83d, 0xdc8e, 0xd83d, 0xdc36, 0xd83d, 0xdc3a, 0xd83d, 0xdc31, 0xd83d, 0xdc2d,
|
||||
0xd83d, 0xdc39, 0xd83d, 0xdc30, 0xd83d, 0xdc38, 0xd83d, 0xdc2f, 0xd83d, 0xdc28, 0xd83d, 0xdc3b,
|
||||
0xd83d, 0xdc37, 0xd83d, 0xdc2e, 0xd83d, 0xdc17, 0xd83d, 0xdc34, 0xd83d, 0xdc11, 0xd83d, 0xdc18,
|
||||
0xd83d, 0xdc3c, 0xd83d, 0xdc27, 0xd83d, 0xdc25, 0xd83d, 0xdc14, 0xd83d, 0xdc0d, 0xd83d, 0xdc22,
|
||||
0xd83d, 0xdc1b, 0xd83d, 0xdc1d, 0xd83d, 0xdc1c, 0xd83d, 0xdc1e, 0xd83d, 0xdc0c, 0xd83d, 0xdc19,
|
||||
0xd83d, 0xdc1a, 0xd83d, 0xdc1f, 0xd83d, 0xdc2c, 0xd83d, 0xdc0b, 0xd83d, 0xdc10, 0xd83d, 0xdc0a,
|
||||
0xd83d, 0xdc2b, 0xd83c, 0xdf40, 0xd83c, 0xdf39, 0xd83c, 0xdf3b, 0xd83c, 0xdf41, 0xd83c, 0xdf3e,
|
||||
0xd83c, 0xdf44, 0xd83c, 0xdf35, 0xd83c, 0xdf34, 0xd83c, 0xdf33, 0xd83c, 0xdf1e, 0xd83c, 0xdf1a,
|
||||
0xd83c, 0xdf19, 0xd83c, 0xdf0e, 0xd83c, 0xdf0b, 0x26a1, 0x2614, 0x2744, 0x26c4, 0xd83c, 0xdf00,
|
||||
0xd83c, 0xdf08, 0xd83c, 0xdf0a, 0xd83c, 0xdf93, 0xd83c, 0xdf86, 0xd83c, 0xdf83, 0xd83d, 0xdc7b,
|
||||
0xd83c, 0xdf85, 0xd83c, 0xdf84, 0xd83c, 0xdf81, 0xd83c, 0xdf88, 0xd83d, 0xdd2e, 0xd83c, 0xdfa5,
|
||||
0xd83d, 0xdcf7, 0xd83d, 0xdcbf, 0xd83d, 0xdcbb, 0x260e, 0xd83d, 0xdce1, 0xd83d, 0xdcfa, 0xd83d,
|
||||
0xdcfb, 0xd83d, 0xdd09, 0xd83d, 0xdd14, 0x23f3, 0x23f0, 0x231a, 0xd83d, 0xdd12, 0xd83d, 0xdd11,
|
||||
0xd83d, 0xdd0e, 0xd83d, 0xdca1, 0xd83d, 0xdd26, 0xd83d, 0xdd0c, 0xd83d, 0xdd0b, 0xd83d, 0xdebf,
|
||||
0xd83d, 0xdebd, 0xd83d, 0xdd27, 0xd83d, 0xdd28, 0xd83d, 0xdeaa, 0xd83d, 0xdeac, 0xd83d, 0xdca3,
|
||||
0xd83d, 0xdd2b, 0xd83d, 0xdd2a, 0xd83d, 0xdc8a, 0xd83d, 0xdc89, 0xd83d, 0xdcb0, 0xd83d, 0xdcb5,
|
||||
0xd83d, 0xdcb3, 0x2709, 0xd83d, 0xdceb, 0xd83d, 0xdce6, 0xd83d, 0xdcc5, 0xd83d, 0xdcc1, 0x2702,
|
||||
0xd83d, 0xdccc, 0xd83d, 0xdcce, 0x2712, 0x270f, 0xd83d, 0xdcd0, 0xd83d, 0xdcda, 0xd83d, 0xdd2c,
|
||||
0xd83d, 0xdd2d, 0xd83c, 0xdfa8, 0xd83c, 0xdfac, 0xd83c, 0xdfa4, 0xd83c, 0xdfa7, 0xd83c, 0xdfb5,
|
||||
0xd83c, 0xdfb9, 0xd83c, 0xdfbb, 0xd83c, 0xdfba, 0xd83c, 0xdfb8, 0xd83d, 0xdc7e, 0xd83c, 0xdfae,
|
||||
0xd83c, 0xdccf, 0xd83c, 0xdfb2, 0xd83c, 0xdfaf, 0xd83c, 0xdfc8, 0xd83c, 0xdfc0, 0x26bd, 0x26be,
|
||||
0xd83c, 0xdfbe, 0xd83c, 0xdfb1, 0xd83c, 0xdfc9, 0xd83c, 0xdfb3, 0xd83c, 0xdfc1, 0xd83c, 0xdfc7,
|
||||
0xd83c, 0xdfc6, 0xd83c, 0xdfca, 0xd83c, 0xdfc4, 0x2615, 0xd83c, 0xdf7c, 0xd83c, 0xdf7a, 0xd83c,
|
||||
0xdf77, 0xd83c, 0xdf74, 0xd83c, 0xdf55, 0xd83c, 0xdf54, 0xd83c, 0xdf5f, 0xd83c, 0xdf57, 0xd83c,
|
||||
0xdf71, 0xd83c, 0xdf5a, 0xd83c, 0xdf5c, 0xd83c, 0xdf61, 0xd83c, 0xdf73, 0xd83c, 0xdf5e, 0xd83c,
|
||||
0xdf69, 0xd83c, 0xdf66, 0xd83c, 0xdf82, 0xd83c, 0xdf70, 0xd83c, 0xdf6a, 0xd83c, 0xdf6b, 0xd83c,
|
||||
0xdf6d, 0xd83c, 0xdf6f, 0xd83c, 0xdf4e, 0xd83c, 0xdf4f, 0xd83c, 0xdf4a, 0xd83c, 0xdf4b, 0xd83c,
|
||||
0xdf52, 0xd83c, 0xdf47, 0xd83c, 0xdf49, 0xd83c, 0xdf53, 0xd83c, 0xdf51, 0xd83c, 0xdf4c, 0xd83c,
|
||||
0xdf50, 0xd83c, 0xdf4d, 0xd83c, 0xdf46, 0xd83c, 0xdf45, 0xd83c, 0xdf3d, 0xd83c, 0xdfe1, 0xd83c,
|
||||
0xdfe5, 0xd83c, 0xdfe6, 0x26ea, 0xd83c, 0xdff0, 0x26fa, 0xd83c, 0xdfed, 0xd83d, 0xddfb, 0xd83d,
|
||||
0xddfd, 0xd83c, 0xdfa0, 0xd83c, 0xdfa1, 0x26f2, 0xd83c, 0xdfa2, 0xd83d, 0xdea2, 0xd83d, 0xdea4,
|
||||
0x2693, 0xd83d, 0xde80, 0x2708, 0xd83d, 0xde81, 0xd83d, 0xde82, 0xd83d, 0xde8b, 0xd83d, 0xde8e,
|
||||
0xd83d, 0xde8c, 0xd83d, 0xde99, 0xd83d, 0xde97, 0xd83d, 0xde95, 0xd83d, 0xde9b, 0xd83d, 0xdea8,
|
||||
0xd83d, 0xde94, 0xd83d, 0xde92, 0xd83d, 0xde91, 0xd83d, 0xdeb2, 0xd83d, 0xdea0, 0xd83d, 0xde9c,
|
||||
0xd83d, 0xdea6, 0x26a0, 0xd83d, 0xdea7, 0x26fd, 0xd83c, 0xdfb0, 0xd83d, 0xddff, 0xd83c, 0xdfaa,
|
||||
0xd83c, 0xdfad, 0xd83c, 0xddef, 0xd83c, 0xddf5, 0xd83c, 0xddf0, 0xd83c, 0xddf7, 0xd83c, 0xdde9,
|
||||
0xd83c, 0xddea, 0xd83c, 0xdde8, 0xd83c, 0xddf3, 0xd83c, 0xddfa, 0xd83c, 0xddf8, 0xd83c, 0xddeb,
|
||||
0xd83c, 0xddf7, 0xd83c, 0xddea, 0xd83c, 0xddf8, 0xd83c, 0xddee, 0xd83c, 0xddf9, 0xd83c, 0xddf7,
|
||||
0xd83c, 0xddfa, 0xd83c, 0xddec, 0xd83c, 0xdde7, 0x0031, 0x20e3, 0x0032, 0x20e3, 0x0033, 0x20e3,
|
||||
0x0034, 0x20e3, 0x0035, 0x20e3, 0x0036, 0x20e3, 0x0037, 0x20e3, 0x0038, 0x20e3, 0x0039, 0x20e3,
|
||||
0x0030, 0x20e3, 0xd83d, 0xdd1f, 0x2757, 0x2753, 0x2665, 0x2666, 0xd83d, 0xdcaf, 0xd83d, 0xdd17,
|
||||
0xd83d, 0xdd31, 0xd83d, 0xdd34, 0xd83d, 0xdd35, 0xd83d, 0xdd36, 0xd83d, 0xdd37,
|
||||
]);
|
||||
|
||||
export const EMOJI_OFFSETS = [
|
||||
0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22,
|
||||
24, 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46,
|
||||
48, 50, 52, 54, 56, 58, 60, 62, 64, 66, 68, 70,
|
||||
72, 74, 76, 78, 80, 82, 84, 86, 87, 88, 90, 92,
|
||||
94, 96, 98, 100, 102, 104, 106, 108, 110, 112, 114, 116,
|
||||
118, 120, 122, 124, 126, 128, 130, 132, 134, 136, 138, 140,
|
||||
142, 144, 146, 148, 150, 152, 154, 156, 158, 160, 162, 164,
|
||||
166, 168, 170, 172, 174, 176, 178, 180, 182, 184, 186, 188,
|
||||
190, 192, 194, 196, 198, 200, 202, 204, 206, 208, 210, 212,
|
||||
214, 216, 218, 220, 222, 224, 226, 228, 230, 232, 234, 236,
|
||||
238, 240, 242, 244, 246, 248, 250, 252, 254, 256, 258, 259,
|
||||
260, 261, 262, 264, 266, 268, 270, 272, 274, 276, 278, 280,
|
||||
282, 284, 286, 288, 290, 292, 294, 295, 297, 299, 301, 303,
|
||||
305, 306, 307, 308, 310, 312, 314, 316, 318, 320, 322, 324,
|
||||
326, 328, 330, 332, 334, 336, 338, 340, 342, 344, 346, 348,
|
||||
350, 351, 353, 355, 357, 359, 360, 362, 364, 365, 366, 368,
|
||||
370, 372, 374, 376, 378, 380, 382, 384, 386, 388, 390, 392,
|
||||
394, 396, 398, 400, 402, 404, 406, 407, 408, 410, 412, 414,
|
||||
416, 418, 420, 422, 424, 426, 427, 429, 431, 433, 435, 437,
|
||||
439, 441, 443, 445, 447, 449, 451, 453, 455, 457, 459, 461,
|
||||
463, 465, 467, 469, 471, 473, 475, 477, 479, 481, 483, 485,
|
||||
487, 489, 491, 493, 495, 497, 499, 501, 503, 505, 507, 508,
|
||||
510, 511, 513, 515, 517, 519, 521, 522, 524, 526, 528, 529,
|
||||
531, 532, 534, 536, 538, 540, 542, 544, 546, 548, 550, 552,
|
||||
554, 556, 558, 560, 562, 564, 566, 567, 569, 570, 572, 574,
|
||||
576, 578, 582, 586, 590, 594, 598, 602, 606, 610, 614, 618,
|
||||
620, 622, 624, 626, 628, 630, 632, 634, 636, 638, 640, 641,
|
||||
642, 643, 644, 646, 648, 650, 652, 654, 656, 658,
|
||||
];
|
||||
@ -80,7 +80,16 @@ module.exports = (env = {}, argv = {}) => {
|
||||
test: /\.scss$/,
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
'css-loader',
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
modules: {
|
||||
exportLocalsConvention: 'camelCase',
|
||||
auto: true,
|
||||
localIdentName: argv['optimize-minimize'] ? '[hash:base64]' : '[path][name]__[local]'
|
||||
}
|
||||
}
|
||||
},
|
||||
'postcss-loader',
|
||||
'sass-loader',
|
||||
],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user