diff --git a/.stylelintrc.json b/.stylelintrc.json index 0f100bc98..9e945e829 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -24,6 +24,12 @@ } ], "plugin/stylelint-group-selectors": [true, { "severity": "warning" }], - "plugin/whole-pixel": [true, { "ignoreList": ["letter-spacing"] }] + "plugin/whole-pixel": [true, { "ignoreList": ["letter-spacing"] }], + "selector-pseudo-class-no-unknown": [ + true, + { + "ignorePseudoClasses": ["global"] + } + ] } } diff --git a/public/call_busy.mp3 b/public/call_busy.mp3 new file mode 100644 index 000000000..968279215 Binary files /dev/null and b/public/call_busy.mp3 differ diff --git a/public/call_connect.mp3 b/public/call_connect.mp3 new file mode 100644 index 000000000..5db9355ea Binary files /dev/null and b/public/call_connect.mp3 differ diff --git a/public/call_end.mp3 b/public/call_end.mp3 new file mode 100644 index 000000000..177373e77 Binary files /dev/null and b/public/call_end.mp3 differ diff --git a/public/call_incoming.mp3 b/public/call_incoming.mp3 new file mode 100644 index 000000000..932a00b2d Binary files /dev/null and b/public/call_incoming.mp3 differ diff --git a/public/call_ringing.mp3 b/public/call_ringing.mp3 new file mode 100644 index 000000000..16125a945 Binary files /dev/null and b/public/call_ringing.mp3 differ diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 8b651cafd..2ea0d835e 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -1,5 +1,7 @@ declare const process: NodeJS.Process; +declare module '*.module.scss'; + declare const APP_REVISION: string; declare namespace React { diff --git a/src/api/gramjs/apiBuilders/calls.ts b/src/api/gramjs/apiBuilders/calls.ts index 4430a6e0a..8e9c1681f 100644 --- a/src/api/gramjs/apiBuilders/calls.ts +++ b/src/api/gramjs/apiBuilders/calls.ts @@ -1,6 +1,12 @@ -import { GroupCallParticipant, GroupCallParticipantVideo, SsrcGroup } from '../../../lib/secret-sauce'; +import type { + ApiCallProtocol, + ApiPhoneCallConnection, + GroupCallParticipant, + GroupCallParticipantVideo, + SsrcGroup, +} from '../../../lib/secret-sauce'; import { Api as GramJs } from '../../../lib/gramjs'; -import { ApiGroupCall } from '../../types'; +import { ApiGroupCall, ApiPhoneCall } from '../../types'; import { getApiChatIdFromMtpPeer, isPeerUser } from './peers'; export function buildApiGroupCallParticipant(participant: GramJs.GroupCallParticipant): GroupCallParticipant { @@ -96,3 +102,139 @@ export function buildApiGroupCall(groupCall: GramJs.TypeGroupCall): ApiGroupCall export function getGroupCallId(groupCall: GramJs.TypeInputGroupCall) { return groupCall.id.toString(); } + +export function buildPhoneCall(call: GramJs.TypePhoneCall): ApiPhoneCall { + const { id } = call; + + let phoneCall: ApiPhoneCall = { + id: id.toString(), + }; + + if (call instanceof GramJs.PhoneCallAccepted + || call instanceof GramJs.PhoneCallWaiting + || call instanceof GramJs.PhoneCall + || call instanceof GramJs.PhoneCallRequested) { + const { + accessHash, adminId, date, video, participantId, protocol, + } = call; + + phoneCall = { + ...phoneCall, + accessHash: accessHash.toString(), + adminId: adminId.toString(), + participantId: participantId.toString(), + date, + isVideo: video, + protocol: buildApiCallProtocol(protocol), + }; + } + + if (call instanceof GramJs.PhoneCall) { + const { + p2pAllowed, gAOrB, keyFingerprint, connections, startDate, + } = call; + + phoneCall = { + ...phoneCall, + state: 'active', + gAOrB: Array.from(gAOrB), + keyFingerprint: keyFingerprint.toString(), + startDate, + p2pAllowed, + connections: connections.map(buildApiCallConnection).filter(Boolean) as ApiPhoneCallConnection[], + }; + } + + if (call instanceof GramJs.PhoneCallDiscarded) { + phoneCall = { + ...phoneCall, + state: 'discarded', + duration: call.duration, + reason: buildApiCallDiscardReason(call.reason), + needRating: call.needRating, + needDebug: call.needDebug, + }; + } + + if (call instanceof GramJs.PhoneCallWaiting) { + phoneCall = { + ...phoneCall, + state: 'waiting', + receiveDate: call.receiveDate, + }; + } + + if (call instanceof GramJs.PhoneCallAccepted) { + phoneCall = { + ...phoneCall, + state: 'accepted', + gB: Array.from(call.gB), + }; + } + + if (call instanceof GramJs.PhoneCallRequested) { + phoneCall = { + ...phoneCall, + state: 'requested', + gAHash: Array.from(call.gAHash), + }; + } + + return phoneCall; +} + +export function buildApiCallDiscardReason(discardReason?: GramJs.TypePhoneCallDiscardReason) { + if (discardReason instanceof GramJs.PhoneCallDiscardReasonMissed) { + return 'missed'; + } else if (discardReason instanceof GramJs.PhoneCallDiscardReasonBusy) { + return 'busy'; + } else if (discardReason instanceof GramJs.PhoneCallDiscardReasonHangup) { + return 'hangup'; + } else { + return 'disconnect'; + } +} + +function buildApiCallConnection(connection: GramJs.TypePhoneConnection): ApiPhoneCallConnection | undefined { + if (connection instanceof GramJs.PhoneConnectionWebrtc) { + const { + username, password, turn, stun, ip, ipv6, port, + } = connection; + + return { + username, + password, + isTurn: turn, + isStun: stun, + ip, + ipv6, + port, + }; + } else { + return undefined; + } +} + +export function buildApiCallProtocol(protocol: GramJs.PhoneCallProtocol): ApiCallProtocol { + const { + libraryVersions, minLayer, maxLayer, udpP2p, udpReflector, + } = protocol; + + return { + libraryVersions, + minLayer, + maxLayer, + isUdpP2p: udpP2p, + isUdpReflector: udpReflector, + }; +} + +export function buildCallProtocol() { + return new GramJs.PhoneCallProtocol({ + libraryVersions: ['4.0.0'], + minLayer: 92, + maxLayer: 92, + udpReflector: true, + udpP2p: true, + }); +} diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 64bd4aff4..67ad93b59 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -30,6 +30,7 @@ import { ApiUser, ApiLocation, ApiGame, + PhoneCallAction, } from '../../types'; import { @@ -50,6 +51,7 @@ import { interpolateArray } from '../../../util/waveform'; import { buildPeer } from '../gramjsBuilders'; import { addPhotoToLocalDb, resolveMessageApiChatId, serializeBytes } from '../helpers'; import { buildApiPeerId, getApiChatIdFromMtpPeer, isPeerUser } from './peers'; +import { buildApiCallDiscardReason } from './calls'; const LOCAL_MEDIA_UPLOADING_TEMP_ID = 'temp'; const INPUT_WAVEFORM_LENGTH = 63; @@ -785,6 +787,7 @@ function buildAction( return undefined; } + let phoneCall: PhoneCallAction | undefined; let call: Partial | undefined; let amount: number | undefined; let currency: string | undefined; @@ -871,6 +874,13 @@ function buildAction( const mins = Math.max(Math.round(action.duration! / 60), 1); translationValues.push(`${mins} min${mins > 1 ? 's' : ''}`); } + + phoneCall = { + isOutgoing, + isVideo: action.video, + duration: action.duration, + reason: buildApiCallDiscardReason(action.reason), + }; } else if (action instanceof GramJs.MessageActionInviteToGroupCall) { text = 'Notification.VoiceChatInvitation'; call = { @@ -933,6 +943,7 @@ function buildAction( currency, translationValues, call, + phoneCall, score, }; } diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts index de5071d42..c091dd87a 100644 --- a/src/api/gramjs/gramjsBuilders/index.ts +++ b/src/api/gramjs/gramjsBuilders/index.ts @@ -11,7 +11,7 @@ import { ApiGroupCall, ApiMessageEntity, ApiMessageEntityTypes, - ApiNewPoll, + ApiNewPoll, ApiPhoneCall, ApiReportReason, ApiSendMessageAction, ApiSticker, @@ -465,3 +465,10 @@ export function buildInputGroupCall(groupCall: Partial) { accessHash: BigInt(groupCall.accessHash!), }); } + +export function buildInputPhoneCall({ id, accessHash }: ApiPhoneCall) { + return new GramJs.InputPhoneCall({ + id: BigInt(id), + accessHash: BigInt(accessHash!), + }); +} diff --git a/src/api/gramjs/methods/calls.ts b/src/api/gramjs/methods/calls.ts index ce22d7c0f..92e7fe040 100644 --- a/src/api/gramjs/methods/calls.ts +++ b/src/api/gramjs/methods/calls.ts @@ -1,14 +1,18 @@ -import { JoinGroupCallPayload } from '../../../lib/secret-sauce'; +import BigInt from 'big-integer'; +import type { JoinGroupCallPayload } from '../../../lib/secret-sauce'; import { - ApiChat, ApiUser, OnApiUpdate, ApiGroupCall, + ApiChat, ApiUser, OnApiUpdate, ApiGroupCall, ApiPhoneCall, } from '../../types'; import { Api as GramJs } from '../../../lib/gramjs'; import { invokeRequest } from './client'; -import { buildInputGroupCall, buildInputPeer, generateRandomInt } from '../gramjsBuilders'; import { + buildInputGroupCall, buildInputPeer, buildInputPhoneCall, generateRandomInt, +} from '../gramjsBuilders'; +import { + buildCallProtocol, buildApiGroupCall, - buildApiGroupCallParticipant, + buildApiGroupCallParticipant, buildPhoneCall, } from '../apiBuilders/calls'; import { buildApiUser } from '../apiBuilders/users'; @@ -234,3 +238,131 @@ export function leaveGroupCallPresentation({ call: buildInputGroupCall(call), }), true); } + +export async function getDhConfig() { + const dhConfig = await invokeRequest(new GramJs.messages.GetDhConfig({})); + + if (!dhConfig || dhConfig instanceof GramJs.messages.DhConfigNotModified) return undefined; + + return { + g: dhConfig.g, + p: Array.from(dhConfig.p), + random: Array.from(dhConfig.random), + }; +} + +export function discardCall({ + call, isBusy, +}: { + call: ApiPhoneCall; isBusy?: boolean; +}) { + return invokeRequest(new GramJs.phone.DiscardCall({ + peer: buildInputPhoneCall(call), + reason: isBusy ? new GramJs.PhoneCallDiscardReasonBusy() : new GramJs.PhoneCallDiscardReasonHangup(), + }), true); +} + +export async function requestCall({ + user, gAHash, isVideo, +}: { + user: ApiUser; gAHash: number[]; isVideo?: boolean; +}) { + const result = await invokeRequest(new GramJs.phone.RequestCall({ + randomId: generateRandomInt(), + userId: buildInputPeer(user.id, user.accessHash), + gAHash: Buffer.from(gAHash), + ...(isVideo && { video: true }), + protocol: buildCallProtocol(), + })); + + if (!result) { + return; + } + + const call = buildPhoneCall(result.phoneCall); + + onUpdate({ + '@type': 'updatePhoneCall', + call, + }); +} + +export function setCallRating({ + call, rating, comment, +}: { + call: ApiPhoneCall; rating: number; comment: string; +}) { + return invokeRequest(new GramJs.phone.SetCallRating({ + rating, + peer: buildInputPhoneCall(call), + comment, + }), true); +} + +export function receivedCall({ + call, +}: { + call: ApiPhoneCall; +}) { + return invokeRequest(new GramJs.phone.ReceivedCall({ + peer: buildInputPhoneCall(call), + })); +} + +export async function acceptCall({ + call, gB, +}: { + call: ApiPhoneCall; gB: number[]; +}) { + const result = await invokeRequest(new GramJs.phone.AcceptCall({ + peer: buildInputPhoneCall(call), + gB: Buffer.from(gB), + protocol: buildCallProtocol(), + })); + + if (!result) { + return; + } + + call = buildPhoneCall(result.phoneCall); + + onUpdate({ + '@type': 'updatePhoneCall', + call, + }); +} + +export async function confirmCall({ + call, gA, keyFingerprint, +}: { + call: ApiPhoneCall; gA: number[]; keyFingerprint: string; +}) { + const result = await invokeRequest(new GramJs.phone.ConfirmCall({ + peer: buildInputPhoneCall(call), + gA: Buffer.from(gA), + keyFingerprint: BigInt(keyFingerprint), + protocol: buildCallProtocol(), + })); + + if (!result) { + return; + } + + call = buildPhoneCall(result.phoneCall); + + onUpdate({ + '@type': 'updatePhoneCall', + call, + }); +} + +export function sendSignalingData({ + data, call, +}: { + data: number[]; call: ApiPhoneCall; +}) { + return invokeRequest(new GramJs.phone.SendSignalingData({ + data: Buffer.from(data), + peer: buildInputPhoneCall(call), + })); +} diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 2b021a711..692e9455d 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -70,6 +70,7 @@ export { getGroupCall, joinGroupCall, discardGroupCall, createGroupCall, editGroupCallTitle, editGroupCallParticipant, exportGroupCallInvite, fetchGroupCallParticipants, joinGroupCallPresentation, leaveGroupCall, leaveGroupCallPresentation, toggleGroupCallStartSubscription, + requestCall, getDhConfig, confirmCall, sendSignalingData, acceptCall, discardCall, setCallRating, receivedCall, } from './calls'; export { @@ -78,3 +79,8 @@ export { } from './reactions'; export { fetchChannelStatistics, fetchGroupStatistics, fetchStatisticsAsyncGraph } from './statistics'; + +export { + acceptPhoneCall, confirmPhoneCall, requestPhoneCall, decodePhoneCallData, createPhoneCallState, + destroyPhoneCallState, encodePhoneCallData, +} from './phoneCallState'; diff --git a/src/api/gramjs/methods/phoneCallState.ts b/src/api/gramjs/methods/phoneCallState.ts new file mode 100644 index 000000000..0cd2f8b6b --- /dev/null +++ b/src/api/gramjs/methods/phoneCallState.ts @@ -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; + + private resolveState?: VoidFunction; + + constructor( + private isOutgoing: boolean, + ) { + this.waitForState = new Promise((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 { + 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) { + currentPhoneCallState = new PhoneCallState(...params); +} + +export function destroyPhoneCallState() { + currentPhoneCallState = undefined; +} + +type FunctionPropertyOf = { + [P in keyof T]: T[P] extends Function + ? P + : never +}[keyof T]; + +type ParamsOf> = Parameters; +type ReturnTypeOf> = ReturnType; + +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); +} diff --git a/src/api/gramjs/updater.ts b/src/api/gramjs/updater.ts index 7b99ec2e7..787850ee6 100644 --- a/src/api/gramjs/updater.ts +++ b/src/api/gramjs/updater.ts @@ -1,4 +1,4 @@ -import { GroupCallConnectionData } from '../../lib/secret-sauce'; +import type { GroupCallConnectionData } from '../../lib/secret-sauce'; import { Api as GramJs, connection } from '../../lib/gramjs'; import { ApiMessage, ApiUpdateConnectionStateType, OnApiUpdate } from '../types'; @@ -44,7 +44,7 @@ import { buildApiNotifyException, buildPrivacyKey, buildPrivacyRules } from './a import { buildApiPhoto } from './apiBuilders/common'; import { buildApiGroupCall, - buildApiGroupCallParticipant, + buildApiGroupCallParticipant, buildPhoneCall, getGroupCallId, } from './apiBuilders/calls'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from './apiBuilders/peers'; @@ -897,6 +897,24 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) { recentRequesterIds: update.recentRequesters.map((id) => buildApiPeerId(id, 'user')), requestsPending: update.requestsPending, }); + } else if (update instanceof GramJs.UpdatePhoneCall) { + // eslint-disable-next-line no-underscore-dangle + const entities = update._entities; + if (entities) { + addEntitiesWithPhotosToLocalDb(entities); + dispatchUserAndChatUpdates(entities); + } + + onUpdate({ + '@type': 'updatePhoneCall', + call: buildPhoneCall(update.phoneCall), + }); + } else if (update instanceof GramJs.UpdatePhoneCallSignalingData) { + onUpdate({ + '@type': 'updatePhoneCallSignalingData', + callId: update.phoneCallId.toString(), + data: Array.from(update.data), + }); } else if (DEBUG) { const params = typeof update === 'object' && 'className' in update ? update.className : update; // eslint-disable-next-line no-console diff --git a/src/api/types/calls.ts b/src/api/types/calls.ts index 7f2a6dc3c..5541bd540 100644 --- a/src/api/types/calls.ts +++ b/src/api/types/calls.ts @@ -1,4 +1,9 @@ -import { GroupCallParticipant, GroupCallConnectionState } from '../../lib/secret-sauce'; +import type { + GroupCallParticipant, + GroupCallConnectionState, + ApiPhoneCallConnection, + ApiCallProtocol, VideoState, VideoRotation, +} from '../../lib/secret-sauce'; export interface ApiGroupCall { chatId?: string; @@ -24,3 +29,45 @@ export interface ApiGroupCall { connectionState: GroupCallConnectionState; isSpeakerDisabled?: boolean; } + +export interface PhoneCallAction { + isOutgoing: boolean; + isVideo?: boolean; + duration?: number; + reason?: 'missed' | 'disconnect' | 'hangup' | 'busy'; +} + +export interface ApiPhoneCall { + state?: 'active' | 'waiting' | 'discarded' | 'requested' | 'accepted' | 'requesting'; + isConnected?: boolean; + id: string; + accessHash?: string; + adminId?: string; + participantId?: string; + isVideo?: boolean; + date?: number; + startDate?: number; + receiveDate?: number; + p2pAllowed?: boolean; + connections?: ApiPhoneCallConnection[]; + protocol?: ApiCallProtocol; + needRating?: boolean; + needDebug?: boolean; + reason?: 'missed' | 'disconnect' | 'hangup' | 'busy'; + duration?: number; + + emojis?: string; + gA?: number[]; + gB?: number[]; + pLast?: number[]; + randomLast?: number[]; + gAOrB?: number[]; + gAHash?: number[]; + keyFingerprint?: string; + + isMuted?: boolean; + videoState?: VideoState; + videoRotation?: VideoRotation; + screencastState?: VideoState; + isBatteryLow?: boolean; +} diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 6068ba2cd..c7b065ece 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -1,4 +1,4 @@ -import { ApiGroupCall } from './calls'; +import { ApiGroupCall, PhoneCallAction } from './calls'; export interface ApiDimensions { width: number; @@ -203,6 +203,7 @@ export interface ApiAction { currency?: string; translationValues: string[]; call?: Partial; + phoneCall?: PhoneCallAction; score?: number; } diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index 3aab76bb1..662f6f7a5 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -1,4 +1,9 @@ -import { GroupCallConnectionData, GroupCallParticipant, GroupCallConnectionState } from '../../lib/secret-sauce'; +import type { + GroupCallConnectionData, + GroupCallParticipant, + GroupCallConnectionState, + VideoState, VideoRotation, +} from '../../lib/secret-sauce'; import { ApiChat, ApiChatFullInfo, @@ -15,7 +20,7 @@ import { ApiError, ApiInviteInfo, ApiNotifyException, ApiSessionData, } from './misc'; import { - ApiGroupCall, + ApiGroupCall, ApiPhoneCall, } from './calls'; export type ApiUpdateReady = { @@ -456,6 +461,31 @@ export type ApiUpdateGroupCallConnectionState = { isSpeakerDisabled?: boolean; }; +export type ApiUpdatePhoneCall = { + '@type': 'updatePhoneCall'; + call: ApiPhoneCall; +}; + +export type ApiUpdatePhoneCallSignalingData = { + '@type': 'updatePhoneCallSignalingData'; + callId: string; + data: number[]; +}; + +export type ApiUpdatePhoneCallMediaState = { + '@type': 'updatePhoneCallMediaState'; + isMuted: boolean; + videoState: VideoState; + videoRotation: VideoRotation; + screencastState: VideoState; + isBatteryLow: boolean; +}; + +export type ApiUpdatePhoneCallConnectionState = { + '@type': 'updatePhoneCallConnectionState'; + connectionState: RTCPeerConnectionState; +}; + export type ApiUpdate = ( ApiUpdateReady | ApiUpdateSession | ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser | @@ -476,7 +506,9 @@ export type ApiUpdate = ( ApiUpdateServerTimeOffset | ApiUpdateShowInvite | ApiUpdateMessageReactions | ApiUpdateGroupCallParticipants | ApiUpdateGroupCallConnection | ApiUpdateGroupCall | ApiUpdateGroupCallStreams | ApiUpdateGroupCallConnectionState | ApiUpdateGroupCallLeavePresentation | ApiUpdateGroupCallChatId | - ApiUpdatePendingJoinRequests | ApiUpdatePaymentVerificationNeeded | ApiUpdatePaymentStateCompleted + ApiUpdatePendingJoinRequests | ApiUpdatePaymentVerificationNeeded | ApiUpdatePaymentStateCompleted | + ApiUpdatePhoneCall | ApiUpdatePhoneCallSignalingData | ApiUpdatePhoneCallMediaState | + ApiUpdatePhoneCallConnectionState ); export type OnApiUpdate = (update: ApiUpdate) => void; diff --git a/src/assets/call-fallback-avatar.png b/src/assets/call-fallback-avatar.png deleted file mode 100644 index 3a850f2ee..000000000 Binary files a/src/assets/call-fallback-avatar.png and /dev/null differ diff --git a/src/assets/fonts/icomoon.woff b/src/assets/fonts/icomoon.woff index e3c35824c..fce9bb749 100644 Binary files a/src/assets/fonts/icomoon.woff and b/src/assets/fonts/icomoon.woff differ diff --git a/src/assets/fonts/icomoon.woff2 b/src/assets/fonts/icomoon.woff2 index 16cbbc484..cdaca717b 100644 Binary files a/src/assets/fonts/icomoon.woff2 and b/src/assets/fonts/icomoon.woff2 differ diff --git a/src/bundles/calls.ts b/src/bundles/calls.ts index a33bd663f..41e31602b 100644 --- a/src/bundles/calls.ts +++ b/src/bundles/calls.ts @@ -1,3 +1,4 @@ export { default as GroupCall } from '../components/calls/group/GroupCall'; export { default as ActiveCallHeader } from '../components/calls/ActiveCallHeader'; -export { default as CallFallbackConfirm } from '../components/calls/CallFallbackConfirm'; +export { default as PhoneCall } from '../components/calls/phone/PhoneCall'; +export { default as RatePhoneCallModal } from '../components/calls/phone/RatePhoneCallModal'; diff --git a/src/components/calls/ActiveCallHeader.async.tsx b/src/components/calls/ActiveCallHeader.async.tsx index a0f4eaf0a..30d74dd35 100644 --- a/src/components/calls/ActiveCallHeader.async.tsx +++ b/src/components/calls/ActiveCallHeader.async.tsx @@ -3,12 +3,12 @@ import useModuleLoader from '../../hooks/useModuleLoader'; import { Bundles } from '../../util/moduleLoader'; type OwnProps = { - groupCallId?: string; + isActive?: boolean; }; const ActiveCallHeaderAsync: FC = (props) => { - const { groupCallId } = props; - const ActiveCallHeader = useModuleLoader(Bundles.Calls, 'ActiveCallHeader', !groupCallId); + const { isActive } = props; + const ActiveCallHeader = useModuleLoader(Bundles.Calls, 'ActiveCallHeader', !isActive); return ActiveCallHeader ? : undefined; }; diff --git a/src/components/calls/ActiveCallHeader.tsx b/src/components/calls/ActiveCallHeader.tsx index 5d3be4d3b..54c68a588 100644 --- a/src/components/calls/ActiveCallHeader.tsx +++ b/src/components/calls/ActiveCallHeader.tsx @@ -1,51 +1,50 @@ -import { GroupCallParticipant } from '../../lib/secret-sauce'; import React, { FC, memo, useEffect, } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; -import { ApiGroupCall } from '../../api/types'; +import { ApiGroupCall, ApiUser } from '../../api/types'; -import { selectActiveGroupCall, selectGroupCallParticipant } from '../../global/selectors/calls'; +import { selectActiveGroupCall, selectPhoneCallUser } from '../../global/selectors/calls'; import buildClassName from '../../util/buildClassName'; import useLang from '../../hooks/useLang'; import './ActiveCallHeader.scss'; type StateProps = { - isGroupCallPanelHidden?: boolean; - meParticipant: GroupCallParticipant; + isCallPanelVisible?: boolean; groupCall?: ApiGroupCall; + phoneCallUser?: ApiUser; }; const ActiveCallHeader: FC = ({ groupCall, - meParticipant, - isGroupCallPanelHidden, + phoneCallUser, + isCallPanelVisible, }) => { const { toggleGroupCallPanel } = getActions(); const lang = useLang(); useEffect(() => { - document.body.classList.toggle('has-group-call-header', isGroupCallPanelHidden); + document.body.classList.toggle('has-call-header', Boolean(isCallPanelVisible)); return () => { - document.body.classList.toggle('has-group-call-header', false); + document.body.classList.toggle('has-call-header', false); }; - }, [isGroupCallPanelHidden]); + }, [isCallPanelVisible]); - if (!groupCall || !meParticipant) return undefined; + if (!groupCall && !phoneCallUser) return undefined; return (
- {groupCall.title || lang('VoipGroupVoiceChat')} + {phoneCallUser?.firstName || groupCall?.title || lang('VoipGroupVoiceChat')}
); }; @@ -54,8 +53,8 @@ export default memo(withGlobal( (global): StateProps => { return { groupCall: selectActiveGroupCall(global), - isGroupCallPanelHidden: global.groupCalls.isGroupCallPanelHidden, - meParticipant: selectGroupCallParticipant(global, global.groupCalls.activeGroupCallId!, global.currentUserId!), + isCallPanelVisible: global.isCallPanelVisible, + phoneCallUser: selectPhoneCallUser(global), }; }, )(ActiveCallHeader)); diff --git a/src/components/calls/CallFallbackConfirm.async.tsx b/src/components/calls/CallFallbackConfirm.async.tsx deleted file mode 100644 index c106cd23f..000000000 --- a/src/components/calls/CallFallbackConfirm.async.tsx +++ /dev/null @@ -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 = ({ isOpen }) => { - const CallFallbackConfirm = useModuleLoader(Bundles.Calls, 'CallFallbackConfirm', !isOpen); - - return CallFallbackConfirm ? : undefined; -}; - -export default memo(CallFallbackConfirmAsync); diff --git a/src/components/calls/CallFallbackConfirm.tsx b/src/components/calls/CallFallbackConfirm.tsx deleted file mode 100644 index 0b6a92716..000000000 --- a/src/components/calls/CallFallbackConfirm.tsx +++ /dev/null @@ -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 = ({ - 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 ( - -

The call will be started in a private channel {channelTitle}.

- -
- ); -}; - -export default memo(withGlobal( - (global): StateProps => { - const { chatId } = selectCurrentMessageList(global) || {}; - const user = chatId ? selectUser(global, chatId) : undefined; - - return { - userFullName: user ? getUserFullName(user) : undefined, - channelTitle: selectCallFallbackChannelTitle(global), - }; - }, -)(CallFallbackConfirm)); diff --git a/src/components/calls/group/GroupCall.tsx b/src/components/calls/group/GroupCall.tsx index 71334a4e5..4e00c2fd7 100644 --- a/src/components/calls/group/GroupCall.tsx +++ b/src/components/calls/group/GroupCall.tsx @@ -47,7 +47,7 @@ export type OwnProps = { }; type StateProps = { - isGroupCallPanelHidden: boolean; + isCallPanelVisible: boolean; connectionState: GroupCallConnectionState; title?: string; meParticipant?: TypeGroupCallParticipant; @@ -59,7 +59,7 @@ type StateProps = { const GroupCall: FC = ({ groupCallId, - isGroupCallPanelHidden, + isCallPanelVisible, connectionState, isSpeakerEnabled, title, @@ -246,7 +246,7 @@ const GroupCall: FC = ({ return ( = ({ > {IS_SCREENSHARE_SUPPORTED && !shouldRaiseHand && ( {lang(hasPresentation ? 'VoipChatStopScreenCapture' : 'VoipChatStartScreenCapture')} @@ -405,7 +405,7 @@ export default memo(withGlobal( isSpeakerEnabled: !isSpeakerDisabled, participantsCount, meParticipant: selectGroupCallParticipant(global, groupCallId, global.currentUserId!), - isGroupCallPanelHidden: Boolean(global.groupCalls.isGroupCallPanelHidden), + isCallPanelVisible: Boolean(global.isCallPanelVisible), isAdmin: selectIsAdminInActiveGroupCall(global), participants, }; diff --git a/src/components/calls/group/MicrophoneButton.tsx b/src/components/calls/group/MicrophoneButton.tsx index 63b5546ea..73b7ba143 100644 --- a/src/components/calls/group/MicrophoneButton.tsx +++ b/src/components/calls/group/MicrophoneButton.tsx @@ -51,7 +51,7 @@ const MicrophoneButton: FC = ({ useEffect(() => { if (prevShouldRaiseHand && !shouldRaiseHand) { - playGroupCallSound('allowTalk'); + playGroupCallSound({ sound: 'allowTalk' }); } }, [playGroupCallSound, prevShouldRaiseHand, shouldRaiseHand]); diff --git a/src/components/calls/phone/PhoneCall.async.tsx b/src/components/calls/phone/PhoneCall.async.tsx new file mode 100644 index 000000000..9122e5e92 --- /dev/null +++ b/src/components/calls/phone/PhoneCall.async.tsx @@ -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 = (props) => { + const { isActive } = props; + const PhoneCall = useModuleLoader(Bundles.Calls, 'PhoneCall', !isActive); + + return PhoneCall ? : undefined; +}; + +export default memo(PhoneCallAsync); diff --git a/src/components/calls/phone/PhoneCall.module.scss b/src/components/calls/phone/PhoneCall.module.scss new file mode 100644 index 000000000..55551473c --- /dev/null +++ b/src/components/calls/phone/PhoneCall.module.scss @@ -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); + } +} diff --git a/src/components/calls/phone/PhoneCall.tsx b/src/components/calls/phone/PhoneCall.tsx new file mode 100644 index 000000000..d8edf20fd --- /dev/null +++ b/src/components/calls/phone/PhoneCall.tsx @@ -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 = ({ + 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(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 ( + + + {phoneCall?.screencastState === 'active' && streams?.presentation + && + ); +}; + +export default memo(withGlobal( + (global): StateProps => { + const { phoneCall, currentUserId } = global; + + return { + isCallPanelVisible: Boolean(global.isCallPanelVisible), + user: selectPhoneCallUser(global), + isOutgoing: phoneCall?.adminId === currentUserId, + phoneCall, + }; + }, +)(PhoneCall)); diff --git a/src/components/calls/phone/PhoneCallButton.module.scss b/src/components/calls/phone/PhoneCallButton.module.scss new file mode 100644 index 000000000..12de885ae --- /dev/null +++ b/src/components/calls/phone/PhoneCallButton.module.scss @@ -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; +} diff --git a/src/components/calls/phone/PhoneCallButton.tsx b/src/components/calls/phone/PhoneCallButton.tsx new file mode 100644 index 000000000..050b744af --- /dev/null +++ b/src/components/calls/phone/PhoneCallButton.tsx @@ -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 = ({ + onClick, + label, + customIcon, + icon, + iconClassName, + className, + isDisabled, + isActive, +}) => { + return ( +
+ +
{label}
+
+ ); +}; + +export default memo(PhoneCallButton); diff --git a/src/components/calls/phone/RatePhoneCallModal.async.tsx b/src/components/calls/phone/RatePhoneCallModal.async.tsx new file mode 100644 index 000000000..14dae1537 --- /dev/null +++ b/src/components/calls/phone/RatePhoneCallModal.async.tsx @@ -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 = (props) => { + const { isOpen } = props; + const RatePhoneCallModal = useModuleLoader(Bundles.Calls, 'RatePhoneCallModal', !isOpen); + + // eslint-disable-next-line react/jsx-props-no-spreading + return RatePhoneCallModal ? : undefined; +}; + +export default memo(RatePhoneCallModalAsync); diff --git a/src/components/calls/phone/RatePhoneCallModal.module.scss b/src/components/calls/phone/RatePhoneCallModal.module.scss new file mode 100644 index 000000000..191fc7e12 --- /dev/null +++ b/src/components/calls/phone/RatePhoneCallModal.module.scss @@ -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; + } +} diff --git a/src/components/calls/phone/RatePhoneCallModal.tsx b/src/components/calls/phone/RatePhoneCallModal.tsx new file mode 100644 index 000000000..e4a922075 --- /dev/null +++ b/src/components/calls/phone/RatePhoneCallModal.tsx @@ -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 = ({ + isOpen, +}) => { + const { closeCallRatingModal, setCallRating } = getActions(); + + // eslint-disable-next-line no-null/no-null + const inputRef = useRef(null); + + const lang = useLang(); + const [rating, setRating] = useState(); + + 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 ( + +
+ {new Array(5).fill(undefined).map((_, i) => { + const isFilled = rating !== undefined && rating >= i; + return ( + + ); + })} +
+ + + {/* eslint-disable-next-line react/jsx-no-bind */} + + +
+ ); +}; + +export default memo(RatePhoneCallModal); diff --git a/src/components/common/Avatar.tsx b/src/components/common/Avatar.tsx index 285cf37ac..1047d7684 100644 --- a/src/components/common/Avatar.tsx +++ b/src/components/common/Avatar.tsx @@ -58,11 +58,12 @@ const Avatar: FC = ({ const isReplies = user && isChatWithRepliesBot(user.id); let imageHash: string | undefined; + const shouldFetchBig = size === 'jumbo'; if (!isSavedMessages && !isDeleted) { if (user) { - imageHash = getChatAvatarHash(user); + imageHash = getChatAvatarHash(user, shouldFetchBig ? 'big' : undefined); } else if (chat) { - imageHash = getChatAvatarHash(chat); + imageHash = getChatAvatarHash(chat, shouldFetchBig ? 'big' : undefined); } else if (photo) { imageHash = `photo${photo.id}?size=m`; } diff --git a/src/components/left/settings/SettingsMain.tsx b/src/components/left/settings/SettingsMain.tsx index 9c98377f8..f56d64b65 100644 --- a/src/components/left/settings/SettingsMain.tsx +++ b/src/components/left/settings/SettingsMain.tsx @@ -44,7 +44,7 @@ const SettingsMain: FC = ({ }, [lastSyncTime, profileId, loadProfilePhotos]); useHistoryBack(isActive, onReset, onScreenSelect, SettingsScreens.Main); - + useEffect(() => { if (lastSyncTime) { loadAuthorizations(); diff --git a/src/components/main/Main.scss b/src/components/main/Main.scss index 8a60ec532..ccf731ce2 100644 --- a/src/components/main/Main.scss +++ b/src/components/main/Main.scss @@ -18,8 +18,8 @@ } } -.has-group-call-header { - --group-call-header-height: 2rem; +.has-call-header { + --call-header-height: 2rem; #LeftColumn, #MiddleColumn, #RightColumn-wrapper { height: calc(100% - 2rem); margin-top: 2rem; diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index e63c33671..395af9aa4 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -49,8 +49,9 @@ import SafeLinkModal from './SafeLinkModal.async'; import HistoryCalendar from './HistoryCalendar.async'; import GroupCall from '../calls/group/GroupCall.async'; import ActiveCallHeader from '../calls/ActiveCallHeader.async'; -import CallFallbackConfirm from '../calls/CallFallbackConfirm.async'; +import PhoneCall from '../calls/phone/PhoneCall.async'; import NewContactModal from './NewContactModal.async'; +import RatePhoneCallModal from '../calls/phone/RatePhoneCallModal.async'; import './Main.scss'; @@ -74,12 +75,13 @@ type StateProps = { animationLevel: number; language?: LangCode; wasTimeFormatSetManually?: boolean; - isCallFallbackConfirmOpen: boolean; + isPhoneCallActive?: boolean; addedSetIds?: string[]; newContactUserId?: string; newContactByPhoneNumber?: boolean; openedGame?: GlobalState['openedGame']; gameTitle?: string; + isRatePhoneCallModalOpen?: boolean; }; const NOTIFICATION_INTERVAL = 1000; @@ -109,12 +111,13 @@ const Main: FC = ({ animationLevel, language, wasTimeFormatSetManually, - isCallFallbackConfirmOpen, addedSetIds, + isPhoneCallActive, newContactUserId, newContactByPhoneNumber, openedGame, gameTitle, + isRatePhoneCallModalOpen, }) => { const { sync, @@ -335,12 +338,8 @@ const Main: FC = ({ onClose={handleStickerSetModalClose} stickerSetShortName={openedStickerSetShortName} /> - {activeGroupCallId && ( - <> - - - - )} + {activeGroupCallId && } + = ({ /> - + + ); }; @@ -406,12 +406,13 @@ export default memo(withGlobal( animationLevel, language, wasTimeFormatSetManually, - isCallFallbackConfirmOpen: Boolean(global.groupCalls.isFallbackConfirmOpen), + isPhoneCallActive: Boolean(global.phoneCall), addedSetIds: global.stickers.added.setIds, newContactUserId: global.newContact?.userId, newContactByPhoneNumber: global.newContact?.isByPhoneNumber, openedGame, gameTitle, + isRatePhoneCallModalOpen: Boolean(global.ratingPhoneCall), }; }, )(Main)); diff --git a/src/components/middle/HeaderActions.tsx b/src/components/middle/HeaderActions.tsx index d0ad9ec3f..9d5c6b22e 100644 --- a/src/components/middle/HeaderActions.tsx +++ b/src/components/middle/HeaderActions.tsx @@ -84,10 +84,9 @@ const HeaderActions: FC = ({ sendBotCommand, openLocalTextSearch, restartBot, - openCallFallbackConfirm, + requestCall, requestNextManagementScreen, } = getActions(); - // eslint-disable-next-line no-null/no-null const menuButtonRef = useRef(null); const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -140,6 +139,10 @@ const HeaderActions: FC = ({ } }, [openLocalTextSearch]); + function handleRequestCall() { + requestCall({ userId: chatId }); + } + useEffect(() => { if (!canSearch) { return undefined; @@ -214,7 +217,8 @@ const HeaderActions: FC = ({ round color="translucent" size="smaller" - onClick={openCallFallbackConfirm} + // eslint-disable-next-line react/jsx-no-bind + onClick={handleRequestCall} ariaLabel="Call" > diff --git a/src/components/middle/HeaderMenuContainer.tsx b/src/components/middle/HeaderMenuContainer.tsx index 61f13eb09..92de5c404 100644 --- a/src/components/middle/HeaderMenuContainer.tsx +++ b/src/components/middle/HeaderMenuContainer.tsx @@ -110,10 +110,9 @@ const HeaderMenuContainer: FC = ({ createGroupCall, openLinkedChat, openAddContactDialog, - openCallFallbackConfirm, + requestCall, toggleStatistics, } = getActions(); - const [isMenuOpen, setIsMenuOpen] = useState(true); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const { x, y } = anchor; @@ -177,10 +176,15 @@ const HeaderMenuContainer: FC = ({ closeMenu(); }, [closeMenu, onSubscribeChannel]); - const handleCall = useCallback(() => { - openCallFallbackConfirm(); + const handleVideoCall = useCallback(() => { + requestCall({ userId: chatId, isVideo: true }); closeMenu(); - }, [closeMenu, openCallFallbackConfirm]); + }, [chatId, closeMenu, requestCall]); + + const handleCall = useCallback(() => { + requestCall({ userId: chatId }); + closeMenu(); + }, [chatId, closeMenu, requestCall]); const handleSearch = useCallback(() => { onSearchClick(); @@ -276,6 +280,14 @@ const HeaderMenuContainer: FC = ({ {lang('Call')}
)} + {canCall && ( + + {lang('VideoCall')} + + )} {IS_SINGLE_COLUMN_LAYOUT && canSearch && ( = ({ senderGroupIndex, senderGroupsArray, ) => { - if (senderGroup.length === 1 && !isAlbum(senderGroup[0]) && isActionMessage(senderGroup[0])) { + if ( + senderGroup.length === 1 + && !isAlbum(senderGroup[0]) + && isActionMessage(senderGroup[0]) + && !senderGroup[0].content.action?.phoneCall + ) { const message = senderGroup[0]; const isLastInList = ( senderGroupIndex === senderGroupsArray.length - 1 diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index dd4afaf6d..aab6a337c 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -114,6 +114,7 @@ import CommentButton from './CommentButton'; import Reactions from './Reactions'; import ReactionStaticEmoji from '../../common/ReactionStaticEmoji'; import LocalAnimatedEmoji from '../../common/LocalAnimatedEmoji'; +import MessagePhoneCall from './MessagePhoneCall'; import './Message.scss'; @@ -354,9 +355,6 @@ const Message: FC = ({ && forwardInfo.fromMessageId )); - const withCommentButton = threadInfo && !isInDocumentGroupNotLast && messageListType === 'thread' && !noComments; - const withQuickReactionButton = !IS_TOUCH_ENV && !isInSelectMode && defaultReaction && !isInDocumentGroupNotLast; - const selectMessage = useCallback((e?: React.MouseEvent, groupedId?: string) => { toggleMessageSelection({ messageId, @@ -462,9 +460,15 @@ const Message: FC = ({ ); const { - text, photo, video, audio, voice, document, sticker, contact, poll, webPage, invoice, location, game, + text, photo, video, audio, voice, document, sticker, contact, poll, webPage, invoice, location, action, game, } = getMessageContent(message); + const { phoneCall } = action || {}; + + const withCommentButton = threadInfo && !isInDocumentGroupNotLast && messageListType === 'thread' && !noComments; + const withQuickReactionButton = !IS_TOUCH_ENV && !phoneCall && !isInSelectMode && defaultReaction + && !isInDocumentGroupNotLast; + const contentClassName = buildContentClassName(message, { hasReply, customShape, @@ -482,7 +486,9 @@ const Message: FC = ({ const textParts = renderMessageText(message, highlight, isEmojiOnlyMessage(customShape)); let metaPosition!: MetaPosition; - if (isInDocumentGroupNotLast) { + if (phoneCall) { + metaPosition = 'none'; + } else if (isInDocumentGroupNotLast) { metaPosition = 'none'; } else if (textParts && !hasAnimatedEmoji && !webPage) { metaPosition = 'in-text'; @@ -680,6 +686,13 @@ const Message: FC = ({ onMediaClick={handleAlbumMediaClick} /> )} + {phoneCall && ( + + )} {!isAlbum && photo && ( = ({ + 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 ( +
+ +
+
{lang(reasonText)}
+
+ + + {duration ? lang('CallMessageWithDuration', [timeFormatted, duration]) : timeFormatted} + +
+
+
+ ); +}; + +export default memo(MessagePhoneCall); diff --git a/src/components/ui/FloatingActionButton.scss b/src/components/ui/FloatingActionButton.scss index 80c185275..b19b99ebb 100644 --- a/src/components/ui/FloatingActionButton.scss +++ b/src/components/ui/FloatingActionButton.scss @@ -2,7 +2,7 @@ position: absolute; right: 1rem; bottom: 1rem; - transform: translateY(calc(5rem - var(--group-call-header-height, 0rem))); + transform: translateY(calc(5rem - var(--call-header-height, 0rem))); transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); body.animation-level-0 & { @@ -10,6 +10,6 @@ } &.revealed { - transform: translateY(calc(0rem - var(--group-call-header-height, 0rem))); + transform: translateY(calc(0rem - var(--call-header-height, 0rem))); } } diff --git a/src/global/actions/api/calls.async.ts b/src/global/actions/api/calls.async.ts index 1cfb8c894..072724c07 100644 --- a/src/global/actions/api/calls.async.ts +++ b/src/global/actions/api/calls.async.ts @@ -5,87 +5,22 @@ import { leaveGroupCall, toggleStream, isStreamEnabled, - setVolume, - handleUpdateGroupCallParticipants, handleUpdateGroupCallConnection, + setVolume, stopPhoneCall, } from '../../../lib/secret-sauce'; import { GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config'; import { callApi } from '../../../api/gramjs'; -import { selectChat, selectCurrentMessageList, selectUser } from '../../selectors'; +import { selectChat, selectUser } from '../../selectors'; import { - selectActiveGroupCall, - selectCallFallbackChannelTitle, - selectGroupCallParticipant, + selectActiveGroupCall, selectPhoneCallUser, } from '../../selectors/calls'; import { removeGroupCall, updateActiveGroupCall, - updateGroupCall, - updateGroupCallParticipant, } from '../../reducers/calls'; -import { omit } from '../../../util/iteratees'; -import { getServerTime } from '../../../util/serverTime'; -import { fetchFile } from '../../../util/files'; import { getGroupCallAudioContext, getGroupCallAudioElement, removeGroupCallAudioElement } from '../ui/calls'; import { loadFullChat } from './chats'; -import callFallbackAvatarPath from '../../../assets/call-fallback-avatar.png'; - -const FALLBACK_INVITE_EXPIRE_SECONDS = 1800; // 30 min - -addActionHandler('apiUpdate', (global, actions, update) => { - const { activeGroupCallId } = global.groupCalls; - - switch (update['@type']) { - case 'updateGroupCallLeavePresentation': { - actions.toggleGroupCallPresentation({ value: false }); - break; - } - case 'updateGroupCallStreams': { - if (!update.userId || !activeGroupCallId) break; - if (!selectGroupCallParticipant(global, activeGroupCallId, update.userId)) break; - - return updateGroupCallParticipant(global, activeGroupCallId, update.userId, omit(update, ['@type', 'userId'])); - } - case 'updateGroupCallConnectionState': { - if (!activeGroupCallId) break; - - if (update.connectionState === 'disconnected') { - actions.leaveGroupCall({ isFromLibrary: true }); - break; - } - - return updateGroupCall(global, activeGroupCallId, { - connectionState: update.connectionState, - isSpeakerDisabled: update.isSpeakerDisabled, - }); - } - case 'updateGroupCallParticipants': { - const { groupCallId, participants } = update; - if (activeGroupCallId === groupCallId) { - void handleUpdateGroupCallParticipants(participants); - } - break; - } - case 'updateGroupCallConnection': { - if (update.data.stream) { - actions.showNotification({ message: 'Big live streams are not yet supported' }); - actions.leaveGroupCall(); - break; - } - void handleUpdateGroupCallConnection(update.data, update.presentation); - - const groupCall = selectActiveGroupCall(global); - if (groupCall?.participants && Object.keys(groupCall.participants).length > 0) { - void handleUpdateGroupCallParticipants(Object.values(groupCall.participants)); - } - break; - } - } - - return undefined; -}); - addActionHandler('leaveGroupCall', async (global, actions, payload) => { const { isFromLibrary, shouldDiscard, shouldRemove, rejoin, @@ -101,18 +36,7 @@ addActionHandler('leaveGroupCall', async (global, actions, payload) => { call: groupCall, }); - let shouldResetFallbackState = false; if (shouldDiscard) { - global = getGlobal(); - - if (global.groupCalls.fallbackChatId === groupCall.chatId) { - shouldResetFallbackState = true; - - global.groupCalls.fallbackUserIdsToRemove?.forEach((userId) => { - actions.deleteChatMember({ chatId: global.groupCalls.fallbackChatId, userId }); - }); - } - await callApi('discardGroupCall', { call: groupCall, }); @@ -129,13 +53,9 @@ addActionHandler('leaveGroupCall', async (global, actions, payload) => { ...global, groupCalls: { ...global.groupCalls, - isGroupCallPanelHidden: true, activeGroupCallId: undefined, - ...(shouldResetFallbackState && { - fallbackChatId: undefined, - fallbackUserIdsToRemove: undefined, - }), }, + isCallPanelVisible: undefined, }); if (!isFromLibrary) { @@ -292,79 +212,106 @@ addActionHandler('connectToActiveGroupCall', async (global, actions) => { } }); -addActionHandler('inviteToCallFallback', async (global, actions, payload) => { - const { chatId } = selectCurrentMessageList(global) || {}; - if (!chatId) { - return; - } +addActionHandler('connectToActivePhoneCall', async (global) => { + const { phoneCall } = global; - const user = selectUser(global, chatId); - if (!user) { - return; - } + if (!phoneCall) return; - const { shouldRemove } = payload; + const user = selectPhoneCallUser(global); - const fallbackChannelTitle = selectCallFallbackChannelTitle(global); + if (!user) return; - let fallbackChannel = Object.values(global.chats.byId).find((channel) => { - return ( - channel.title === fallbackChannelTitle - && channel.isCreator - && !channel.isRestricted - && !channel.isForbidden - ); - }); - if (!fallbackChannel) { - fallbackChannel = await callApi('createChannel', { - title: fallbackChannelTitle, - users: [user], - }); + const dhConfig = await callApi('getDhConfig'); - if (!fallbackChannel) { - return; - } + if (!dhConfig) return; - const photo = await fetchFile(callFallbackAvatarPath, 'avatar.png'); - void callApi('editChatPhoto', { - chatId: fallbackChannel.id, - accessHash: fallbackChannel.accessHash, - photo, - }); - } else { - actions.updateChatMemberBannedRights({ - chatId: fallbackChannel.id, - userId: chatId, - bannedRights: {}, - }); + await callApi('createPhoneCallState', [true]); - void callApi('addChatMembers', fallbackChannel, [user], true); - } + const gAHash = await callApi('requestPhoneCall', [dhConfig])!; - const inviteLink = await callApi('updatePrivateLink', { - chat: fallbackChannel, - usageLimit: 1, - expireDate: getServerTime(global.serverTimeOffset) + FALLBACK_INVITE_EXPIRE_SECONDS, - }); - if (!inviteLink) { - return; - } - - if (shouldRemove) { - global = getGlobal(); - const fallbackUserIdsToRemove = global.groupCalls.fallbackUserIdsToRemove || []; - setGlobal({ - ...global, - groupCalls: { - ...global.groupCalls, - fallbackChatId: fallbackChannel.id, - fallbackUserIdsToRemove: [...fallbackUserIdsToRemove, chatId], - }, - }); - } - - actions.sendMessage({ text: `Join a call: ${inviteLink}` }); - actions.openChat({ id: fallbackChannel.id }); - actions.createGroupCall({ chatId: fallbackChannel.id }); - actions.closeCallFallbackConfirm(); + await callApi('requestCall', { user, gAHash, isVideo: phoneCall.isVideo }); +}); + +addActionHandler('acceptCall', async (global) => { + const { phoneCall } = global; + + if (!phoneCall) return; + + const dhConfig = await callApi('getDhConfig'); + if (!dhConfig) return; + + await callApi('createPhoneCallState', [false]); + + const gB = await callApi('acceptPhoneCall', [dhConfig])!; + callApi('acceptCall', { call: phoneCall, gB }); +}); + +addActionHandler('sendSignalingData', (global, actions, payload) => { + const { phoneCall } = global; + if (!phoneCall) { + return; + } + + const data = JSON.stringify(payload); + + (async () => { + const encodedData = await callApi('encodePhoneCallData', [data]); + + if (!encodedData) return; + + callApi('sendSignalingData', { data: encodedData, call: phoneCall }); + })(); +}); + +addActionHandler('closeCallRatingModal', (global) => { + return { + ...global, + ratingPhoneCall: undefined, + }; +}); + +addActionHandler('setCallRating', (global, actions, payload) => { + const { ratingPhoneCall } = global; + if (!ratingPhoneCall) { + return undefined; + } + + const { rating, comment } = payload; + + callApi('setCallRating', { call: ratingPhoneCall, rating, comment }); + + return { + ...global, + ratingPhoneCall: undefined, + }; +}); + +addActionHandler('hangUp', (global) => { + const { phoneCall } = global; + + if (!phoneCall) return undefined; + + if (phoneCall.state === 'discarded') { + callApi('destroyPhoneCallState'); + stopPhoneCall(); + return { + ...global, + phoneCall: undefined, + isCallPanelVisible: undefined, + }; + } + + callApi('destroyPhoneCallState'); + stopPhoneCall(); + callApi('discardCall', { call: phoneCall }); + + if (phoneCall.state === 'requesting') { + return { + ...global, + phoneCall: undefined, + isCallPanelVisible: undefined, + }; + } + + return undefined; }); diff --git a/src/global/actions/apiUpdaters/calls.async.ts b/src/global/actions/apiUpdaters/calls.async.ts new file mode 100644 index 000000000..fc372782c --- /dev/null +++ b/src/global/actions/apiUpdaters/calls.async.ts @@ -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'; + }); +} diff --git a/src/global/actions/apiUpdaters/calls.ts b/src/global/actions/apiUpdaters/calls.ts index 7891232ae..8de1b42ae 100644 --- a/src/global/actions/apiUpdaters/calls.ts +++ b/src/global/actions/apiUpdaters/calls.ts @@ -3,6 +3,10 @@ import { removeGroupCall, updateGroupCall, updateGroupCallParticipant } from '.. import { omit } from '../../../util/iteratees'; import { selectChat } from '../../selectors'; import { updateChat } from '../../reducers'; +import { ARE_CALLS_SUPPORTED } from '../../../util/environment'; +import { notifyAboutCall } from '../../../util/notifications'; +import { selectPhoneCallUser } from '../../selectors/calls'; +import { initializeSoundsForSafari } from '../ui/calls'; addActionHandler('apiUpdate', (global, actions, update) => { switch (update['@type']) { @@ -54,6 +58,32 @@ addActionHandler('apiUpdate', (global, actions, update) => { } return global; } + case 'updatePhoneCall': { + if (!ARE_CALLS_SUPPORTED) return undefined; + + const { + phoneCall, + currentUserId, + } = global; + + if (phoneCall) return undefined; + + const { call } = update; + const isOutgoing = call?.adminId === currentUserId; + + if (!isOutgoing && call.state === 'requested') { + notifyAboutCall({ + call, + user: selectPhoneCallUser(global)!, + }); + void initializeSoundsForSafari(); + return { + ...global, + phoneCall: call, + isCallPanelVisible: false, + }; + } + } } return undefined; diff --git a/src/global/actions/calls.ts b/src/global/actions/calls.ts index 5828e6c0a..293c9c317 100644 --- a/src/global/actions/calls.ts +++ b/src/global/actions/calls.ts @@ -1 +1,2 @@ import './api/calls.async'; +import './apiUpdaters/calls.async'; diff --git a/src/global/actions/ui/calls.ts b/src/global/actions/ui/calls.ts index d457bb227..1f1c37a30 100644 --- a/src/global/actions/ui/calls.ts +++ b/src/global/actions/ui/calls.ts @@ -1,7 +1,7 @@ import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { selectActiveGroupCall, selectChatGroupCall, selectGroupCall } from '../../selectors/calls'; import { callApi } from '../../../api/gramjs'; -import { selectChat } from '../../selectors'; +import { selectChat, selectUser } from '../../selectors'; import { copyTextToClipboard } from '../../../util/clipboard'; import { ApiGroupCall } from '../../../api/types'; import { updateGroupCall } from '../../reducers/calls'; @@ -11,29 +11,43 @@ import { fetchChatByUsername, loadFullChat } from '../api/chats'; import safePlay from '../../../util/safePlay'; import { ARE_CALLS_SUPPORTED } from '../../../util/environment'; import * as langProvider from '../../../util/langProvider'; +import { CallSound } from '../../types'; // Workaround for Safari not playing audio without user interaction let audioElement: HTMLAudioElement | undefined; let audioContext: AudioContext | undefined; -const joinAudio = new Audio('./voicechat_join.mp3'); -const connectingAudio = new Audio('./voicechat_connecting.mp3'); -connectingAudio.loop = true; -const leaveAudio = new Audio('./voicechat_leave.mp3'); -const allowTalkAudio = new Audio('./voicechat_onallowtalk.mp3'); - -const sounds: Record = { - join: joinAudio, - allowTalk: allowTalkAudio, - leave: leaveAudio, - connecting: connectingAudio, -}; - +let sounds: Record; let initializationPromise: Promise | undefined = Promise.resolve(); -const initializeSoundsForSafari = () => { +export const initializeSoundsForSafari = () => { if (!initializationPromise) return Promise.resolve(); + const joinAudio = new Audio('./voicechat_join.mp3'); + const connectingAudio = new Audio('./voicechat_connecting.mp3'); + connectingAudio.loop = true; + const leaveAudio = new Audio('./voicechat_leave.mp3'); + const allowTalkAudio = new Audio('./voicechat_onallowtalk.mp3'); + const busyAudio = new Audio('./call_busy.mp3'); + const connectAudio = new Audio('./call_connect.mp3'); + const endAudio = new Audio('./call_end.mp3'); + const incomingAudio = new Audio('./call_incoming.mp3'); + incomingAudio.loop = true; + const ringingAudio = new Audio('./call_ringing.mp3'); + ringingAudio.loop = true; + + sounds = { + join: joinAudio, + allowTalk: allowTalkAudio, + leave: leaveAudio, + connecting: connectingAudio, + incoming: incomingAudio, + end: endAudio, + connect: connectAudio, + busy: busyAudio, + ringing: ringingAudio, + }; + initializationPromise = Promise.all(Object.values(sounds).map((l) => { l.muted = true; l.volume = 0.0001; @@ -95,10 +109,7 @@ async function fetchGroupCallParticipants(groupCall: Partial, next addActionHandler('toggleGroupCallPanel', (global) => { return { ...global, - groupCalls: { - ...global.groupCalls, - isGroupCallPanelHidden: !global.groupCalls.isGroupCallPanelHidden, - }, + isCallPanelVisible: !global.isCallPanelVisible, }; }); @@ -194,6 +205,11 @@ addActionHandler('joinVoiceChatByLink', async (global, actions, payload) => { addActionHandler('joinGroupCall', async (global, actions, payload) => { if (!ARE_CALLS_SUPPORTED) return undefined; + if (global.phoneCall) { + actions.toggleGroupCallPanel(); + return undefined; + } + const { chatId, id, accessHash, inviteHash, } = payload; @@ -246,8 +262,8 @@ addActionHandler('joinGroupCall', async (global, actions, payload) => { groupCalls: { ...global.groupCalls, activeGroupCallId: groupCall.id, - isGroupCallPanelHidden: false, }, + isCallPanelVisible: false, }; return global; }); @@ -259,15 +275,23 @@ addActionHandler('playGroupCallSound', (global, actions, payload) => { return; } - if (initializationPromise) { - initializationPromise.then(() => { - safePlay(sounds[sound]); - }); - } else { + const doPlay = () => { if (sound !== 'connecting') { sounds.connecting.pause(); } + if (sound !== 'incoming') { + sounds.incoming.pause(); + } + if (sound !== 'ringing') { + sounds.ringing.pause(); + } safePlay(sounds[sound]); + }; + + if (initializationPromise) { + initializationPromise.then(doPlay); + } else { + doPlay(); } }); @@ -280,6 +304,35 @@ addActionHandler('loadMoreGroupCallParticipants', (global) => { void fetchGroupCallParticipants(groupCall, groupCall.nextOffset); }); +addActionHandler('requestCall', async (global, actions, payload) => { + const { userId, isVideo } = payload; + + if (global.phoneCall) { + actions.toggleGroupCallPanel(); + return; + } + + const user = selectUser(global, userId); + + if (!user) { + return; + } + + await initializeSoundsForSafari(); + + setGlobal({ + ...getGlobal(), + phoneCall: { + id: '', + state: 'requesting', + participantId: userId, + isVideo, + adminId: global.currentUserId, + }, + isCallPanelVisible: false, + }); +}); + function createAudioContext() { return (new (window.AudioContext || (window as any).webkitAudioContext)()); } @@ -312,23 +365,3 @@ export function removeGroupCallAudioElement() { audioContext = undefined; audioElement = undefined; } - -addActionHandler('openCallFallbackConfirm', (global) => { - return { - ...global, - groupCalls: { - ...global.groupCalls, - isFallbackConfirmOpen: true, - }, - }; -}); - -addActionHandler('closeCallFallbackConfirm', (global) => { - return { - ...global, - groupCalls: { - ...global.groupCalls, - isFallbackConfirmOpen: false, - }, - }; -}); diff --git a/src/global/cache.ts b/src/global/cache.ts index 371c60116..2cb608a10 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -276,6 +276,7 @@ function updateCache() { chatFolders: reduceChatFolders(global), groupCalls: reduceGroupCalls(global), availableReactions: reduceAvailableReactions(global), + isCallPanelVisible: undefined, }; const json = JSON.stringify(reducedGlobal); @@ -389,8 +390,6 @@ function reduceGroupCalls(global: GlobalState): GlobalState['groupCalls'] { ...global.groupCalls, byId: {}, activeGroupCallId: undefined, - isGroupCallPanelHidden: undefined, - isFallbackConfirmOpen: undefined, }; } diff --git a/src/global/helpers/messages.ts b/src/global/helpers/messages.ts index 30a836cca..cda288a16 100644 --- a/src/global/helpers/messages.ts +++ b/src/global/helpers/messages.ts @@ -44,7 +44,8 @@ export function getMessageOriginalId(message: ApiMessage) { export function getMessageText(message: ApiMessage) { const { - text, sticker, photo, video, audio, voice, document, poll, webPage, contact, invoice, location, game, + text, sticker, photo, video, audio, voice, document, poll, webPage, contact, invoice, location, + game, action, } = message.content; if (text) { @@ -52,7 +53,7 @@ export function getMessageText(message: ApiMessage) { } if (sticker || photo || video || audio || voice || document - || contact || poll || webPage || invoice || location || game) { + || contact || poll || webPage || invoice || location || game || action?.phoneCall) { return undefined; } diff --git a/src/global/selectors/calls.ts b/src/global/selectors/calls.ts index c7e2f4c09..961edf5e0 100644 --- a/src/global/selectors/calls.ts +++ b/src/global/selectors/calls.ts @@ -1,6 +1,6 @@ import { GlobalState } from '../types'; import { selectChat } from './chats'; -import { getUserFullName, isChatBasicGroup } from '../helpers'; +import { isChatBasicGroup } from '../helpers'; import { selectUser } from './users'; export function selectChatGroupCall(global: GlobalState, chatId: string) { @@ -38,8 +38,12 @@ export function selectActiveGroupCall(global: GlobalState) { return selectGroupCall(global, activeGroupCallId); } -export function selectCallFallbackChannelTitle(global: GlobalState) { - const currentUser = selectUser(global, global.currentUserId!); +export function selectPhoneCallUser(global: GlobalState) { + const { phoneCall, currentUserId } = global; + if (!phoneCall || !phoneCall.participantId || !phoneCall.adminId) { + return undefined; + } - return `Calls: ${getUserFullName(currentUser!)}`; + const id = phoneCall.adminId === currentUserId ? phoneCall.participantId : phoneCall.adminId; + return selectUser(global, id); } diff --git a/src/global/types.ts b/src/global/types.ts index a10debb35..c54e12204 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -31,6 +31,7 @@ import { ApiPaymentFormNativeParams, ApiUpdate, ApiKeyboardButton, + ApiPhoneCall, } from '../api/types'; import { FocusDirection, @@ -58,6 +59,7 @@ import { ManagementState, } from '../types'; import { typify } from '../lib/teact/teactn'; +import type { P2pMessage } from '../lib/secret-sauce'; export type MessageListType = 'thread' @@ -200,12 +202,12 @@ export type GlobalState = { groupCalls: { byId: Record; activeGroupCallId?: string; - isGroupCallPanelHidden?: boolean; - isFallbackConfirmOpen?: boolean; - fallbackChatId?: string; - fallbackUserIdsToRemove?: string[]; }; + isCallPanelVisible?: boolean; + phoneCall?: ApiPhoneCall; + ratingPhoneCall?: ApiPhoneCall; + scheduledMessages: { byChatId: Record; @@ -541,6 +543,10 @@ export type GlobalState = { }; }; +export type CallSound = ( + 'join' | 'allowTalk' | 'leave' | 'connecting' | 'incoming' | 'end' | 'connect' | 'busy' | 'ringing' +); + export interface ActionPayloads { // Initial signOut: { forceInitApi?: boolean } | undefined; @@ -673,6 +679,24 @@ export interface ActionPayloads { isQuiz?: boolean; }; closePollModal: {}; + + // Calls + requestCall: { + userId: string; + isVideo?: boolean; + }; + sendSignalingData: P2pMessage; + hangUp: {}; + acceptCall: {}; + setCallRating: { + rating: number; + comment: string; + }; + closeCallRatingModal: {}; + playGroupCallSound: { + sound: CallSound; + }; + connectToActivePhoneCall: {}; } export type NonTypedActionNames = ( @@ -769,8 +793,7 @@ export type NonTypedActionNames = ( 'joinGroupCall' | 'toggleGroupCallMute' | 'toggleGroupCallPresentation' | 'leaveGroupCall' | 'toggleGroupCallVideo' | 'requestToSpeak' | 'setGroupCallParticipantVolume' | 'toggleGroupCallPanel' | 'createGroupCall' | 'joinVoiceChatByLink' | 'subscribeToGroupCallUpdates' | 'createGroupCallInviteLink' | - 'loadMoreGroupCallParticipants' | 'connectToActiveGroupCall' | 'playGroupCallSound' | - 'openCallFallbackConfirm' | 'closeCallFallbackConfirm' | 'inviteToCallFallback' | + 'loadMoreGroupCallParticipants' | 'connectToActiveGroupCall' | // stats 'loadStatistics' | 'loadStatisticsAsyncGraph' ); diff --git a/src/lib/gramjs/Helpers.js b/src/lib/gramjs/Helpers.js index 0306fddb8..f7e68a2e7 100644 --- a/src/lib/gramjs/Helpers.js +++ b/src/lib/gramjs/Helpers.js @@ -38,7 +38,6 @@ function toSignedLittleBuffer(big, number = 8) { return Buffer.from(byteArray); } - /** * converts a big int to a buffer * @param bigInt {bigInt.BigInteger} @@ -205,7 +204,6 @@ function sha1(data) { return shaSum.digest(); } - /** * Calculates the SHA256 digest for the given data * @param data @@ -241,10 +239,9 @@ function modExp(a, b, n) { return result; } - /** * Gets the arbitrary-length byte array corresponding to the given integer - * @param integer {number,BigInteger} + * @param integer {any} * @param signed {boolean} * @returns {Buffer} */ diff --git a/src/lib/gramjs/network/MTProtoState.js b/src/lib/gramjs/network/MTProtoState.js index 21e7a5411..b45b6d06b 100644 --- a/src/lib/gramjs/network/MTProtoState.js +++ b/src/lib/gramjs/network/MTProtoState.js @@ -1,4 +1,5 @@ const BigInt = require('big-integer'); +const aes = require('@cryptography/aes'); const Helpers = require('../Helpers'); const IGE = require('../crypto/IGE'); @@ -39,10 +40,14 @@ class MTProtoState { authentication process, at which point the `MTProtoPlainSender` is better * @param authKey * @param loggers + * @param isCall + * @param isOutgoing */ - constructor(authKey, loggers) { + constructor(authKey, loggers, isCall = false, isOutgoing = false) { this.authKey = authKey; this._log = loggers; + this._isCall = isCall; + this._isOutgoing = isOutgoing; this.timeOffset = 0; this.salt = 0; @@ -81,12 +86,20 @@ class MTProtoState { * @returns {{iv: Buffer, key: Buffer}} */ async _calcKey(authKey, msgKey, client) { - const x = client === true ? 0 : 8; + const x = (this._isCall ? 128 + ((this._isOutgoing ^ client) ? 8 : 0) : (client === true ? 0 : 8)); const [sha256a, sha256b] = await Promise.all([ Helpers.sha256(Buffer.concat([msgKey, authKey.slice(x, x + 36)])), Helpers.sha256(Buffer.concat([authKey.slice(x + 40, x + 76), msgKey])), ]); const key = Buffer.concat([sha256a.slice(0, 8), sha256b.slice(8, 24), sha256a.slice(24, 32)]); + if (this._isCall) { + const iv = Buffer.concat([sha256b.slice(0, 4), sha256a.slice(8, 16), sha256b.slice(24, 28)]); + + return { + key, + iv, + }; + } const iv = Buffer.concat([sha256b.slice(0, 8), sha256a.slice(8, 24), sha256b.slice(24, 32)]); return { key, @@ -133,24 +146,48 @@ class MTProtoState { */ async encryptMessageData(data) { await this.authKey.waitForKey(); - const s = toSignedLittleBuffer(this.salt, 8); - const i = toSignedLittleBuffer(this.id, 8); - data = Buffer.concat([Buffer.concat([s, i]), data]); - const padding = Helpers.generateRandomBytes(Helpers.mod(-(data.length + 12), 16) + 12); - // Being substr(what, offset, length); x = 0 for client - // "msg_key_large = SHA256(substr(auth_key, 88+x, 32) + pt + padding)" - const msgKeyLarge = await Helpers.sha256(Buffer.concat([this.authKey.getKey() - .slice(88, 88 + 32), data, padding])); - // "msg_key = substr (msg_key_large, 8, 16)" - const msgKey = msgKeyLarge.slice(8, 24); + if (this._isCall) { + const x = 128 + (this._isOutgoing ? 0 : 8); + const lengthStart = data.length; - const { - iv, - key, - } = await this._calcKey(this.authKey.getKey(), msgKey, true); + data = Buffer.from(data); + if (lengthStart % 4 !== 0) { + data = Buffer.concat([data, Buffer.from(new Array(4 - (lengthStart % 4)).fill(0x20))]); + } - const keyId = Helpers.readBufferFromBigInt(this.authKey.keyId, 8); - return Buffer.concat([keyId, msgKey, new IGE(key, iv).encryptIge(Buffer.concat([data, padding]))]); + const msgKeyLarge = await Helpers.sha256(Buffer.concat([this.authKey.getKey() + .slice(88 + x, 88 + x + 32), Buffer.from(data)])); + + const msgKey = msgKeyLarge.slice(8, 24); + + const { + iv, + key, + } = await this._calcKey(this.authKey.getKey(), msgKey, true); + + data = Helpers.convertToLittle(new aes.CTR(key, iv).encrypt(data)); + // data = data.slice(0, lengthStart) + return Buffer.concat([msgKey, data]); + } else { + const s = toSignedLittleBuffer(this.salt, 8); + const i = toSignedLittleBuffer(this.id, 8); + data = Buffer.concat([Buffer.concat([s, i]), data]); + const padding = Helpers.generateRandomBytes(Helpers.mod(-(data.length + 12), 16) + 12); + // Being substr(what, offset, length); x = 0 for client + // "msg_key_large = SHA256(substr(auth_key, 88+x, 32) + pt + padding)" + const msgKeyLarge = await Helpers.sha256(Buffer.concat([this.authKey.getKey() + .slice(88, 88 + 32), data, padding])); + // "msg_key = substr (msg_key_large, 8, 16)" + const msgKey = msgKeyLarge.slice(8, 24); + + const { + iv, + key, + } = await this._calcKey(this.authKey.getKey(), msgKey, true); + + const keyId = Helpers.readBufferFromBigInt(this.authKey.keyId, 8); + return Buffer.concat([keyId, msgKey, new IGE(key, iv).encryptIge(Buffer.concat([data, padding]))]); + } } /** @@ -164,65 +201,87 @@ class MTProtoState { if (body.length < 0) { // length needs to be positive throw new SecurityError('Server replied with negative length'); } - if (body.length % 4 !== 0) { + if (body.length % 4 !== 0 && !this._isCall) { throw new SecurityError('Server replied with length not divisible by 4'); } // TODO Check salt,sessionId, and sequenceNumber - const keyId = Helpers.readBigIntFromBuffer(body.slice(0, 8)); - if (keyId.neq(this.authKey.keyId)) { - throw new SecurityError('Server replied with an invalid auth key'); - } + if (!this._isCall) { + const keyId = Helpers.readBigIntFromBuffer(body.slice(0, 8)); - const msgKey = body.slice(8, 24); + if (keyId.neq(this.authKey.keyId)) { + throw new SecurityError('Server replied with an invalid auth key'); + } + } + const msgKey = this._isCall ? body.slice(0, 16) : body.slice(8, 24); + + const x = this._isCall ? 128 + (this.isOutgoing ? 8 : 0) : undefined; const { iv, key, } = await this._calcKey(this.authKey.getKey(), msgKey, false); - body = new IGE(key, iv).decryptIge(body.slice(24)); + if (this._isCall) { + body = body.slice(16); + const lengthStart = body.length; + + body = Buffer.concat([body, Buffer.from(new Array(4 - (lengthStart % 4)).fill(0))]); + + body = Helpers.convertToLittle(new aes.CTR(key, iv).decrypt(body)); + + body = body.slice(0, lengthStart); + } else { + body = new IGE(key, iv).decryptIge(this._isCall ? body.slice(16) : body.slice(24)); + } // https://core.telegram.org/mtproto/security_guidelines // Sections "checking sha256 hash" and "message length" - const ourKey = await Helpers.sha256(Buffer.concat([this.authKey.getKey() - .slice(96, 96 + 32), body])); + const ourKey = this._isCall + ? await Helpers.sha256(Buffer.concat([this.authKey.getKey() + .slice(88 + x, 88 + x + 32), body])) + : await Helpers.sha256(Buffer.concat([this.authKey.getKey() + .slice(96, 96 + 32), body])); - if (!msgKey.equals(ourKey.slice(8, 24))) { + if (!this._isCall && !msgKey.equals(ourKey.slice(8, 24))) { throw new SecurityError('Received msg_key doesn\'t match with expected one'); } - const reader = new BinaryReader(body); - reader.readLong(); // removeSalt - const serverId = reader.readLong(); - if (!serverId.eq(this.id)) { - throw new SecurityError('Server replied with a wrong session ID'); - } - const remoteMsgId = reader.readLong(); - // if we get a duplicate message id we should ignore it. - if (this.msgIds.includes(remoteMsgId.toString())) { - throw new SecurityError('Duplicate msgIds'); - } - // we only store the latest 500 message ids from the server - if (this.msgIds.length > 500) { - this.msgIds.shift(); - } - this.msgIds.push(remoteMsgId.toString()); + if (this._isCall) { + // Seq + reader.readInt(false); + return reader.read(body.length - 4); + } else { + reader.readLong(); // removeSalt + const serverId = reader.readLong(); + if (!serverId.eq(this.id)) { + throw new SecurityError('Server replied with a wrong session ID'); + } - const remoteSequence = reader.readInt(); - const containerLen = reader.readInt(); // msgLen for the inner object, padding ignored - const diff = body.length - containerLen; - // We want to check if it's between 12 and 1024 - // https://core.telegram.org/mtproto/security_guidelines#checking-message-length - if (diff < 12 || diff > 1024) { - throw new SecurityError('Server replied with the wrong message padding'); + const remoteMsgId = reader.readLong(); + // if we get a duplicate message id we should ignore it. + if (this.msgIds.includes(remoteMsgId.toString())) { + throw new SecurityError('Duplicate msgIds'); + } + // we only store the latest 500 message ids from the server + if (this.msgIds.length > 500) { + this.msgIds.shift(); + } + this.msgIds.push(remoteMsgId.toString());const remoteSequence = reader.readInt(); + const containerLen = reader.readInt(); // msgLen for the inner object, padding ignored + const diff = body.length - containerLen; + // We want to check if it's between 12 and 1024 + // https://core.telegram.org/mtproto/security_guidelines#checking-message-length + if (diff < 12 || diff > 1024) { + throw new SecurityError('Server replied with the wrong message padding'); + } + + // We could read msg_len bytes and use those in a new reader to read + // the next TLObject without including the padding, but since the + // reader isn't used for anything else after this, it's unnecessary. + const obj = reader.tgReadObject(); + + return new TLMessage(remoteMsgId, remoteSequence, obj); } - - // We could read msg_len bytes and use those in a new reader to read - // the next TLObject without including the padding, but since the - // reader isn't used for anything else after this, it's unnecessary. - const obj = reader.tgReadObject(); - - return new TLMessage(remoteMsgId, remoteSequence, obj); } /** diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 3cf7ae3bb..16866f1c7 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -1154,6 +1154,14 @@ payments.getPaymentReceipt#2478d1cc peer:InputPeer msg_id:int = payments.Payment payments.validateRequestedInfo#db103170 flags:# save:flags.0?true peer:InputPeer msg_id:int info:PaymentRequestedInfo = payments.ValidatedRequestedInfo; payments.sendPaymentForm#30c3bc9d flags:# form_id:long peer:InputPeer msg_id:int requested_info_id:flags.0?string shipping_option_id:flags.1?string credentials:InputPaymentCredentials tip_amount:flags.2?long = payments.PaymentResult; payments.getSavedInfo#227d824b = payments.SavedInfo; +phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall; +phone.acceptCall#3bd2b4a0 peer:InputPhoneCall g_b:bytes protocol:PhoneCallProtocol = phone.PhoneCall; +phone.confirmCall#2efe1722 peer:InputPhoneCall g_a:bytes key_fingerprint:long protocol:PhoneCallProtocol = phone.PhoneCall; +phone.receivedCall#17d54f61 peer:InputPhoneCall = Bool; +phone.discardCall#b2cbc1c0 flags:# video:flags.0?true peer:InputPhoneCall duration:int reason:PhoneCallDiscardReason connection_id:long = Updates; +phone.setCallRating#59ead627 flags:# user_initiative:flags.0?true peer:InputPhoneCall rating:int comment:string = Updates; +phone.saveCallDebug#277add7e peer:InputPhoneCall debug:DataJSON = Bool; +phone.sendSignalingData#ff7a9383 peer:InputPhoneCall data:bytes = Bool; phone.createGroupCall#48cdc6d8 flags:# rtmp_stream:flags.2?true peer:InputPeer random_id:int title:flags.0?string schedule_date:flags.1?int = Updates; phone.joinGroupCall#b132ff7b flags:# muted:flags.0?true video_stopped:flags.2?true call:InputGroupCall join_as:InputPeer invite_hash:flags.1?string params:DataJSON = Updates; phone.leaveGroupCall#500377f9 call:InputGroupCall source:int = Updates; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index aeef75df6..09840e2fb 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -203,6 +203,14 @@ "phone.toggleGroupCallStartSubscription", "phone.joinGroupCallPresentation", "phone.leaveGroupCallPresentation", + "phone.requestCall", + "phone.acceptCall", + "phone.confirmCall", + "phone.receivedCall", + "phone.discardCall", + "phone.setCallRating", + "phone.saveCallDebug", + "phone.sendSignalingData", "messages.sendReaction", "messages.getMessagesReactions", "messages.getMessageReactionsList", diff --git a/src/lib/secret-sauce/buildSdp.d.ts b/src/lib/secret-sauce/buildSdp.d.ts index f2e1d31f8..ea2c1c99f 100644 --- a/src/lib/secret-sauce/buildSdp.d.ts +++ b/src/lib/secret-sauce/buildSdp.d.ts @@ -17,5 +17,5 @@ export declare type Ssrc = { isPresentation?: boolean; sourceGroups: SsrcGroup[]; }; -declare const _default: (conference: Conference, isAnswer?: boolean, isPresentation?: boolean) => string; +declare const _default: (conference: Conference, isAnswer?: boolean, isPresentation?: boolean, isP2p?: boolean) => string; export default _default; diff --git a/src/lib/secret-sauce/index.d.ts b/src/lib/secret-sauce/index.d.ts index f887e1781..098fe6c24 100644 --- a/src/lib/secret-sauce/index.d.ts +++ b/src/lib/secret-sauce/index.d.ts @@ -1,3 +1,5 @@ export { handleUpdateGroupCallConnection, startSharingScreen, joinGroupCall, getDevices, getUserStreams, setVolume, isStreamEnabled, toggleStream, leaveGroupCall, handleUpdateGroupCallParticipants, switchCameraInput, toggleSpeaker, toggleNoiseSuppression, } from './secretsauce'; +export { joinPhoneCall, processSignalingMessage, getStreams, toggleStreamP2p, stopPhoneCall, switchCameraInputP2p, } from './p2p'; +export * from './p2pMessage'; export { IS_SCREENSHARE_SUPPORTED, THRESHOLD, } from './utils'; export * from './types'; diff --git a/src/lib/secret-sauce/index.js b/src/lib/secret-sauce/index.js index 5bb934341..42361cd8f 100644 --- a/src/lib/secret-sauce/index.js +++ b/src/lib/secret-sauce/index.js @@ -1,2 +1,2 @@ /*! For license information please see index.js.LICENSE.txt */ -(()=>{"use strict";var e={"./src/blacksilence.ts":(e,t,n)=>{n.r(t),n.d(t,{silence:()=>a,black:()=>i});const a=e=>{const t=e.createOscillator(),n=t.connect(e.createMediaStreamDestination());return t.start(),new MediaStream([Object.assign(n.stream.getAudioTracks()[0],{enabled:!1})])},i=({width:e=640,height:t=480}={})=>{const n=Object.assign(document.createElement("canvas"),{width:e,height:t}),a=n.getContext("2d");if(!a)throw Error("Cannot create canvas ctx");a.fillRect(0,0,e,t);const i=n.captureStream();return new MediaStream([Object.assign(i.getVideoTracks()[0],{enabled:!1})])}},"./src/buildSdp.ts":(e,t,n)=>{n.r(t),n.d(t,{default:()=>i});var a=n("./src/utils.ts");const i=(e,t=!1,n=!1)=>{const i=[],r=e=>{i.push(e)},{sessionId:s,ssrcs:o,audioExtensions:c,videoExtensions:d,audioPayloadTypes:p,videoPayloadTypes:u,transport:{ufrag:l,pwd:m,fingerprints:f,candidates:g}}=e;r("v=0"),r(`o=- ${s} 2 IN IP4 0.0.0.0`),r("s=-"),r("t=0 0"),r(`a=group:BUNDLE ${o.map((e=>e.endpoint)).join(" ")}${n?"":" 2"}`),r("a=ice-lite");const S=e=>{let t="";t+="a=candidate:",t+=`${e.foundation} ${e.component} ${e.protocol} ${e.priority} ${e.ip} ${e.port} typ ${e.type}`,"rel-addr"in e&&(t+=` raddr ${e["rel-addr"]} rport ${e["rel-port"]}`),t+=` generation ${e.generation}`,r(t)},v=()=>{r(`a=ice-ufrag:${l}`),r(`a=ice-pwd:${m}`),f.forEach((e=>{r(`a=fingerprint:${e.hash} ${e.fingerprint}`),r("a=setup:passive")})),g.forEach(S)},h=e=>{const{channels:t,id:n,name:a,clockrate:i,parameters:s}=e;var o=t?`/${t}`:"";r(`a=rtpmap:${n} ${a}/${i}${o}`),s&&(o=Object.keys(s).map((e=>`${e}=${s[e]};`)).join(" "),r(`a=fmtp:${n} ${o}`)),e["rtcp-fbs"]?.forEach((e=>{r(`a=rtcp-fb:${n} ${e.type}${e.subtype?` ${e.subtype}`:""}`)}))};return e=e=>{const n=e.isVideo?u:p;var i=e.isVideo?"video":"audio";r(`m=${i} ${e.isMain?1:0} RTP/SAVPF ${n.map((e=>e.id)).join(" ")}`),r("c=IN IP4 0.0.0.0"),r("b=AS:1300"),r(`a=mid:${e.endpoint}`),r("a=rtcp-mux"),n.forEach(h),r("a=rtcp:1 IN IP4 0.0.0.0"),e.isVideo&&r("a=rtcp-rsize"),(e.isVideo?d:c).forEach((({id:e,uri:t})=>{r(`a=extmap:${e} ${t}`)})),e.isRemoved?r("a=inactive"):(v(),t?r("a=recvonly"):(e.isMain?r("a=sendrecv"):(r("a=sendonly"),r("a=bundle-only")),e.sourceGroups.forEach((t=>{r(`a=ssrc-group:${t.semantics} ${t.sources.map(a.fromTelegramSource).join(" ")}`),t.sources.forEach((t=>{t=(0,a.fromTelegramSource)(t),r(`a=ssrc:${t} cname:${e.endpoint}`),r(`a=ssrc:${t} msid:${e.endpoint} ${e.endpoint}`),r(`a=ssrc:${t} mslabel:${e.endpoint}`),r(`a=ssrc:${t} label:${e.endpoint}`)}))}))))},o.filter((e=>"0"===e.endpoint||"1"===e.endpoint)).map(e),n||(r("m=application 1 UDP/DTLS/SCTP webrtc-datachannel"),r("c=IN IP4 0.0.0.0"),v(),r("a=ice-options:trickle"),r("a=mid:"+(n?"1":"2")),r("a=sctp-port:5000"),r("a=max-message-size:262144")),o.filter((e=>"0"!==e.endpoint&&"1"!==e.endpoint)).map(e),`${i.join("\n")}\n`}},"./src/parseSdp.ts":(e,t,n)=>{n.r(t),n.d(t,{default:()=>i});var a=n("./src/utils.ts");const i=e=>{if(!e||!e.sdp)throw Error("Failed parsing SDP: session description is null");const t=e.sdp.split("\r\nm=").map(((e,t)=>0===t?e:`m=${e}`)).reduce(((e,t)=>(e[t.match(/^m=(.+?)\s/)?.[1]||"header"]=t.split("\r\n").filter(Boolean),e)),{});var n=(e,n)=>n?t[n]?.find((t=>t.startsWith(e)))?.substr(e.length):Object.values(t).map((t=>t.find((t=>t.startsWith(e)))?.substr(e.length))).filter(Boolean)[0];const i=n("a=ssrc:","audio");var r=i&&Number(i.split(" ")[0]);const s=n("a=ssrc-group:","video")?.split(" ")||void 0;if(!s)throw Error("Failed parsing SDP: no video ssrc");var[o,c]=n("a=fingerprint:")?.split(" ")||[];if(!o||!c)throw Error("Failed parsing SDP: no fingerprint");if(e=n("a=ice-ufrag:"),n=n("a=ice-pwd:"),!e||!n)throw Error("Failed parsing SDP: no ICE ufrag or pwd");return{fingerprints:[{fingerprint:c,hash:o,setup:"active"}],pwd:n,ufrag:e,...r&&{ssrc:(0,a.toTelegramSource)(r)},...s&&{"ssrc-groups":[{semantics:s[0],sources:s.slice(1,s.length).map(Number).map(a.toTelegramSource)}]}}}},"./src/secretsauce.ts":(e,t,n)=>{n.r(t),n.d(t,{getDevices:()=>async function(e,t=!0){return(await navigator.mediaDevices.enumerateDevices()).filter((n=>n.kind===`${e}${t?"input":"output"}`))},toggleSpeaker:()=>function(){o&&(o.isSpeakerDisabled=!o.isSpeakerDisabled,o?.onUpdate?.({"@type":"updateGroupCallConnectionState",connectionState:"connected",isSpeakerDisabled:o.isSpeakerDisabled}),o.participantFunctions&&Object.values(o.participantFunctions).forEach((e=>{e.toggleMute?.(!!o?.isSpeakerDisabled)})))},toggleNoiseSuppression:()=>function(){if(o&&o.myId&&o.streams){const n=o.streams[o.myId].audio;if(n){const a=n.getTracks()[0];var e,t;a&&(({echoCancellation:e,noiseSuppression:t}=a.getConstraints()),a.applyConstraints({echoCancellation:!e,noiseSuppression:!t}))}}},getUserStreams:()=>d,setVolume:()=>function(e,t){const n=o?.participantFunctions?.[e];n&&n.setVolume?.(t)},isStreamEnabled:()=>p,switchCameraInput:()=>async function(){if(o?.myId&&o.connection&&o.streams&&o.facingMode){const e=d(o.myId)?.video;if(e){const t=e.getTracks()[0];if(t){const e=o.connection.getSenders().find((e=>t.id===e.track?.id));if(e){o.facingMode="environment"===o.facingMode?"user":"environment";try{const t=await l("video",o.facingMode);await e.replaceTrack(t.getTracks()[0]),o.streams[o.myId].video=t}catch(e){}}}}}},toggleStream:()=>m,leaveGroupCall:()=>g,handleUpdateGroupCallParticipants:()=>async function(e){if(o){const{participants:a,conference:r,connection:s,myId:c}=o;if(a&&r&&s&&r.ssrcs&&r.transport&&c)if(e.find((e=>e.isSelf&&e.source!==o?.conference?.ssrcs?.find((e=>e.isMain&&!e.isVideo))?.sourceGroups[0].sources[0])))g();else{const a=[];if(e.forEach((e=>{if(e.isSelf)e.isMuted&&!e.canSelfUnmute&&(m("audio",!1),m("video",!1),m("presentation",!1));else{var t=e.isLeft;const n=e.isMuted||e.isMutedByMe,i=!e.isVideoJoined||!e.video||t,s=!e.presentation||t;let o=!1,c=!1,d=!1;r.ssrcs.filter((t=>t.userId===e.id)).forEach((t=>{t.isVideo||(t.sourceGroups[0].sources[0]===e.source&&(c=!0),t.isRemoved=n),t.isVideo&&(t.isPresentation||(e.video&&t.endpoint===e.video.endpoint&&(o=!0),t.isRemoved=i),t.isPresentation&&(e.presentation&&t.endpoint===e.presentation.endpoint&&(d=!0),t.isRemoved=s))})),n||c||r.ssrcs.push({userId:e.id,isMain:!1,endpoint:`audio${e.source}`,isVideo:!1,sourceGroups:[{semantics:"FID",sources:[e.source]}]}),i||o||!e.video||(a.push(e.video.endpoint),r.ssrcs.push({userId:e.id,isMain:!1,endpoint:e.video.endpoint,isVideo:!0,sourceGroups:e.video.sourceGroups})),s||d||!e.presentation||r.ssrcs.push({isPresentation:!0,userId:e.id,isMain:!1,endpoint:e.presentation.endpoint,isVideo:!0,sourceGroups:e.presentation.sourceGroups})}})),o.updatingParticipantsQueue)o.updatingParticipantsQueue.push(r);else{o.updatingParticipantsQueue=[],e=(0,i.default)(r),await s.setRemoteDescription({type:"offer",sdp:e});try{var t=await s.createAnswer();if(await s.setLocalDescription(t),u(c),0async function(e,t){if(o){var n=t?o.screenshareConference:o.conference;const r=t?o.screenshareConnection:o.connection;if(n&&r&&n.ssrcs){var a=Date.now();e={...n,transport:e.transport,sessionId:a,audioExtensions:e.audio?.["rtp-hdrexts"],audioPayloadTypes:e.audio?.["payload-types"],videoExtensions:e.video?.["rtp-hdrexts"],videoPayloadTypes:e.video?.["payload-types"]};o={...o,...t?{screenshareConference:e}:{conference:e}};try{await r.setRemoteDescription({type:"answer",sdp:(0,i.default)(e,!0,t)})}catch(e){console.error(e)}}}},startSharingScreen:()=>async function(){if(o)try{const e=await l("presentation");return e?(e.getTracks()[0].onended=()=>{o&&o.myId&&(o.streams?.[o.myId].presentation,u(o.myId),c())},new Promise((t=>{var{connection:n,dataChannel:t}=h([e],t,!0);o={...o,screenshareConnection:n,screenshareDataChannel:t}}))):void 0}catch(e){return}},joinGroupCall:()=>function(e,t,n,a){if(o)throw Error("Already in call");f("connecting");var i=new MediaStream;return n.srcObject=i,n.play().catch((e=>console.warn(e))),o={onUpdate:a,participants:[],myId:e,speaking:{},silence:(0,r.silence)(t),black:(0,r.black)({width:640,height:480}),analyserInterval:setInterval(S,1e3),audioElement:n,audioContext:t,mediaStream:i},new Promise((e=>{o={...o,...h([o.silence,o.black],e)}}))}});var a=n("./src/parseSdp.ts"),i=n("./src/buildSdp.ts"),r=n("./src/blacksilence.ts"),s=n("./src/utils.ts");let o;function c(e){o&&(o.screenshareDataChannel?.close(),o.screenshareConnection?.close(),e||o.onUpdate?.({"@type":"updateGroupCallLeavePresentation"}))}function d(e){return o?.streams?.[e]}function p(e,t){const n=(t=t||o?.myId)&&d(t)?.[e];return!!n&&n.getTracks()[0]?.enabled}function u(e){o?.onUpdate?.({"@type":"updateGroupCallStreams",userId:e,hasAudioStream:p("audio",e),hasVideoStream:p("video",e),hasPresentationStream:p("presentation",e),amplitude:o.speaking?.[e]})}function l(e,t="user"){return"presentation"===e?navigator.mediaDevices.getDisplayMedia({audio:!1,video:!0}):navigator.mediaDevices.getUserMedia({audio:"audio"===e&&{...s.IS_ECHO_CANCELLATION_SUPPORTED&&{echoCancellation:!0},...s.IS_NOISE_SUPPRESSION_SUPPORTED&&{noiseSuppression:!0}},video:"video"===e&&{facingMode:t}})}async function m(e,t){if(o&&o.myId&&o.connection&&o.streams){const n=d(o.myId)?.[e];if(n){const a=n.getTracks()[0];if(a){const n=[...o.connection.getSenders(),...o.screenshareConnection?.getSenders()||[]].find((e=>a.id===e.track?.id));if(n){t=void 0===t?!a.enabled:t;try{if(t&&!a.enabled){const t=await l(e);if(await n.replaceTrack(t.getTracks()[0]),o.streams[o.myId][e]=t,"video"===e)o.facingMode="user";else if("audio"===e){const e=o.audioContext;if(!e)return;const n=e.createMediaStreamSource(t),a=e.createAnalyser();a.minDecibels=-100,a.maxDecibels=-30,a.smoothingTimeConstant=.05,a.fftSize=1024,n.connect(a),o={...o,participantFunctions:{...o.participantFunctions,[o.myId]:{...o.participantFunctions?.[o.myId],getCurrentAmplitude:()=>{var e=new Uint8Array(a.frequencyBinCount);return a.getByteFrequencyData(e),(0,s.getAmplitude)(e,1.5)}}}}}}else if(!t&&a.enabled){a.stop();const t="audio"===e?o.silence:o.black;if(!t)return;await n.replaceTrack(t.getTracks()[0]),o.streams[o.myId][e]=t,"video"===e&&(o.facingMode=void 0)}u(o.myId),"presentation"!==e||t||c(!0)}catch(e){}}}}}}function f(e){o?.onUpdate?.({"@type":"updateGroupCallConnectionState",connectionState:e})}function g(){o&&(o.myId&&o.streams?.[o.myId]&&Object.values(o.streams[o.myId]||{}).forEach((e=>{e?.getTracks().forEach((e=>{e.stop()}))})),c(!0),o.dataChannel?.close(),o.connection?.close(),f("disconnected"),o.analyserInterval&&clearInterval(o.analyserInterval),o=void 0)}function S(){o&&o.participantFunctions&&Object.keys(o.participantFunctions).forEach((e=>{const t=o.participantFunctions[Number(e)].getCurrentAmplitude;var n,a;t&&(n=t(),a=o.speaking[e]||0,((o.speaking[e]=n)>s.THRESHOLD&&a<=s.THRESHOLD||n<=s.THRESHOLD&&a>s.THRESHOLD)&&u(e))}))}function v(e){if(o&&o.audioElement&&o.audioContext&&o.mediaStream){var t=o.conference?.ssrcs?.find((t=>t.endpoint===e.track.id));if(t&&t.userId){const{userId:a,isPresentation:i}=t;var n=o.participants?.find((e=>e.id===a));const r="video"===e.track.kind?i?"presentation":"video":"audio";if(e.track.onended=()=>{o?.streams?.[a][r],u(a)},t=e.streams[0],"audio"===e.track.kind){const e=o.mediaStream,i=new window.AudioContext,r=i.createMediaStreamSource(t),c=i.createGain();c.gain.value=(n?.volume||1e4)/1e4;const d=i.createGain();c.gain.value=1;const p=i.createAnalyser();p.minDecibels=-100,p.maxDecibels=-30,p.smoothingTimeConstant=.05,p.fftSize=1024,r.connect(p).connect(d).connect(c).connect(i.destination),e.addTrack(r.mediaStream.getAudioTracks()[0]);const u=new Audio;u.srcObject=t,u.muted=!0,u.remove(),o={...o,participantFunctions:{...o.participantFunctions,[a]:{...o.participantFunctions?.[a],setVolume:e=>{c.gain.value=1{d.gain.value=e?0:1},getCurrentAmplitude:()=>{var e=new Uint8Array(p.frequencyBinCount);return p.getByteFrequencyData(e),(0,s.getAmplitude)(e,1.5)}}}}}o={...o,streams:{...o.streams,[a]:{...o.streams?.[a],[r]:t}}},u(a)}}}function h(e,t,n=!1){const i=new RTCPeerConnection;var r=n?void 0:function(e){const t=e.createDataChannel("data",{id:0});return t.onopen=()=>{},t.onmessage=e=>{JSON.parse(e.data).colibriClass},t.onerror=e=>{console.log("%conerror","background: green; font-size: 5em"),console.error(e)},t}(i);return e.forEach((e=>e.getTracks().forEach((t=>{i.addTrack(t,e)})))),n||(i.oniceconnectionstatechange=()=>{var e=i.iceConnectionState;"connected"===e||"completed"===e?f("connected"):"checking"===e||"new"===e?f("connecting"):"disconnected"===i.iceConnectionState&&f("reconnecting")}),i.ontrack=v,i.onnegotiationneeded=async()=>{if(o){var r=o.myId;if(r){var s=await i.createOffer({offerToReceiveVideo:!0,offerToReceiveAudio:!n});if(await i.setLocalDescription(s),s.sdp){var c=(0,a.default)(s),d=n?void 0:{userId:"",sourceGroups:[{semantics:"FID",sources:[c.ssrc||0]}],isRemoved:n,isMain:!0,isVideo:!1,isPresentation:n,endpoint:n?"1":"0"},p=c["ssrc-groups"]&&{isPresentation:n,userId:"",sourceGroups:c["ssrc-groups"],isMain:!0,isVideo:!0,endpoint:n?"0":"1"};s=n?o.screenshareConference:o.conference;const i=[];n?(p&&i.push(p),d&&i.push(d)):(d&&i.push(d),p&&i.push(p)),d=e.find((e=>"audio"===e.getTracks()[0].kind)),p=e.find((e=>"video"===e.getTracks()[0].kind)),o={...o,...n?{screenshareConference:{...s,ssrcs:i}}:{conference:{...s,ssrcs:i}},streams:{...o.streams,[r]:{...o.streams?.[r],...d&&{audio:d},...!n&&p?{video:p}:{presentation:p}}}},u(r),t(c)}}}},{connection:i,dataChannel:r}}},"./src/types.ts":(e,t,n)=>{n.r(t)},"./src/utils.ts":(e,t,n)=>{function a(){var{userAgent:e,platform:t}=window.navigator;let n;return-1!==["Macintosh","MacIntel","MacPPC","Mac68K"].indexOf(t)?n="macOS":-1!==["iPhone","iPad","iPod"].indexOf(t)?n="iOS":-1!==["Win32","Win64","Windows","WinCE"].indexOf(t)?n="Windows":/Android/.test(e)?n="Android":/Linux/.test(t)&&(n="Linux"),n}n.r(t),n.d(t,{toTelegramSource:()=>function(e){return e<<0},fromTelegramSource:()=>function(e){return e>>>0},getAmplitude:()=>function(e,t=3){if(!e)return 0;var n=e.length;let a=0;for(let t=0;ta,THRESHOLD:()=>i,PLATFORM_ENV:()=>r,IS_MAC_OS:()=>s,IS_IOS:()=>o,IS_SCREENSHARE_SUPPORTED:()=>c,IS_ECHO_CANCELLATION_SUPPORTED:()=>d,IS_NOISE_SUPPRESSION_SUPPORTED:()=>p});const i=.1,r=a(),s="macOS"===r,o="iOS"===r,c="getDisplayMedia"in(navigator?.mediaDevices||{}),d=navigator?.mediaDevices?.getSupportedConstraints().echoCancellation,p=navigator?.mediaDevices?.getSupportedConstraints().noiseSuppression}},t={};function n(a){var i=t[a];return void 0!==i||(i=t[a]={exports:{}},e[a](i,i.exports,n)),i.exports}n.d=(e,t)=>{for(var a in t)n.o(t,a)&&!n.o(e,a)&&Object.defineProperty(e,a,{enumerable:!0,get:t[a]})},n.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),n.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})};var a={};(()=>{n.r(a),n.d(a,{handleUpdateGroupCallConnection:()=>e.handleUpdateGroupCallConnection,startSharingScreen:()=>e.startSharingScreen,joinGroupCall:()=>e.joinGroupCall,getDevices:()=>e.getDevices,getUserStreams:()=>e.getUserStreams,setVolume:()=>e.setVolume,isStreamEnabled:()=>e.isStreamEnabled,toggleStream:()=>e.toggleStream,leaveGroupCall:()=>e.leaveGroupCall,handleUpdateGroupCallParticipants:()=>e.handleUpdateGroupCallParticipants,switchCameraInput:()=>e.switchCameraInput,toggleSpeaker:()=>e.toggleSpeaker,toggleNoiseSuppression:()=>e.toggleNoiseSuppression,IS_SCREENSHARE_SUPPORTED:()=>t.IS_SCREENSHARE_SUPPORTED,THRESHOLD:()=>t.THRESHOLD});var e=n("./src/secretsauce.ts"),t=n("./src/utils.ts");n("./src/types.ts")})();var i,r=exports;for(i in a)r[i]=a[i];a.__esModule&&Object.defineProperty(r,"__esModule",{value:!0})})(); \ No newline at end of file +(()=>{"use strict";var e={"./src/blacksilence.ts":(e,t,a)=>{a.r(t),a.d(t,{silence:()=>n,black:()=>s});const n=e=>{const t=e.createOscillator(),a=t.connect(e.createMediaStreamDestination());return t.start(),new MediaStream([Object.assign(a.stream.getAudioTracks()[0],{enabled:!1})])},s=({width:e=640,height:t=480}={})=>{const a=Object.assign(document.createElement("canvas"),{width:e,height:t}),n=a.getContext("2d");if(!n)throw Error("Cannot create canvas ctx");n.fillRect(0,0,e,t);const s=a.captureStream();return new MediaStream([Object.assign(s.getVideoTracks()[0],{enabled:!1})])}},"./src/buildSdp.ts":(e,t,a)=>{a.r(t),a.d(t,{default:()=>s});var n=a("./src/utils.ts");const s=(e,t=!1,a=!1,s=!1)=>{const i=[],r=e=>{i.push(e)},{sessionId:o,ssrcs:c,audioExtensions:d,videoExtensions:p,audioPayloadTypes:u,videoPayloadTypes:l,transport:{ufrag:m,pwd:f,fingerprints:g,candidates:v}}=e;r("v=0"),r(`o=- ${o} 2 IN IP4 0.0.0.0`),r("s=-"),r("t=0 0"),r("a=ice-options:trickle"),r("a=msid-semantic:WMS *"),r(`a=group:BUNDLE ${c.map((e=>e.endpoint)).join(" ")}${a?"":" "+(s?"3":"2")}`),s||r("a=ice-lite");const S=e=>{if(e.sdpString)r(`a=${e.sdpString}`);else{let t="";t+="a=candidate:",t+=`${e.foundation} ${e.component} ${e.protocol} ${e.priority} ${e.ip} ${e.port} typ ${e.type}`,"rel-addr"in e&&(t+=` raddr ${e["rel-addr"]} rport ${e["rel-port"]}`),t+=` generation ${e.generation}`,r(t)}},y=()=>{r(`a=ice-ufrag:${m}`),r(`a=ice-pwd:${f}`),g.forEach((e=>{r(`a=fingerprint:${e.hash} ${e.fingerprint}`),r(`a=setup:${s?e.setup:"passive"}`)})),v.forEach(S)},h=e=>{const{channels:t,id:a,name:n,clockrate:s,parameters:i}=e;var o=t?`/${t}`:"";r(`a=rtpmap:${a} ${n}/${s}${o}`),i&&(o=Object.keys(i).map((e=>`${e}=${i[e]};`)).join(" "),r(`a=fmtp:${a} ${o}`)),e["rtcp-fbs"]?.forEach((e=>{r(`a=rtcp-fb:${a} ${e.type}${e.subtype?` ${e.subtype}`:""}`)}))};return e=e=>{const a=e.isVideo?l:u;var i=e.isVideo?"video":"audio";if(r(`m=${i} ${e.isMain?1:0} RTP/SAVPF ${a.map((e=>e.id)).join(" ")}`),r("c=IN IP4 0.0.0.0"),r("b=AS:1300"),r(`a=mid:${e.endpoint}`),r("a=rtcp-mux"),a.forEach(h),r("a=rtcp:1 IN IP4 0.0.0.0"),e.isVideo&&r("a=rtcp-rsize"),(e.isVideo?p:d).forEach((({id:e,uri:t})=>{r(`a=extmap:${e} ${t}`)})),e.isRemoved)r("a=inactive");else{if(y(),s)r("a=sendrecv"),r("a=bundle-only");else{if(t)return void r("a=recvonly");e.isMain?r("a=sendrecv"):(r("a=sendonly"),r("a=bundle-only"))}e.sourceGroups.forEach((t=>{r(`a=ssrc-group:${t.semantics} ${t.sources.map(n.fromTelegramSource).join(" ")}`),t.sources.forEach((t=>{t=(0,n.fromTelegramSource)(t),r(`a=ssrc:${t} cname:${e.endpoint}`),r(`a=ssrc:${t} msid:${e.endpoint} ${e.endpoint}`),r(`a=ssrc:${t} mslabel:${e.endpoint}`),r(`a=ssrc:${t} label:${e.endpoint}`)}))}))}},s?c.filter(e):c.filter((e=>"0"===e.endpoint||"1"===e.endpoint)).map(e),a||(r("m=application 1 UDP/DTLS/SCTP webrtc-datachannel"),r("c=IN IP4 0.0.0.0"),y(),r("a=ice-options:trickle"),r("a=mid:"+(s?"3":a?"1":"2")),r("a=sctp-port:5000"),r("a=max-message-size:262144")),s||c.filter((e=>"0"!==e.endpoint&&"1"!==e.endpoint)).map(e),`${i.join("\n")}\n`}},"./src/p2p.ts":(e,t,a)=>{a.r(t),a.d(t,{getStreams:()=>function(){return o?.streams},switchCameraInputP2p:()=>async function(){if(o&&o.facingMode){const e=o.streams.ownVideo;if(e){const t=e.getTracks()[0];if(t){const e=o.connection.getSenders().find((e=>t.id===e.track?.id));if(e){o.facingMode="environment"===o.facingMode?"user":"environment";try{const t=await d("video",o.facingMode);await e.replaceTrack(t.getTracks()[0]),o.streams.ownVideo=t,c()}catch(e){}}}}}},toggleStreamP2p:()=>p,joinPhoneCall:()=>async function(e,t,a,i,r){const d=new RTCPeerConnection({iceServers:e.map((e=>({urls:[e.isTurn&&`turn:${e.ip}:${e.port}`,e.isStun&&`stun:${e.ip}:${e.port}`].filter(Boolean),username:e.username,credentialType:"password",credential:e.password}))),iceCandidatePoolSize:2}),u=(0,n.silence)(new AudioContext),l=(0,n.black)({width:640,height:480}),g=(0,n.black)({width:640,height:480});d.addTrack(u.getTracks()[0],u),d.addTrack(l.getTracks()[0],l),d.addTrack(g.getTracks()[0],g),d.onicecandidate=e=>{e.candidate&&t({"@type":"Candidates",candidates:[{sdpString:e.candidate.candidate}]})},d.onconnectionstatechange=()=>{r({"@type":"updatePhoneCallConnectionState",connectionState:d.connectionState})},d.ontrack=e=>{var t;o&&(t=e.streams[0],"audio"===e.track.kind?(o.audio.srcObject=t,o.audio.play().catch(),o.streams.audio=t):"1"===e.transceiver.mid?o.streams.video=t:o.streams.presentation=t,c())};const v=d.createDataChannel("data",{id:0,negotiated:!0});v.onmessage=e=>{f(JSON.parse(e.data))},e=new Audio,o={audio:e,connection:d,emitSignalingData:t,isOutgoing:a,candidates:[],onUpdate:r,streams:{ownVideo:l,ownAudio:u,ownPresentation:g},mediaState:{isBatteryLow:!1,screencastState:"inactive",videoState:"inactive",videoRotation:0,isMuted:!0},blackVideo:l,blackPresentation:g,silence:u,dataChannel:v};try{i&&p("video",!0),p("audio",!0)}catch(e){}a&&(a=await d.createOffer({offerToReceiveAudio:!0,offerToReceiveVideo:!0}),await d.setLocalDescription(a),m((0,s.default)(a,!0)))},stopPhoneCall:()=>function(){o&&(o.streams.ownVideo?.getTracks().forEach((e=>e.stop())),o.streams.ownPresentation?.getTracks().forEach((e=>e.stop())),o.streams.ownAudio?.getTracks().forEach((e=>e.stop())),o.dataChannel.close(),o.connection.close(),o=void 0)},processSignalingMessage:()=>f});var n=a("./src/blacksilence.ts"),s=a("./src/parseSdp.ts"),i=a("./src/utils.ts"),r=a("./src/buildSdp.ts");let o;function c(){o?.onUpdate({...o.mediaState,"@type":"updatePhoneCallMediaState"})}function d(e,t="user"){return"presentation"===e?navigator.mediaDevices.getDisplayMedia({audio:!1,video:!0}):navigator.mediaDevices.getUserMedia({audio:"audio"===e&&{...i.IS_ECHO_CANCELLATION_SUPPORTED&&{echoCancellation:!0},...i.IS_NOISE_SUPPRESSION_SUPPORTED&&{noiseSuppression:!0}},video:"video"===e&&{facingMode:t}})}async function p(e,t){if(o){const a="audio"===e?o.streams.ownAudio:"video"===e?o.streams.ownVideo:o.streams.ownPresentation;if(a){const n=a.getTracks()[0];if(n){const a=o.connection.getSenders().find((e=>n.id===e.track?.id));if(a){t=void 0===t?!n.enabled:t;try{if(t&&!n.enabled){const t=await d(e);t.getTracks()[0].onended=()=>{p(e,!1)},await a.replaceTrack(t.getTracks()[0]),"audio"===e?o.streams.ownAudio=t:"video"===e?(o.streams.ownVideo=t,o.facingMode="user"):o.streams.ownPresentation=t,"video"!==e&&"presentation"!==e||p("video"===e?"presentation":"video",!1)}else if(!t&&n.enabled){n.stop();const t="audio"===e?o.silence:"video"===e?o.blackVideo:o.blackPresentation;if(!t)return;await a.replaceTrack(t.getTracks()[0]),"audio"===e?o.streams.ownAudio=t:"video"===e?o.streams.ownVideo=t:o.streams.ownPresentation=t}c(),u()}catch(e){}}}}}}function u(){if(o){const{emitSignalingData:e,streams:t}=o;e({"@type":"MediaState",videoRotation:0,isMuted:!t.ownAudio?.getTracks()[0].enabled,isBatteryLow:!0,videoState:t.ownVideo?.getTracks()[0].enabled?"active":"inactive",screencastState:t.ownPresentation?.getTracks()[0].enabled?"active":"inactive"})}}function l(e){if(!o||o.isOutgoing)return e;const t=e.payloadTypes;var a=t.findIndex((e=>"VP8"===e.name));const n=t[a];var s=t.findIndex((e=>Number(e.parameters?.apt)===n.id));return e.payloadTypes=[t[a],t[s]],e}function m(e){if(o){const t=o.emitSignalingData;e.ssrc&&e["ssrc-groups"]&&e["ssrc-groups"][0]&&e["ssrc-groups"][1]&&t({"@type":"InitialSetup",fingerprints:e.fingerprints,ufrag:e.ufrag,pwd:e.pwd,audio:{ssrc:(0,i.fromTelegramSource)(e.ssrc).toString(),ssrcGroups:[],payloadTypes:e.audioPayloadTypes,rtpExtensions:e.audioExtmap},video:l({ssrc:(0,i.fromTelegramSource)(e["ssrc-groups"][0].sources[0]).toString(),ssrcGroups:[{semantics:e["ssrc-groups"][0].semantics,ssrcs:e["ssrc-groups"][0].sources.map(i.fromTelegramSource)}],payloadTypes:e.videoPayloadTypes,rtpExtensions:e.videoExtmap}),screencast:l({ssrc:(0,i.fromTelegramSource)(e["ssrc-groups"][1].sources[0]).toString(),ssrcGroups:[{semantics:e["ssrc-groups"][1].semantics,ssrcs:e["ssrc-groups"][1].sources.map(i.fromTelegramSource)}],payloadTypes:e.screencastPayloadTypes,rtpExtensions:e.screencastExtmap})})}}async function f(e){if(o&&o.connection)switch(e["@type"]){case"MediaState":o.mediaState=e,c(),u();break;case"Candidates":var{candidates:t,gotInitialSetup:a}=o;if(!t)return;e.candidates.forEach((e=>{o.candidates.push(e.sdpString)})),a&&await Promise.all(o.candidates.map((e=>o.connection.addIceCandidate({candidate:e,sdpMLineIndex:0}))));break;case"InitialSetup":{const{connection:t,isOutgoing:n}=o;if(!t)return;if(a={transport:{candidates:[],ufrag:e.ufrag,pwd:e.pwd,fingerprints:e.fingerprints,"rtcp-mux":!1,xmlns:""},sessionId:Date.now(),ssrcs:[e.audio&&{isVideo:!1,isMain:!1,userId:"123",endpoint:"0",sourceGroups:[{semantics:"FID",sources:[e.audio.ssrc]}]},e.video&&{isVideo:!0,isPresentation:!1,isMain:!1,userId:"123",endpoint:"1",sourceGroups:e.video.ssrcGroups.map((e=>({semantics:e.semantics,sources:e.ssrcs})))},e.screencast&&{isVideo:!0,isPresentation:!0,isMain:!1,userId:"123",endpoint:"2",sourceGroups:e.screencast.ssrcGroups.map((e=>({semantics:e.semantics,sources:e.ssrcs})))}],audioPayloadTypes:e.audio.payloadTypes?.map(i.p2pPayloadTypeToConference)||[],audioExtensions:e.audio.rtpExtensions,videoPayloadTypes:l(e.video).payloadTypes?.map(i.p2pPayloadTypeToConference)||[],videoExtensions:e.video.rtpExtensions},await t.setRemoteDescription({sdp:(0,r.default)(a,n,void 0,!0),type:n?"answer":"offer"}),o.conference=a,!n){if(a=await t.createAnswer(),!a)return;await t.setLocalDescription(a),m((0,s.default)(a,!0))}o.gotInitialSetup=!0,await Promise.all(o.candidates.map((e=>t.addIceCandidate({candidate:e,sdpMLineIndex:0}))));break}}}},"./src/p2pMessage.ts":(e,t,a)=>{a.r(t)},"./src/parseSdp.ts":(e,t,a)=>{a.r(t),a.d(t,{default:()=>s});var n=a("./src/utils.ts");const s=(e,t=!1)=>{if(!e||!e.sdp)throw Error("Failed parsing SDP: session description is null");const a=e.sdp.split("\r\nm=").map(((e,t)=>0===t?e:`m=${e}`)).reduce(((e,t)=>{var a=t.match(/^m=(.+?)\s/)?.[1]||"header";return e[e.hasOwnProperty(a)&&"video"===a?"screencast":a]=t.split("\r\n").filter(Boolean),e}),{});var s=(e,t)=>t?a[t]?.find((t=>t.startsWith(e)))?.substr(e.length):Object.values(a).map((t=>t.find((t=>t.startsWith(e)))?.substr(e.length))).filter(Boolean)[0],i=e=>a[e].filter((e=>e.startsWith("a=extmap"))).map((e=>{var[,t,e]=e.match(/extmap:(\d+)(?:\/.+)?\s(.+)/);return{id:Number(t),uri:e}})),r=e=>{const t=a[e].filter((e=>e.startsWith("a=rtpmap"))).map((e=>{const[,t,a]=e.match(/:(\d+)\s(.+)/)||[];var[n,s,e]=a.split("/");return{id:Number(t),name:n,clockrate:Number(s),...e&&{channels:Number(e)}}})),n=a[e].filter((e=>e.startsWith("a=rtcp-fb"))).map((e=>{const[,t,a]=e.match(/:(\d+)\s(.+)/)||[];var[n,e]=a.split(" ");return{id:Number(t),type:n,subtype:e||""}})),s=a[e].filter((e=>e.startsWith("a=fmtp"))).map((e=>{const[,t,a]=e.match(/:(\d+)\s(.+)/)||[];if(e=a.split(";").reduce(((e,t)=>{var[a,t]=t.split("=");return e[a]=t,e}),{}),!Object.values(e).some((e=>!e)))return{id:Number(t),data:e}})).filter(Boolean);return t.map((e=>{var t=s.filter((t=>t.id===e.id)).map((e=>e.data)).reduce(((e,t)=>Object.assign(e,t)),{}),a=n.filter((t=>t.id===e.id)).map((e=>({type:e.type,subtype:e.subtype})));return{...e,...0{a.r(t),a.d(t,{getDevices:()=>async function(e,t=!0){return(await navigator.mediaDevices.enumerateDevices()).filter((a=>a.kind===`${e}${t?"input":"output"}`))},toggleSpeaker:()=>function(){o&&(o.isSpeakerDisabled=!o.isSpeakerDisabled,o?.onUpdate?.({"@type":"updateGroupCallConnectionState",connectionState:"connected",isSpeakerDisabled:o.isSpeakerDisabled}),o.participantFunctions&&Object.values(o.participantFunctions).forEach((e=>{e.toggleMute?.(!!o?.isSpeakerDisabled)})))},toggleNoiseSuppression:()=>function(){if(o&&o.myId&&o.streams){const a=o.streams[o.myId].audio;if(a){const n=a.getTracks()[0];var e,t;n&&(({echoCancellation:e,noiseSuppression:t}=n.getConstraints()),n.applyConstraints({echoCancellation:!e,noiseSuppression:!t}))}}},getUserStreams:()=>d,setVolume:()=>function(e,t){const a=o?.participantFunctions?.[e];a&&a.setVolume?.(t)},isStreamEnabled:()=>p,switchCameraInput:()=>async function(){if(o?.myId&&o.connection&&o.streams&&o.facingMode){const e=d(o.myId)?.video;if(e){const t=e.getTracks()[0];if(t){const e=o.connection.getSenders().find((e=>t.id===e.track?.id));if(e){o.facingMode="environment"===o.facingMode?"user":"environment";try{const t=await l("video",o.facingMode);await e.replaceTrack(t.getTracks()[0]),o.streams[o.myId].video=t}catch(e){}}}}}},toggleStream:()=>m,leaveGroupCall:()=>g,handleUpdateGroupCallParticipants:()=>async function(e){if(o){const{participants:n,conference:i,connection:r,myId:c}=o;if(n&&i&&r&&i.ssrcs&&i.transport&&c)if(e.find((e=>e.isSelf&&e.source!==o?.conference?.ssrcs?.find((e=>e.isMain&&!e.isVideo))?.sourceGroups[0].sources[0])))g();else{const n=[];if(e.forEach((e=>{if(e.isSelf)e.isMuted&&!e.canSelfUnmute&&(m("audio",!1),m("video",!1),m("presentation",!1));else{var t=e.isLeft;const a=e.isMuted||e.isMutedByMe,s=!e.isVideoJoined||!e.video||t,r=!e.presentation||t;let o=!1,c=!1,d=!1;i.ssrcs.filter((t=>t.userId===e.id)).forEach((t=>{t.isVideo||(t.sourceGroups[0].sources[0]===e.source&&(c=!0),t.isRemoved=a),t.isVideo&&(t.isPresentation||(e.video&&t.endpoint===e.video.endpoint&&(o=!0),t.isRemoved=s),t.isPresentation&&(e.presentation&&t.endpoint===e.presentation.endpoint&&(d=!0),t.isRemoved=r))})),a||c||i.ssrcs.push({userId:e.id,isMain:!1,endpoint:`audio${e.source}`,isVideo:!1,sourceGroups:[{semantics:"FID",sources:[e.source]}]}),s||o||!e.video||(n.push(e.video.endpoint),i.ssrcs.push({userId:e.id,isMain:!1,endpoint:e.video.endpoint,isVideo:!0,sourceGroups:e.video.sourceGroups})),r||d||!e.presentation||i.ssrcs.push({isPresentation:!0,userId:e.id,isMain:!1,endpoint:e.presentation.endpoint,isVideo:!0,sourceGroups:e.presentation.sourceGroups})}})),o.updatingParticipantsQueue)o.updatingParticipantsQueue.push(i);else{o.updatingParticipantsQueue=[],e=(0,s.default)(i),await r.setRemoteDescription({type:"offer",sdp:e});try{var t=await r.createAnswer();if(await r.setLocalDescription(t),u(c),0async function(e,t){if(o){var a=t?o.screenshareConference:o.conference;const i=t?o.screenshareConnection:o.connection;if(a&&i&&a.ssrcs){var n=Date.now();e={...a,transport:e.transport,sessionId:n,audioExtensions:e.audio?.["rtp-hdrexts"],audioPayloadTypes:e.audio?.["payload-types"],videoExtensions:e.video?.["rtp-hdrexts"],videoPayloadTypes:e.video?.["payload-types"]};o={...o,...t?{screenshareConference:e}:{conference:e}};try{await i.setRemoteDescription({type:"answer",sdp:(0,s.default)(e,!0,t)})}catch(e){console.error(e)}}}},startSharingScreen:()=>async function(){if(o)try{const e=await l("presentation");return e?(e.getTracks()[0].onended=()=>{o&&o.myId&&(o.streams?.[o.myId].presentation,u(o.myId),c())},new Promise((t=>{var{connection:a,dataChannel:t}=y([e],t,!0);o={...o,screenshareConnection:a,screenshareDataChannel:t}}))):void 0}catch(e){return}},joinGroupCall:()=>function(e,t,a,n){if(o)throw Error("Already in call");f("connecting");var s=new MediaStream;return a.srcObject=s,a.play().catch((e=>console.warn(e))),o={onUpdate:n,participants:[],myId:e,speaking:{},silence:(0,i.silence)(t),black:(0,i.black)({width:640,height:480}),analyserInterval:setInterval(v,1e3),audioElement:a,audioContext:t,mediaStream:s},new Promise((e=>{o={...o,...y([o.silence,o.black],e)}}))}});var n=a("./src/parseSdp.ts"),s=a("./src/buildSdp.ts"),i=a("./src/blacksilence.ts"),r=a("./src/utils.ts");let o;function c(e){o&&(o.screenshareDataChannel?.close(),o.screenshareConnection?.close(),e||o.onUpdate?.({"@type":"updateGroupCallLeavePresentation"}))}function d(e){return o?.streams?.[e]}function p(e,t){const a=(t=t||o?.myId)&&d(t)?.[e];return!!a&&a.getTracks()[0]?.enabled}function u(e){o?.onUpdate?.({"@type":"updateGroupCallStreams",userId:e,hasAudioStream:p("audio",e),hasVideoStream:p("video",e),hasPresentationStream:p("presentation",e),amplitude:o.speaking?.[e]})}function l(e,t="user"){return"presentation"===e?navigator.mediaDevices.getDisplayMedia({audio:!1,video:!0}):navigator.mediaDevices.getUserMedia({audio:"audio"===e&&{...r.IS_ECHO_CANCELLATION_SUPPORTED&&{echoCancellation:!0},...r.IS_NOISE_SUPPRESSION_SUPPORTED&&{noiseSuppression:!0}},video:"video"===e&&{facingMode:t}})}async function m(e,t){if(o&&o.myId&&o.connection&&o.streams){const a=d(o.myId)?.[e];if(a){const n=a.getTracks()[0];if(n){const a=[...o.connection.getSenders(),...o.screenshareConnection?.getSenders()||[]].find((e=>n.id===e.track?.id));if(a){t=void 0===t?!n.enabled:t;try{if(t&&!n.enabled){const t=await l(e);if(await a.replaceTrack(t.getTracks()[0]),o.streams[o.myId][e]=t,"video"===e)o.facingMode="user";else if("audio"===e){const e=o.audioContext;if(!e)return;const a=e.createMediaStreamSource(t),n=e.createAnalyser();n.minDecibels=-100,n.maxDecibels=-30,n.smoothingTimeConstant=.05,n.fftSize=1024,a.connect(n),o={...o,participantFunctions:{...o.participantFunctions,[o.myId]:{...o.participantFunctions?.[o.myId],getCurrentAmplitude:()=>{var e=new Uint8Array(n.frequencyBinCount);return n.getByteFrequencyData(e),(0,r.getAmplitude)(e,1.5)}}}}}}else if(!t&&n.enabled){n.stop();const t="audio"===e?o.silence:o.black;if(!t)return;await a.replaceTrack(t.getTracks()[0]),o.streams[o.myId][e]=t,"video"===e&&(o.facingMode=void 0)}u(o.myId),"presentation"!==e||t||c(!0)}catch(e){}}}}}}function f(e){o?.onUpdate?.({"@type":"updateGroupCallConnectionState",connectionState:e})}function g(){o&&(o.myId&&o.streams?.[o.myId]&&Object.values(o.streams[o.myId]||{}).forEach((e=>{e?.getTracks().forEach((e=>{e.stop()}))})),c(!0),o.dataChannel?.close(),o.connection?.close(),f("disconnected"),o.analyserInterval&&clearInterval(o.analyserInterval),o=void 0)}function v(){o&&o.participantFunctions&&Object.keys(o.participantFunctions).forEach((e=>{const t=o.participantFunctions[Number(e)].getCurrentAmplitude;var a,n;t&&(a=t(),n=o.speaking[e]||0,((o.speaking[e]=a)>r.THRESHOLD&&n<=r.THRESHOLD||a<=r.THRESHOLD&&n>r.THRESHOLD)&&u(e))}))}function S(e){if(o&&o.audioElement&&o.audioContext&&o.mediaStream){var t=o.conference?.ssrcs?.find((t=>t.endpoint===e.track.id));if(t&&t.userId){const{userId:n,isPresentation:s}=t;var a=o.participants?.find((e=>e.id===n));const i="video"===e.track.kind?s?"presentation":"video":"audio";if(e.track.onended=()=>{o?.streams?.[n][i],u(n)},t=e.streams[0],"audio"===e.track.kind){const e=o.mediaStream,s=new window.AudioContext,i=s.createMediaStreamSource(t),c=s.createGain();c.gain.value=(a?.volume||1e4)/1e4;const d=s.createGain();c.gain.value=1;const p=s.createAnalyser();p.minDecibels=-100,p.maxDecibels=-30,p.smoothingTimeConstant=.05,p.fftSize=1024,i.connect(p).connect(d).connect(c).connect(s.destination),e.addTrack(i.mediaStream.getAudioTracks()[0]);const u=new Audio;u.srcObject=t,u.muted=!0,u.remove(),o={...o,participantFunctions:{...o.participantFunctions,[n]:{...o.participantFunctions?.[n],setVolume:e=>{c.gain.value=1{d.gain.value=e?0:1},getCurrentAmplitude:()=>{var e=new Uint8Array(p.frequencyBinCount);return p.getByteFrequencyData(e),(0,r.getAmplitude)(e,1.5)}}}}}o={...o,streams:{...o.streams,[n]:{...o.streams?.[n],[i]:t}}},u(n)}}}function y(e,t,a=!1){const s=new RTCPeerConnection;var i=a?void 0:function(e){const t=e.createDataChannel("data",{id:0});return t.onopen=()=>{},t.onmessage=e=>{JSON.parse(e.data).colibriClass},t.onerror=e=>{console.log("%conerror","background: green; font-size: 5em"),console.error(e)},t}(s);return e.forEach((e=>e.getTracks().forEach((t=>{s.addTrack(t,e)})))),a||(s.oniceconnectionstatechange=()=>{var e=s.iceConnectionState;"connected"===e||"completed"===e?f("connected"):"checking"===e||"new"===e?f("connecting"):"disconnected"===s.iceConnectionState&&f("reconnecting")}),s.ontrack=S,s.onnegotiationneeded=async()=>{if(o){var i=o.myId;if(i){var r=await s.createOffer({offerToReceiveVideo:!0,offerToReceiveAudio:!a});if(await s.setLocalDescription(r),r.sdp){var c=(0,n.default)(r),d=a?void 0:{userId:"",sourceGroups:[{semantics:"FID",sources:[c.ssrc||0]}],isRemoved:a,isMain:!0,isVideo:!1,isPresentation:a,endpoint:a?"1":"0"},p=c["ssrc-groups"]&&{isPresentation:a,userId:"",sourceGroups:c["ssrc-groups"],isMain:!0,isVideo:!0,endpoint:a?"0":"1"};r=a?o.screenshareConference:o.conference;const s=[];a?(p&&s.push(p),d&&s.push(d)):(d&&s.push(d),p&&s.push(p)),d=e.find((e=>"audio"===e.getTracks()[0].kind)),p=e.find((e=>"video"===e.getTracks()[0].kind)),o={...o,...a?{screenshareConference:{...r,ssrcs:s}}:{conference:{...r,ssrcs:s}},streams:{...o.streams,[i]:{...o.streams?.[i],...d&&{audio:d},...!a&&p?{video:p}:{presentation:p}}}},u(i),t(c)}}}},{connection:s,dataChannel:i}}},"./src/types.ts":(e,t,a)=>{a.r(t)},"./src/utils.ts":(e,t,a)=>{a.r(t),a.d(t,{toTelegramSource:()=>function(e){return e<<0},fromTelegramSource:()=>function(e){return e>>>0},getAmplitude:()=>function(e,t=3){if(!e)return 0;var a=e.length;let n=0;for(let t=0;tfunction(e){return{id:e.id,name:e.name,"rtcp-fbs":e.feedbackTypes,clockrate:e.clockrate,parameters:e.parameters,channels:e.channels}},THRESHOLD:()=>n,IS_SCREENSHARE_SUPPORTED:()=>s,IS_ECHO_CANCELLATION_SUPPORTED:()=>i,IS_NOISE_SUPPRESSION_SUPPORTED:()=>r});const n=.1,s="getDisplayMedia"in(navigator?.mediaDevices||{}),i=navigator?.mediaDevices?.getSupportedConstraints().echoCancellation,r=navigator?.mediaDevices?.getSupportedConstraints().noiseSuppression}},t={};function a(n){var s=t[n];return void 0!==s||(s=t[n]={exports:{}},e[n](s,s.exports,a)),s.exports}a.d=(e,t)=>{for(var n in t)a.o(t,n)&&!a.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},a.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),a.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})};var n={};(()=>{a.r(n),a.d(n,{handleUpdateGroupCallConnection:()=>e.handleUpdateGroupCallConnection,startSharingScreen:()=>e.startSharingScreen,joinGroupCall:()=>e.joinGroupCall,getDevices:()=>e.getDevices,getUserStreams:()=>e.getUserStreams,setVolume:()=>e.setVolume,isStreamEnabled:()=>e.isStreamEnabled,toggleStream:()=>e.toggleStream,leaveGroupCall:()=>e.leaveGroupCall,handleUpdateGroupCallParticipants:()=>e.handleUpdateGroupCallParticipants,switchCameraInput:()=>e.switchCameraInput,toggleSpeaker:()=>e.toggleSpeaker,toggleNoiseSuppression:()=>e.toggleNoiseSuppression,joinPhoneCall:()=>t.joinPhoneCall,processSignalingMessage:()=>t.processSignalingMessage,getStreams:()=>t.getStreams,toggleStreamP2p:()=>t.toggleStreamP2p,stopPhoneCall:()=>t.stopPhoneCall,switchCameraInputP2p:()=>t.switchCameraInputP2p,IS_SCREENSHARE_SUPPORTED:()=>s.IS_SCREENSHARE_SUPPORTED,THRESHOLD:()=>s.THRESHOLD});var e=a("./src/secretsauce.ts"),t=a("./src/p2p.ts"),s=(a("./src/p2pMessage.ts"),a("./src/utils.ts"));a("./src/types.ts")})();var s,i=exports;for(s in n)i[s]=n[s];n.__esModule&&Object.defineProperty(i,"__esModule",{value:!0})})(); \ No newline at end of file diff --git a/src/lib/secret-sauce/p2p.d.ts b/src/lib/secret-sauce/p2p.d.ts new file mode 100644 index 000000000..3e646493b --- /dev/null +++ b/src/lib/secret-sauce/p2p.d.ts @@ -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; +export declare function toggleStreamP2p(streamType: StreamType, value?: boolean | undefined): Promise; +export declare function joinPhoneCall(connections: ApiPhoneCallConnection[], emitSignalingData: (data: P2pMessage) => void, isOutgoing: boolean, shouldStartVideo: boolean, onUpdate: (...args: any[]) => void): Promise; +export declare function stopPhoneCall(): void; +export declare function processSignalingMessage(message: P2pMessage): Promise; diff --git a/src/lib/secret-sauce/p2pMessage.d.ts b/src/lib/secret-sauce/p2pMessage.d.ts new file mode 100644 index 000000000..ace6969e4 --- /dev/null +++ b/src/lib/secret-sauce/p2pMessage.d.ts @@ -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; + feedbackTypes?: RTCPFeedbackParam[]; +} +declare type P2pSsrcGroup = { + semantics: string; + ssrcs: number[]; +}; +declare type P2pCandidate = { + sdpString: string; +}; +export declare type P2pMessage = CandidatesMessage | InitialSetupMessage | MediaStateMessage; +export {}; diff --git a/src/lib/secret-sauce/parseSdp.d.ts b/src/lib/secret-sauce/parseSdp.d.ts index d789bd62d..1d3c0246d 100644 --- a/src/lib/secret-sauce/parseSdp.d.ts +++ b/src/lib/secret-sauce/parseSdp.d.ts @@ -1,3 +1,3 @@ import { JoinGroupCallPayload } from './types'; -declare const _default: (sessionDescription: RTCSessionDescriptionInit) => JoinGroupCallPayload; +declare const _default: (sessionDescription: RTCSessionDescriptionInit, isP2p?: boolean) => JoinGroupCallPayload; export default _default; diff --git a/src/lib/secret-sauce/secretsauce.d.ts b/src/lib/secret-sauce/secretsauce.d.ts index 8d3829af9..10f35f456 100644 --- a/src/lib/secret-sauce/secretsauce.d.ts +++ b/src/lib/secret-sauce/secretsauce.d.ts @@ -1,5 +1,5 @@ import { GroupCallConnectionData, GroupCallParticipant, JoinGroupCallPayload } from './types'; -declare type StreamType = 'audio' | 'video' | 'presentation'; +export declare type StreamType = 'audio' | 'video' | 'presentation'; export declare function getDevices(streamType: StreamType, isInput?: boolean): Promise; export declare function toggleSpeaker(): void; export declare function toggleNoiseSuppression(): void; @@ -17,4 +17,3 @@ export declare function handleUpdateGroupCallParticipants(updatedParticipants: G export declare function handleUpdateGroupCallConnection(data: GroupCallConnectionData, isPresentation: boolean): Promise; export declare function startSharingScreen(): Promise; export declare function joinGroupCall(myId: string, audioContext: AudioContext, audioElement: HTMLAudioElement, onUpdate: (...args: any[]) => void): Promise; -export {}; diff --git a/src/lib/secret-sauce/types.d.ts b/src/lib/secret-sauce/types.d.ts index 57029540f..88f5af42f 100644 --- a/src/lib/secret-sauce/types.d.ts +++ b/src/lib/secret-sauce/types.d.ts @@ -1,3 +1,4 @@ +import { P2PPayloadType } from './p2pMessage'; export interface GroupCallParticipant { isSelf?: boolean; isMuted?: boolean; @@ -52,6 +53,7 @@ export declare type Candidate = { network: string; 'rel-addr': string; 'rel-port': string; + sdpString?: string; }; export declare type JoinGroupCallPayload = { ufrag: string; @@ -60,6 +62,14 @@ export declare type JoinGroupCallPayload = { ssrc?: number; 'ssrc-groups'?: SsrcGroup[]; }; +export declare type P2pParsedSdp = JoinGroupCallPayload & { + audioExtmap: RTPExtension[]; + videoExtmap: RTPExtension[]; + screencastExtmap: RTPExtension[]; + audioPayloadTypes: P2PPayloadType[]; + videoPayloadTypes: P2PPayloadType[]; + screencastPayloadTypes: P2PPayloadType[]; +}; export interface RTPExtension { id: number; uri: string; @@ -98,3 +108,19 @@ export interface GroupCallConnectionData { }; stream?: boolean; } +export interface ApiPhoneCallConnection { + username: string; + password: string; + isTurn?: boolean; + isStun?: boolean; + ip: string; + ipv6: string; + port: number; +} +export interface ApiCallProtocol { + libraryVersions: string[]; + minLayer: number; + maxLayer: number; + isUdpP2p?: boolean; + isUdpReflector?: boolean; +} diff --git a/src/lib/secret-sauce/utils.d.ts b/src/lib/secret-sauce/utils.d.ts index 2540de229..bd0cd6662 100644 --- a/src/lib/secret-sauce/utils.d.ts +++ b/src/lib/secret-sauce/utils.d.ts @@ -1,11 +1,10 @@ +import { P2PPayloadType } from './p2pMessage'; +import { PayloadType } from './types'; export declare function toTelegramSource(source: number): number; export declare function fromTelegramSource(source: number): number; export declare function getAmplitude(array: Uint8Array, scale?: number): number; -export declare function getPlatform(): "Windows" | "macOS" | "iOS" | "Android" | "Linux" | undefined; +export declare function p2pPayloadTypeToConference(p: P2PPayloadType): PayloadType; export declare const THRESHOLD = 0.1; -export declare const PLATFORM_ENV: "Windows" | "macOS" | "iOS" | "Android" | "Linux" | undefined; -export declare const IS_MAC_OS: boolean; -export declare const IS_IOS: boolean; export declare const IS_SCREENSHARE_SUPPORTED: boolean; export declare const IS_ECHO_CANCELLATION_SUPPORTED: boolean | undefined; export declare const IS_NOISE_SUPPRESSION_SUPPORTED: any; diff --git a/src/styles/Telegram T.json b/src/styles/Telegram T.json index f08d01098..bfe028fd5 100644 --- a/src/styles/Telegram T.json +++ b/src/styles/Telegram T.json @@ -2,7 +2,7 @@ "metadata": { "name": "Telegram T", "lastOpened": 0, - "created": 1648282440794 + "created": 1648554659780 }, "iconSets": [ { @@ -157,13 +157,37 @@ }, { "selection": [ + { + "order": 705, + "id": 53, + "name": "favorite-filled", + "prevSize": 32, + "code": 59800, + "tempChar": "" + }, + { + "order": 702, + "id": 52, + "name": "share-screen", + "prevSize": 32, + "code": 59770, + "tempChar": "" + }, + { + "order": 701, + "id": 51, + "name": "video-outlined", + "prevSize": 32, + "code": 59799, + "tempChar": "" + }, { "order": 700, "id": 50, "name": "stats", "prevSize": 32, "code": 59798, - "tempChar": "" + "tempChar": "" }, { "order": 699, @@ -171,7 +195,7 @@ "name": "copy-media", "prevSize": 32, "code": 59797, - "tempChar": "" + "tempChar": "" }, { "order": 698, @@ -179,7 +203,7 @@ "name": "reaction-filled", "prevSize": 32, "code": 59796, - "tempChar": "" + "tempChar": "" }, { "order": 695, @@ -187,15 +211,15 @@ "name": "reactions", "prevSize": 32, "code": 59795, - "tempChar": "" + "tempChar": "" }, { - "order": 693, + "order": 704, "id": 46, "name": "sidebar", "prevSize": 32, "code": 59794, - "tempChar": "" + "tempChar": "" }, { "order": 690, @@ -203,7 +227,7 @@ "name": "video-stop", "prevSize": 32, "code": 59787, - "tempChar": "" + "tempChar": "" }, { "order": 678, @@ -211,7 +235,7 @@ "name": "speaker", "prevSize": 32, "code": 59777, - "tempChar": "" + "tempChar": "" }, { "order": 679, @@ -219,7 +243,7 @@ "name": "speaker-outline", "prevSize": 32, "code": 59778, - "tempChar": "" + "tempChar": "" }, { "order": 680, @@ -227,7 +251,7 @@ "name": "phone-discard-outline", "prevSize": 32, "code": 59779, - "tempChar": "" + "tempChar": "" }, { "order": 681, @@ -235,7 +259,7 @@ "name": "allow-speak", "prevSize": 32, "code": 59780, - "tempChar": "" + "tempChar": "" }, { "order": 682, @@ -243,15 +267,15 @@ "name": "stop-raising-hand", "prevSize": 32, "code": 59781, - "tempChar": "" + "tempChar": "" }, { "order": 683, "id": 39, - "name": "share-screen", + "name": "share-screen-outlined", "prevSize": 32, "code": 59782, - "tempChar": "" + "tempChar": "" }, { "order": 684, @@ -259,7 +283,7 @@ "name": "voice-chat", "prevSize": 32, "code": 59783, - "tempChar": "" + "tempChar": "" }, { "order": 689, @@ -267,7 +291,7 @@ "name": "video", "prevSize": 32, "code": 59784, - "tempChar": "" + "tempChar": "" }, { "order": 686, @@ -275,15 +299,15 @@ "name": "noise-suppression", "prevSize": 32, "code": 59785, - "tempChar": "" + "tempChar": "" }, { - "order": 688, + "order": 703, "id": 35, "name": "phone-discard", "prevSize": 32, "code": 59786, - "tempChar": "" + "tempChar": "" }, { "order": 667, @@ -291,7 +315,7 @@ "name": "bot-commands-filled", "prevSize": 32, "code": 59775, - "tempChar": "" + "tempChar": "" }, { "order": 664, @@ -299,7 +323,7 @@ "name": "reply-filled", "prevSize": 32, "code": 59776, - "tempChar": "" + "tempChar": "" }, { "order": 656, @@ -307,7 +331,7 @@ "name": "bug", "prevSize": 32, "code": 59774, - "tempChar": "" + "tempChar": "" }, { "order": 619, @@ -315,7 +339,7 @@ "name": "data", "prevSize": 32, "code": 59773, - "tempChar": "" + "tempChar": "" }, { "order": 622, @@ -323,7 +347,7 @@ "name": "darkmode", "prevSize": 32, "code": 59769, - "tempChar": "" + "tempChar": "" }, { "order": 0, @@ -339,7 +363,7 @@ "name": "enter", "prevSize": 32, "code": 59771, - "tempChar": "" + "tempChar": "" }, { "order": 627, @@ -347,7 +371,7 @@ "name": "fontsize", "prevSize": 32, "code": 59772, - "tempChar": "" + "tempChar": "" }, { "order": 630, @@ -355,7 +379,7 @@ "name": "permissions", "prevSize": 32, "code": 59766, - "tempChar": "" + "tempChar": "" }, { "order": 631, @@ -363,7 +387,7 @@ "name": "card", "prevSize": 32, "code": 59767, - "tempChar": "" + "tempChar": "" }, { "order": 634, @@ -371,7 +395,7 @@ "name": "truck", "prevSize": 32, "code": 59768, - "tempChar": "" + "tempChar": "" }, { "order": 663, @@ -379,7 +403,7 @@ "name": "share-filled", "prevSize": 32, "code": 59738, - "tempChar": "" + "tempChar": "" }, { "order": 638, @@ -387,7 +411,7 @@ "name": "bold", "prevSize": 32, "code": 59745, - "tempChar": "" + "tempChar": "" }, { "order": 639, @@ -395,7 +419,7 @@ "name": "bot-command", "prevSize": 32, "code": 59746, - "tempChar": "" + "tempChar": "" }, { "order": 642, @@ -403,7 +427,7 @@ "name": "calendar-filter", "prevSize": 32, "code": 59747, - "tempChar": "" + "tempChar": "" }, { "order": 643, @@ -411,7 +435,7 @@ "name": "comments", "prevSize": 32, "code": 59748, - "tempChar": "" + "tempChar": "" }, { "order": 645, @@ -419,7 +443,7 @@ "name": "comments-sticker", "prevSize": 32, "code": 59749, - "tempChar": "" + "tempChar": "" }, { "order": 646, @@ -427,7 +451,7 @@ "name": "arrow-down", "prevSize": 32, "code": 59750, - "tempChar": "" + "tempChar": "" }, { "order": 668, @@ -435,7 +459,7 @@ "name": "email", "prevSize": 32, "code": 59751, - "tempChar": "" + "tempChar": "" }, { "order": 648, @@ -443,7 +467,7 @@ "name": "italic", "prevSize": 32, "code": 59752, - "tempChar": "" + "tempChar": "" }, { "order": 620, @@ -451,7 +475,7 @@ "name": "link", "prevSize": 32, "code": 59753, - "tempChar": "" + "tempChar": "" }, { "order": 621, @@ -459,7 +483,7 @@ "name": "mention", "prevSize": 32, "code": 59754, - "tempChar": "" + "tempChar": "" }, { "order": 624, @@ -467,7 +491,7 @@ "name": "monospace", "prevSize": 32, "code": 59755, - "tempChar": "" + "tempChar": "" }, { "order": 625, @@ -475,7 +499,7 @@ "name": "next", "prevSize": 32, "code": 59756, - "tempChar": "" + "tempChar": "" }, { "order": 628, @@ -483,7 +507,7 @@ "name": "password-off", "prevSize": 32, "code": 59757, - "tempChar": "" + "tempChar": "" }, { "order": 629, @@ -491,7 +515,7 @@ "name": "pin-list", "prevSize": 32, "code": 59758, - "tempChar": "" + "tempChar": "" }, { "order": 632, @@ -499,7 +523,7 @@ "name": "previous", "prevSize": 32, "code": 59759, - "tempChar": "" + "tempChar": "" }, { "order": 633, @@ -507,7 +531,7 @@ "name": "replace", "prevSize": 32, "code": 59760, - "tempChar": "" + "tempChar": "" }, { "order": 636, @@ -515,7 +539,7 @@ "name": "schedule", "prevSize": 32, "code": 59761, - "tempChar": "" + "tempChar": "" }, { "order": 691, @@ -523,7 +547,7 @@ "name": "strikethrough", "prevSize": 32, "code": 59762, - "tempChar": "" + "tempChar": "" }, { "order": 692, @@ -531,7 +555,7 @@ "name": "underlined", "prevSize": 32, "code": 59763, - "tempChar": "" + "tempChar": "" }, { "order": 641, @@ -539,7 +563,7 @@ "name": "zoom-in", "prevSize": 32, "code": 59764, - "tempChar": "" + "tempChar": "" }, { "order": 649, @@ -547,7 +571,7 @@ "name": "zoom-out", "prevSize": 32, "code": 59765, - "tempChar": "" + "tempChar": "" } ], "id": 2, @@ -561,6 +585,51 @@ "height": 1024, "prevSize": 32, "icons": [ + { + "id": 53, + "paths": [ + "M1004.533 455.333l-222.4 194 66.8 288.267c2.933 12.533 1.467 25.6-3.867 37.067l-3.067 5.6c-16.533 27.067-51.733 35.733-78.8 19.2l-251.2-152.533-251.2 152.667c-10.933 6.667-23.867 9.467-36.4 8l-6.267-1.067c-30.933-7.2-50.133-38-42.933-68.933l66.667-288.267-222.4-194c-9.6-8.4-16.133-19.733-18.533-32.133l-0.933-6.267c-2.667-31.6 20.667-59.333 52.267-62.133l292.533-24.933 114.4-271.6c4.933-11.867 13.733-21.6 24.933-27.867l5.733-2.8c29.2-12.267 62.8 1.467 75.2 30.667l114.267 271.6 292.533 24.933c12.667 1.067 24.667 6.4 34 14.933l4.4 4.533c20.667 24 18.133 60.267-5.733 81.067z" + ], + "attrs": [ + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "favorite-filled" + ] + }, + { + "id": 52, + "paths": [ + "M42.667 358.4c0-95.582 0-143.373 18.601-179.881 16.363-32.113 42.471-58.222 74.584-74.584 36.508-18.601 84.299-18.601 179.881-18.601h307.2c95.582 0 143.373 0 179.883 18.601 32.111 16.363 58.219 42.471 74.581 74.584 18.603 36.508 18.603 84.299 18.603 179.881v136.533c0 9.178 0 17.911-0.017 26.24l-57.766-53.389c-16.098-14.878-32.128-29.7-46.199-40.546-12.898-9.94-39.228-29.061-74.833-30.877-40.213-2.051-79.044 14.937-104.832 45.862-18.999 22.788-24.841 48.994-27.102 66.492-74.615 13.956-134.413 46.891-180.568 91.836-51.985 50.62-82.353 112.196-100.339 167.45-88.048-0.030-133.463-0.755-168.491-18.603-32.113-16.363-58.222-42.47-74.584-74.581-18.601-36.51-18.601-84.301-18.601-179.883v-136.533zM782.596 532.578l119.697 110.622c18.842 17.408 28.258 26.112 31.753 36.309 3.068 8.96 3.068 18.688 0 27.648-3.494 10.197-12.911 18.901-31.753 36.309l-119.697 110.622c-36.22 33.472-54.327 50.206-69.76 50.995-13.406 0.683-26.347-4.979-34.944-15.287-9.899-11.87-9.899-36.527-9.899-85.841v-3.955c-92.523 0-159.77 24.922-207.275 55.091-47.136 29.935-70.704 44.902-78.879 43.627-7.913-1.237-12.819-4.851-16.339-12.045-3.637-7.433 2.144-28.689 13.705-71.202 4.233-15.565 9.449-31.543 15.872-47.471 36.913-91.52 113.7-181.333 272.915-181.333v-3.955c0-49.314 0-73.971 9.899-85.841 1.075-1.289 2.219-2.505 3.418-3.644 8.422-7.979 19.797-12.241 31.526-11.644 0 0 0 0 0 0 1.929 0.098 3.9 0.448 5.943 1.045 14.302 4.19 32.124 20.663 63.817 49.946 0 0 0 0.004 0 0.004z" + ], + "attrs": [ + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "share-screen" + ] + }, + { + "id": 51, + "paths": [ + "M290.134 149.333h232.161v0c34.347-0 62.694-0.001 85.781 1.886 23.983 1.959 46.029 6.164 66.739 16.716 32.111 16.362 58.219 42.471 74.581 74.584 10.551 20.708 14.758 42.754 16.717 66.737 1.22 14.914 1.651 32.020 1.805 51.519l83.46-80.125c48.9-46.945 129.954-12.115 129.954 55.434v351.831c0 67.669-81.169 102.268-129.984 55.403l-83.43-80.094c-0.154 19.499-0.585 36.608-1.805 51.52-1.958 23.983-6.165 46.029-16.717 66.739-16.363 32.111-42.47 58.219-74.581 74.581-20.71 10.551-42.756 14.758-66.739 16.717-23.087 1.886-51.435 1.886-85.781 1.886h-233.925c-34.345 0-62.691 0-85.781-1.886-23.983-1.958-46.029-6.165-66.737-16.717-32.113-16.363-58.222-42.47-74.584-74.581-10.551-20.71-14.756-42.756-16.716-66.739-1.887-23.091-1.886-51.435-1.885-85.781v-233.926c-0.001-34.345-0.001-62.691 1.885-85.781 1.96-23.983 6.165-46.029 16.716-66.737 16.362-32.113 42.471-58.222 74.584-74.584 20.708-10.551 42.754-14.757 66.737-16.716 23.091-1.887 51.437-1.886 85.782-1.886h1.763zM768 497.088c0 0.026 0 0.055 0 0.081v29.662c0 0.026 0 0.055 0 0.081 0.021 11.588 4.757 22.669 13.12 30.694l114.88 110.289v-311.791l-114.893 110.297c-8.354 8.021-13.086 19.089-13.107 30.686zM682.667 497.067v-100.267c0-36.547-0.034-61.392-1.6-80.596-1.532-18.706-4.301-28.272-7.701-34.945-8.179-16.056-21.235-29.111-37.291-37.292-6.673-3.4-16.239-6.17-34.944-7.699-19.204-1.569-44.049-1.602-80.597-1.602h-230.399c-36.547 0-61.392 0.033-80.596 1.602-18.706 1.528-28.272 4.299-34.945 7.699-16.057 8.181-29.111 21.236-37.292 37.292-3.4 6.673-6.17 16.239-7.698 34.945-1.569 19.204-1.602 44.048-1.602 80.596v230.4c0 36.548 0.033 61.393 1.602 80.597 1.528 18.705 4.298 28.271 7.698 34.944 8.181 16.055 21.236 29.111 37.292 37.291 6.673 3.401 16.239 6.17 34.944 7.697 19.204 1.57 44.049 1.604 80.596 1.604h230.399c36.548 0 61.393-0.034 80.597-1.604 18.705-1.527 28.271-4.297 34.944-7.697 16.055-8.179 29.111-21.235 37.291-37.291 3.401-6.673 6.17-16.239 7.701-34.944 1.566-19.204 1.6-44.049 1.6-80.597v-100.271c0-0.030 0-0.064 0-0.098v-29.662c0-0.034 0-0.068 0-0.102z" + ], + "attrs": [ + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "video-outlined" + ] + }, { "id": 50, "paths": [ @@ -640,33 +709,27 @@ { "id": 46, "paths": [ - "M868 886.533h-734.4c-43.867 0-79.467-35.733-79.467-79.467v-569.067c0-43.867 35.733-79.467 79.467-79.467h734.4c43.867 0 79.467 35.733 79.467 79.467v569.067c0 43.733-35.733 79.467-79.467 79.467zM133.6 211.733c-14.4 0-26.133 11.733-26.133 26.133v569.067c0 14.4 11.733 26.133 26.133 26.133h734.4c14.4 0 26.133-11.733 26.133-26.133v-568.933c0-14.4-11.733-26.133-26.133-26.133h-734.4z", - "M346.667 185.067h53.333v674.667h-53.333v-674.667z", - "M272.533 549.2h-109.2c-14.667 0-26.667-12-26.667-26.667s12-26.667 26.667-26.667h109.2c14.667 0 26.667 12 26.667 26.667s-12 26.667-26.667 26.667z", - "M272.533 455.067h-107.867c-14.667 0-26.667-12-26.667-26.667s12-26.667 26.667-26.667h107.867c14.667 0 26.667 12 26.667 26.667s-12 26.667-26.667 26.667z", - "M272.533 356.4h-107.867c-14.667 0-26.667-12-26.667-26.667s12-26.667 26.667-26.667h107.867c14.667 0 26.667 12 26.667 26.667s-12 26.667-26.667 26.667z" + "M768 853.333h-512c-93.867 0-170.667-76.8-170.667-170.667v-341.333c0-93.867 76.8-170.667 170.667-170.667h512c93.867 0 170.667 76.8 170.667 170.667v341.333c0 93.867-76.8 170.667-170.667 170.667zM256 256c-46.933 0-85.333 38.4-85.333 85.333v341.333c0 46.933 38.4 85.333 85.333 85.333h512c46.933 0 85.333-38.4 85.333-85.333v-341.333c0-46.933-38.4-85.333-85.333-85.333h-512z", + "M533.333 213.333h85.333v597.333h-85.333v-597.333z" ], "attrs": [ - {}, - {}, - {}, {}, {} ], - "isMulticolor": false, - "isMulticolor2": false, "grid": 24, "tags": [ "sidebar" - ] + ], + "isMulticolor": false, + "isMulticolor2": false }, { "id": 45, "paths": [ - "M694.667 811.6c18.933-19.2 30.667-45.733 30.667-74.8v-406.933c0-58.933-47.733-106.667-106.667-106.667h-512c-0.133 0-0.4 0-0.533 0l588.533 588.4z", - "M42.667 244.533c-25.867 19.467-42.667 50.4-42.667 85.333v407.067c0 58.933 47.733 106.667 106.667 106.667h512c7.2 0 14.133-0.667 20.933-2.133l-596.933-596.933z", - "M806.8 477.067v104.133c0 40.533 0.8 46.133 27.733 72.933l128.4 128.4c8.933 8.933 34.933 8.933 44.4 0l15.733-15.733v-464.667l-15.733-16.267c-10.267-10.267-36.267-9.333-45.6 0l-130.133 130.933c-18.8 18.4-24.8 26.933-24.8 60.267v0z", - "M858.4 964.933c-7.733 0-15.333-2.933-21.2-8.8l-771.733-771.733c-11.733-11.733-11.733-30.667 0-42.4s30.667-11.733 42.4 0l771.733 771.733c11.733 11.733 11.733 30.667 0 42.4-5.867 5.867-13.467 8.8-21.2 8.8z" + "M217.6 844.8c25.6 0 51.2 0 89.6 0h132.267c89.6 0 136.533 0 170.667-17.067 29.867-17.067 55.467-42.667 72.533-72.533 17.067-34.133 17.067-81.067 17.067-170.667v-132.267c0-76.8 0-119.467-8.533-153.6l-473.6 546.133z", + "M140.8 836.267l512-593.067c-12.8-12.8-29.867-25.6-42.667-34.133-34.133-17.067-81.067-17.067-170.667-17.067h-136.533c-89.6 0-136.533 0-170.667 17.067-29.867 17.067-55.467 42.667-72.533 72.533-17.067 34.133-17.067 81.067-17.067 174.933v132.267c0 89.6 0 136.533 17.067 170.667 17.067 29.867 42.667 55.467 72.533 72.533 4.267 0 8.533 0 8.533 4.267z", + "M891.733 285.867l-140.8 170.667v110.933c0 12.8 4.267 25.6 12.8 34.133l128 153.6c25.6 29.867 76.8 12.8 76.8-29.867v-409.6c0-42.667-51.2-59.733-76.8-29.867z", + "M98.133 878.933c-8.533 0-12.8-4.267-21.333-8.533-12.8-12.8-12.8-29.867-4.267-46.933l597.333-691.2c12.8-12.8 29.867-12.8 46.933-4.267 12.8 12.8 12.8 29.867 4.267 46.933l-597.333 691.2c-8.533 8.533-17.067 12.8-25.6 12.8z" ], "attrs": [ {}, @@ -674,12 +737,12 @@ {}, {} ], - "isMulticolor": false, - "isMulticolor2": false, "grid": 24, "tags": [ "video-stop" - ] + ], + "isMulticolor": false, + "isMulticolor2": false }, { "id": 44, @@ -722,17 +785,17 @@ { "id": 42, "paths": [ - "M989.867 456.96c-236.373-208.64-715.52-208.64-951.893 0-44.373 39.253-45.227 108.8-3.413 150.613l46.933 46.933c37.12 33.707 93.867 35.413 133.973 3.84l69.973-55.467 5.12-4.267c21.76-19.627 34.133-47.787 34.133-77.227l7.253-111.36 16.213-3.413c73.813-14.080 273.92-12.8 347.307 3.413h0.427l7.253 113.493v6.4c1.707 27.733 15.787 55.040 39.253 73.387l69.12 55.040 5.12 3.84c41.387 28.587 97.707 24.32 133.547-11.947l46.933-46.933c37.973-42.24 35.84-108.373-7.253-146.347zM254.72 524.8c-1.28 9.813-5.547 17.92-12.8 23.893l-69.547 55.467-3.413 2.133c-13.653 8.533-31.147 6.4-42.24-4.693l-42.667-42.667-2.987-3.413c-11.52-14.080-10.24-34.987 2.987-46.507l8.96-7.68c50.347-42.667 106.24-74.667 165.12-96.853l3.413-1.28-6.827 121.6zM944.213 558.507l-42.667 42.667-2.987 2.56c-12.8 9.813-30.293 10.24-43.52 0l-69.12-54.613-3.413-2.987c-6.4-6.4-9.813-14.933-9.813-24.32l-5.973-118.187 3.413 1.28c62.293 23.040 121.6 58.027 174.080 104.533 13.653 11.947 14.080 34.987 0 49.067z" + "M182.8 677.333c-30.533 0-60.8-11.867-83.6-34.933l-46.4-46.4c-23.2-23.2-36-55.467-34.933-88.4 1.067-32.4 14.8-62 38.8-83.2 259.6-229.2 650.933-229.2 910.533 0 24 21.2 37.733 50.8 38.8 83.2 1.067 32.933-11.733 65.067-34.933 88.4l-46.533 46.533c-42.533 42.533-110.267 46.533-157.6 9.2l-0.133-0.133-75.467-59.867c-28.4-22.4-44.667-55.733-45.067-91.6l-7.733-148.933c-83.467-17.867-169.867-17.867-253.333 0l-7.733 148.4c-0.4 35.867-16.667 69.2-44.933 91.6l-76.133 60.533c-21.867 17.2-47.867 25.6-73.6 25.6zM298.4 376.8c-66.533 25.067-129.333 62.267-185.333 111.733-8.267 7.333-9.867 16.8-10 21.867-0.267 9.467 3.333 18.667 10 25.333l46.8 46.8c11.467 11.733 30.267 12.8 43.6 2.267l76-60.4c8-6.4 12.667-15.867 12.667-26v-2.267l6.267-119.333zM819.6 584.8c13.333 10.533 32.4 9.333 44.4-2.533l46.533-46.533c6.667-6.667 10.267-15.867 10-25.333-0.133-5.067-1.6-14.533-10-21.867-55.867-49.333-118.8-86.667-185.333-111.733l6.267 121.067v1.067c0 10.133 4.533 19.6 12.667 26l75.467 59.867z" ], "attrs": [ {} ], - "isMulticolor": false, - "isMulticolor2": false, "grid": 24, "tags": [ "phone-discard-outline" - ] + ], + "isMulticolor": false, + "isMulticolor2": false }, { "id": 41, @@ -781,23 +844,17 @@ { "id": 39, "paths": [ - "M896 1023.147h-547.84c-70.827 0-128-57.6-128-128v-769.707c0-70.827 57.6-128 128-128h547.84c70.827 0 128 57.6 128 128v769.707c0 70.4-57.6 128-128 128zM348.16 82.347c-23.467 0-42.667 19.2-42.667 42.667v769.707c0 23.467 19.2 42.667 42.667 42.667h547.84c23.467 0 42.667-19.2 42.667-42.667v-769.28c0-23.467-19.2-42.667-42.667-42.667h-547.84z", - "M148.053 968.96h-20.053c-70.4 0-128-57.173-128-128v-605.867c0-70.4 57.173-128 128-128h24.32c23.467 0 42.667 19.2 42.667 42.667s-19.2 42.667-42.667 42.667h-24.32c-23.467 0-42.667 19.2-42.667 42.667v606.293c0 23.467 19.2 42.667 42.667 42.667h20.48c23.467 0 42.667 19.2 42.667 42.667s-19.627 42.24-43.093 42.24z", - "M622.080 734.293c-23.467 0-42.667-19.2-42.667-42.667v-249.6l-87.893 87.893c-16.64 16.64-43.52 16.64-60.16 0s-16.64-43.52 0-60.16l153.173-153.173c13.227-13.653 33.28-17.92 51.2-10.24 17.493 7.253 29.013 24.32 29.013 43.52v342.187c0 23.467-19.2 42.24-42.667 42.24z", - "M622.080 734.293c-23.467 0-42.667-19.2-42.667-42.667v-341.76c0-19.2 11.52-36.267 29.013-43.52 17.92-7.253 37.973-3.413 51.2 10.24l153.173 152.747c16.64 16.64 16.64 43.52 0 60.16s-43.52 16.64-60.16 0l-87.893-87.893v249.6c0 24.32-19.2 43.093-42.667 43.093z" + "M768 170.667h-512c-47.128 0-85.333 38.205-85.333 85.333v341.333c0 47.13 38.205 85.333 85.333 85.333h128.356c-16.301 28.587-28.367 57.749-37.347 85.333h-91.009c-94.257 0-170.667-76.412-170.667-170.667v-341.333c0-94.257 76.41-170.667 170.667-170.667h512c94.255 0 170.667 76.41 170.667 170.667v265.186l-57.783-53.402c-9.327-8.623-18.633-17.225-27.55-25.045v-186.739c0-47.128-38.204-85.333-85.333-85.333zM437.747 768c11.942-29.619 28.066-59.059 49.809-85.333 44.023-53.205 111.087-93.431 213.167-95.881 3.277-0.081 6.588-0.119 9.937-0.119v0-3.955c0-49.314 0-73.971 9.899-85.841 7.757-9.301 19.051-14.818 31.031-15.309 1.297-0.051 2.603-0.043 3.913 0.021 15.433 0.789 33.54 17.523 69.76 50.995l119.697 110.622c18.837 17.408 28.258 26.112 31.753 36.309 3.068 8.96 3.068 18.688 0 27.648-1.715 5.009-4.864 9.655-9.852 15.155-4.535 4.992-10.59 10.688-18.479 17.993l-123.17 113.83c-31.659 29.257-49.472 45.717-63.765 49.903-2.044 0.597-4.015 0.947-5.943 1.045-10.052 0.512-19.849-2.543-27.742-8.452-2.633-1.971-5.052-4.258-7.202-6.835-9.899-11.87-9.899-36.527-9.899-85.841v-3.955c-2.748 0-5.47 0.021-8.175 0.064-100.326 1.609-173.965 32.661-225.003 66.978v0.004c-20.749 13.948-36.143 24.299-47.334 30.886-2.364 0.742-4.233 1.003-5.642 0.785-7.913-1.237-12.819-4.851-16.339-12.045-3.637-7.433 2.144-28.689 13.705-71.202l0.534-1.946c0.923-3.337 1.891-6.694 2.908-10.061 3.537-11.725 7.65-23.607 12.433-35.465z" ], "attrs": [ - {}, - {}, - {}, {} ], - "isMulticolor": false, - "isMulticolor2": false, "grid": 24, "tags": [ - "share-screen" - ] + "share-screen-outlined" + ], + "isMulticolor": false, + "isMulticolor2": false }, { "id": 38, @@ -823,19 +880,17 @@ { "id": 37, "paths": [ - "M618.667 843.52h-512c-58.88 0-106.667-47.787-106.667-106.667v-407.040c0-58.88 47.787-106.667 106.667-106.667h512c58.88 0 106.667 47.787 106.667 106.667v407.040c0 58.88-47.787 106.667-106.667 106.667z", - "M806.827 477.013v104.107c0 40.533 0.853 46.080 27.733 72.96l128.427 128.427c8.96 8.96 34.987 8.96 44.373 0l15.787-15.787v-464.64l-15.787-16.213c-10.24-10.24-36.267-9.387-45.653 0l-130.133 130.987c-18.773 18.347-24.747 26.88-24.747 60.16z" + "M59.733 759.467c-17.067-34.133-17.067-81.067-17.067-174.933v-132.267c0-89.6 0-136.533 17.067-170.667 17.067-29.867 42.667-55.467 72.533-72.533 34.133-17.067 81.067-17.067 170.667-17.067h132.267c89.6 0 136.533 0 170.667 17.067 29.867 17.067 55.467 42.667 72.533 72.533 17.067 34.133 17.067 81.067 17.067 170.667v132.267c0 89.6 0 136.533-17.067 170.667-17.067 29.867-42.667 55.467-72.533 72.533-34.133 17.067-81.067 17.067-170.667 17.067h-132.267c-89.6 0-136.533 0-170.667-17.067-29.867-12.8-55.467-38.4-72.533-68.267v0zM763.733 601.6c-8.533-8.533-12.8-21.333-12.8-34.133v-110.933l140.8-170.667c25.6-29.867 76.8-12.8 76.8 29.867v409.6c0 42.667-51.2 59.733-76.8 29.867l-128-153.6z" ], "attrs": [ - {}, {} ], - "isMulticolor": false, - "isMulticolor2": false, "grid": 24, "tags": [ "video" - ] + ], + "isMulticolor": false, + "isMulticolor2": false }, { "id": 36, @@ -855,9 +910,11 @@ { "id": 35, "paths": [ - "M997.267 610.72l-4.213 4.467-42.747 42.733c-36.16 36.173-92.573 40.787-134.12 11.88l-5.107-3.787-69.44-55.107c-23.307-18.493-37.493-45.733-39.36-73.64l-0.213-6.453-7.36-114-0.4-0.093c-73.907-16.213-274.587-17.333-348.84-3.267l-16.173 3.307-7.307 111.707c0 29.653-12.533 57.693-34.4 77.48l-5.213 4.413-70.040 55.693c-40.267 31.72-97.107 29.733-134.28-3.973l-4.36-4.2-42.747-42.733c-42.2-42.2-41.373-111.893 3.307-151.36 237.133-209.387 718.307-209.427 955.493 0.040 43.147 38.107 45.387 104.387 7.52 146.893z" + "M940.667 565.867l-46.533 46.533c-27.333 27.467-70.667 29.867-101.067 5.867l-75.467-59.867c-18.267-14.533-28.8-36.267-28.8-59.333l-8.133-155.867h-337.733l-8.133 155.333c0 23.067-10.533 44.933-28.8 59.333l-76 60.4c-30.4 23.867-73.6 21.467-100.533-5.867l-46.533-46.533c-30.4-30.4-30.4-80.933 1.867-109.333 243.467-214.933 610.267-215.2 854 0 32.267 28.4 32.267 78.933 1.867 109.333z", + "M182.8 677.333c-30.533 0-60.8-11.867-83.6-34.933l-46.4-46.4c-23.2-23.2-36-55.467-34.933-88.4 1.067-32.4 14.8-62 38.8-83.2 259.6-229.2 650.933-229.2 910.533 0 24 21.2 37.733 50.8 38.8 83.2 1.067 32.933-11.733 65.067-34.933 88.4l-46.533 46.533c-42.533 42.533-110.267 46.533-157.6 9.2l-0.133-0.133-75.467-59.867c-28.4-22.4-44.667-55.733-45.067-91.6l-7.733-148.933c-83.467-17.867-169.867-17.867-253.333 0l-7.733 148.4c-0.4 35.867-16.667 69.2-44.933 91.6l-76.133 60.533c-21.867 17.2-47.867 25.6-73.6 25.6zM298.4 376.8c-66.533 25.067-129.333 62.267-185.333 111.733-8.267 7.333-9.867 16.8-10 21.867-0.267 9.467 3.333 18.667 10 25.333l46.8 46.8c11.467 11.733 30.267 12.8 43.6 2.267l76-60.4c8-6.4 12.667-15.867 12.667-26v-2.267l6.267-119.333zM819.6 584.8c13.333 10.533 32.4 9.333 44.4-2.533l46.533-46.533c6.667-6.667 10.267-15.867 10-25.333-0.133-5.067-1.6-14.533-10-21.867-55.867-49.333-118.8-86.667-185.333-111.733l6.267 121.067v1.067c0 10.133 4.533 19.6 12.667 26l75.467 59.867z" ], "attrs": [ + {}, {} ], "grid": 24, @@ -2919,7 +2976,7 @@ "name": "select", "prevSize": 32, "code": 59744, - "tempChar": "" + "tempChar": "" }, { "order": 480, @@ -2927,7 +2984,7 @@ "name": "folder", "prevSize": 32, "code": 59667, - "tempChar": "" + "tempChar": "" }, { "order": 481, @@ -2935,7 +2992,7 @@ "name": "bots", "prevSize": 32, "code": 59669, - "tempChar": "" + "tempChar": "" }, { "order": 482, @@ -2943,7 +3000,7 @@ "name": "calendar", "prevSize": 32, "code": 59670, - "tempChar": "" + "tempChar": "" }, { "order": 483, @@ -2951,7 +3008,7 @@ "name": "cloud-download", "prevSize": 32, "code": 59671, - "tempChar": "" + "tempChar": "" }, { "order": 484, @@ -2959,7 +3016,7 @@ "name": "colorize", "prevSize": 32, "code": 59672, - "tempChar": "" + "tempChar": "" }, { "order": 651, @@ -2967,7 +3024,7 @@ "name": "forward", "prevSize": 32, "code": 59687, - "tempChar": "" + "tempChar": "" }, { "order": 650, @@ -2975,7 +3032,7 @@ "name": "reply", "prevSize": 32, "code": 59719, - "tempChar": "" + "tempChar": "" }, { "order": 487, @@ -2983,7 +3040,7 @@ "name": "help", "prevSize": 32, "code": 59690, - "tempChar": "" + "tempChar": "" }, { "order": 488, @@ -2991,7 +3048,7 @@ "name": "info", "prevSize": 32, "code": 59691, - "tempChar": "" + "tempChar": "" }, { "order": 489, @@ -2999,7 +3056,7 @@ "name": "info-filled", "prevSize": 32, "code": 59675, - "tempChar": "" + "tempChar": "" }, { "order": 490, @@ -3007,7 +3064,7 @@ "name": "delete-filled", "prevSize": 32, "code": 59676, - "tempChar": "" + "tempChar": "" }, { "order": 491, @@ -3015,7 +3072,7 @@ "name": "delete", "prevSize": 32, "code": 59677, - "tempChar": "" + "tempChar": "" }, { "order": 492, @@ -3023,7 +3080,7 @@ "name": "edit", "prevSize": 32, "code": 59683, - "tempChar": "" + "tempChar": "" }, { "order": 493, @@ -3031,7 +3088,7 @@ "name": "new-chat-filled", "prevSize": 32, "code": 59705, - "tempChar": "" + "tempChar": "" }, { "order": 494, @@ -3039,7 +3096,7 @@ "name": "send", "prevSize": 32, "code": 59722, - "tempChar": "" + "tempChar": "" }, { "order": 495, @@ -3047,7 +3104,7 @@ "name": "send-outline", "prevSize": 32, "code": 59723, - "tempChar": "" + "tempChar": "" }, { "order": 496, @@ -3055,7 +3112,7 @@ "name": "add-user-filled", "prevSize": 32, "code": 59652, - "tempChar": "" + "tempChar": "" }, { "order": 497, @@ -3063,7 +3120,7 @@ "name": "add-user", "prevSize": 32, "code": 59653, - "tempChar": "" + "tempChar": "" }, { "order": 498, @@ -3071,7 +3128,7 @@ "name": "delete-user", "prevSize": 32, "code": 59678, - "tempChar": "" + "tempChar": "" }, { "order": 499, @@ -3079,7 +3136,7 @@ "name": "microphone", "prevSize": 32, "code": 59701, - "tempChar": "" + "tempChar": "" }, { "order": 500, @@ -3087,7 +3144,7 @@ "name": "microphone-alt", "prevSize": 32, "code": 59707, - "tempChar": "" + "tempChar": "" }, { "order": 501, @@ -3095,7 +3152,7 @@ "name": "poll", "prevSize": 32, "code": 59704, - "tempChar": "" + "tempChar": "" }, { "order": 502, @@ -3103,7 +3160,7 @@ "name": "revote", "prevSize": 32, "code": 59706, - "tempChar": "" + "tempChar": "" }, { "order": 503, @@ -3111,7 +3168,7 @@ "name": "photo", "prevSize": 32, "code": 59712, - "tempChar": "" + "tempChar": "" }, { "order": 504, @@ -3119,7 +3176,7 @@ "name": "document", "prevSize": 32, "code": 59679, - "tempChar": "" + "tempChar": "" }, { "order": 505, @@ -3127,7 +3184,7 @@ "name": "camera", "prevSize": 32, "code": 59662, - "tempChar": "" + "tempChar": "" }, { "order": 506, @@ -3135,7 +3192,7 @@ "name": "camera-add", "prevSize": 32, "code": 59663, - "tempChar": "" + "tempChar": "" }, { "order": 507, @@ -3143,7 +3200,7 @@ "name": "logout", "prevSize": 32, "code": 59698, - "tempChar": "" + "tempChar": "" }, { "order": 508, @@ -3151,7 +3208,7 @@ "name": "saved-messages", "prevSize": 32, "code": 59720, - "tempChar": "" + "tempChar": "" }, { "order": 509, @@ -3159,7 +3216,7 @@ "name": "settings", "prevSize": 32, "code": 59726, - "tempChar": "" + "tempChar": "" }, { "order": 652, @@ -3167,7 +3224,7 @@ "name": "phone", "prevSize": 32, "code": 59711, - "tempChar": "" + "tempChar": "" }, { "order": 653, @@ -3175,7 +3232,7 @@ "name": "attach", "prevSize": 32, "code": 59657, - "tempChar": "" + "tempChar": "" }, { "order": 512, @@ -3183,7 +3240,7 @@ "name": "copy", "prevSize": 32, "code": 59674, - "tempChar": "" + "tempChar": "" }, { "order": 513, @@ -3191,7 +3248,7 @@ "name": "channel", "prevSize": 32, "code": 59665, - "tempChar": "" + "tempChar": "" }, { "order": 514, @@ -3199,7 +3256,7 @@ "name": "group", "prevSize": 32, "code": 59689, - "tempChar": "" + "tempChar": "" }, { "order": 515, @@ -3207,7 +3264,7 @@ "name": "user", "prevSize": 32, "code": 59737, - "tempChar": "" + "tempChar": "" }, { "order": 516, @@ -3215,7 +3272,7 @@ "name": "non-contacts", "prevSize": 32, "code": 59688, - "tempChar": "" + "tempChar": "" }, { "order": 517, @@ -3223,7 +3280,7 @@ "name": "active-sessions", "prevSize": 32, "code": 59650, - "tempChar": "" + "tempChar": "" }, { "order": 518, @@ -3231,7 +3288,7 @@ "name": "admin", "prevSize": 32, "code": 59654, - "tempChar": "" + "tempChar": "" }, { "order": 519, @@ -3239,7 +3296,7 @@ "name": "download", "prevSize": 32, "code": 59681, - "tempChar": "" + "tempChar": "" }, { "order": 520, @@ -3247,7 +3304,7 @@ "name": "location", "prevSize": 32, "code": 59696, - "tempChar": "" + "tempChar": "" }, { "order": 521, @@ -3255,7 +3312,7 @@ "name": "stop", "prevSize": 32, "code": 59730, - "tempChar": "" + "tempChar": "" }, { "order": 523, @@ -3263,7 +3320,7 @@ "name": "archive", "prevSize": 32, "code": 59656, - "tempChar": "" + "tempChar": "" }, { "order": 524, @@ -3271,7 +3328,7 @@ "name": "unarchive", "prevSize": 32, "code": 59731, - "tempChar": "" + "tempChar": "" }, { "order": 525, @@ -3279,7 +3336,7 @@ "name": "readchats", "prevSize": 32, "code": 59699, - "tempChar": "" + "tempChar": "" }, { "order": 526, @@ -3287,7 +3344,7 @@ "name": "unread", "prevSize": 32, "code": 59735, - "tempChar": "" + "tempChar": "" }, { "order": 654, @@ -3295,7 +3352,7 @@ "name": "message", "prevSize": 32, "code": 59700, - "tempChar": "" + "tempChar": "" }, { "order": 659, @@ -3303,7 +3360,7 @@ "name": "lock", "prevSize": 32, "code": 59697, - "tempChar": "" + "tempChar": "" }, { "order": 529, @@ -3311,7 +3368,7 @@ "name": "unlock", "prevSize": 32, "code": 59732, - "tempChar": "" + "tempChar": "" }, { "order": 530, @@ -3319,7 +3376,7 @@ "name": "mute", "prevSize": 32, "code": 59703, - "tempChar": "" + "tempChar": "" }, { "order": 531, @@ -3327,7 +3384,7 @@ "name": "unmute", "prevSize": 32, "code": 59733, - "tempChar": "" + "tempChar": "" }, { "order": 532, @@ -3335,7 +3392,7 @@ "name": "pin", "prevSize": 32, "code": 59713, - "tempChar": "" + "tempChar": "" }, { "order": 533, @@ -3343,7 +3400,7 @@ "name": "unpin", "prevSize": 32, "code": 59734, - "tempChar": "" + "tempChar": "" }, { "order": 534, @@ -3351,7 +3408,7 @@ "name": "smallscreen", "prevSize": 32, "code": 59742, - "tempChar": "" + "tempChar": "" }, { "order": 535, @@ -3359,7 +3416,7 @@ "name": "fullscreen", "prevSize": 32, "code": 59743, - "tempChar": "" + "tempChar": "" }, { "order": 536, @@ -3367,7 +3424,7 @@ "name": "large-pause", "prevSize": 32, "code": 59694, - "tempChar": "" + "tempChar": "" }, { "order": 537, @@ -3375,7 +3432,7 @@ "name": "large-play", "prevSize": 32, "code": 59695, - "tempChar": "" + "tempChar": "" }, { "order": 538, @@ -3383,7 +3440,7 @@ "name": "pause", "prevSize": 32, "code": 59709, - "tempChar": "" + "tempChar": "" }, { "order": 539, @@ -3391,7 +3448,7 @@ "name": "play", "prevSize": 32, "code": 59715, - "tempChar": "" + "tempChar": "" }, { "order": 540, @@ -3399,7 +3456,7 @@ "name": "channelviews", "prevSize": 32, "code": 59666, - "tempChar": "" + "tempChar": "" }, { "order": 541, @@ -3407,7 +3464,7 @@ "name": "message-succeeded", "prevSize": 32, "code": 59648, - "tempChar": "" + "tempChar": "" }, { "order": 657, @@ -3415,7 +3472,7 @@ "name": "message-read", "prevSize": 32, "code": 59649, - "tempChar": "" + "tempChar": "" }, { "order": 543, @@ -3423,7 +3480,7 @@ "name": "message-pending", "prevSize": 32, "code": 59724, - "tempChar": "" + "tempChar": "" }, { "order": 544, @@ -3431,7 +3488,7 @@ "name": "message-failed", "prevSize": 32, "code": 59725, - "tempChar": "" + "tempChar": "" }, { "order": 545, @@ -3439,7 +3496,7 @@ "name": "favorite", "prevSize": 32, "code": 59710, - "tempChar": "" + "tempChar": "" }, { "order": 546, @@ -3447,7 +3504,7 @@ "name": "keyboard", "prevSize": 32, "code": 59716, - "tempChar": "" + "tempChar": "" }, { "order": 547, @@ -3455,7 +3512,7 @@ "name": "delete-left", "prevSize": 32, "code": 59717, - "tempChar": "" + "tempChar": "" }, { "order": 548, @@ -3463,7 +3520,7 @@ "name": "recent", "prevSize": 32, "code": 59718, - "tempChar": "" + "tempChar": "" }, { "order": 549, @@ -3471,7 +3528,7 @@ "name": "gifs", "prevSize": 32, "code": 59727, - "tempChar": "" + "tempChar": "" }, { "order": 550, @@ -3479,7 +3536,7 @@ "name": "stickers", "prevSize": 32, "code": 59739, - "tempChar": "" + "tempChar": "" }, { "order": 551, @@ -3487,7 +3544,7 @@ "name": "smile", "prevSize": 32, "code": 59728, - "tempChar": "" + "tempChar": "" }, { "order": 552, @@ -3495,7 +3552,7 @@ "name": "animals", "prevSize": 32, "code": 59655, - "tempChar": "" + "tempChar": "" }, { "order": 553, @@ -3503,7 +3560,7 @@ "name": "eats", "prevSize": 32, "code": 59682, - "tempChar": "" + "tempChar": "" }, { "order": 554, @@ -3511,7 +3568,7 @@ "name": "sport", "prevSize": 32, "code": 59729, - "tempChar": "" + "tempChar": "" }, { "order": 555, @@ -3519,7 +3576,7 @@ "name": "car", "prevSize": 32, "code": 59664, - "tempChar": "" + "tempChar": "" }, { "order": 556, @@ -3527,7 +3584,7 @@ "name": "lamp", "prevSize": 32, "code": 59692, - "tempChar": "" + "tempChar": "" }, { "order": 557, @@ -3535,7 +3592,7 @@ "name": "language", "prevSize": 32, "code": 59693, - "tempChar": "" + "tempChar": "" }, { "order": 558, @@ -3543,7 +3600,7 @@ "name": "flag", "prevSize": 32, "code": 59686, - "tempChar": "" + "tempChar": "" }, { "order": 559, @@ -3551,7 +3608,7 @@ "name": "more", "prevSize": 32, "code": 59702, - "tempChar": "" + "tempChar": "" }, { "order": 560, @@ -3559,7 +3616,7 @@ "name": "search", "prevSize": 32, "code": 59721, - "tempChar": "" + "tempChar": "" }, { "order": 561, @@ -3567,7 +3624,7 @@ "name": "remove", "prevSize": 32, "code": 59740, - "tempChar": "" + "tempChar": "" }, { "order": 562, @@ -3575,7 +3632,7 @@ "name": "add", "prevSize": 32, "code": 59651, - "tempChar": "" + "tempChar": "" }, { "order": 563, @@ -3583,7 +3640,7 @@ "name": "check", "prevSize": 32, "code": 59668, - "tempChar": "" + "tempChar": "" }, { "order": 564, @@ -3591,7 +3648,7 @@ "name": "close", "prevSize": 32, "code": 59673, - "tempChar": "" + "tempChar": "" }, { "order": 610, @@ -3599,7 +3656,7 @@ "name": "arrow-left", "prevSize": 32, "code": 59661, - "tempChar": "" + "tempChar": "" }, { "order": 566, @@ -3607,7 +3664,7 @@ "name": "arrow-right", "prevSize": 32, "code": 59708, - "tempChar": "" + "tempChar": "" }, { "order": 567, @@ -3615,7 +3672,7 @@ "name": "down", "prevSize": 32, "code": 59680, - "tempChar": "" + "tempChar": "" }, { "order": 568, @@ -3623,7 +3680,7 @@ "name": "up", "prevSize": 32, "code": 59736, - "tempChar": "" + "tempChar": "" }, { "order": 569, @@ -3631,7 +3688,7 @@ "name": "eye-closed", "prevSize": 32, "code": 59685, - "tempChar": "" + "tempChar": "" }, { "order": 570, @@ -3639,7 +3696,7 @@ "name": "eye", "prevSize": 32, "code": 59684, - "tempChar": "" + "tempChar": "" }, { "order": 571, @@ -3647,7 +3704,7 @@ "name": "muted", "prevSize": 32, "code": 59741, - "tempChar": "" + "tempChar": "" }, { "order": 572, @@ -3655,7 +3712,7 @@ "name": "avatar-archived-chats", "prevSize": 32, "code": 59658, - "tempChar": "" + "tempChar": "" }, { "order": 573, @@ -3663,7 +3720,7 @@ "name": "avatar-deleted-account", "prevSize": 32, "code": 59659, - "tempChar": "" + "tempChar": "" }, { "order": 574, @@ -3671,7 +3728,7 @@ "name": "avatar-saved-messages", "prevSize": 32, "code": 59660, - "tempChar": "" + "tempChar": "" }, { "order": 575, @@ -3679,7 +3736,7 @@ "name": "pinned-chat", "prevSize": 32, "code": 59714, - "tempChar": "" + "tempChar": "" } ], "prevSize": 32, @@ -3727,4 +3784,4 @@ "showLiga": false }, "uid": -1 -} +} \ No newline at end of file diff --git a/src/styles/icons.scss b/src/styles/icons.scss index 35124fce8..da1c22e89 100644 --- a/src/styles/icons.scss +++ b/src/styles/icons.scss @@ -51,6 +51,15 @@ .icon-volume-3:before { content: "\e991"; } +.icon-favorite-filled:before { + content: "\e998"; +} +.icon-share-screen:before { + content: "\e97a"; +} +.icon-video-outlined:before { + content: "\e997"; +} .icon-stats:before { content: "\e996"; } @@ -84,7 +93,7 @@ .icon-stop-raising-hand:before { content: "\e985"; } -.icon-share-screen:before { +.icon-share-screen-outlined:before { content: "\e986"; } .icon-voice-chat:before { diff --git a/src/util/dateFormat.ts b/src/util/dateFormat.ts index d7090c50c..25fde8338 100644 --- a/src/util/dateFormat.ts +++ b/src/util/dateFormat.ts @@ -121,6 +121,47 @@ export function formatLastUpdated(lang: LangFn, currentTime: number, lastUpdated } } +type DurationType = 'Seconds' | 'Minutes' | 'Hours' | 'Days' | 'Weeks'; + +export function formatTimeDuration(lang: LangFn, duration: number, showLast = 2) { + if (!duration) { + return undefined; + } + + const durationRecords: { duration: number; type: DurationType }[] = []; + const labels = [ + { multiplier: 1, type: 'Seconds' }, + { multiplier: 60, type: 'Minutes' }, + { multiplier: 60, type: 'Hours' }, + { multiplier: 24, type: 'Days' }, + { multiplier: 7, type: 'Weeks' }, + ] as Array<{ multiplier: number; type: DurationType }>; + let t = 1; + labels.forEach((label, idx) => { + t *= label.multiplier; + + if (duration < t) { + return; + } + + const modulus = labels[idx === (labels.length - 1) ? idx : idx + 1].multiplier!; + durationRecords.push({ + duration: Math.floor((duration / t) % modulus), + type: label.type, + }); + }); + + const out = durationRecords.slice(-showLast).reverse(); + for (let i = out.length - 1; i >= 0; --i) { + if (out[i].duration === 0) { + out.splice(i, 1); + } + } + + // TODO In arabic we don't use "," as delimiter rather we use "and" each time + return out.map((l) => lang(l.type, l.duration, 'i')).join(', '); +} + export function formatHumanDate( lang: LangFn, datetime: number | Date, diff --git a/src/util/notifications.ts b/src/util/notifications.ts index 0145ae0ef..ec5f57040 100644 --- a/src/util/notifications.ts +++ b/src/util/notifications.ts @@ -1,6 +1,6 @@ import { callApi } from '../api/gramjs'; import { - ApiChat, ApiMediaFormat, ApiMessage, ApiUser, ApiUserReaction, + ApiChat, ApiMediaFormat, ApiMessage, ApiPhoneCall, ApiUser, ApiUserReaction, } from '../api/types'; import { renderActionMessageText } from '../components/common/helpers/renderActionMessageText'; import { DEBUG, IS_TEST } from '../config'; @@ -12,7 +12,7 @@ import { getMessageRecentReaction, getMessageSenderName, getMessageSummaryText, - getPrivateChatUserId, + getPrivateChatUserId, getUserFullName, isActionMessage, isChatChannel, selectIsChatMuted, @@ -322,7 +322,7 @@ function getNotificationContent(chat: ApiChat, message: ApiMessage, reaction?: A }; } -async function getAvatar(chat: ApiChat) { +async function getAvatar(chat: ApiChat | ApiUser) { const imageHash = getChatAvatarHash(chat); if (!imageHash) return undefined; let mediaData = mediaLoader.getFromMemory(imageHash); @@ -333,6 +333,39 @@ async function getAvatar(chat: ApiChat) { return mediaData; } +export async function notifyAboutCall({ + call, user, +}: { + call: ApiPhoneCall; user: ApiUser; +}) { + const { hasWebNotifications } = await loadNotificationSettings(); + if (document.hasFocus() || !hasWebNotifications) return; + const areNotificationsSupported = checkIfNotificationsSupported(); + if (!areNotificationsSupported) return; + + const icon = await getAvatar(user); + + const options: NotificationOptions = { + body: getUserFullName(user), + icon, + badge: icon, + tag: `call_${call.id}`, + }; + + if ('vibrate' in navigator) { + options.vibrate = [200, 100, 200]; + } + + const notification = new Notification(getTranslation('VoipIncoming'), options); + + notification.onclick = () => { + notification.close(); + if (window.focus) { + window.focus(); + } + }; +} + export async function notifyAboutMessage({ chat, message, diff --git a/src/util/phoneCallEmojiConstants.ts b/src/util/phoneCallEmojiConstants.ts new file mode 100644 index 000000000..c02b5ab8d --- /dev/null +++ b/src/util/phoneCallEmojiConstants.ts @@ -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, +]; diff --git a/webpack.config.js b/webpack.config.js index 1e8881bf9..9f2c965a3 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -80,7 +80,16 @@ module.exports = (env = {}, argv = {}) => { test: /\.scss$/, use: [ MiniCssExtractPlugin.loader, - 'css-loader', + { + loader: 'css-loader', + options: { + modules: { + exportLocalsConvention: 'camelCase', + auto: true, + localIdentName: argv['optimize-minimize'] ? '[hash:base64]' : '[path][name]__[local]' + } + } + }, 'postcss-loader', 'sass-loader', ],