Calls: Introduce peer-to-peer calls (#1791)

This commit is contained in:
Alexander Zinchuk 2022-04-08 20:59:45 +02:00
parent 075893c37e
commit 1da41443ca
74 changed files with 2706 additions and 628 deletions

View File

@ -24,6 +24,12 @@
} }
], ],
"plugin/stylelint-group-selectors": [true, { "severity": "warning" }], "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

Binary file not shown.

BIN
public/call_connect.mp3 Normal file

Binary file not shown.

BIN
public/call_end.mp3 Normal file

Binary file not shown.

BIN
public/call_incoming.mp3 Normal file

Binary file not shown.

BIN
public/call_ringing.mp3 Normal file

Binary file not shown.

View File

@ -1,5 +1,7 @@
declare const process: NodeJS.Process; declare const process: NodeJS.Process;
declare module '*.module.scss';
declare const APP_REVISION: string; declare const APP_REVISION: string;
declare namespace React { declare namespace React {

View File

@ -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 { Api as GramJs } from '../../../lib/gramjs';
import { ApiGroupCall } from '../../types'; import { ApiGroupCall, ApiPhoneCall } from '../../types';
import { getApiChatIdFromMtpPeer, isPeerUser } from './peers'; import { getApiChatIdFromMtpPeer, isPeerUser } from './peers';
export function buildApiGroupCallParticipant(participant: GramJs.GroupCallParticipant): GroupCallParticipant { export function buildApiGroupCallParticipant(participant: GramJs.GroupCallParticipant): GroupCallParticipant {
@ -96,3 +102,139 @@ export function buildApiGroupCall(groupCall: GramJs.TypeGroupCall): ApiGroupCall
export function getGroupCallId(groupCall: GramJs.TypeInputGroupCall) { export function getGroupCallId(groupCall: GramJs.TypeInputGroupCall) {
return groupCall.id.toString(); 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,
});
}

View File

@ -30,6 +30,7 @@ import {
ApiUser, ApiUser,
ApiLocation, ApiLocation,
ApiGame, ApiGame,
PhoneCallAction,
} from '../../types'; } from '../../types';
import { import {
@ -50,6 +51,7 @@ import { interpolateArray } from '../../../util/waveform';
import { buildPeer } from '../gramjsBuilders'; import { buildPeer } from '../gramjsBuilders';
import { addPhotoToLocalDb, resolveMessageApiChatId, serializeBytes } from '../helpers'; import { addPhotoToLocalDb, resolveMessageApiChatId, serializeBytes } from '../helpers';
import { buildApiPeerId, getApiChatIdFromMtpPeer, isPeerUser } from './peers'; import { buildApiPeerId, getApiChatIdFromMtpPeer, isPeerUser } from './peers';
import { buildApiCallDiscardReason } from './calls';
const LOCAL_MEDIA_UPLOADING_TEMP_ID = 'temp'; const LOCAL_MEDIA_UPLOADING_TEMP_ID = 'temp';
const INPUT_WAVEFORM_LENGTH = 63; const INPUT_WAVEFORM_LENGTH = 63;
@ -785,6 +787,7 @@ function buildAction(
return undefined; return undefined;
} }
let phoneCall: PhoneCallAction | undefined;
let call: Partial<ApiGroupCall> | undefined; let call: Partial<ApiGroupCall> | undefined;
let amount: number | undefined; let amount: number | undefined;
let currency: string | undefined; let currency: string | undefined;
@ -871,6 +874,13 @@ function buildAction(
const mins = Math.max(Math.round(action.duration! / 60), 1); const mins = Math.max(Math.round(action.duration! / 60), 1);
translationValues.push(`${mins} min${mins > 1 ? 's' : ''}`); translationValues.push(`${mins} min${mins > 1 ? 's' : ''}`);
} }
phoneCall = {
isOutgoing,
isVideo: action.video,
duration: action.duration,
reason: buildApiCallDiscardReason(action.reason),
};
} else if (action instanceof GramJs.MessageActionInviteToGroupCall) { } else if (action instanceof GramJs.MessageActionInviteToGroupCall) {
text = 'Notification.VoiceChatInvitation'; text = 'Notification.VoiceChatInvitation';
call = { call = {
@ -933,6 +943,7 @@ function buildAction(
currency, currency,
translationValues, translationValues,
call, call,
phoneCall,
score, score,
}; };
} }

View File

@ -11,7 +11,7 @@ import {
ApiGroupCall, ApiGroupCall,
ApiMessageEntity, ApiMessageEntity,
ApiMessageEntityTypes, ApiMessageEntityTypes,
ApiNewPoll, ApiNewPoll, ApiPhoneCall,
ApiReportReason, ApiReportReason,
ApiSendMessageAction, ApiSendMessageAction,
ApiSticker, ApiSticker,
@ -465,3 +465,10 @@ export function buildInputGroupCall(groupCall: Partial<ApiGroupCall>) {
accessHash: BigInt(groupCall.accessHash!), accessHash: BigInt(groupCall.accessHash!),
}); });
} }
export function buildInputPhoneCall({ id, accessHash }: ApiPhoneCall) {
return new GramJs.InputPhoneCall({
id: BigInt(id),
accessHash: BigInt(accessHash!),
});
}

View File

@ -1,14 +1,18 @@
import { JoinGroupCallPayload } from '../../../lib/secret-sauce'; import BigInt from 'big-integer';
import type { JoinGroupCallPayload } from '../../../lib/secret-sauce';
import { import {
ApiChat, ApiUser, OnApiUpdate, ApiGroupCall, ApiChat, ApiUser, OnApiUpdate, ApiGroupCall, ApiPhoneCall,
} from '../../types'; } from '../../types';
import { Api as GramJs } from '../../../lib/gramjs'; import { Api as GramJs } from '../../../lib/gramjs';
import { invokeRequest } from './client'; import { invokeRequest } from './client';
import { buildInputGroupCall, buildInputPeer, generateRandomInt } from '../gramjsBuilders';
import { import {
buildInputGroupCall, buildInputPeer, buildInputPhoneCall, generateRandomInt,
} from '../gramjsBuilders';
import {
buildCallProtocol,
buildApiGroupCall, buildApiGroupCall,
buildApiGroupCallParticipant, buildApiGroupCallParticipant, buildPhoneCall,
} from '../apiBuilders/calls'; } from '../apiBuilders/calls';
import { buildApiUser } from '../apiBuilders/users'; import { buildApiUser } from '../apiBuilders/users';
@ -234,3 +238,131 @@ export function leaveGroupCallPresentation({
call: buildInputGroupCall(call), call: buildInputGroupCall(call),
}), true); }), 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),
}));
}

View File

@ -70,6 +70,7 @@ export {
getGroupCall, joinGroupCall, discardGroupCall, createGroupCall, getGroupCall, joinGroupCall, discardGroupCall, createGroupCall,
editGroupCallTitle, editGroupCallParticipant, exportGroupCallInvite, fetchGroupCallParticipants, editGroupCallTitle, editGroupCallParticipant, exportGroupCallInvite, fetchGroupCallParticipants,
joinGroupCallPresentation, leaveGroupCall, leaveGroupCallPresentation, toggleGroupCallStartSubscription, joinGroupCallPresentation, leaveGroupCall, leaveGroupCallPresentation, toggleGroupCallStartSubscription,
requestCall, getDhConfig, confirmCall, sendSignalingData, acceptCall, discardCall, setCallRating, receivedCall,
} from './calls'; } from './calls';
export { export {
@ -78,3 +79,8 @@ export {
} from './reactions'; } from './reactions';
export { fetchChannelStatistics, fetchGroupStatistics, fetchStatisticsAsyncGraph } from './statistics'; export { fetchChannelStatistics, fetchGroupStatistics, fetchStatisticsAsyncGraph } from './statistics';
export {
acceptPhoneCall, confirmPhoneCall, requestPhoneCall, decodePhoneCallData, createPhoneCallState,
destroyPhoneCallState, encodePhoneCallData,
} from './phoneCallState';

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

View File

@ -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 { Api as GramJs, connection } from '../../lib/gramjs';
import { ApiMessage, ApiUpdateConnectionStateType, OnApiUpdate } from '../types'; import { ApiMessage, ApiUpdateConnectionStateType, OnApiUpdate } from '../types';
@ -44,7 +44,7 @@ import { buildApiNotifyException, buildPrivacyKey, buildPrivacyRules } from './a
import { buildApiPhoto } from './apiBuilders/common'; import { buildApiPhoto } from './apiBuilders/common';
import { import {
buildApiGroupCall, buildApiGroupCall,
buildApiGroupCallParticipant, buildApiGroupCallParticipant, buildPhoneCall,
getGroupCallId, getGroupCallId,
} from './apiBuilders/calls'; } from './apiBuilders/calls';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './apiBuilders/peers'; 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')), recentRequesterIds: update.recentRequesters.map((id) => buildApiPeerId(id, 'user')),
requestsPending: update.requestsPending, 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) { } else if (DEBUG) {
const params = typeof update === 'object' && 'className' in update ? update.className : update; const params = typeof update === 'object' && 'className' in update ? update.className : update;
// eslint-disable-next-line no-console // eslint-disable-next-line no-console

View File

@ -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 { export interface ApiGroupCall {
chatId?: string; chatId?: string;
@ -24,3 +29,45 @@ export interface ApiGroupCall {
connectionState: GroupCallConnectionState; connectionState: GroupCallConnectionState;
isSpeakerDisabled?: boolean; 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;
}

View File

@ -1,4 +1,4 @@
import { ApiGroupCall } from './calls'; import { ApiGroupCall, PhoneCallAction } from './calls';
export interface ApiDimensions { export interface ApiDimensions {
width: number; width: number;
@ -203,6 +203,7 @@ export interface ApiAction {
currency?: string; currency?: string;
translationValues: string[]; translationValues: string[];
call?: Partial<ApiGroupCall>; call?: Partial<ApiGroupCall>;
phoneCall?: PhoneCallAction;
score?: number; score?: number;
} }

View File

@ -1,4 +1,9 @@
import { GroupCallConnectionData, GroupCallParticipant, GroupCallConnectionState } from '../../lib/secret-sauce'; import type {
GroupCallConnectionData,
GroupCallParticipant,
GroupCallConnectionState,
VideoState, VideoRotation,
} from '../../lib/secret-sauce';
import { import {
ApiChat, ApiChat,
ApiChatFullInfo, ApiChatFullInfo,
@ -15,7 +20,7 @@ import {
ApiError, ApiInviteInfo, ApiNotifyException, ApiSessionData, ApiError, ApiInviteInfo, ApiNotifyException, ApiSessionData,
} from './misc'; } from './misc';
import { import {
ApiGroupCall, ApiGroupCall, ApiPhoneCall,
} from './calls'; } from './calls';
export type ApiUpdateReady = { export type ApiUpdateReady = {
@ -456,6 +461,31 @@ export type ApiUpdateGroupCallConnectionState = {
isSpeakerDisabled?: boolean; 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 = ( export type ApiUpdate = (
ApiUpdateReady | ApiUpdateSession | ApiUpdateReady | ApiUpdateSession |
ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser | ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser |
@ -476,7 +506,9 @@ export type ApiUpdate = (
ApiUpdateServerTimeOffset | ApiUpdateShowInvite | ApiUpdateMessageReactions | ApiUpdateServerTimeOffset | ApiUpdateShowInvite | ApiUpdateMessageReactions |
ApiUpdateGroupCallParticipants | ApiUpdateGroupCallConnection | ApiUpdateGroupCall | ApiUpdateGroupCallStreams | ApiUpdateGroupCallParticipants | ApiUpdateGroupCallConnection | ApiUpdateGroupCall | ApiUpdateGroupCallStreams |
ApiUpdateGroupCallConnectionState | ApiUpdateGroupCallLeavePresentation | ApiUpdateGroupCallChatId | ApiUpdateGroupCallConnectionState | ApiUpdateGroupCallLeavePresentation | ApiUpdateGroupCallChatId |
ApiUpdatePendingJoinRequests | ApiUpdatePaymentVerificationNeeded | ApiUpdatePaymentStateCompleted ApiUpdatePendingJoinRequests | ApiUpdatePaymentVerificationNeeded | ApiUpdatePaymentStateCompleted |
ApiUpdatePhoneCall | ApiUpdatePhoneCallSignalingData | ApiUpdatePhoneCallMediaState |
ApiUpdatePhoneCallConnectionState
); );
export type OnApiUpdate = (update: ApiUpdate) => void; export type OnApiUpdate = (update: ApiUpdate) => void;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Binary file not shown.

View File

@ -1,3 +1,4 @@
export { default as GroupCall } from '../components/calls/group/GroupCall'; export { default as GroupCall } from '../components/calls/group/GroupCall';
export { default as ActiveCallHeader } from '../components/calls/ActiveCallHeader'; 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';

View File

@ -3,12 +3,12 @@ import useModuleLoader from '../../hooks/useModuleLoader';
import { Bundles } from '../../util/moduleLoader'; import { Bundles } from '../../util/moduleLoader';
type OwnProps = { type OwnProps = {
groupCallId?: string; isActive?: boolean;
}; };
const ActiveCallHeaderAsync: FC<OwnProps> = (props) => { const ActiveCallHeaderAsync: FC<OwnProps> = (props) => {
const { groupCallId } = props; const { isActive } = props;
const ActiveCallHeader = useModuleLoader(Bundles.Calls, 'ActiveCallHeader', !groupCallId); const ActiveCallHeader = useModuleLoader(Bundles.Calls, 'ActiveCallHeader', !isActive);
return ActiveCallHeader ? <ActiveCallHeader /> : undefined; return ActiveCallHeader ? <ActiveCallHeader /> : undefined;
}; };

View File

@ -1,51 +1,50 @@
import { GroupCallParticipant } from '../../lib/secret-sauce';
import React, { import React, {
FC, memo, useEffect, FC, memo, useEffect,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global'; 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 buildClassName from '../../util/buildClassName';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import './ActiveCallHeader.scss'; import './ActiveCallHeader.scss';
type StateProps = { type StateProps = {
isGroupCallPanelHidden?: boolean; isCallPanelVisible?: boolean;
meParticipant: GroupCallParticipant;
groupCall?: ApiGroupCall; groupCall?: ApiGroupCall;
phoneCallUser?: ApiUser;
}; };
const ActiveCallHeader: FC<StateProps> = ({ const ActiveCallHeader: FC<StateProps> = ({
groupCall, groupCall,
meParticipant, phoneCallUser,
isGroupCallPanelHidden, isCallPanelVisible,
}) => { }) => {
const { toggleGroupCallPanel } = getActions(); const { toggleGroupCallPanel } = getActions();
const lang = useLang(); const lang = useLang();
useEffect(() => { useEffect(() => {
document.body.classList.toggle('has-group-call-header', isGroupCallPanelHidden); document.body.classList.toggle('has-call-header', Boolean(isCallPanelVisible));
return () => { 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 ( return (
<div <div
className={buildClassName( className={buildClassName(
'ActiveCallHeader', 'ActiveCallHeader',
isGroupCallPanelHidden && 'open', isCallPanelVisible && 'open',
)} )}
onClick={toggleGroupCallPanel} onClick={toggleGroupCallPanel}
> >
<span className="title">{groupCall.title || lang('VoipGroupVoiceChat')}</span> <span className="title">{phoneCallUser?.firstName || groupCall?.title || lang('VoipGroupVoiceChat')}</span>
</div> </div>
); );
}; };
@ -54,8 +53,8 @@ export default memo(withGlobal(
(global): StateProps => { (global): StateProps => {
return { return {
groupCall: selectActiveGroupCall(global), groupCall: selectActiveGroupCall(global),
isGroupCallPanelHidden: global.groupCalls.isGroupCallPanelHidden, isCallPanelVisible: global.isCallPanelVisible,
meParticipant: selectGroupCallParticipant(global, global.groupCalls.activeGroupCallId!, global.currentUserId!), phoneCallUser: selectPhoneCallUser(global),
}; };
}, },
)(ActiveCallHeader)); )(ActiveCallHeader));

View File

@ -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);

View File

@ -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));

View File

@ -47,7 +47,7 @@ export type OwnProps = {
}; };
type StateProps = { type StateProps = {
isGroupCallPanelHidden: boolean; isCallPanelVisible: boolean;
connectionState: GroupCallConnectionState; connectionState: GroupCallConnectionState;
title?: string; title?: string;
meParticipant?: TypeGroupCallParticipant; meParticipant?: TypeGroupCallParticipant;
@ -59,7 +59,7 @@ type StateProps = {
const GroupCall: FC<OwnProps & StateProps> = ({ const GroupCall: FC<OwnProps & StateProps> = ({
groupCallId, groupCallId,
isGroupCallPanelHidden, isCallPanelVisible,
connectionState, connectionState,
isSpeakerEnabled, isSpeakerEnabled,
title, title,
@ -246,7 +246,7 @@ const GroupCall: FC<OwnProps & StateProps> = ({
return ( return (
<Modal <Modal
isOpen={!isGroupCallPanelHidden && !isLeaving} isOpen={!isCallPanelVisible && !isLeaving}
onClose={toggleGroupCallPanel} onClose={toggleGroupCallPanel}
className={buildClassName( className={buildClassName(
'GroupCall', 'GroupCall',
@ -287,7 +287,7 @@ const GroupCall: FC<OwnProps & StateProps> = ({
> >
{IS_SCREENSHARE_SUPPORTED && !shouldRaiseHand && ( {IS_SCREENSHARE_SUPPORTED && !shouldRaiseHand && (
<MenuItem <MenuItem
icon="share-screen" icon="share-screen-outlined"
onClick={toggleGroupCallPresentation} onClick={toggleGroupCallPresentation}
> >
{lang(hasPresentation ? 'VoipChatStopScreenCapture' : 'VoipChatStartScreenCapture')} {lang(hasPresentation ? 'VoipChatStopScreenCapture' : 'VoipChatStartScreenCapture')}
@ -405,7 +405,7 @@ export default memo(withGlobal<OwnProps>(
isSpeakerEnabled: !isSpeakerDisabled, isSpeakerEnabled: !isSpeakerDisabled,
participantsCount, participantsCount,
meParticipant: selectGroupCallParticipant(global, groupCallId, global.currentUserId!), meParticipant: selectGroupCallParticipant(global, groupCallId, global.currentUserId!),
isGroupCallPanelHidden: Boolean(global.groupCalls.isGroupCallPanelHidden), isCallPanelVisible: Boolean(global.isCallPanelVisible),
isAdmin: selectIsAdminInActiveGroupCall(global), isAdmin: selectIsAdminInActiveGroupCall(global),
participants, participants,
}; };

View File

@ -51,7 +51,7 @@ const MicrophoneButton: FC<StateProps> = ({
useEffect(() => { useEffect(() => {
if (prevShouldRaiseHand && !shouldRaiseHand) { if (prevShouldRaiseHand && !shouldRaiseHand) {
playGroupCallSound('allowTalk'); playGroupCallSound({ sound: 'allowTalk' });
} }
}, [playGroupCallSound, prevShouldRaiseHand, shouldRaiseHand]); }, [playGroupCallSound, prevShouldRaiseHand, shouldRaiseHand]);

View File

@ -0,0 +1,16 @@
import React, { FC, memo } from '../../../lib/teact/teact';
import useModuleLoader from '../../../hooks/useModuleLoader';
import { Bundles } from '../../../util/moduleLoader';
type OwnProps = {
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);

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

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

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

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

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

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

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

View File

@ -58,11 +58,12 @@ const Avatar: FC<OwnProps> = ({
const isReplies = user && isChatWithRepliesBot(user.id); const isReplies = user && isChatWithRepliesBot(user.id);
let imageHash: string | undefined; let imageHash: string | undefined;
const shouldFetchBig = size === 'jumbo';
if (!isSavedMessages && !isDeleted) { if (!isSavedMessages && !isDeleted) {
if (user) { if (user) {
imageHash = getChatAvatarHash(user); imageHash = getChatAvatarHash(user, shouldFetchBig ? 'big' : undefined);
} else if (chat) { } else if (chat) {
imageHash = getChatAvatarHash(chat); imageHash = getChatAvatarHash(chat, shouldFetchBig ? 'big' : undefined);
} else if (photo) { } else if (photo) {
imageHash = `photo${photo.id}?size=m`; imageHash = `photo${photo.id}?size=m`;
} }

View File

@ -44,7 +44,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
}, [lastSyncTime, profileId, loadProfilePhotos]); }, [lastSyncTime, profileId, loadProfilePhotos]);
useHistoryBack(isActive, onReset, onScreenSelect, SettingsScreens.Main); useHistoryBack(isActive, onReset, onScreenSelect, SettingsScreens.Main);
useEffect(() => { useEffect(() => {
if (lastSyncTime) { if (lastSyncTime) {
loadAuthorizations(); loadAuthorizations();

View File

@ -18,8 +18,8 @@
} }
} }
.has-group-call-header { .has-call-header {
--group-call-header-height: 2rem; --call-header-height: 2rem;
#LeftColumn, #MiddleColumn, #RightColumn-wrapper { #LeftColumn, #MiddleColumn, #RightColumn-wrapper {
height: calc(100% - 2rem); height: calc(100% - 2rem);
margin-top: 2rem; margin-top: 2rem;

View File

@ -49,8 +49,9 @@ import SafeLinkModal from './SafeLinkModal.async';
import HistoryCalendar from './HistoryCalendar.async'; import HistoryCalendar from './HistoryCalendar.async';
import GroupCall from '../calls/group/GroupCall.async'; import GroupCall from '../calls/group/GroupCall.async';
import ActiveCallHeader from '../calls/ActiveCallHeader.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 NewContactModal from './NewContactModal.async';
import RatePhoneCallModal from '../calls/phone/RatePhoneCallModal.async';
import './Main.scss'; import './Main.scss';
@ -74,12 +75,13 @@ type StateProps = {
animationLevel: number; animationLevel: number;
language?: LangCode; language?: LangCode;
wasTimeFormatSetManually?: boolean; wasTimeFormatSetManually?: boolean;
isCallFallbackConfirmOpen: boolean; isPhoneCallActive?: boolean;
addedSetIds?: string[]; addedSetIds?: string[];
newContactUserId?: string; newContactUserId?: string;
newContactByPhoneNumber?: boolean; newContactByPhoneNumber?: boolean;
openedGame?: GlobalState['openedGame']; openedGame?: GlobalState['openedGame'];
gameTitle?: string; gameTitle?: string;
isRatePhoneCallModalOpen?: boolean;
}; };
const NOTIFICATION_INTERVAL = 1000; const NOTIFICATION_INTERVAL = 1000;
@ -109,12 +111,13 @@ const Main: FC<StateProps> = ({
animationLevel, animationLevel,
language, language,
wasTimeFormatSetManually, wasTimeFormatSetManually,
isCallFallbackConfirmOpen,
addedSetIds, addedSetIds,
isPhoneCallActive,
newContactUserId, newContactUserId,
newContactByPhoneNumber, newContactByPhoneNumber,
openedGame, openedGame,
gameTitle, gameTitle,
isRatePhoneCallModalOpen,
}) => { }) => {
const { const {
sync, sync,
@ -335,12 +338,8 @@ const Main: FC<StateProps> = ({
onClose={handleStickerSetModalClose} onClose={handleStickerSetModalClose}
stickerSetShortName={openedStickerSetShortName} stickerSetShortName={openedStickerSetShortName}
/> />
{activeGroupCallId && ( {activeGroupCallId && <GroupCall groupCallId={activeGroupCallId} />}
<> <ActiveCallHeader isActive={Boolean(activeGroupCallId || isPhoneCallActive)} />
<GroupCall groupCallId={activeGroupCallId} />
<ActiveCallHeader groupCallId={activeGroupCallId} />
</>
)}
<NewContactModal <NewContactModal
isOpen={Boolean(newContactUserId || newContactByPhoneNumber)} isOpen={Boolean(newContactUserId || newContactByPhoneNumber)}
userId={newContactUserId} userId={newContactUserId}
@ -348,8 +347,9 @@ const Main: FC<StateProps> = ({
/> />
<GameModal openedGame={openedGame} gameTitle={gameTitle} /> <GameModal openedGame={openedGame} gameTitle={gameTitle} />
<DownloadManager /> <DownloadManager />
<CallFallbackConfirm isOpen={isCallFallbackConfirmOpen} /> <PhoneCall isActive={isPhoneCallActive} />
<UnreadCount isForAppBadge /> <UnreadCount isForAppBadge />
<RatePhoneCallModal isOpen={isRatePhoneCallModalOpen} />
</div> </div>
); );
}; };
@ -406,12 +406,13 @@ export default memo(withGlobal(
animationLevel, animationLevel,
language, language,
wasTimeFormatSetManually, wasTimeFormatSetManually,
isCallFallbackConfirmOpen: Boolean(global.groupCalls.isFallbackConfirmOpen), isPhoneCallActive: Boolean(global.phoneCall),
addedSetIds: global.stickers.added.setIds, addedSetIds: global.stickers.added.setIds,
newContactUserId: global.newContact?.userId, newContactUserId: global.newContact?.userId,
newContactByPhoneNumber: global.newContact?.isByPhoneNumber, newContactByPhoneNumber: global.newContact?.isByPhoneNumber,
openedGame, openedGame,
gameTitle, gameTitle,
isRatePhoneCallModalOpen: Boolean(global.ratingPhoneCall),
}; };
}, },
)(Main)); )(Main));

View File

@ -84,10 +84,9 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
sendBotCommand, sendBotCommand,
openLocalTextSearch, openLocalTextSearch,
restartBot, restartBot,
openCallFallbackConfirm, requestCall,
requestNextManagementScreen, requestNextManagementScreen,
} = getActions(); } = getActions();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const menuButtonRef = useRef<HTMLButtonElement>(null); const menuButtonRef = useRef<HTMLButtonElement>(null);
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
@ -140,6 +139,10 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
} }
}, [openLocalTextSearch]); }, [openLocalTextSearch]);
function handleRequestCall() {
requestCall({ userId: chatId });
}
useEffect(() => { useEffect(() => {
if (!canSearch) { if (!canSearch) {
return undefined; return undefined;
@ -214,7 +217,8 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
round round
color="translucent" color="translucent"
size="smaller" size="smaller"
onClick={openCallFallbackConfirm} // eslint-disable-next-line react/jsx-no-bind
onClick={handleRequestCall}
ariaLabel="Call" ariaLabel="Call"
> >
<i className="icon-phone" /> <i className="icon-phone" />

View File

@ -110,10 +110,9 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
createGroupCall, createGroupCall,
openLinkedChat, openLinkedChat,
openAddContactDialog, openAddContactDialog,
openCallFallbackConfirm, requestCall,
toggleStatistics, toggleStatistics,
} = getActions(); } = getActions();
const [isMenuOpen, setIsMenuOpen] = useState(true); const [isMenuOpen, setIsMenuOpen] = useState(true);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const { x, y } = anchor; const { x, y } = anchor;
@ -177,10 +176,15 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
closeMenu(); closeMenu();
}, [closeMenu, onSubscribeChannel]); }, [closeMenu, onSubscribeChannel]);
const handleCall = useCallback(() => { const handleVideoCall = useCallback(() => {
openCallFallbackConfirm(); requestCall({ userId: chatId, isVideo: true });
closeMenu(); closeMenu();
}, [closeMenu, openCallFallbackConfirm]); }, [chatId, closeMenu, requestCall]);
const handleCall = useCallback(() => {
requestCall({ userId: chatId });
closeMenu();
}, [chatId, closeMenu, requestCall]);
const handleSearch = useCallback(() => { const handleSearch = useCallback(() => {
onSearchClick(); onSearchClick();
@ -276,6 +280,14 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
{lang('Call')} {lang('Call')}
</MenuItem> </MenuItem>
)} )}
{canCall && (
<MenuItem
icon="video-outlined"
onClick={handleVideoCall}
>
{lang('VideoCall')}
</MenuItem>
)}
{IS_SINGLE_COLUMN_LAYOUT && canSearch && ( {IS_SINGLE_COLUMN_LAYOUT && canSearch && (
<MenuItem <MenuItem
icon="search" icon="search"

View File

@ -122,7 +122,12 @@ const MessageListContent: FC<OwnProps> = ({
senderGroupIndex, senderGroupIndex,
senderGroupsArray, 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 message = senderGroup[0];
const isLastInList = ( const isLastInList = (
senderGroupIndex === senderGroupsArray.length - 1 senderGroupIndex === senderGroupsArray.length - 1

View File

@ -114,6 +114,7 @@ import CommentButton from './CommentButton';
import Reactions from './Reactions'; import Reactions from './Reactions';
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji'; import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
import LocalAnimatedEmoji from '../../common/LocalAnimatedEmoji'; import LocalAnimatedEmoji from '../../common/LocalAnimatedEmoji';
import MessagePhoneCall from './MessagePhoneCall';
import './Message.scss'; import './Message.scss';
@ -354,9 +355,6 @@ const Message: FC<OwnProps & StateProps> = ({
&& forwardInfo.fromMessageId && 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) => { const selectMessage = useCallback((e?: React.MouseEvent<HTMLDivElement, MouseEvent>, groupedId?: string) => {
toggleMessageSelection({ toggleMessageSelection({
messageId, messageId,
@ -462,9 +460,15 @@ const Message: FC<OwnProps & StateProps> = ({
); );
const { 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); } = 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, { const contentClassName = buildContentClassName(message, {
hasReply, hasReply,
customShape, customShape,
@ -482,7 +486,9 @@ const Message: FC<OwnProps & StateProps> = ({
const textParts = renderMessageText(message, highlight, isEmojiOnlyMessage(customShape)); const textParts = renderMessageText(message, highlight, isEmojiOnlyMessage(customShape));
let metaPosition!: MetaPosition; let metaPosition!: MetaPosition;
if (isInDocumentGroupNotLast) { if (phoneCall) {
metaPosition = 'none';
} else if (isInDocumentGroupNotLast) {
metaPosition = 'none'; metaPosition = 'none';
} else if (textParts && !hasAnimatedEmoji && !webPage) { } else if (textParts && !hasAnimatedEmoji && !webPage) {
metaPosition = 'in-text'; metaPosition = 'in-text';
@ -680,6 +686,13 @@ const Message: FC<OwnProps & StateProps> = ({
onMediaClick={handleAlbumMediaClick} onMediaClick={handleAlbumMediaClick}
/> />
)} )}
{phoneCall && (
<MessagePhoneCall
message={message}
phoneCall={phoneCall}
chatId={chatId}
/>
)}
{!isAlbum && photo && ( {!isAlbum && photo && (
<Photo <Photo
message={message} message={message}

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

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

View File

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

View File

@ -5,87 +5,22 @@ import {
leaveGroupCall, leaveGroupCall,
toggleStream, toggleStream,
isStreamEnabled, isStreamEnabled,
setVolume, setVolume, stopPhoneCall,
handleUpdateGroupCallParticipants, handleUpdateGroupCallConnection,
} from '../../../lib/secret-sauce'; } from '../../../lib/secret-sauce';
import { GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config'; import { GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';
import { callApi } from '../../../api/gramjs'; import { callApi } from '../../../api/gramjs';
import { selectChat, selectCurrentMessageList, selectUser } from '../../selectors'; import { selectChat, selectUser } from '../../selectors';
import { import {
selectActiveGroupCall, selectActiveGroupCall, selectPhoneCallUser,
selectCallFallbackChannelTitle,
selectGroupCallParticipant,
} from '../../selectors/calls'; } from '../../selectors/calls';
import { import {
removeGroupCall, removeGroupCall,
updateActiveGroupCall, updateActiveGroupCall,
updateGroupCall,
updateGroupCallParticipant,
} from '../../reducers/calls'; } 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 { getGroupCallAudioContext, getGroupCallAudioElement, removeGroupCallAudioElement } from '../ui/calls';
import { loadFullChat } from './chats'; 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) => { addActionHandler('leaveGroupCall', async (global, actions, payload) => {
const { const {
isFromLibrary, shouldDiscard, shouldRemove, rejoin, isFromLibrary, shouldDiscard, shouldRemove, rejoin,
@ -101,18 +36,7 @@ addActionHandler('leaveGroupCall', async (global, actions, payload) => {
call: groupCall, call: groupCall,
}); });
let shouldResetFallbackState = false;
if (shouldDiscard) { 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', { await callApi('discardGroupCall', {
call: groupCall, call: groupCall,
}); });
@ -129,13 +53,9 @@ addActionHandler('leaveGroupCall', async (global, actions, payload) => {
...global, ...global,
groupCalls: { groupCalls: {
...global.groupCalls, ...global.groupCalls,
isGroupCallPanelHidden: true,
activeGroupCallId: undefined, activeGroupCallId: undefined,
...(shouldResetFallbackState && {
fallbackChatId: undefined,
fallbackUserIdsToRemove: undefined,
}),
}, },
isCallPanelVisible: undefined,
}); });
if (!isFromLibrary) { if (!isFromLibrary) {
@ -292,79 +212,106 @@ addActionHandler('connectToActiveGroupCall', async (global, actions) => {
} }
}); });
addActionHandler('inviteToCallFallback', async (global, actions, payload) => { addActionHandler('connectToActivePhoneCall', async (global) => {
const { chatId } = selectCurrentMessageList(global) || {}; const { phoneCall } = global;
if (!chatId) {
return;
}
const user = selectUser(global, chatId); if (!phoneCall) return;
if (!user) {
return;
}
const { shouldRemove } = payload; const user = selectPhoneCallUser(global);
const fallbackChannelTitle = selectCallFallbackChannelTitle(global); if (!user) return;
let fallbackChannel = Object.values(global.chats.byId).find((channel) => { const dhConfig = await callApi('getDhConfig');
return (
channel.title === fallbackChannelTitle
&& channel.isCreator
&& !channel.isRestricted
&& !channel.isForbidden
);
});
if (!fallbackChannel) {
fallbackChannel = await callApi('createChannel', {
title: fallbackChannelTitle,
users: [user],
});
if (!fallbackChannel) { if (!dhConfig) return;
return;
}
const photo = await fetchFile(callFallbackAvatarPath, 'avatar.png'); await callApi('createPhoneCallState', [true]);
void callApi('editChatPhoto', {
chatId: fallbackChannel.id,
accessHash: fallbackChannel.accessHash,
photo,
});
} else {
actions.updateChatMemberBannedRights({
chatId: fallbackChannel.id,
userId: chatId,
bannedRights: {},
});
void callApi('addChatMembers', fallbackChannel, [user], true); const gAHash = await callApi('requestPhoneCall', [dhConfig])!;
}
const inviteLink = await callApi('updatePrivateLink', { await callApi('requestCall', { user, gAHash, isVideo: phoneCall.isVideo });
chat: fallbackChannel, });
usageLimit: 1,
expireDate: getServerTime(global.serverTimeOffset) + FALLBACK_INVITE_EXPIRE_SECONDS, addActionHandler('acceptCall', async (global) => {
}); const { phoneCall } = global;
if (!inviteLink) {
return; if (!phoneCall) return;
}
const dhConfig = await callApi('getDhConfig');
if (shouldRemove) { if (!dhConfig) return;
global = getGlobal();
const fallbackUserIdsToRemove = global.groupCalls.fallbackUserIdsToRemove || []; await callApi('createPhoneCallState', [false]);
setGlobal({
...global, const gB = await callApi('acceptPhoneCall', [dhConfig])!;
groupCalls: { callApi('acceptCall', { call: phoneCall, gB });
...global.groupCalls, });
fallbackChatId: fallbackChannel.id,
fallbackUserIdsToRemove: [...fallbackUserIdsToRemove, chatId], addActionHandler('sendSignalingData', (global, actions, payload) => {
}, const { phoneCall } = global;
}); if (!phoneCall) {
} return;
}
actions.sendMessage({ text: `Join a call: ${inviteLink}` });
actions.openChat({ id: fallbackChannel.id }); const data = JSON.stringify(payload);
actions.createGroupCall({ chatId: fallbackChannel.id });
actions.closeCallFallbackConfirm(); (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;
}); });

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

View File

@ -3,6 +3,10 @@ import { removeGroupCall, updateGroupCall, updateGroupCallParticipant } from '..
import { omit } from '../../../util/iteratees'; import { omit } from '../../../util/iteratees';
import { selectChat } from '../../selectors'; import { selectChat } from '../../selectors';
import { updateChat } from '../../reducers'; 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) => { addActionHandler('apiUpdate', (global, actions, update) => {
switch (update['@type']) { switch (update['@type']) {
@ -54,6 +58,32 @@ addActionHandler('apiUpdate', (global, actions, update) => {
} }
return global; 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; return undefined;

View File

@ -1 +1,2 @@
import './api/calls.async'; import './api/calls.async';
import './apiUpdaters/calls.async';

View File

@ -1,7 +1,7 @@
import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { addActionHandler, getGlobal, setGlobal } from '../../index';
import { selectActiveGroupCall, selectChatGroupCall, selectGroupCall } from '../../selectors/calls'; import { selectActiveGroupCall, selectChatGroupCall, selectGroupCall } from '../../selectors/calls';
import { callApi } from '../../../api/gramjs'; import { callApi } from '../../../api/gramjs';
import { selectChat } from '../../selectors'; import { selectChat, selectUser } from '../../selectors';
import { copyTextToClipboard } from '../../../util/clipboard'; import { copyTextToClipboard } from '../../../util/clipboard';
import { ApiGroupCall } from '../../../api/types'; import { ApiGroupCall } from '../../../api/types';
import { updateGroupCall } from '../../reducers/calls'; import { updateGroupCall } from '../../reducers/calls';
@ -11,29 +11,43 @@ import { fetchChatByUsername, loadFullChat } from '../api/chats';
import safePlay from '../../../util/safePlay'; import safePlay from '../../../util/safePlay';
import { ARE_CALLS_SUPPORTED } from '../../../util/environment'; import { ARE_CALLS_SUPPORTED } from '../../../util/environment';
import * as langProvider from '../../../util/langProvider'; import * as langProvider from '../../../util/langProvider';
import { CallSound } from '../../types';
// Workaround for Safari not playing audio without user interaction // Workaround for Safari not playing audio without user interaction
let audioElement: HTMLAudioElement | undefined; let audioElement: HTMLAudioElement | undefined;
let audioContext: AudioContext | undefined; let audioContext: AudioContext | undefined;
const joinAudio = new Audio('./voicechat_join.mp3'); let sounds: Record<CallSound, HTMLAudioElement>;
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 initializationPromise: Promise<void> | undefined = Promise.resolve(); let initializationPromise: Promise<void> | undefined = Promise.resolve();
const initializeSoundsForSafari = () => { export const initializeSoundsForSafari = () => {
if (!initializationPromise) return Promise.resolve(); 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) => { initializationPromise = Promise.all(Object.values(sounds).map((l) => {
l.muted = true; l.muted = true;
l.volume = 0.0001; l.volume = 0.0001;
@ -95,10 +109,7 @@ async function fetchGroupCallParticipants(groupCall: Partial<ApiGroupCall>, next
addActionHandler('toggleGroupCallPanel', (global) => { addActionHandler('toggleGroupCallPanel', (global) => {
return { return {
...global, ...global,
groupCalls: { isCallPanelVisible: !global.isCallPanelVisible,
...global.groupCalls,
isGroupCallPanelHidden: !global.groupCalls.isGroupCallPanelHidden,
},
}; };
}); });
@ -194,6 +205,11 @@ addActionHandler('joinVoiceChatByLink', async (global, actions, payload) => {
addActionHandler('joinGroupCall', async (global, actions, payload) => { addActionHandler('joinGroupCall', async (global, actions, payload) => {
if (!ARE_CALLS_SUPPORTED) return undefined; if (!ARE_CALLS_SUPPORTED) return undefined;
if (global.phoneCall) {
actions.toggleGroupCallPanel();
return undefined;
}
const { const {
chatId, id, accessHash, inviteHash, chatId, id, accessHash, inviteHash,
} = payload; } = payload;
@ -246,8 +262,8 @@ addActionHandler('joinGroupCall', async (global, actions, payload) => {
groupCalls: { groupCalls: {
...global.groupCalls, ...global.groupCalls,
activeGroupCallId: groupCall.id, activeGroupCallId: groupCall.id,
isGroupCallPanelHidden: false,
}, },
isCallPanelVisible: false,
}; };
return global; return global;
}); });
@ -259,15 +275,23 @@ addActionHandler('playGroupCallSound', (global, actions, payload) => {
return; return;
} }
if (initializationPromise) { const doPlay = () => {
initializationPromise.then(() => {
safePlay(sounds[sound]);
});
} else {
if (sound !== 'connecting') { if (sound !== 'connecting') {
sounds.connecting.pause(); sounds.connecting.pause();
} }
if (sound !== 'incoming') {
sounds.incoming.pause();
}
if (sound !== 'ringing') {
sounds.ringing.pause();
}
safePlay(sounds[sound]); safePlay(sounds[sound]);
};
if (initializationPromise) {
initializationPromise.then(doPlay);
} else {
doPlay();
} }
}); });
@ -280,6 +304,35 @@ addActionHandler('loadMoreGroupCallParticipants', (global) => {
void fetchGroupCallParticipants(groupCall, groupCall.nextOffset); 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() { function createAudioContext() {
return (new (window.AudioContext || (window as any).webkitAudioContext)()); return (new (window.AudioContext || (window as any).webkitAudioContext)());
} }
@ -312,23 +365,3 @@ export function removeGroupCallAudioElement() {
audioContext = undefined; audioContext = undefined;
audioElement = undefined; audioElement = undefined;
} }
addActionHandler('openCallFallbackConfirm', (global) => {
return {
...global,
groupCalls: {
...global.groupCalls,
isFallbackConfirmOpen: true,
},
};
});
addActionHandler('closeCallFallbackConfirm', (global) => {
return {
...global,
groupCalls: {
...global.groupCalls,
isFallbackConfirmOpen: false,
},
};
});

View File

@ -276,6 +276,7 @@ function updateCache() {
chatFolders: reduceChatFolders(global), chatFolders: reduceChatFolders(global),
groupCalls: reduceGroupCalls(global), groupCalls: reduceGroupCalls(global),
availableReactions: reduceAvailableReactions(global), availableReactions: reduceAvailableReactions(global),
isCallPanelVisible: undefined,
}; };
const json = JSON.stringify(reducedGlobal); const json = JSON.stringify(reducedGlobal);
@ -389,8 +390,6 @@ function reduceGroupCalls(global: GlobalState): GlobalState['groupCalls'] {
...global.groupCalls, ...global.groupCalls,
byId: {}, byId: {},
activeGroupCallId: undefined, activeGroupCallId: undefined,
isGroupCallPanelHidden: undefined,
isFallbackConfirmOpen: undefined,
}; };
} }

View File

@ -44,7 +44,8 @@ export function getMessageOriginalId(message: ApiMessage) {
export function getMessageText(message: ApiMessage) { export function getMessageText(message: ApiMessage) {
const { 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; } = message.content;
if (text) { if (text) {
@ -52,7 +53,7 @@ export function getMessageText(message: ApiMessage) {
} }
if (sticker || photo || video || audio || voice || document if (sticker || photo || video || audio || voice || document
|| contact || poll || webPage || invoice || location || game) { || contact || poll || webPage || invoice || location || game || action?.phoneCall) {
return undefined; return undefined;
} }

View File

@ -1,6 +1,6 @@
import { GlobalState } from '../types'; import { GlobalState } from '../types';
import { selectChat } from './chats'; import { selectChat } from './chats';
import { getUserFullName, isChatBasicGroup } from '../helpers'; import { isChatBasicGroup } from '../helpers';
import { selectUser } from './users'; import { selectUser } from './users';
export function selectChatGroupCall(global: GlobalState, chatId: string) { export function selectChatGroupCall(global: GlobalState, chatId: string) {
@ -38,8 +38,12 @@ export function selectActiveGroupCall(global: GlobalState) {
return selectGroupCall(global, activeGroupCallId); return selectGroupCall(global, activeGroupCallId);
} }
export function selectCallFallbackChannelTitle(global: GlobalState) { export function selectPhoneCallUser(global: GlobalState) {
const currentUser = selectUser(global, global.currentUserId!); 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);
} }

View File

@ -31,6 +31,7 @@ import {
ApiPaymentFormNativeParams, ApiPaymentFormNativeParams,
ApiUpdate, ApiUpdate,
ApiKeyboardButton, ApiKeyboardButton,
ApiPhoneCall,
} from '../api/types'; } from '../api/types';
import { import {
FocusDirection, FocusDirection,
@ -58,6 +59,7 @@ import {
ManagementState, ManagementState,
} from '../types'; } from '../types';
import { typify } from '../lib/teact/teactn'; import { typify } from '../lib/teact/teactn';
import type { P2pMessage } from '../lib/secret-sauce';
export type MessageListType = export type MessageListType =
'thread' 'thread'
@ -200,12 +202,12 @@ export type GlobalState = {
groupCalls: { groupCalls: {
byId: Record<string, ApiGroupCall>; byId: Record<string, ApiGroupCall>;
activeGroupCallId?: string; activeGroupCallId?: string;
isGroupCallPanelHidden?: boolean;
isFallbackConfirmOpen?: boolean;
fallbackChatId?: string;
fallbackUserIdsToRemove?: string[];
}; };
isCallPanelVisible?: boolean;
phoneCall?: ApiPhoneCall;
ratingPhoneCall?: ApiPhoneCall;
scheduledMessages: { scheduledMessages: {
byChatId: Record<string, { byChatId: Record<string, {
byId: Record<number, ApiMessage>; 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 { export interface ActionPayloads {
// Initial // Initial
signOut: { forceInitApi?: boolean } | undefined; signOut: { forceInitApi?: boolean } | undefined;
@ -673,6 +679,24 @@ export interface ActionPayloads {
isQuiz?: boolean; isQuiz?: boolean;
}; };
closePollModal: {}; closePollModal: {};
// Calls
requestCall: {
userId: string;
isVideo?: boolean;
};
sendSignalingData: P2pMessage;
hangUp: {};
acceptCall: {};
setCallRating: {
rating: number;
comment: string;
};
closeCallRatingModal: {};
playGroupCallSound: {
sound: CallSound;
};
connectToActivePhoneCall: {};
} }
export type NonTypedActionNames = ( export type NonTypedActionNames = (
@ -769,8 +793,7 @@ export type NonTypedActionNames = (
'joinGroupCall' | 'toggleGroupCallMute' | 'toggleGroupCallPresentation' | 'leaveGroupCall' | 'joinGroupCall' | 'toggleGroupCallMute' | 'toggleGroupCallPresentation' | 'leaveGroupCall' |
'toggleGroupCallVideo' | 'requestToSpeak' | 'setGroupCallParticipantVolume' | 'toggleGroupCallPanel' | 'toggleGroupCallVideo' | 'requestToSpeak' | 'setGroupCallParticipantVolume' | 'toggleGroupCallPanel' |
'createGroupCall' | 'joinVoiceChatByLink' | 'subscribeToGroupCallUpdates' | 'createGroupCallInviteLink' | 'createGroupCall' | 'joinVoiceChatByLink' | 'subscribeToGroupCallUpdates' | 'createGroupCallInviteLink' |
'loadMoreGroupCallParticipants' | 'connectToActiveGroupCall' | 'playGroupCallSound' | 'loadMoreGroupCallParticipants' | 'connectToActiveGroupCall' |
'openCallFallbackConfirm' | 'closeCallFallbackConfirm' | 'inviteToCallFallback' |
// stats // stats
'loadStatistics' | 'loadStatisticsAsyncGraph' 'loadStatistics' | 'loadStatisticsAsyncGraph'
); );

View File

@ -38,7 +38,6 @@ function toSignedLittleBuffer(big, number = 8) {
return Buffer.from(byteArray); return Buffer.from(byteArray);
} }
/** /**
* converts a big int to a buffer * converts a big int to a buffer
* @param bigInt {bigInt.BigInteger} * @param bigInt {bigInt.BigInteger}
@ -205,7 +204,6 @@ function sha1(data) {
return shaSum.digest(); return shaSum.digest();
} }
/** /**
* Calculates the SHA256 digest for the given data * Calculates the SHA256 digest for the given data
* @param data * @param data
@ -241,10 +239,9 @@ function modExp(a, b, n) {
return result; return result;
} }
/** /**
* Gets the arbitrary-length byte array corresponding to the given integer * Gets the arbitrary-length byte array corresponding to the given integer
* @param integer {number,BigInteger} * @param integer {any}
* @param signed {boolean} * @param signed {boolean}
* @returns {Buffer} * @returns {Buffer}
*/ */

View File

@ -1,4 +1,5 @@
const BigInt = require('big-integer'); const BigInt = require('big-integer');
const aes = require('@cryptography/aes');
const Helpers = require('../Helpers'); const Helpers = require('../Helpers');
const IGE = require('../crypto/IGE'); const IGE = require('../crypto/IGE');
@ -39,10 +40,14 @@ class MTProtoState {
authentication process, at which point the `MTProtoPlainSender` is better authentication process, at which point the `MTProtoPlainSender` is better
* @param authKey * @param authKey
* @param loggers * @param loggers
* @param isCall
* @param isOutgoing
*/ */
constructor(authKey, loggers) { constructor(authKey, loggers, isCall = false, isOutgoing = false) {
this.authKey = authKey; this.authKey = authKey;
this._log = loggers; this._log = loggers;
this._isCall = isCall;
this._isOutgoing = isOutgoing;
this.timeOffset = 0; this.timeOffset = 0;
this.salt = 0; this.salt = 0;
@ -81,12 +86,20 @@ class MTProtoState {
* @returns {{iv: Buffer, key: Buffer}} * @returns {{iv: Buffer, key: Buffer}}
*/ */
async _calcKey(authKey, msgKey, client) { 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([ const [sha256a, sha256b] = await Promise.all([
Helpers.sha256(Buffer.concat([msgKey, authKey.slice(x, x + 36)])), Helpers.sha256(Buffer.concat([msgKey, authKey.slice(x, x + 36)])),
Helpers.sha256(Buffer.concat([authKey.slice(x + 40, x + 76), msgKey])), 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)]); 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)]); const iv = Buffer.concat([sha256b.slice(0, 8), sha256a.slice(8, 24), sha256b.slice(24, 32)]);
return { return {
key, key,
@ -133,24 +146,48 @@ class MTProtoState {
*/ */
async encryptMessageData(data) { async encryptMessageData(data) {
await this.authKey.waitForKey(); await this.authKey.waitForKey();
const s = toSignedLittleBuffer(this.salt, 8); if (this._isCall) {
const i = toSignedLittleBuffer(this.id, 8); const x = 128 + (this._isOutgoing ? 0 : 8);
data = Buffer.concat([Buffer.concat([s, i]), data]); const lengthStart = data.length;
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 { data = Buffer.from(data);
iv, if (lengthStart % 4 !== 0) {
key, data = Buffer.concat([data, Buffer.from(new Array(4 - (lengthStart % 4)).fill(0x20))]);
} = await this._calcKey(this.authKey.getKey(), msgKey, true); }
const keyId = Helpers.readBufferFromBigInt(this.authKey.keyId, 8); const msgKeyLarge = await Helpers.sha256(Buffer.concat([this.authKey.getKey()
return Buffer.concat([keyId, msgKey, new IGE(key, iv).encryptIge(Buffer.concat([data, padding]))]); .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 if (body.length < 0) { // length needs to be positive
throw new SecurityError('Server replied with negative length'); 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'); throw new SecurityError('Server replied with length not divisible by 4');
} }
// TODO Check salt,sessionId, and sequenceNumber // TODO Check salt,sessionId, and sequenceNumber
const keyId = Helpers.readBigIntFromBuffer(body.slice(0, 8)); if (!this._isCall) {
if (keyId.neq(this.authKey.keyId)) { const keyId = Helpers.readBigIntFromBuffer(body.slice(0, 8));
throw new SecurityError('Server replied with an invalid auth key');
}
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 { const {
iv, iv,
key, key,
} = await this._calcKey(this.authKey.getKey(), msgKey, false); } = 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 // https://core.telegram.org/mtproto/security_guidelines
// Sections "checking sha256 hash" and "message length" // Sections "checking sha256 hash" and "message length"
const ourKey = await Helpers.sha256(Buffer.concat([this.authKey.getKey() const ourKey = this._isCall
.slice(96, 96 + 32), body])); ? 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'); throw new SecurityError('Received msg_key doesn\'t match with expected one');
} }
const reader = new BinaryReader(body); 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 (this._isCall) {
// if we get a duplicate message id we should ignore it. // Seq
if (this.msgIds.includes(remoteMsgId.toString())) { reader.readInt(false);
throw new SecurityError('Duplicate msgIds'); return reader.read(body.length - 4);
} } else {
// we only store the latest 500 message ids from the server reader.readLong(); // removeSalt
if (this.msgIds.length > 500) { const serverId = reader.readLong();
this.msgIds.shift(); if (!serverId.eq(this.id)) {
} throw new SecurityError('Server replied with a wrong session ID');
this.msgIds.push(remoteMsgId.toString()); }
const remoteSequence = reader.readInt(); const remoteMsgId = reader.readLong();
const containerLen = reader.readInt(); // msgLen for the inner object, padding ignored // if we get a duplicate message id we should ignore it.
const diff = body.length - containerLen; if (this.msgIds.includes(remoteMsgId.toString())) {
// We want to check if it's between 12 and 1024 throw new SecurityError('Duplicate msgIds');
// https://core.telegram.org/mtproto/security_guidelines#checking-message-length }
if (diff < 12 || diff > 1024) { // we only store the latest 500 message ids from the server
throw new SecurityError('Server replied with the wrong message padding'); 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);
} }
/** /**

View File

@ -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.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.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; 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.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.joinGroupCall#b132ff7b flags:# muted:flags.0?true video_stopped:flags.2?true call:InputGroupCall join_as:InputPeer invite_hash:flags.1?string params:DataJSON = Updates;
phone.leaveGroupCall#500377f9 call:InputGroupCall source:int = Updates; phone.leaveGroupCall#500377f9 call:InputGroupCall source:int = Updates;

View File

@ -203,6 +203,14 @@
"phone.toggleGroupCallStartSubscription", "phone.toggleGroupCallStartSubscription",
"phone.joinGroupCallPresentation", "phone.joinGroupCallPresentation",
"phone.leaveGroupCallPresentation", "phone.leaveGroupCallPresentation",
"phone.requestCall",
"phone.acceptCall",
"phone.confirmCall",
"phone.receivedCall",
"phone.discardCall",
"phone.setCallRating",
"phone.saveCallDebug",
"phone.sendSignalingData",
"messages.sendReaction", "messages.sendReaction",
"messages.getMessagesReactions", "messages.getMessagesReactions",
"messages.getMessageReactionsList", "messages.getMessageReactionsList",

View File

@ -17,5 +17,5 @@ export declare type Ssrc = {
isPresentation?: boolean; isPresentation?: boolean;
sourceGroups: SsrcGroup[]; 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; export default _default;

View File

@ -1,3 +1,5 @@
export { handleUpdateGroupCallConnection, startSharingScreen, joinGroupCall, getDevices, getUserStreams, setVolume, isStreamEnabled, toggleStream, leaveGroupCall, handleUpdateGroupCallParticipants, switchCameraInput, toggleSpeaker, toggleNoiseSuppression, } from './secretsauce'; 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 { IS_SCREENSHARE_SUPPORTED, THRESHOLD, } from './utils';
export * from './types'; 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
View 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
View 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 {};

View File

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

View File

@ -1,5 +1,5 @@
import { GroupCallConnectionData, GroupCallParticipant, JoinGroupCallPayload } from './types'; 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 getDevices(streamType: StreamType, isInput?: boolean): Promise<MediaDeviceInfo[]>;
export declare function toggleSpeaker(): void; export declare function toggleSpeaker(): void;
export declare function toggleNoiseSuppression(): 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 handleUpdateGroupCallConnection(data: GroupCallConnectionData, isPresentation: boolean): Promise<void>;
export declare function startSharingScreen(): Promise<JoinGroupCallPayload | undefined>; export declare function startSharingScreen(): Promise<JoinGroupCallPayload | undefined>;
export declare function joinGroupCall(myId: string, audioContext: AudioContext, audioElement: HTMLAudioElement, onUpdate: (...args: any[]) => void): Promise<JoinGroupCallPayload>; export declare function joinGroupCall(myId: string, audioContext: AudioContext, audioElement: HTMLAudioElement, onUpdate: (...args: any[]) => void): Promise<JoinGroupCallPayload>;
export {};

View File

@ -1,3 +1,4 @@
import { P2PPayloadType } from './p2pMessage';
export interface GroupCallParticipant { export interface GroupCallParticipant {
isSelf?: boolean; isSelf?: boolean;
isMuted?: boolean; isMuted?: boolean;
@ -52,6 +53,7 @@ export declare type Candidate = {
network: string; network: string;
'rel-addr': string; 'rel-addr': string;
'rel-port': string; 'rel-port': string;
sdpString?: string;
}; };
export declare type JoinGroupCallPayload = { export declare type JoinGroupCallPayload = {
ufrag: string; ufrag: string;
@ -60,6 +62,14 @@ export declare type JoinGroupCallPayload = {
ssrc?: number; ssrc?: number;
'ssrc-groups'?: SsrcGroup[]; 'ssrc-groups'?: SsrcGroup[];
}; };
export declare type P2pParsedSdp = JoinGroupCallPayload & {
audioExtmap: RTPExtension[];
videoExtmap: RTPExtension[];
screencastExtmap: RTPExtension[];
audioPayloadTypes: P2PPayloadType[];
videoPayloadTypes: P2PPayloadType[];
screencastPayloadTypes: P2PPayloadType[];
};
export interface RTPExtension { export interface RTPExtension {
id: number; id: number;
uri: string; uri: string;
@ -98,3 +108,19 @@ export interface GroupCallConnectionData {
}; };
stream?: boolean; 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;
}

View File

@ -1,11 +1,10 @@
import { P2PPayloadType } from './p2pMessage';
import { PayloadType } from './types';
export declare function toTelegramSource(source: number): number; export declare function toTelegramSource(source: number): number;
export declare function fromTelegramSource(source: number): number; export declare function fromTelegramSource(source: number): number;
export declare function getAmplitude(array: Uint8Array, scale?: 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 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_SCREENSHARE_SUPPORTED: boolean;
export declare const IS_ECHO_CANCELLATION_SUPPORTED: boolean | undefined; export declare const IS_ECHO_CANCELLATION_SUPPORTED: boolean | undefined;
export declare const IS_NOISE_SUPPRESSION_SUPPORTED: any; export declare const IS_NOISE_SUPPRESSION_SUPPORTED: any;

File diff suppressed because it is too large Load Diff

View File

@ -51,6 +51,15 @@
.icon-volume-3:before { .icon-volume-3:before {
content: "\e991"; content: "\e991";
} }
.icon-favorite-filled:before {
content: "\e998";
}
.icon-share-screen:before {
content: "\e97a";
}
.icon-video-outlined:before {
content: "\e997";
}
.icon-stats:before { .icon-stats:before {
content: "\e996"; content: "\e996";
} }
@ -84,7 +93,7 @@
.icon-stop-raising-hand:before { .icon-stop-raising-hand:before {
content: "\e985"; content: "\e985";
} }
.icon-share-screen:before { .icon-share-screen-outlined:before {
content: "\e986"; content: "\e986";
} }
.icon-voice-chat:before { .icon-voice-chat:before {

View File

@ -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( export function formatHumanDate(
lang: LangFn, lang: LangFn,
datetime: number | Date, datetime: number | Date,

View File

@ -1,6 +1,6 @@
import { callApi } from '../api/gramjs'; import { callApi } from '../api/gramjs';
import { import {
ApiChat, ApiMediaFormat, ApiMessage, ApiUser, ApiUserReaction, ApiChat, ApiMediaFormat, ApiMessage, ApiPhoneCall, ApiUser, ApiUserReaction,
} from '../api/types'; } from '../api/types';
import { renderActionMessageText } from '../components/common/helpers/renderActionMessageText'; import { renderActionMessageText } from '../components/common/helpers/renderActionMessageText';
import { DEBUG, IS_TEST } from '../config'; import { DEBUG, IS_TEST } from '../config';
@ -12,7 +12,7 @@ import {
getMessageRecentReaction, getMessageRecentReaction,
getMessageSenderName, getMessageSenderName,
getMessageSummaryText, getMessageSummaryText,
getPrivateChatUserId, getPrivateChatUserId, getUserFullName,
isActionMessage, isActionMessage,
isChatChannel, isChatChannel,
selectIsChatMuted, 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); const imageHash = getChatAvatarHash(chat);
if (!imageHash) return undefined; if (!imageHash) return undefined;
let mediaData = mediaLoader.getFromMemory(imageHash); let mediaData = mediaLoader.getFromMemory(imageHash);
@ -333,6 +333,39 @@ async function getAvatar(chat: ApiChat) {
return mediaData; 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({ export async function notifyAboutMessage({
chat, chat,
message, message,

View 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,
];

View File

@ -80,7 +80,16 @@ module.exports = (env = {}, argv = {}) => {
test: /\.scss$/, test: /\.scss$/,
use: [ use: [
MiniCssExtractPlugin.loader, MiniCssExtractPlugin.loader,
'css-loader', {
loader: 'css-loader',
options: {
modules: {
exportLocalsConvention: 'camelCase',
auto: true,
localIdentName: argv['optimize-minimize'] ? '[hash:base64]' : '[path][name]__[local]'
}
}
},
'postcss-loader', 'postcss-loader',
'sass-loader', 'sass-loader',
], ],