diff --git a/.eslintrc b/.eslintrc index ab6dd23bb..28eb23c2f 100644 --- a/.eslintrc +++ b/.eslintrc @@ -74,6 +74,7 @@ }, "ignorePatterns": [ "webpack.config.js", - "jest.config.js" + "jest.config.js", + "src/lib/secret-sauce" ] } diff --git a/package-lock.json b/package-lock.json index 2b17d202d..ea57d1297 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8633,11 +8633,11 @@ } }, "ext": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.4.0.tgz", - "integrity": "sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.5.0.tgz", + "integrity": "sha512-+ONcYoWj/SoQwUofMr94aGu05Ou4FepKi7N7b+O8T4jVfyIsZQV1/xeS8jpaBzF0csAk0KLXoHCxU7cKYZjo1Q==", "requires": { - "type": "^2.0.0" + "type": "^2.5.0" }, "dependencies": { "type": { @@ -15127,9 +15127,9 @@ } }, "node-gyp-build": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.2.3.tgz", - "integrity": "sha512-MN6ZpzmfNCRM+3t57PTJHgHyw/h4OWnZ6mR8P5j/uZtqQr46RRuDE/P+g3n0YR/AiYXeWixZZzaip77gdICfRg==" + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.3.0.tgz", + "integrity": "sha512-iWjXZvmboq0ja1pUGULQBexmxq8CV4xBhX7VDOTbL7ZR4FOowwY/VOtRxBN/yKxmdGoIp4j5ysNT4u3S2pDQ3Q==" }, "node-int64": { "version": "0.4.0", diff --git a/public/voicechat_connecting.mp3 b/public/voicechat_connecting.mp3 new file mode 100644 index 000000000..b5cd7af45 Binary files /dev/null and b/public/voicechat_connecting.mp3 differ diff --git a/public/voicechat_join.mp3 b/public/voicechat_join.mp3 new file mode 100644 index 000000000..2a1bbb464 Binary files /dev/null and b/public/voicechat_join.mp3 differ diff --git a/public/voicechat_leave.mp3 b/public/voicechat_leave.mp3 new file mode 100644 index 000000000..942dd0c65 Binary files /dev/null and b/public/voicechat_leave.mp3 differ diff --git a/public/voicechat_onallowtalk.mp3 b/public/voicechat_onallowtalk.mp3 new file mode 100644 index 000000000..b5dfaa606 Binary files /dev/null and b/public/voicechat_onallowtalk.mp3 differ diff --git a/public/voicechat_recordstart.mp3 b/public/voicechat_recordstart.mp3 new file mode 100644 index 000000000..0060e82c3 Binary files /dev/null and b/public/voicechat_recordstart.mp3 differ diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index d6cf98c52..a8f799828 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -14,6 +14,10 @@ declare namespace React { interface ImgHTMLAttributes extends HTMLAttributes { loading?: 'auto' | 'eager' | 'lazy'; } + + interface VideoHTMLAttributes { + srcObject?: MediaStream; + } } type AnyLiteral = Record; diff --git a/src/api/gramjs/apiBuilders/calls.ts b/src/api/gramjs/apiBuilders/calls.ts new file mode 100644 index 000000000..4430a6e0a --- /dev/null +++ b/src/api/gramjs/apiBuilders/calls.ts @@ -0,0 +1,98 @@ +import { GroupCallParticipant, GroupCallParticipantVideo, SsrcGroup } from '../../../lib/secret-sauce'; +import { Api as GramJs } from '../../../lib/gramjs'; +import { ApiGroupCall } from '../../types'; +import { getApiChatIdFromMtpPeer, isPeerUser } from './peers'; + +export function buildApiGroupCallParticipant(participant: GramJs.GroupCallParticipant): GroupCallParticipant { + const { + self, min, about, date, versioned, canSelfUnmute, justJoined, left, muted, mutedByYou, source, volume, + volumeByAdmin, videoJoined, peer, video, presentation, raiseHandRating, + } = participant; + + return { + isSelf: self, + isMin: min, + canSelfUnmute, + isLeft: left, + isMuted: muted, + isMutedByMe: mutedByYou, + hasJustJoined: justJoined, + isVolumeByAdmin: volumeByAdmin, + isVersioned: versioned, + isVideoJoined: videoJoined, + about, + source, + raiseHandRating: raiseHandRating?.toString(), + volume, + date: new Date(date), + isUser: isPeerUser(peer), + id: getApiChatIdFromMtpPeer(peer), + video: video ? buildApiGroupCallParticipantVideo(video) : undefined, + presentation: presentation ? buildApiGroupCallParticipantVideo(presentation) : undefined, + }; +} + +function buildApiGroupCallParticipantVideo( + participantVideo: GramJs.GroupCallParticipantVideo, +): GroupCallParticipantVideo { + const { + audioSource, endpoint, paused, sourceGroups, + } = participantVideo; + return { + audioSource, + endpoint, + isPaused: paused, + sourceGroups: sourceGroups.map(buildApiGroupCallParticipantVideoSourceGroup), + }; +} + +function buildApiGroupCallParticipantVideoSourceGroup( + participantVideoSourceGroup: GramJs.GroupCallParticipantVideoSourceGroup, +): SsrcGroup { + return { + semantics: participantVideoSourceGroup.semantics, + sources: participantVideoSourceGroup.sources, + }; +} + +export function buildApiGroupCall(groupCall: GramJs.TypeGroupCall): ApiGroupCall { + const { + id, accessHash, + } = groupCall; + + if (groupCall instanceof GramJs.GroupCallDiscarded) { + return { + connectionState: 'discarded', + id: id.toString(), + accessHash: accessHash.toString(), + participantsCount: 0, + version: 0, + participants: {}, + }; + } + + const { + version, participantsCount, streamDcId, scheduleDate, canChangeJoinMuted, joinMuted, canStartVideo, + scheduleStartSubscribed, + } = groupCall; + + return { + connectionState: 'disconnected', + isLoaded: true, + id: id.toString(), + accessHash: accessHash.toString(), + version, + participantsCount, + streamDcId, + scheduleDate, + canChangeJoinMuted, + joinMuted, + canStartVideo, + scheduleStartSubscribed, + participants: {}, + }; +} + +export function getGroupCallId(groupCall: GramJs.TypeInputGroupCall) { + return groupCall.id.toString(); +} diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index a5abe5e55..00e382ab2 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -21,6 +21,7 @@ import { ApiChat, ApiThreadInfo, ApiInvoice, + ApiGroupCall, } from '../../types'; import { @@ -593,6 +594,7 @@ function buildAction( return undefined; } + let call: Partial | undefined; let amount: number | undefined; let currency: string | undefined; let text: string; @@ -677,6 +679,13 @@ function buildAction( const mins = Math.max(Math.round(action.duration! / 60), 1); translationValues.push(`${mins} min${mins > 1 ? 's' : ''}`); } + } else if (action instanceof GramJs.MessageActionInviteToGroupCall) { + text = 'Notification.VoiceChatInvitation'; + call = { + id: action.call.id.toString(), + accessHash: action.call.accessHash.toString(), + }; + translationValues.push('%action_origin%', '%target_user%'); } else if (action instanceof GramJs.MessageActionContactSignUp) { text = 'Notification.Joined'; translationValues.push('%action_origin%'); @@ -696,6 +705,10 @@ function buildAction( translationValues.push(`${mins} min${mins > 1 ? 's' : ''}`); } else { text = 'Notification.VoiceChatStartedChannel'; + call = { + id: action.call.id.toString(), + accessHash: action.call.accessHash.toString(), + }; } } else if (action instanceof GramJs.MessageActionBotAllowed) { text = 'Chat.Service.BotPermissionAllowed'; @@ -718,6 +731,7 @@ function buildAction( amount, currency, translationValues, + call, }; } diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts index 21700fb85..ff48a3685 100644 --- a/src/api/gramjs/gramjsBuilders/index.ts +++ b/src/api/gramjs/gramjsBuilders/index.ts @@ -5,15 +5,16 @@ import { ApiPrivacyKey } from '../../../types'; import { generateRandomBytes, readBigIntFromBuffer } from '../../../lib/gramjs/Helpers'; import { - ApiSticker, - ApiVideo, - ApiNewPoll, + ApiChatAdminRights, + ApiChatBannedRights, + ApiChatFolder, + ApiGroupCall, ApiMessageEntity, ApiMessageEntityTypes, - ApiChatFolder, - ApiChatBannedRights, - ApiChatAdminRights, + ApiNewPoll, ApiReportReason, + ApiSticker, + ApiVideo, } from '../../types'; import localDb from '../localDb'; import { pick } from '../../../util/iteratees'; @@ -237,6 +238,10 @@ export function generateRandomBigInt() { return readBigIntFromBuffer(generateRandomBytes(8), true, true); } +export function generateRandomInt() { + return readBigIntFromBuffer(generateRandomBytes(4), true, true).toJSNumber(); +} + export function buildMessageFromUpdate( id: number, chatId: string, @@ -424,3 +429,10 @@ export function buildMtpPeerId(id: string, type: 'user' | 'chat' | 'channel') { return type === 'user' ? BigInt(id) : BigInt(id.slice(1)); } + +export function buildInputGroupCall(groupCall: Partial) { + return new GramJs.InputGroupCall({ + id: BigInt(groupCall.id!), + accessHash: BigInt(groupCall.accessHash!), + }); +} diff --git a/src/api/gramjs/helpers.ts b/src/api/gramjs/helpers.ts index 9080b6f1c..b2a0cabcd 100644 --- a/src/api/gramjs/helpers.ts +++ b/src/api/gramjs/helpers.ts @@ -47,3 +47,7 @@ export function addChatToLocalDb(chat: GramJs.TypeChat) { localDb.chats[buildApiPeerId(chat.id, chat instanceof GramJs.Chat ? 'chat' : 'channel')] = chat; } } + +export function addUserToLocalDb(user: GramJs.User) { + localDb.users[buildApiPeerId(user.id, 'user')] = user; +} diff --git a/src/api/gramjs/methods/bots.ts b/src/api/gramjs/methods/bots.ts index e1cc37d29..ee95eb326 100644 --- a/src/api/gramjs/methods/bots.ts +++ b/src/api/gramjs/methods/bots.ts @@ -9,7 +9,7 @@ import { buildInputPeer, generateRandomBigInt } from '../gramjsBuilders'; import { buildApiUser } from '../apiBuilders/users'; import { buildApiBotInlineMediaResult, buildApiBotInlineResult, buildBotSwitchPm } from '../apiBuilders/bots'; import { buildApiChatFromPreview } from '../apiBuilders/chats'; -import { buildApiPeerId } from '../apiBuilders/peers'; +import { addUserToLocalDb } from '../helpers'; export function init() { } @@ -158,10 +158,6 @@ function getInlineBotResultsNextOffset(username: string, nextOffset?: string) { return username === 'gif' && nextOffset === '0' ? '' : nextOffset; } -function addUserToLocalDb(user: GramJs.User) { - localDb.users[buildApiPeerId(user.id, 'user')] = user; -} - function addDocumentToLocalDb(document: GramJs.Document) { localDb.documents[String(document.id)] = document; } diff --git a/src/api/gramjs/methods/calls.ts b/src/api/gramjs/methods/calls.ts new file mode 100644 index 000000000..d2d00ab13 --- /dev/null +++ b/src/api/gramjs/methods/calls.ts @@ -0,0 +1,236 @@ +import { JoinGroupCallPayload } from '../../../lib/secret-sauce'; +import { + ApiChat, ApiUser, OnApiUpdate, ApiGroupCall, +} from '../../types'; +import { Api as GramJs } from '../../../lib/gramjs'; + +import { invokeRequest } from './client'; +import { buildInputGroupCall, buildInputPeer, generateRandomInt } from '../gramjsBuilders'; +import { + buildApiGroupCall, + buildApiGroupCallParticipant, + +} from '../apiBuilders/calls'; +import { buildApiUser } from '../apiBuilders/users'; +import { buildApiChatFromPreview } from '../apiBuilders/chats'; +import { addChatToLocalDb, addUserToLocalDb } from '../helpers'; +import { GROUP_CALL_PARTICIPANTS_LIMIT } from '../../../config'; + +let onUpdate: OnApiUpdate; + +export function init(_onUpdate: OnApiUpdate) { + onUpdate = _onUpdate; +} + +export async function getGroupCall({ + call, +}: { + call: Partial; +}) { + const result = await invokeRequest(new GramJs.phone.GetGroupCall({ + call: buildInputGroupCall(call), + })); + + if (!result) { + return undefined; + } + + result.users.map(addUserToLocalDb); + result.chats.map(addChatToLocalDb); + + const users = result.users.map(buildApiUser).filter(Boolean as any); + const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean as any); + + return { + groupCall: buildApiGroupCall(result.call), + users, + chats, + }; +} + +export function discardGroupCall({ + call, +}: { + call: ApiGroupCall; +}) { + return invokeRequest(new GramJs.phone.DiscardGroupCall({ + call: buildInputGroupCall(call), + }), true); +} + +export function editGroupCallParticipant({ + call, participant, muted, presentationPaused, videoStopped, videoPaused, volume, + raiseHand, +}: { + call: ApiGroupCall; participant: ApiUser; muted?: boolean; presentationPaused?: boolean; + videoStopped?: boolean; videoPaused?: boolean; raiseHand?: boolean; volume?: number; +}) { + return invokeRequest(new GramJs.phone.EditGroupCallParticipant({ + call: buildInputGroupCall(call), + participant: buildInputPeer(participant.id, participant.accessHash), + ...(videoStopped !== undefined && { videoStopped }), + ...(videoPaused !== undefined && { videoPaused }), + ...(muted !== undefined && { muted }), + ...(presentationPaused !== undefined && { presentationPaused }), + ...(raiseHand !== undefined && { raiseHand }), + ...(volume !== undefined && { volume }), + }), true); +} + +export function editGroupCallTitle({ + groupCall, title, +}: { + groupCall: ApiGroupCall; title: string; +}) { + return invokeRequest(new GramJs.phone.EditGroupCallTitle({ + title, + call: buildInputGroupCall(groupCall), + }), true); +} + +export async function exportGroupCallInvite({ + call, canSelfUnmute, +}: { + call: ApiGroupCall; canSelfUnmute: boolean; +}) { + const result = await invokeRequest(new GramJs.phone.ExportGroupCallInvite({ + canSelfUnmute: canSelfUnmute || undefined, + call: buildInputGroupCall(call), + })); + + if (!result) { + return undefined; + } + + return result.link; +} + +export async function fetchGroupCallParticipants({ + call, offset, +}: { + call: ApiGroupCall; offset?: string; +}) { + const result = await invokeRequest(new GramJs.phone.GetGroupParticipants({ + call: buildInputGroupCall(call), + ids: [], + sources: [], + offset: offset || '', + limit: GROUP_CALL_PARTICIPANTS_LIMIT, + })); + + if (!result) { + return undefined; + } + + result.users.map(addUserToLocalDb); + result.chats.map(addChatToLocalDb); + + const users = result.users.map(buildApiUser).filter(Boolean as any); + const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean as any); + + onUpdate({ + '@type': 'updateGroupCallParticipants', + groupCallId: call.id, + participants: result.participants.map(buildApiGroupCallParticipant), + nextOffset: result.nextOffset, + }); + + return { + users, chats, + }; +} + +export function leaveGroupCall({ + call, +}: { + call: ApiGroupCall; +}) { + return invokeRequest(new GramJs.phone.LeaveGroupCall({ + call: buildInputGroupCall(call), + }), true); +} + +export async function joinGroupCall({ + call, inviteHash, params, +}: { + call: ApiGroupCall; inviteHash?: string; params: JoinGroupCallPayload; +}) { + const result = await invokeRequest(new GramJs.phone.JoinGroupCall({ + call: buildInputGroupCall(call), + joinAs: new GramJs.InputPeerSelf(), + muted: true, + videoStopped: true, + params: new GramJs.DataJSON({ + data: JSON.stringify(params), + }), + inviteHash, + }), true); + + if (!result) return undefined; + + if (result instanceof GramJs.Updates) { + const update = result.updates.find((u) => u instanceof GramJs.UpdateGroupCall); + if (!(update instanceof GramJs.UpdateGroupCall)) return undefined; + + return buildApiGroupCall(update.call); + } + + return undefined; +} + +export async function createGroupCall({ + peer, +}: { + peer: ApiChat; +}) { + const randomId = generateRandomInt(); + const result = await invokeRequest(new GramJs.phone.CreateGroupCall({ + peer: buildInputPeer(peer.id, peer.accessHash), + randomId, + }), true); + + if (!result) return undefined; + + if (result instanceof GramJs.Updates) { + const update = result.updates[0]; + if (update instanceof GramJs.UpdateGroupCall) { + return buildApiGroupCall(update.call); + } + } + + return undefined; +} + +export function joinGroupCallPresentation({ + call, params, +}: { + call: ApiGroupCall; params: JoinGroupCallPayload; +}) { + return invokeRequest(new GramJs.phone.JoinGroupCallPresentation({ + call: buildInputGroupCall(call), + params: new GramJs.DataJSON({ + data: JSON.stringify(params), + }), + }), true); +} + +export function toggleGroupCallStartSubscription({ + call, subscribed, +}: { + call: ApiGroupCall; subscribed: boolean; +}) { + return invokeRequest(new GramJs.phone.ToggleGroupCallStartSubscription({ + call: buildInputGroupCall(call), + subscribed, + }), true); +} + +export function leaveGroupCallPresentation({ + call, +}: { + call: ApiGroupCall; +}) { + return invokeRequest(new GramJs.phone.LeaveGroupCallPresentation({ + call: buildInputGroupCall(call), + }), true); +} diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index aae826131..5932da779 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -11,6 +11,7 @@ import { ApiChatFolder, ApiChatBannedRights, ApiChatAdminRights, + ApiGroupCall, } from '../../types'; import { @@ -323,6 +324,7 @@ export function clearDraft(chat: ApiChat) { async function getFullChatInfo(chatId: string): Promise<{ fullInfo: ApiChatFullInfo; users?: ApiUser[]; + groupCall?: Partial; } | undefined> { const result = await invokeRequest(new GramJs.messages.GetFullChat({ chatId: buildInputEntity(chatId) as BigInt.BigInteger, @@ -339,6 +341,7 @@ async function getFullChatInfo(chatId: string): Promise<{ participants, exportedInvite, botInfo, + call, } = result.fullChat; const members = buildChatMembers(participants); @@ -355,8 +358,19 @@ async function getFullChatInfo(chatId: string): Promise<{ ...(exportedInvite && { inviteLink: exportedInvite.link, }), + groupCallId: call?.id.toString(), }, users: result.users.map(buildApiUser).filter(Boolean as any), + groupCall: call ? { + chatId, + isLoaded: false, + id: call.id.toString(), + accessHash: call.accessHash.toString(), + connectionState: 'disconnected', + participantsCount: 0, + version: 0, + participants: {}, + } : undefined, }; } @@ -364,7 +378,11 @@ async function getFullChannelInfo( id: string, accessHash: string, adminRights?: ApiChatAdminRights, -) { +): Promise<{ + fullInfo: ApiChatFullInfo; + users?: ApiUser[]; + groupCall?: Partial; + } | undefined> { const result = await invokeRequest(new GramJs.channels.GetFullChannel({ channel: buildInputEntity(id, accessHash) as GramJs.InputChannel, })); @@ -438,6 +456,16 @@ async function getFullChannelInfo( botCommands, }, users: [...(users || []), ...(bannedUsers || []), ...(adminUsers || [])], + groupCall: call ? { + chatId: id, + isLoaded: false, + id: call.id.toString(), + accessHash: call?.accessHash.toString(), + participants: {}, + version: 0, + participantsCount: 0, + connectionState: 'disconnected', + } : undefined, }; } diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 3f2848f1c..09988c31d 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -60,3 +60,9 @@ export { export { validateRequestedInfo, sendPaymentForm, getPaymentForm, getReceipt, } from './payments'; + +export { + getGroupCall, joinGroupCall, discardGroupCall, createGroupCall, + editGroupCallTitle, editGroupCallParticipant, exportGroupCallInvite, fetchGroupCallParticipants, + joinGroupCallPresentation, leaveGroupCall, leaveGroupCallPresentation, toggleGroupCallStartSubscription, +} from './calls'; diff --git a/src/api/gramjs/provider.ts b/src/api/gramjs/provider.ts index 07eedacf3..0a509dea6 100644 --- a/src/api/gramjs/provider.ts +++ b/src/api/gramjs/provider.ts @@ -17,6 +17,7 @@ import { init as initClient } from './methods/client'; import { init as initStickers } from './methods/symbols'; import { init as initManagement } from './methods/management'; import { init as initTwoFaSettings } from './methods/twoFaSettings'; +import { init as initCalls } from './methods/calls'; import * as methods from './methods'; let onUpdate: OnApiUpdate; @@ -32,6 +33,7 @@ export async function initApi(_onUpdate: OnApiUpdate, initialArgs: ApiInitialArg initStickers(handleUpdate); initManagement(handleUpdate); initTwoFaSettings(handleUpdate); + initCalls(handleUpdate); await initClient(handleUpdate, initialArgs); } diff --git a/src/api/gramjs/updater.ts b/src/api/gramjs/updater.ts index 713eef467..fabd83f7e 100644 --- a/src/api/gramjs/updater.ts +++ b/src/api/gramjs/updater.ts @@ -1,3 +1,4 @@ +import { GroupCallConnectionData } from '../../lib/secret-sauce'; import { Api as GramJs, connection } from '../../lib/gramjs'; import { ApiMessage, ApiUpdateConnectionStateType, OnApiUpdate } from '../types'; @@ -33,6 +34,11 @@ import { DEBUG } from '../../config'; import { addMessageToLocalDb, addPhotoToLocalDb, resolveMessageApiChatId } from './helpers'; import { buildApiNotifyException, buildPrivacyKey, buildPrivacyRules } from './apiBuilders/misc'; import { buildApiPhoto } from './apiBuilders/common'; +import { + buildApiGroupCall, + buildApiGroupCallParticipant, + getGroupCallId, +} from './apiBuilders/calls'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from './apiBuilders/peers'; type Update = ( @@ -50,6 +56,39 @@ export function init(_onUpdate: OnApiUpdate) { const sentMessageIds = new Set(); let serverTimeOffset = 0; +function addEntities(entities: (GramJs.TypeUser | GramJs.TypeChat)[] | undefined) { + if (entities?.length) { + entities + .filter((e) => e instanceof GramJs.User) + .map(buildApiUser) + .forEach((user) => { + if (!user) { + return; + } + + onUpdate({ + '@type': 'updateUser', + id: user.id, + user, + }); + }); + entities + .filter((e) => e instanceof GramJs.Chat || e instanceof GramJs.Channel) + .map((e) => buildApiChatFromPreview(e)) + .forEach((chat) => { + if (!chat) { + return; + } + + onUpdate({ + '@type': 'updateChat', + id: chat.id, + chat, + }); + }); + } +} + export function updater(update: Update, originRequest?: GramJs.AnyRequest) { if (update instanceof connection.UpdateServerTimeOffset) { serverTimeOffset = update.timeOffset; @@ -111,37 +150,7 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) { } // eslint-disable-next-line no-underscore-dangle - const entities = update._entities; - if (entities?.length) { - entities - .filter((e) => e instanceof GramJs.User) - .map(buildApiUser) - .forEach((user) => { - if (!user) { - return; - } - - onUpdate({ - '@type': 'updateUser', - id: user.id, - user, - }); - }); - entities - .filter((e) => e instanceof GramJs.Chat || e instanceof GramJs.Channel) - .map((e) => buildApiChatFromPreview(e)) - .forEach((chat) => { - if (!chat) { - return; - } - - onUpdate({ - '@type': 'updateChat', - id: chat.id, - chat, - }); - }); - } + addEntities(update._entities); if (update instanceof GramJs.UpdateNewScheduledMessage) { onUpdate({ @@ -232,6 +241,17 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) { id: message.chatId, }); } + } else if (action instanceof GramJs.MessageActionGroupCall) { + if (!action.duration && action.call) { + onUpdate({ + '@type': 'updateGroupCallChatId', + chatId: message.chatId, + call: { + id: action.call.id.toString(), + accessHash: action.call.accessHash.toString(), + }, + }); + } } } } else if ( @@ -785,6 +805,26 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) { onUpdate({ '@type': 'updateResetContactList' }); } else if (update instanceof GramJs.UpdateFavedStickers) { onUpdate({ '@type': 'updateFavoriteStickers' }); + } else if (update instanceof GramJs.UpdateGroupCall) { + onUpdate({ + '@type': 'updateGroupCall', + call: buildApiGroupCall(update.call), + }); + } else if (update instanceof GramJs.UpdateGroupCallConnection) { + onUpdate({ + '@type': 'updateGroupCallConnection', + data: JSON.parse(update.params.data) as GroupCallConnectionData, + presentation: Boolean(update.presentation), + }); + } else if (update instanceof GramJs.UpdateGroupCallParticipants) { + // eslint-disable-next-line no-underscore-dangle + addEntities(update._entities); + + onUpdate({ + '@type': 'updateGroupCallParticipants', + groupCallId: getGroupCallId(update.call), + participants: update.participants.map(buildApiGroupCallParticipant), + }); } else if (DEBUG) { const params = typeof update === 'object' && 'className' in update ? update.className : update; // eslint-disable-next-line no-console diff --git a/src/api/types/calls.ts b/src/api/types/calls.ts new file mode 100644 index 000000000..7f2a6dc3c --- /dev/null +++ b/src/api/types/calls.ts @@ -0,0 +1,26 @@ +import { GroupCallParticipant, GroupCallConnectionState } from '../../lib/secret-sauce'; + +export interface ApiGroupCall { + chatId?: string; + isLoaded?: boolean; + id: string; + accessHash: string; + joinMuted?: true; + canChangeJoinMuted?: true; + canStartVideo?: true; + joinDateAsc?: true; + scheduleStartSubscribed?: true; + participantsCount: number; + params?: any; + title?: string; + streamDcId?: number; + recordStartDate?: number; + scheduleDate?: number; + version: number; + inviteHash?: string; + + nextOffset?: string; + participants: Record; + connectionState: GroupCallConnectionState; + isSpeakerDisabled?: boolean; +} diff --git a/src/api/types/chats.ts b/src/api/types/chats.ts index 0f1b24ed3..0cf8d59d4 100644 --- a/src/api/types/chats.ts +++ b/src/api/types/chats.ts @@ -108,6 +108,7 @@ export interface ApiChatAdminRights { pinMessages?: true; addAdmins?: true; anonymous?: true; + manageCall?: true; } export interface ApiChatBannedRights { diff --git a/src/api/types/index.ts b/src/api/types/index.ts index b7c320395..d0059b8a2 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -7,3 +7,4 @@ export * from './payments'; export * from './settings'; export * from './bots'; export * from './misc'; +export * from './calls'; diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index af8c8ac15..4be0c1812 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -1,3 +1,5 @@ +import { ApiGroupCall } from './calls'; + export interface ApiDimensions { width: number; height: number; @@ -155,6 +157,7 @@ export interface ApiAction { amount?: number; currency?: string; translationValues: string[]; + call?: Partial; } export interface ApiWebPage { diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index 7ca4693af..b051d3bf9 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -1,3 +1,4 @@ +import { GroupCallConnectionData, GroupCallParticipant, GroupCallConnectionState } from '../../lib/secret-sauce'; import { ApiChat, ApiChatFullInfo, @@ -12,6 +13,9 @@ import { ApiUser, ApiUserFullInfo, ApiUserStatus } from './users'; import { ApiError, ApiInviteInfo, ApiNotifyException, ApiSessionData, } from './misc'; +import { + ApiGroupCall, +} from './calls'; export type ApiUpdateReady = { '@type': 'updateApiReady'; @@ -378,6 +382,48 @@ export type ApiUpdateServerTimeOffset = { serverTimeOffset: number; }; +export type ApiUpdateGroupCall = { + '@type': 'updateGroupCall'; + call: ApiGroupCall; +}; + +export type ApiUpdateGroupCallChatId = { + '@type': 'updateGroupCallChatId'; + call: Partial; + chatId: string; +}; + +export type ApiUpdateGroupCallLeavePresentation = { + '@type': 'updateGroupCallLeavePresentation'; +}; + +export type ApiUpdateGroupCallParticipants = { + '@type': 'updateGroupCallParticipants'; + groupCallId: string; + participants: GroupCallParticipant[]; + nextOffset?: string; +}; + +export type ApiUpdateGroupCallConnection = { + '@type': 'updateGroupCallConnection'; + data: GroupCallConnectionData; + presentation: boolean; +}; + +export type ApiUpdateGroupCallStreams = { + '@type': 'updateGroupCallStreams'; + userId: string; + hasAudioStream: boolean; + hasVideoStream: boolean; + hasPresentationStream: boolean; +}; + +export type ApiUpdateGroupCallConnectionState = { + '@type': 'updateGroupCallConnectionState'; + connectionState: GroupCallConnectionState; + isSpeakerDisabled?: boolean; +}; + export type ApiUpdate = ( ApiUpdateReady | ApiUpdateSession | ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser | @@ -395,7 +441,9 @@ export type ApiUpdate = ( ApiUpdateDeleteScheduledMessages | ApiUpdateResetMessages | ApiUpdateTwoFaError | ApiUpdateTwoFaStateWaitCode | ApiUpdateNotifySettings | ApiUpdateNotifyExceptions | ApiUpdatePeerBlocked | ApiUpdatePrivacy | - ApiUpdateServerTimeOffset | ApiUpdateShowInvite + ApiUpdateServerTimeOffset | ApiUpdateShowInvite | + ApiUpdateGroupCallParticipants | ApiUpdateGroupCallConnection | ApiUpdateGroupCall | ApiUpdateGroupCallStreams | + ApiUpdateGroupCallConnectionState | ApiUpdateGroupCallLeavePresentation | ApiUpdateGroupCallChatId ); export type OnApiUpdate = (update: ApiUpdate) => void; diff --git a/src/assets/animatedIcons/CallSchedule.tgs b/src/assets/animatedIcons/CallSchedule.tgs new file mode 100644 index 000000000..17ab60453 Binary files /dev/null and b/src/assets/animatedIcons/CallSchedule.tgs differ diff --git a/src/assets/animatedIcons/CameraFlip.tgs b/src/assets/animatedIcons/CameraFlip.tgs new file mode 100644 index 000000000..50f957bb3 Binary files /dev/null and b/src/assets/animatedIcons/CameraFlip.tgs differ diff --git a/src/assets/animatedIcons/HandFilled.tgs b/src/assets/animatedIcons/HandFilled.tgs new file mode 100644 index 000000000..a36e0b409 Binary files /dev/null and b/src/assets/animatedIcons/HandFilled.tgs differ diff --git a/src/assets/animatedIcons/HandOutline.tgs b/src/assets/animatedIcons/HandOutline.tgs new file mode 100644 index 000000000..3bc7f9932 Binary files /dev/null and b/src/assets/animatedIcons/HandOutline.tgs differ diff --git a/src/assets/animatedIcons/Speaker.tgs b/src/assets/animatedIcons/Speaker.tgs new file mode 100644 index 000000000..6d3314949 Binary files /dev/null and b/src/assets/animatedIcons/Speaker.tgs differ diff --git a/src/assets/animatedIcons/VoiceAllowTalk.tgs b/src/assets/animatedIcons/VoiceAllowTalk.tgs new file mode 100644 index 000000000..f3144b7a2 Binary files /dev/null and b/src/assets/animatedIcons/VoiceAllowTalk.tgs differ diff --git a/src/assets/animatedIcons/VoiceMini.tgs b/src/assets/animatedIcons/VoiceMini.tgs new file mode 100644 index 000000000..1e75fe309 Binary files /dev/null and b/src/assets/animatedIcons/VoiceMini.tgs differ diff --git a/src/assets/animatedIcons/VoiceMuted.tgs b/src/assets/animatedIcons/VoiceMuted.tgs new file mode 100644 index 000000000..db3aa69af Binary files /dev/null and b/src/assets/animatedIcons/VoiceMuted.tgs differ diff --git a/src/assets/animatedIcons/VoiceOutlined.tgs b/src/assets/animatedIcons/VoiceOutlined.tgs new file mode 100644 index 000000000..2ee903eb7 Binary files /dev/null and b/src/assets/animatedIcons/VoiceOutlined.tgs differ diff --git a/src/assets/animatedIcons/VoipGroupRemoved.tgs b/src/assets/animatedIcons/VoipGroupRemoved.tgs new file mode 100644 index 000000000..c5e834f4e Binary files /dev/null and b/src/assets/animatedIcons/VoipGroupRemoved.tgs differ diff --git a/src/assets/animatedIcons/VoipInvite.tgs b/src/assets/animatedIcons/VoipInvite.tgs new file mode 100644 index 000000000..7d93d0c1b Binary files /dev/null and b/src/assets/animatedIcons/VoipInvite.tgs differ diff --git a/src/assets/animatedIcons/VoipMuted.tgs b/src/assets/animatedIcons/VoipMuted.tgs new file mode 100644 index 000000000..a7cec2f27 Binary files /dev/null and b/src/assets/animatedIcons/VoipMuted.tgs differ diff --git a/src/assets/animatedIcons/VoipRecordSave.tgs b/src/assets/animatedIcons/VoipRecordSave.tgs new file mode 100644 index 000000000..362e19fa1 Binary files /dev/null and b/src/assets/animatedIcons/VoipRecordSave.tgs differ diff --git a/src/assets/animatedIcons/VoipRecordStart.tgs b/src/assets/animatedIcons/VoipRecordStart.tgs new file mode 100644 index 000000000..9f8fb7e8d Binary files /dev/null and b/src/assets/animatedIcons/VoipRecordStart.tgs differ diff --git a/src/assets/animatedIcons/VoipUnmuted.tgs b/src/assets/animatedIcons/VoipUnmuted.tgs new file mode 100644 index 000000000..e5fdc374c Binary files /dev/null and b/src/assets/animatedIcons/VoipUnmuted.tgs differ diff --git a/src/assets/fonts/icomoon.woff b/src/assets/fonts/icomoon.woff index 112b736f3..9cfcfcb6f 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 17707ddda..98d10e616 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 new file mode 100644 index 000000000..cc2f86d7f --- /dev/null +++ b/src/bundles/calls.ts @@ -0,0 +1,2 @@ +export { default as GroupCall } from '../components/calls/group/GroupCall'; +export { default as ActiveCallHeader } from '../components/calls/ActiveCallHeader'; diff --git a/src/components/calls/ActiveCallHeader.async.tsx b/src/components/calls/ActiveCallHeader.async.tsx new file mode 100644 index 000000000..a0f4eaf0a --- /dev/null +++ b/src/components/calls/ActiveCallHeader.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 = { + groupCallId?: string; +}; + +const ActiveCallHeaderAsync: FC = (props) => { + const { groupCallId } = props; + const ActiveCallHeader = useModuleLoader(Bundles.Calls, 'ActiveCallHeader', !groupCallId); + + return ActiveCallHeader ? : undefined; +}; + +export default memo(ActiveCallHeaderAsync); diff --git a/src/components/calls/ActiveCallHeader.scss b/src/components/calls/ActiveCallHeader.scss new file mode 100644 index 000000000..e17ae83be --- /dev/null +++ b/src/components/calls/ActiveCallHeader.scss @@ -0,0 +1,22 @@ +.ActiveCallHeader { + position: absolute; + top: 0; + left: 0; + height: 2rem; + width: 100%; + z-index: 1; + + display: flex; + justify-content: center; + font-weight: 500; + font-size: 0.875rem; + color: #fff; + align-items: center; + padding: 0 1rem; + background: linear-gradient(90deg, rgb(82, 206, 93), rgb(0, 177, 192)); + transform: translateY(-100%); + + &.open { + transform: translateY(0); + } +} diff --git a/src/components/calls/ActiveCallHeader.tsx b/src/components/calls/ActiveCallHeader.tsx new file mode 100644 index 000000000..d2d912631 --- /dev/null +++ b/src/components/calls/ActiveCallHeader.tsx @@ -0,0 +1,63 @@ +import { GroupCallParticipant } from '../../lib/secret-sauce'; +import React, { + FC, memo, useEffect, +} from '../../lib/teact/teact'; +import { withGlobal } from '../../lib/teact/teactn'; + +import { GlobalActions } from '../../global/types'; +import { ApiGroupCall } from '../../api/types'; + +import { selectActiveGroupCall, selectGroupCallParticipant } from '../../modules/selectors/calls'; +import { pick } from '../../util/iteratees'; +import buildClassName from '../../util/buildClassName'; +import useLang from '../../hooks/useLang'; + +import './ActiveCallHeader.scss'; + +type StateProps = { + isGroupCallPanelHidden?: boolean; + meParticipant: GroupCallParticipant; + groupCall?: ApiGroupCall; +}; + +type DispatchProps = Pick; + +const ActiveCallHeader: FC = ({ + groupCall, + meParticipant, + isGroupCallPanelHidden, + toggleGroupCallPanel, +}) => { + const lang = useLang(); + + useEffect(() => { + document.body.classList.toggle('has-group-call-header', isGroupCallPanelHidden); + }, [isGroupCallPanelHidden]); + + if (!groupCall || !meParticipant) return undefined; + + return ( +
+ {groupCall.title || lang('VoipGroupVoiceChat')} +
+ ); +}; + +export default memo(withGlobal( + (global): StateProps => { + return { + groupCall: selectActiveGroupCall(global), + isGroupCallPanelHidden: global.groupCalls.isGroupCallPanelHidden, + meParticipant: selectGroupCallParticipant(global, global.groupCalls.activeGroupCallId!, global.currentUserId!), + }; + }, + (setGlobal, actions) => pick(actions, [ + 'toggleGroupCallPanel', + ]), +)(ActiveCallHeader)); diff --git a/src/components/calls/group/GroupCall.async.tsx b/src/components/calls/group/GroupCall.async.tsx new file mode 100644 index 000000000..1317e4201 --- /dev/null +++ b/src/components/calls/group/GroupCall.async.tsx @@ -0,0 +1,14 @@ +import React, { FC, memo } from '../../../lib/teact/teact'; +import useModuleLoader from '../../../hooks/useModuleLoader'; +import { Bundles } from '../../../util/moduleLoader'; +import { OwnProps } from './GroupCall'; + +const GroupCallAsync: FC = (props) => { + const { groupCallId } = props; + const GroupCall = useModuleLoader(Bundles.Calls, 'GroupCall', !groupCallId); + + // eslint-disable-next-line react/jsx-props-no-spreading + return GroupCall ? : undefined; +}; + +export default memo(GroupCallAsync); diff --git a/src/components/calls/group/GroupCall.scss b/src/components/calls/group/GroupCall.scss new file mode 100644 index 000000000..0532036e6 --- /dev/null +++ b/src/components/calls/group/GroupCall.scss @@ -0,0 +1,348 @@ +.GroupCall { + .modal-content { + display: flex; + flex-direction: column; + align-items: center; + height: 37.5rem; + } + + .modal-dialog { + max-height: calc(100% - 4rem); + background: #181F27; + } + + .Menu .bubble { + --color-background: #232A34; + --color-chat-hover: #2F363E; + --color-item-active: #2F363E; + --color-text: #fff; + box-shadow: 0 0.25rem 0.5rem 0.125rem rgba(16, 16, 16, 0.3); + } + + // Compact menu items + .MenuItem { + padding: 0.75rem 1rem !important; + } + + &.single-column { + opacity: 1 !important; + + .modal-dialog { + max-width: 100% !important; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + margin-top: auto; + margin-bottom: 0; + transform: translate3d(0, 100%, 0); + transition: transform .3s ease, opacity .3s ease; + } + + .modal-backdrop { + opacity: 0; + transition: opacity 0.2s ease; + } + + &.open { + .modal-backdrop { + opacity: 1; + } + + .modal-dialog { + transform: translate3d(0, 0, 0); + } + } + } + + .header { + width: 100%; + display: flex; + align-items: center; + color: #fff; + margin-bottom: 0.5rem; + + h3 { + font-size: 1.25rem; + font-weight: 500; + margin: 0 auto 0 0.5rem; + } + } + + .videos { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + + .participants { + margin-top: 0.75rem; + background: #222B34; + border-radius: 0.75rem; + + .Loading { + padding: 2rem 0; + } + + .invite-btn { + padding: 0.25rem 0.75rem; + display: flex; + align-items: center; + border-radius: 0.75rem; + transition: .15s ease-out background-color; + cursor: pointer; + color: var(--color-text-secondary); + + &:hover { + background: #2F363E; + } + + .text { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + + .icon { + display: flex; + justify-content: center; + align-items: center; + width: 2.75rem; + height: 2.75rem; + font-size: 1.5rem; + margin-right: 1rem; + } + } + } + + .scrollable { + overflow: auto; + padding-bottom: 2rem; + max-width: 37.5rem; + width: 100%; + } + + .buttons { + max-width: 37.5rem; + margin-top: auto; + display: flex; + align-items: center; + justify-content: space-around; + width: 100%; + position: relative; + height: 8.75rem; + + button { + cursor: pointer; + } + + &::before { + position: absolute; + content: ""; + width: 100%; + height: 2rem; + background: linear-gradient(0deg, #181F27, rgba(24, 31, 39, 0)); + z-index: 0; + top: -2rem; + } + + .button-wrapper { + width: 4rem; + display: flex; + flex-direction: column; + align-items: center; + + .button-text { + white-space: nowrap; + font-size: 0.75rem; + margin-top: 0.5rem; + color: #fff; + } + + &.microphone-wrapper { + width: 6rem; + + .button-text { + margin-top: 0.75rem; + font-size: 1rem; + } + } + } + + .Loading { + position: absolute; + transform: translate(0, -1.125rem); + + .Spinner { + --spinner-size: 6.5rem; + } + } + + .video-buttons { + display: flex; + flex-direction: column; + align-items: center; + } + + .small-button, .smaller-button { + outline: none; + border: 0; + background: #15415b; + border-radius: 50%; + width: 3rem; + height: 3rem; + color: #fff; + font-size: 1.375rem; + display: flex; + align-items: center; + justify-content: center; + transition: 0.25s ease-out background-color; + + &:hover { + background: #11364b; + } + } + + .small-button.camera.active { + background: #15415b; + + &:hover { + background: #11364b; + } + } + + .small-button.speaker { + background: #2B3A51; + + &.active { + background: #496092; + } + } + + .small-button.leave { + background: #5A2824; + + &:hover { + background: #49201d; + } + } + + .smaller-button { + width: 2.5rem; + height: 2.5rem; + margin-bottom: 0.5rem; + padding: 0; + } + } + + + &.landscape .scrollable { + display: flex; + flex-direction: row; + flex-grow: 1; + gap: 1rem; + align-items: flex-start; + max-width: 100%; + max-height: 100%; + } + + &.landscape .GroupCallParticipantVideo { + max-height: initial; + + video { + height: 100%; + } + } + + &.landscape .buttons { + position: absolute; + left: calc(50% - 15.625rem / 2); + transform: translateX(-50%); + width: auto; + gap: 1rem; + bottom: 4rem; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(10px); + border-radius: 1rem; + z-index: 5; + padding: 0.75rem 1rem; + height: auto; + + .button-text { + display: none; + } + + .video-buttons { + flex-direction: row; + gap: 1rem; + + .smaller-button { + margin-bottom: 0; + } + } + + .Loading { + transform: none; + .Spinner { + --spinner-size: 3.25rem; + } + } + + .MicrophoneButton { + canvas { + width: 2rem !important; + height: 2rem !important; + } + } + + .MicrophoneButton, .microphone-wrapper { + width: 3rem; + height: 3rem; + + .AnimatedSticker { + display: flex; + align-items: center; + justify-content: center; + } + } + + &::before { + display: none; + } + } + + &.landscape.no-sidebar .buttons { + left: calc(50%); + } + + &.landscape .streams { + width: 100%; + height: 100%; + } + + &.landscape .videos { + width: 100%; + height: 100%; + + display: grid; + --column-count: 1; + grid-template-columns: repeat(var(--column-count), 1fr); + grid-auto-rows: 1fr; + + .GroupCallParticipantVideo { + max-height: 100%; + width: 100%; + + .thumbnail-wrapper { + height: 100%; + } + } + + &.span-last-video .GroupCallParticipantVideo:last-child { + grid-column: span var(--column-count); + } + } + + &.landscape .participants { + width: 15.625rem; + margin-top: 0; + } +} diff --git a/src/components/calls/group/GroupCall.tsx b/src/components/calls/group/GroupCall.tsx new file mode 100644 index 000000000..e9847a844 --- /dev/null +++ b/src/components/calls/group/GroupCall.tsx @@ -0,0 +1,412 @@ +import { + GroupCallConnectionState, GroupCallParticipant as TypeGroupCallParticipant, + IS_SCREENSHARE_SUPPORTED, switchCameraInput, toggleSpeaker, +} from '../../../lib/secret-sauce'; +import React, { + FC, memo, useCallback, useEffect, useMemo, useRef, useState, +} from '../../../lib/teact/teact'; +import { withGlobal } from '../../../lib/teact/teactn'; +import '../../../modules/actions/calls'; + +import { GlobalActions } from '../../../global/types'; +import { IAnchorPosition } from '../../../types'; + +import { + IS_ANDROID, + IS_IOS, + IS_REQUEST_FULLSCREEN_SUPPORTED, + IS_SINGLE_COLUMN_LAYOUT, +} from '../../../util/environment'; +import { pick } from '../../../util/iteratees'; +import buildClassName from '../../../util/buildClassName'; +import { + selectGroupCall, + selectGroupCallParticipant, + selectIsAdminInActiveGroupCall, +} from '../../../modules/selectors/calls'; +import useFlag from '../../../hooks/useFlag'; +import useLang from '../../../hooks/useLang'; + +import Loading from '../../ui/Loading'; +import Button from '../../ui/Button'; +import DropdownMenu from '../../ui/DropdownMenu'; +import MenuItem from '../../ui/MenuItem'; +import Modal from '../../ui/Modal'; +import MicrophoneButton from './MicrophoneButton'; +import AnimatedIcon from '../../common/AnimatedIcon'; +import Checkbox from '../../ui/Checkbox'; +import GroupCallParticipantMenu from './GroupCallParticipantMenu'; +import GroupCallParticipantList from './GroupCallParticipantList'; +import GroupCallParticipantStreams from './GroupCallParticipantStreams'; + +import './GroupCall.scss'; + +const CAMERA_FLIP_PLAY_SEGMENT: [number, number] = [0, 10]; +const PARTICIPANT_HEIGHT = 60; + +export type OwnProps = { + groupCallId: string; +}; + +type StateProps = { + isGroupCallPanelHidden: boolean; + connectionState: GroupCallConnectionState; + title?: string; + meParticipant?: TypeGroupCallParticipant; + participantsCount?: number; + isSpeakerEnabled?: boolean; + isAdmin: boolean; + participants: Record; +}; + +type DispatchProps = Pick; + +const GroupCall: FC = ({ + groupCallId, + isGroupCallPanelHidden, + connectionState, + isSpeakerEnabled, + title, + meParticipant, + isAdmin, + participants, + + toggleGroupCallVideo, + toggleGroupCallPresentation, + leaveGroupCall, + toggleGroupCallPanel, + connectToActiveGroupCall, + playGroupCallSound, +}) => { + const lang = useLang(); + // eslint-disable-next-line no-null/no-null + const containerRef = useRef(null); + + const [isLeaving, setIsLeaving] = useState(false); + const [isFullscreen, openFullscreen, closeFullscreen] = useFlag(); + const [isSidebarOpen, openSidebar, closeSidebar] = useFlag(true); + const hasVideoParticipants = participants && Object.values(participants).some((l) => l.video || l.presentation); + const isLandscape = isFullscreen && !IS_SINGLE_COLUMN_LAYOUT && hasVideoParticipants; + + const [participantMenu, setParticipantMenu] = useState<{ + participant: TypeGroupCallParticipant; + anchor: IAnchorPosition; + } | undefined>(); + const [isParticipantMenuOpen, openParticipantMenu, closeParticipantMenu] = useFlag(); + + const [isConfirmLeaveModalOpen, openConfirmLeaveModal, closeConfirmLeaveModal] = useFlag(); + const [isEndGroupCallModal, setIsEndGroupCallModal] = useState(false); + const [shouldEndGroupCall, setShouldEndGroupCall] = useState(false); + + const hasVideo = meParticipant?.hasVideoStream; + const hasPresentation = meParticipant?.hasPresentationStream; + const isConnecting = connectionState !== 'connected'; + const canSelfUnmute = meParticipant?.canSelfUnmute; + const shouldRaiseHand = !canSelfUnmute && meParticipant?.isMuted; + + const handleOpenParticipantMenu = useCallback((anchor: HTMLDivElement, participant: TypeGroupCallParticipant) => { + const rect = anchor.getBoundingClientRect(); + const container = containerRef.current!; + + setParticipantMenu({ + anchor: { x: rect.left, y: rect.top - container.offsetTop + PARTICIPANT_HEIGHT }, + participant, + }); + + openParticipantMenu(); + }, [openParticipantMenu]); + + useEffect(() => { + if (connectionState === 'connected') { + playGroupCallSound({ sound: 'join' }); + } else if (connectionState === 'reconnecting') { + playGroupCallSound({ sound: 'connecting' }); + } + }, [connectionState, playGroupCallSound]); + + const handleShouldEndGroupCallChange = useCallback(() => { + setShouldEndGroupCall(!shouldEndGroupCall); + }, [shouldEndGroupCall]); + + const handleCloseConfirmLeaveModal = () => { + closeConfirmLeaveModal(); + setIsEndGroupCallModal(false); + }; + + const MainButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => { + return ({ onTrigger, isOpen }) => ( + + ); + }, [lang]); + + const handleToggleFullscreen = useCallback(() => { + if (!containerRef.current) return; + + if (isFullscreen) { + document.exitFullscreen().then(closeFullscreen); + } else { + containerRef.current.requestFullscreen().then(openFullscreen); + } + }, [closeFullscreen, isFullscreen, openFullscreen]); + + const handleToggleSidebar = () => { + if (isSidebarOpen) { + closeSidebar(); + } else { + openSidebar(); + } + }; + + const handleStreamsDoubleClick = useCallback(() => { + if (!IS_REQUEST_FULLSCREEN_SUPPORTED) return; + + if (!isFullscreen) { + closeSidebar(); + handleToggleFullscreen(); + } else { + handleToggleFullscreen(); + } + }, [closeSidebar, handleToggleFullscreen, isFullscreen]); + + const toggleFullscreen = useCallback(() => { + if (isFullscreen) { + closeFullscreen(); + } else { + openFullscreen(); + } + }, [closeFullscreen, isFullscreen, openFullscreen]); + + useEffect(() => { + if (!IS_REQUEST_FULLSCREEN_SUPPORTED) return undefined; + const container = containerRef.current; + if (!container) return undefined; + + container.addEventListener('fullscreenchange', toggleFullscreen); + + return () => { + container.removeEventListener('fullscreenchange', toggleFullscreen); + }; + }, [toggleFullscreen]); + + const handleClickVideoOrSpeaker = () => { + if (shouldRaiseHand) { + toggleSpeaker(); + } else { + toggleGroupCallVideo(); + } + }; + + useEffect(() => { + connectToActiveGroupCall(); + }, [connectToActiveGroupCall, groupCallId]); + + const endGroupCall = () => { + setIsEndGroupCallModal(true); + setShouldEndGroupCall(true); + openConfirmLeaveModal(); + if (isFullscreen) { + handleToggleFullscreen(); + } + }; + + const handleLeaveGroupCall = () => { + if (isAdmin && !isConfirmLeaveModalOpen) { + openConfirmLeaveModal(); + if (isFullscreen) { + handleToggleFullscreen(); + } + return; + } + playGroupCallSound({ sound: 'leave' }); + setIsLeaving(true); + closeConfirmLeaveModal(); + }; + + const handleCloseAnimationEnd = () => { + if (isLeaving) { + leaveGroupCall({ + shouldDiscard: shouldEndGroupCall, + }); + } + }; + + return ( + +
+

{title || lang('VoipGroupVoiceChat')}

+ {IS_REQUEST_FULLSCREEN_SUPPORTED && ( + + )} + {isLandscape && ( + + )} + {((IS_SCREENSHARE_SUPPORTED && !shouldRaiseHand) || isAdmin) && ( + + {IS_SCREENSHARE_SUPPORTED && !shouldRaiseHand && ( + + {lang(hasPresentation ? 'VoipChatStopScreenCapture' : 'VoipChatStartScreenCapture')} + + )} + {isAdmin && ( + + {lang('VoipGroupLeaveAlertEndChat')} + + )} + + )} + +
+ +
+ + + {(!isLandscape || isSidebarOpen) + && } +
+ + + +
+ {isConnecting && } + +
+
+ {hasVideo && (IS_ANDROID || IS_IOS) && ( + + )} + +
+ +
+ {lang(shouldRaiseHand ? 'VoipSpeaker' : 'VoipCamera')} +
+
+ + + +
+ + +
+ {lang('VoipGroupLeave')} +
+
+
+ + +

{lang(isEndGroupCallModal ? 'VoipGroupEndAlertText' : 'VoipGroupLeaveAlertText')}

+ {!isEndGroupCallModal && ( + + )} + + +
+
+ ); +}; + +export default memo(withGlobal( + (global, { groupCallId }): StateProps => { + const { + connectionState, title, isSpeakerDisabled, participants, participantsCount, + } = selectGroupCall(global, groupCallId)! || {}; + + return { + connectionState, + title, + isSpeakerEnabled: !isSpeakerDisabled, + participantsCount, + meParticipant: selectGroupCallParticipant(global, groupCallId, global.currentUserId!), + isGroupCallPanelHidden: !!global.groupCalls.isGroupCallPanelHidden, + isAdmin: selectIsAdminInActiveGroupCall(global), + participants, + }; + }, + (setGlobal, actions): DispatchProps => pick(actions, [ + 'toggleGroupCallVideo', + 'leaveGroupCall', + 'toggleGroupCallPresentation', + 'toggleGroupCallPanel', + 'connectToActiveGroupCall', + 'playGroupCallSound', + ]), +)(GroupCall)); diff --git a/src/components/calls/group/GroupCallParticipant.scss b/src/components/calls/group/GroupCallParticipant.scss new file mode 100644 index 000000000..edefdc39d --- /dev/null +++ b/src/components/calls/group/GroupCallParticipant.scss @@ -0,0 +1,78 @@ +.GroupCallParticipant { + position: relative; + display: flex; + flex-direction: row; + align-items: center; + color: #fff; + padding: 0.5rem 0.75rem; + border-radius: 0.75rem; + transition: .15s ease-out background-color; + cursor: pointer; + + &:hover { + background: #2F363E; + } + + audio { + display: none; + } + + .Avatar { + margin-right: 1rem; + } + + .info { + min-width: 0; + display: flex; + flex-direction: column; + + .name { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + + .about { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + color: #848D94; + font-size: 0.75rem; + + &.blue { + color: #4DA6E0; + } + + &.green { + color: #57BC6C; + } + + &.red { + color: #FF706F; + } + } + } + + .microphone { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 2.75rem; + height: 2.75rem; + margin-left: auto; + font-size: 1.5rem; + color: #FF706F; + } + + &.can-self-unmute { + .microphone { + color: #848D94; + } + } + + .streams { + cursor: pointer; + display: flex; + } +} diff --git a/src/components/calls/group/GroupCallParticipant.tsx b/src/components/calls/group/GroupCallParticipant.tsx new file mode 100644 index 000000000..908333a67 --- /dev/null +++ b/src/components/calls/group/GroupCallParticipant.tsx @@ -0,0 +1,101 @@ +import { GroupCallParticipant as TypeGroupCallParticipant, THRESHOLD } from '../../../lib/secret-sauce'; +import React, { + FC, memo, useMemo, useRef, +} from '../../../lib/teact/teact'; +import { withGlobal } from '../../../lib/teact/teactn'; + +import { ApiChat, ApiUser } from '../../../api/types'; + +import buildClassName from '../../../util/buildClassName'; +import { selectChat, selectUser } from '../../../modules/selectors'; +import useLang from '../../../hooks/useLang'; +import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config'; + +import Avatar from '../../common/Avatar'; +import OutlinedMicrophoneIcon from './OutlinedMicrophoneIcon'; + +import './GroupCallParticipant.scss'; + +type OwnProps = { + participant: TypeGroupCallParticipant; + openParticipantMenu: (anchor: HTMLDivElement, participant: TypeGroupCallParticipant) => void; +}; + +type StateProps = { + user?: ApiUser; + chat?: ApiChat; +}; + +const GroupCallParticipant: FC = ({ + openParticipantMenu, + participant, + user, + chat, +}) => { + // eslint-disable-next-line no-null/no-null + const anchorRef = useRef(null); + const lang = useLang(); + + const { isSelf, isMutedByMe, isMuted } = participant; + const isSpeaking = (participant.amplitude || 0) > THRESHOLD; + const isRaiseHand = Boolean(participant.raiseHandRating); + + const handleOnClick = () => { + if (isSelf) return; + openParticipantMenu(anchorRef.current!, participant); + }; + + const [aboutText, aboutColor] = useMemo(() => { + if (isSelf) { + return [lang('ThisIsYou'), 'blue']; + } + if (isMutedByMe) { + return [lang('VoipGroupMutedForMe'), 'red']; + } + return isRaiseHand + ? [lang('WantsToSpeak'), 'blue'] + : (!isMuted && isSpeaking ? [ + participant.volume && participant.volume !== GROUP_CALL_DEFAULT_VOLUME + ? lang('SpeakingWithVolume', + (participant.volume / GROUP_CALL_VOLUME_MULTIPLIER).toString()) + .replace('%%', '%') : lang('Speaking'), + 'green', + ] + : (participant.about ? [participant.about, ''] : [lang('Listening'), 'blue'])); + }, [isSpeaking, participant.volume, lang, isSelf, isMutedByMe, isRaiseHand, isMuted, participant.about]); + + if (!user && !chat) { + return undefined; + } + + const name = user ? `${user.firstName || ''} ${user.lastName || ''}` : chat?.title; + + return ( +
+ +
+ {name} + {aboutText} +
+
+ +
+
+ ); +}; + +export default memo(withGlobal( + (global, { participant }): StateProps => { + return { + user: participant.isUser ? selectUser(global, participant.id) : undefined, + chat: !participant.isUser ? selectChat(global, participant.id) : undefined, + }; + }, +)(GroupCallParticipant)); diff --git a/src/components/calls/group/GroupCallParticipantList.tsx b/src/components/calls/group/GroupCallParticipantList.tsx new file mode 100644 index 000000000..4f46aa37a --- /dev/null +++ b/src/components/calls/group/GroupCallParticipantList.tsx @@ -0,0 +1,95 @@ +import { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce'; +import React, { FC, memo, useMemo } from '../../../lib/teact/teact'; +import { withGlobal } from '../../../lib/teact/teactn'; + +import { GlobalActions } from '../../../global/types'; + +import { pick } from '../../../util/iteratees'; +import useLang from '../../../hooks/useLang'; +import { selectActiveGroupCall } from '../../../modules/selectors/calls'; +import useInfiniteScroll from '../../../hooks/useInfiniteScroll'; +import { selectChat } from '../../../modules/selectors'; + +import GroupCallParticipant from './GroupCallParticipant'; +import InfiniteScroll from '../../ui/InfiniteScroll'; + +type OwnProps = { + openParticipantMenu: (anchor: HTMLDivElement, participant: TypeGroupCallParticipant) => void; +}; + +type StateProps = { + participantsCount: number; + participants?: Record; + canInvite?: boolean; +}; + +type DispatchProps = Pick; + +const GroupCallParticipantList: FC = ({ + createGroupCallInviteLink, + loadMoreGroupCallParticipants, + participants, + participantsCount, + openParticipantMenu, + canInvite, +}) => { + const lang = useLang(); + + const participantsIds = useMemo(() => { + return Object.keys(participants || {}); + }, [participants]); + + const [viewportIds, getMore] = useInfiniteScroll( + loadMoreGroupCallParticipants, + participantsIds, + participantsIds.length >= participantsCount, + ); + + return ( +
+ {canInvite && ( +
+
+ +
+
{lang('VoipGroupInviteMember')}
+
+ )} + + + {viewportIds?.map( + (participantId) => ( + participants![participantId] && ( + + ) + ), + )} + + +
+ ); +}; + +export default memo(withGlobal( + (global): StateProps => { + const { participantsCount, participants, chatId } = selectActiveGroupCall(global) || {}; + const chat = chatId && selectChat(global, chatId); + + return { + participants, + participantsCount: participantsCount || 0, + canInvite: !!chat && !!chat.username, + }; + }, + (setGlobal, actions): DispatchProps => pick(actions, [ + 'createGroupCallInviteLink', + 'loadMoreGroupCallParticipants', + ]), +)(GroupCallParticipantList)); diff --git a/src/components/calls/group/GroupCallParticipantMenu.scss b/src/components/calls/group/GroupCallParticipantMenu.scss new file mode 100644 index 000000000..28001713d --- /dev/null +++ b/src/components/calls/group/GroupCallParticipantMenu.scss @@ -0,0 +1,98 @@ +@import '../../../styles/mixins'; + +.participant-menu { + position: absolute; + .bubble { + background: none; + border-radius: 0; + padding: 0; + border: none !important; + box-shadow: none !important; + overflow: visible; + color: #ffffff; + + .group { + box-shadow: 0 0.25rem 0.5rem 0.125rem rgba(16, 16, 16, 0.3); + overflow: hidden; + background: var(--color-background); + border-radius: var(--border-radius-default); + margin-bottom: 0.5rem; + } + } + + .volume-control { + height: 3rem; + + .info { + pointer-events: none; + position: relative; + z-index: 1; + height: 100%; + display: flex; + flex-direction: row; + align-items: center; + padding: 0.75rem 1rem; + + .AnimatedSticker { + margin-right: 2rem; + } + } + + &.high { + --range-color: #4DA6E0; + } + + &.normal { + --range-color: #57BC6C; + } + + &.medium { + --range-color: #CAA53B; + } + + &.low { + --range-color: #CB5757; + } + + position: relative; + overflow: hidden; + cursor: pointer; + + @mixin thumb-styles() { + border: none; + height: 3rem; + width: 1.5rem; + background: var(--range-color); + border-radius: var(--border-radius-default); + box-shadow: -13.5rem 0 0 12.75rem var(--range-color); + transition: 0.25s ease-in-out background-color, 0.25s ease-in-out box-shadow; + } + + @include reset-range(); + + // Apply custom styles + input[type="range"] { + height: 3rem; + position: absolute; + left: -1.5rem; + top: 0; + width: calc(100% + 3rem); + margin: 0; + z-index: 0; + + // Note that while we're repeating code here, that's necessary as you can't comma-separate these type of selectors. + // Browsers will drop the entire selector if it doesn't understand a part of it. + &::-webkit-slider-thumb { + @include thumb-styles(); + } + + &::-moz-range-thumb { + @include thumb-styles(); + } + + &::-ms-thumb { + @include thumb-styles(); + } + } + } +} diff --git a/src/components/calls/group/GroupCallParticipantMenu.tsx b/src/components/calls/group/GroupCallParticipantMenu.tsx new file mode 100644 index 000000000..b75186d26 --- /dev/null +++ b/src/components/calls/group/GroupCallParticipantMenu.tsx @@ -0,0 +1,239 @@ +import { GroupCallParticipant } from '../../../lib/secret-sauce'; +import React, { + FC, memo, useCallback, useEffect, useState, +} from '../../../lib/teact/teact'; +import { withGlobal } from '../../../lib/teact/teactn'; + +import { IAnchorPosition } from '../../../types'; +import { GlobalActions } from '../../../global/types'; + +import buildClassName from '../../../util/buildClassName'; +import useThrottle from '../../../hooks/useThrottle'; +import useFlag from '../../../hooks/useFlag'; +import useLang from '../../../hooks/useLang'; +import { selectIsAdminInActiveGroupCall } from '../../../modules/selectors/calls'; +import { pick } from '../../../util/iteratees'; +import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config'; + +import Menu from '../../ui/Menu'; +import MenuItem from '../../ui/MenuItem'; +import AnimatedIcon from '../../common/AnimatedIcon'; +import DeleteMemberModal from '../../right/DeleteMemberModal'; + +import './GroupCallParticipantMenu.scss'; + +const SPEAKER_ICON_DISABLED_SEGMENT: [number, number] = [0, 17]; +const SPEAKER_ICON_ENABLED_SEGMENT: [number, number] = [17, 34]; + +type OwnProps = { + participant?: GroupCallParticipant; + closeDropdown: VoidFunction; + isDropdownOpen: boolean; + anchor?: IAnchorPosition; +}; + +type StateProps = { + isAdmin: boolean; +}; + +type DispatchProps = Pick; + +const VOLUME_ZERO = 0; +const VOLUME_LOW = 50; +const VOLUME_MEDIUM = 100; +const VOLUME_NORMAL = 150; + +const VOLUME_CHANGE_THROTTLE = 500; + +const SPEAKER_ICON_SIZE = 24; + +const GroupCallParticipantMenu: FC = ({ + participant, + closeDropdown, + isDropdownOpen, + anchor, + + isAdmin, + toggleGroupCallMute, + setGroupCallParticipantVolume, + toggleGroupCallPanel, + openChat, + requestToSpeak, +}) => { + const lang = useLang(); + const [isDeleteUserModalOpen, openDeleteUserModal, closeDeleteUserModal] = useFlag(); + + const id = participant?.id; + const { + isMutedByMe, isMuted, isSelf, canSelfUnmute, + } = participant || {}; + const isRaiseHand = Boolean(participant?.raiseHandRating); + const shouldRaiseHand = !canSelfUnmute && isMuted; + + const [localVolume, setLocalVolume] = useState( + isMutedByMe ? VOLUME_ZERO : ((participant?.volume || GROUP_CALL_DEFAULT_VOLUME) / GROUP_CALL_VOLUME_MULTIPLIER), + ); + + useEffect(() => { + setLocalVolume(isMutedByMe + ? VOLUME_ZERO + : ((participant?.volume || GROUP_CALL_DEFAULT_VOLUME) / GROUP_CALL_VOLUME_MULTIPLIER)); + // We only want to initialize local volume when switching participants and ignore following updates from server + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + const runThrottled = useThrottle(VOLUME_CHANGE_THROTTLE); + + const handleRemove = useCallback((e: React.SyntheticEvent) => { + e.stopPropagation(); + openDeleteUserModal(); + closeDropdown(); + }, [openDeleteUserModal, closeDropdown]); + + const handleCancelRequestToSpeak = useCallback((e: React.SyntheticEvent) => { + e.stopPropagation(); + requestToSpeak({ + value: false, + }); + closeDropdown(); + }, [requestToSpeak, closeDropdown]); + + const handleMute = useCallback((e: React.SyntheticEvent) => { + e.stopPropagation(); + closeDropdown(); + + if (!isAdmin) { + setLocalVolume(isMutedByMe ? GROUP_CALL_DEFAULT_VOLUME / GROUP_CALL_VOLUME_MULTIPLIER : VOLUME_ZERO); + } + + toggleGroupCallMute({ + participantId: id, + value: isAdmin ? !shouldRaiseHand : !isMutedByMe, + }); + }, [closeDropdown, toggleGroupCallMute, id, isAdmin, shouldRaiseHand, isMutedByMe]); + + const handleOpenProfile = useCallback((e: React.SyntheticEvent) => { + e.stopPropagation(); + toggleGroupCallPanel(); + openChat({ + id, + }); + closeDropdown(); + }, [toggleGroupCallPanel, closeDropdown, openChat, id]); + + const isLocalVolumeZero = localVolume === VOLUME_ZERO; + const speakerIconPlaySegment = isLocalVolumeZero ? SPEAKER_ICON_DISABLED_SEGMENT : SPEAKER_ICON_ENABLED_SEGMENT; + + const handleChangeVolume = (e: React.ChangeEvent) => { + const value = Number(e.target.value); + setLocalVolume(value); + runThrottled(() => { + if (value === VOLUME_ZERO) { + toggleGroupCallMute({ + participantId: id, + value: true, + }); + } else { + setGroupCallParticipantVolume({ + participantId: id, + volume: Math.floor(value * GROUP_CALL_VOLUME_MULTIPLIER), + }); + } + }); + }; + + return ( +
+ + {!isSelf && !shouldRaiseHand && ( +
+
= VOLUME_LOW && localVolume < VOLUME_MEDIUM && 'medium', + localVolume >= VOLUME_MEDIUM && localVolume < VOLUME_NORMAL && 'normal', + localVolume >= VOLUME_NORMAL && 'high', + )} + > + +
+ + {localVolume}% +
+
+
+ )} +
+ {(isRaiseHand && isSelf) && ( + + {lang('VoipGroupCancelRaiseHand')} + + )} + {!isSelf && {lang('VoipGroupOpenProfile')}} + {!isSelf && ( + // TODO cross mic + + {isAdmin + ? lang(shouldRaiseHand ? 'VoipGroupAllowToSpeak' : 'VoipMute') + : lang(isMutedByMe ? 'VoipGroupUnmuteForMe' : 'VoipGroupMuteForMe')} + + )} + {!isSelf && isAdmin && ( + // TODO replace with hand + + {lang('VoipGroupUserRemove')} + + )} +
+
+ + {!isSelf && isAdmin && ( + + )} +
+ ); +}; + +export default memo(withGlobal( + (global): StateProps => { + return { + isAdmin: selectIsAdminInActiveGroupCall(global), + }; + }, + (setGlobal, actions): DispatchProps => pick(actions, [ + 'setGroupCallParticipantVolume', + 'toggleGroupCallMute', + 'openChat', + 'toggleGroupCallPanel', + 'requestToSpeak', + ]), +)(GroupCallParticipantMenu)); diff --git a/src/components/calls/group/GroupCallParticipantStreams.tsx b/src/components/calls/group/GroupCallParticipantStreams.tsx new file mode 100644 index 000000000..11a4aef49 --- /dev/null +++ b/src/components/calls/group/GroupCallParticipantStreams.tsx @@ -0,0 +1,105 @@ +import { GroupCallParticipant } from '../../../lib/secret-sauce'; +import React, { + FC, memo, useCallback, useMemo, useState, +} from '../../../lib/teact/teact'; +import { withGlobal } from '../../../lib/teact/teactn'; +import GroupCallParticipantVideo from './GroupCallParticipantVideo'; +import { selectActiveGroupCall } from '../../../modules/selectors/calls'; +import buildClassName from '../../../util/buildClassName'; + +type OwnProps = { + onDoubleClick?: VoidFunction; +}; + +type StateProps = { + participants?: Record; +}; + +type SelectedVideo = { + type: 'video' | 'presentation'; + id: string; +}; + +const GroupCallParticipantStreams: FC = ({ + participants, + onDoubleClick, +}) => { + const [selectedVideo, setSelectedVideo] = useState(undefined); + const presentationParticipants = useMemo(() => { + return Object.values(participants || {}).filter((l) => l.hasPresentationStream); + }, [participants]); + const videoParticipants = useMemo(() => { + return Object.values(participants || {}).filter((l) => l.hasVideoStream); + }, [participants]); + + const totalVideoCount = videoParticipants.length + presentationParticipants.length; + // TODO replace with more adequate solution. + // There's a max of 30 videos or so right now + const columnCount = totalVideoCount <= 2 ? 1 : ( + totalVideoCount <= 6 ? 2 : ( + totalVideoCount <= 9 ? 3 : 4 + ) + ); + + const shouldSpanLastVideo = totalVideoCount === 3 || (columnCount === 2 && totalVideoCount % 2 !== 0); + + const handleClickVideo = useCallback((id: string, type: 'video' | 'presentation') => { + if (!selectedVideo || (id !== selectedVideo.id || type !== selectedVideo.type)) { + setSelectedVideo({ + id, + type, + }); + } else { + setSelectedVideo(undefined); + } + }, [selectedVideo]); + + return ( +
+
+ {selectedVideo && ( + + )} + + {!selectedVideo ? presentationParticipants.map((participant) => ( + + )) : undefined} + {!selectedVideo ? videoParticipants.map((participant) => ( + + )) : undefined} +
+
+ ); +}; + +export default memo(withGlobal( + (global): StateProps => { + const { participants } = selectActiveGroupCall(global) || {}; + return { + participants, + }; + }, +)(GroupCallParticipantStreams)); diff --git a/src/components/calls/group/GroupCallParticipantVideo.scss b/src/components/calls/group/GroupCallParticipantVideo.scss new file mode 100644 index 000000000..49fcf1ad7 --- /dev/null +++ b/src/components/calls/group/GroupCallParticipantVideo.scss @@ -0,0 +1,125 @@ +.GroupCallParticipantVideo { + border-radius: 0.75rem; + overflow: hidden; + position: relative; + max-height: 12.875rem; + width: calc(50% - 0.25rem); + transition: 0.25s ease-out width; + cursor: pointer; + + .thumbnail-avatar { + position: absolute; + border-radius: 0; + width: 100%; + height: 100%; + transform: scale(1.1); + + img { + filter: blur(10px); + border-radius: 0; + object-fit: cover; + } + } + + &:last-child:nth-child(odd) { + width: 100%; + } + + &::before { + box-shadow: 0 0 0 3px transparent inset; + width: 100%; + height: 100%; + position: absolute; + display: block; + content: ""; + z-index: 5; + border-radius: 0.75rem; + transition: 0.25s ease-out box-shadow; + } + + &.active::before { + box-shadow: 0px 0px 0px 3px #78ee7e inset; + } + + .back-button { + position: absolute; + z-index: 5; + top: 0.75rem; + left: 0.75rem; + background: rgba(0, 0, 0, 0.3); + border: 0; + color: white; + border-radius: 1rem; + padding: 0.25rem 0.75rem; + display: flex; + align-items: center; + gap: 0.25rem; + transition: 0.25s ease-out opacity, 0.25s ease-out background-color; + opacity: 0; + cursor: pointer; + outline: none !important; + + &:hover { + background: rgba(0, 0, 0, 0.4); + } + } + + video { + display: block; + width: 100%; + } + + .video { + object-fit: contain; + height: 12.5rem; + position: relative; + } + + .thumbnail-wrapper { + position: absolute; + top: 50%; + left: 50%; + z-index: 0; + width: 100%; + transform: translate(-50%, -50%) scale(1.5); + background: black; + } + + .thumbnail { + filter: blur(10px) brightness(0.5); + object-fit: cover; + } + + .info { + position: absolute; + bottom: 0; + color: #fff; + display: flex; + align-items: center; + padding: 0 0.5rem 0.25rem; + width: 100%; + height: 2rem; + background: linear-gradient(0deg, #000, transparent); + transition: 0.25s ease-out opacity; + opacity: 0; + + .name { + margin-left: 0.5rem; + } + + .last-icon { + margin-left: auto; + } + } +} + +.videos:hover .GroupCallParticipantVideo { + + .info { + opacity: 1; + } + + .back-button { + opacity: 1; + } +} diff --git a/src/components/calls/group/GroupCallParticipantVideo.tsx b/src/components/calls/group/GroupCallParticipantVideo.tsx new file mode 100644 index 000000000..b2ab55096 --- /dev/null +++ b/src/components/calls/group/GroupCallParticipantVideo.tsx @@ -0,0 +1,86 @@ +import { getUserStreams, GroupCallParticipant as TypeGroupCallParticipant, THRESHOLD } from '../../../lib/secret-sauce'; +import React, { FC, memo, useCallback } from '../../../lib/teact/teact'; +import { withGlobal } from '../../../lib/teact/teactn'; + +import { ApiChat, ApiUser } from '../../../api/types'; + +import buildClassName from '../../../util/buildClassName'; +import { selectChat, selectUser } from '../../../modules/selectors'; +import useLang from '../../../hooks/useLang'; +import { ENABLE_THUMBNAIL_VIDEO } from '../../../config'; + +import Avatar from '../../common/Avatar'; + +import './GroupCallParticipantVideo.scss'; + +type OwnProps = { + participant: TypeGroupCallParticipant; + type: 'video' | 'presentation'; + onClick?: (id: string, type: 'video' | 'presentation') => void; + isFullscreen?: boolean; +}; + +type StateProps = { + user?: ApiUser; + chat?: ApiChat; + currentUserId?: string; + isActive?: boolean; +}; + +const GroupCallParticipantVideo: FC = ({ + type, + onClick, + user, + chat, + isActive, + isFullscreen, +}) => { + const lang = useLang(); + + const handleClick = useCallback(() => { + if (onClick) { + onClick(user?.id || chat!.id, type); + } + }, [chat, onClick, type, user?.id]); + + if (!user && !chat) return undefined; + + const streams = getUserStreams(user?.id || chat!.id); + + return ( +
+ {isFullscreen && ( + + )} + + {ENABLE_THUMBNAIL_VIDEO && ( +
+
+ )} +
+ ); +}; + +export default memo(withGlobal( + (global, { participant }): StateProps => { + return { + currentUserId: global.currentUserId, + user: participant.isUser ? selectUser(global, participant.id) : undefined, + chat: !participant.isUser ? selectChat(global, participant.id) : undefined, + isActive: (participant.amplitude || 0) > THRESHOLD, + }; + }, +)(GroupCallParticipantVideo)); diff --git a/src/components/calls/group/GroupCallTopPane.scss b/src/components/calls/group/GroupCallTopPane.scss new file mode 100644 index 000000000..ece4000ed --- /dev/null +++ b/src/components/calls/group/GroupCallTopPane.scss @@ -0,0 +1,89 @@ +.GroupCallTopPane { + position: absolute; + top: 100%; + left: 0; + right: 0; + height: 2.875rem; + overflow: hidden; + box-shadow: 0 2px 2px var(--color-light-shadow); + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 0.375rem 0.5rem 0.375rem 0.75rem; + background: var(--color-background); + z-index: -1; + cursor: pointer; + + &::before { + content: ""; + display: block; + position: absolute; + top: -.1875rem; + left: 0; + right: 0; + height: .125rem; + box-shadow: 0 .125rem .125rem var(--color-light-shadow); + } + + &.is-hidden { + display: none; + } + + @media (max-width: 600px) { + &.has-pinned-offset { + top: calc(100% + 2.875rem); + } + } + + .info { + display: flex; + flex-direction: column; + + .title { + font-size: 0.875rem; + color: var(--color-text); + } + + .participants { + font-size: 0.75rem; + color: var(--color-text-secondary); + } + } + + .avatars { + display: flex; + flex-direction: row; + align-items: center; + + .Avatar { + margin: 0 0 0 -0.75rem; + + &:first-child { + width: 2rem; + height: 2rem; + } + + &:not(:first-child) { + width: 2.25rem; + height: 2.25rem; + border: 0.125rem solid var(--color-background); + } + } + } + + .join { + height: 1.5rem; + border-radius: 1.5rem; + font-weight: 500; + padding: 1rem 1rem; + width: auto; + } +} + + +@media (min-width: 1440px) { + #Main.right-column-open .MiddleHeader .GroupCallTopPane { + width: calc(100% - var(--right-column-width)); + } +} diff --git a/src/components/calls/group/GroupCallTopPane.tsx b/src/components/calls/group/GroupCallTopPane.tsx new file mode 100644 index 000000000..e345ca802 --- /dev/null +++ b/src/components/calls/group/GroupCallTopPane.tsx @@ -0,0 +1,139 @@ +import React, { + FC, memo, useCallback, useEffect, useMemo, +} from '../../../lib/teact/teact'; +import { withGlobal } from '../../../lib/teact/teactn'; + +import { GlobalActions } from '../../../global/types'; +import { ApiChat, ApiGroupCall, ApiUser } from '../../../api/types'; + +import { pick } from '../../../util/iteratees'; +import { selectChatGroupCall } from '../../../modules/selectors/calls'; +import buildClassName from '../../../util/buildClassName'; +import { selectChat } from '../../../modules/selectors'; +import useLang from '../../../hooks/useLang'; + +import Button from '../../ui/Button'; +import Avatar from '../../common/Avatar'; + +import './GroupCallTopPane.scss'; + +type OwnProps = { + chatId: string; + hasPinnedOffset: boolean; +}; + +type StateProps = { + groupCall?: ApiGroupCall; + isActive: boolean; + usersById: Record; + chatsById: Record; +}; + +type DispatchProps = Pick; + +const GroupCallTopPane: FC = ({ + chatId, + isActive, + groupCall, + hasPinnedOffset, + joinGroupCall, + subscribeToGroupCallUpdates, + usersById, + chatsById, +}) => { + const lang = useLang(); + + const handleJoinGroupCall = useCallback(() => { + joinGroupCall({ + chatId, + }); + }, [joinGroupCall, chatId]); + + const participants = groupCall?.participants; + + const fetchedParticipants = useMemo(() => { + if (participants) { + return Object.values(participants).filter((_, i) => i < 3).map(({ id, isUser }) => { + if (isUser) { + if (!usersById[id]) { + return undefined; + } + return { user: usersById[id] }; + } else { + if (!chatsById[id]) { + return undefined; + } + return { chat: chatsById[id] }; + } + }).filter(Boolean); + } else return []; + }, [chatsById, participants, usersById]); + + useEffect(() => { + if (!groupCall?.id) return undefined; + if (!isActive && groupCall.isLoaded) return undefined; + + subscribeToGroupCallUpdates({ + id: groupCall.id, + subscribed: true, + }); + + return () => { + subscribeToGroupCallUpdates({ + id: groupCall.id, + subscribed: false, + }); + }; + }, [groupCall?.id, groupCall?.isLoaded, isActive, subscribeToGroupCallUpdates]); + + if (!groupCall) return undefined; + + return ( +
+
+ {lang('VoipGroupVoiceChat')} + {lang('Participants', groupCall.participantsCount || 0, 'i')} +
+
+ {fetchedParticipants.map((p) => { + if (!p) return undefined; + if (p.user) { + return ; + } else { + return ; + } + })} +
+ +
+ ); +}; + +export default memo(withGlobal( + (global, { chatId }) => { + const chat = selectChat(global, chatId)!; + const groupCall = selectChatGroupCall(global, chatId); + return { + groupCall, + usersById: global.users.byId, + chatsById: global.chats.byId, + activeGroupCallId: global.groupCalls.activeGroupCallId, + isActive: ((!groupCall ? (chat && chat.isCallNotEmpty && chat.isCallActive) + : (groupCall.participantsCount > 0 && groupCall.isLoaded))) + && (global.groupCalls.activeGroupCallId !== groupCall?.id), + }; + }, + (setGlobal, actions) => pick(actions, [ + 'joinGroupCall', + 'subscribeToGroupCallUpdates', + ]), +)(GroupCallTopPane)); diff --git a/src/components/calls/group/MicrophoneButton.scss b/src/components/calls/group/MicrophoneButton.scss new file mode 100644 index 000000000..ca1360937 --- /dev/null +++ b/src/components/calls/group/MicrophoneButton.scss @@ -0,0 +1,58 @@ +.MicrophoneButton { + display: flex; + justify-content: center; + align-items: center; + outline: none !important; + position: relative; + width: 6rem; + height: 6rem; + border: 0; + background: radial-gradient(100% 100% at 100% 0%, #00a0b9 0%, #33c659 55%, #33c659 100%); + border-radius: 50%; + font-size: 2rem; + color: #fff; + transition: 0.25s ease-out filter; + + &::before { + content: ""; + display: block; + position: absolute; + width: 8rem; + height: 8rem; + background: #64C166; + border-radius: 50%; + filter: blur(10px); + opacity: 0.2; + pointer-events: none; + + body.is-ios & { + display: none; + } + } + + &:hover { + filter: brightness(0.9); + } + + &.crossed { + background: radial-gradient(100% 100% at 100% 0%, #00AFFE 0%, #00AFFE 55%, #007FFF 100%); + + &::before { + background: #00AFFE; + } + } + + &.muted-by-admin { + background: radial-gradient(85.5% 103.5% at 87.5% 20.65%, #CE4D74 0%, #3D52DF 100%); + &::before { + background: #3D52DF; + } + } + + &.is-connecting, &.is-connecting:hover { + background: #222B34; + &::before { + background: transparent; + } + } +} diff --git a/src/components/calls/group/MicrophoneButton.tsx b/src/components/calls/group/MicrophoneButton.tsx new file mode 100644 index 000000000..c9e6d20c6 --- /dev/null +++ b/src/components/calls/group/MicrophoneButton.tsx @@ -0,0 +1,187 @@ +import { GroupCallConnectionState } from '../../../lib/secret-sauce'; +import React, { + FC, memo, useEffect, useMemo, useRef, useState, +} from '../../../lib/teact/teact'; +import { withGlobal } from '../../../lib/teact/teactn'; + +import { GlobalActions } from '../../../global/types'; + +import buildClassName from '../../../util/buildClassName'; +import { vibrateShort } from '../../../util/vibrate'; +import { pick } from '../../../util/iteratees'; +import usePrevious from '../../../hooks/usePrevious'; +import { selectActiveGroupCall, selectGroupCallParticipant } from '../../../modules/selectors/calls'; +import useLang from '../../../hooks/useLang'; + +import AnimatedIcon from '../../common/AnimatedIcon'; + +import './MicrophoneButton.scss'; + +const CONNECTION_STATE_DEFAULT = 'discarded'; + +type StateProps = { + connectionState?: GroupCallConnectionState; + hasRequestedToSpeak: boolean; + isMuted?: boolean; + canSelfUnmute?: boolean; + noAudioStream: boolean; +}; + +type DispatchProps = Pick; + +const REQUEST_TO_SPEAK_THROTTLE = 3000; +const HOLD_TO_SPEAK_TIME = 200; +const ICON_SIZE = 48; + +const MicrophoneButton: FC = ({ + noAudioStream, + canSelfUnmute, + isMuted, + hasRequestedToSpeak, + connectionState, + toggleGroupCallMute, + requestToSpeak, + playGroupCallSound, +}) => { + const lang = useLang(); + const muteMouseDownState = useRef('up'); + + const [isRequestingToSpeak, setIsRequestingToSpeak] = useState(false); + const isConnecting = connectionState !== 'connected'; + const shouldRaiseHand = !canSelfUnmute && isMuted; + const prevShouldRaiseHand = usePrevious(shouldRaiseHand); + + useEffect(() => { + if (prevShouldRaiseHand && !shouldRaiseHand) { + playGroupCallSound('allowTalk'); + } + }, [playGroupCallSound, prevShouldRaiseHand, shouldRaiseHand]); + + // Voice mini + // unmuted -> muted [69, 99] + // muted -> unmuted [36, 69] + // raise -> muted [0, 36] + // muted -> raise [99, 136] + // unmuted -> raise [136, 172] + // TODO should probably move to other component + const playSegment: [number, number] = useMemo(() => { + if (isRequestingToSpeak) { + const r = Math.floor(Math.random() * 100); + return (r < 32 ? [0, 120] + : (r < 64 ? [120, 240] + : (r < 97 ? [240, 420] + : [420, 540] + ) + ) + ); + } + if (!prevShouldRaiseHand && shouldRaiseHand) { + return noAudioStream ? [99, 135] : [136, 172]; + } + if (prevShouldRaiseHand && !shouldRaiseHand) { + return [0, 36]; + } + if (!shouldRaiseHand) { + return noAudioStream ? [69, 99] : [36, 69]; + } + return [0, 0]; + }, [prevShouldRaiseHand, isRequestingToSpeak, noAudioStream, shouldRaiseHand]); + + const animatedIconName = isRequestingToSpeak ? 'HandFilled' : 'VoiceMini'; + + const toggleMute = () => { + vibrateShort(); + toggleGroupCallMute(); + }; + + const handleMouseDownMute = () => { + if (shouldRaiseHand) { + if (isRequestingToSpeak) return; + vibrateShort(); + requestToSpeak(); + setIsRequestingToSpeak(true); + setTimeout(() => { + setIsRequestingToSpeak(false); + }, REQUEST_TO_SPEAK_THROTTLE); + return; + } + muteMouseDownState.current = 'down'; + if (noAudioStream) { + setTimeout(() => { + if (muteMouseDownState.current === 'down') { + muteMouseDownState.current = 'hold'; + toggleMute(); + } + }, HOLD_TO_SPEAK_TIME); + } + }; + + const handleMouseUpMute = () => { + if (shouldRaiseHand) { + return; + } + toggleMute(); + muteMouseDownState.current = 'up'; + }; + + const buttonText = useMemo(() => { + return lang( + hasRequestedToSpeak ? 'VoipMutedTapedForSpeak' : ( + shouldRaiseHand ? 'VoipMutedByAdmin' : ( + noAudioStream ? 'VoipUnmute' : 'VoipTapToMute' + ) + ), + ); + }, [hasRequestedToSpeak, noAudioStream, lang, shouldRaiseHand]); + + return ( +
+ +
+ {buttonText} +
+
+ ); +}; + +export default memo(withGlobal( + (global): StateProps => { + const groupCall = selectActiveGroupCall(global); + + const { connectionState } = groupCall || {}; + const meParticipant = groupCall && selectGroupCallParticipant(global, groupCall.id, global.currentUserId!); + + const { + raiseHandRating, hasAudioStream, canSelfUnmute, isMuted, + } = meParticipant || {}; + + return { + connectionState: connectionState || CONNECTION_STATE_DEFAULT, + hasRequestedToSpeak: Boolean(raiseHandRating), + noAudioStream: !hasAudioStream, + canSelfUnmute, + isMuted, + }; + }, + (setGlobal, actions): DispatchProps => pick(actions, [ + 'toggleGroupCallMute', + 'requestToSpeak', + 'playGroupCallSound', + ]), +)(MicrophoneButton)); diff --git a/src/components/calls/group/OutlinedMicrophoneIcon.tsx b/src/components/calls/group/OutlinedMicrophoneIcon.tsx new file mode 100644 index 000000000..a9dea3834 --- /dev/null +++ b/src/components/calls/group/OutlinedMicrophoneIcon.tsx @@ -0,0 +1,68 @@ +import { GroupCallParticipant, THRESHOLD } from '../../../lib/secret-sauce'; +import React, { FC, memo, useMemo } from '../../../lib/teact/teact'; +import AnimatedIcon from '../../common/AnimatedIcon'; +import usePrevious from '../../../hooks/usePrevious'; + +type OwnProps = { + participant: GroupCallParticipant; + noColor?: boolean; +}; + +const OutlinedMicrophoneIcon: FC = ({ + participant, + noColor, +}) => { + const { isMuted, isMutedByMe } = participant; + const isSpeaking = (participant.amplitude || 0) > THRESHOLD; + const isRaiseHand = Boolean(participant.raiseHandRating); + const prevIsRaiseHand = usePrevious(isRaiseHand); + const canSelfUnmute = !!participant?.canSelfUnmute; + const shouldRaiseHand = !canSelfUnmute && isMuted; + const prevIsMuted = usePrevious(isMuted); + + const playSegment: [number, number] = useMemo(() => { + if (isMuted && !prevIsMuted) { + return [43, 64]; + } + + if (!isMuted && prevIsMuted) { + return [22, 42]; + } + + if (isRaiseHand && !prevIsRaiseHand) { + return [65, 84]; + } + + if (!shouldRaiseHand && prevIsRaiseHand) { + return [0, 21]; + } + + // TODO cancel request to speak should play in reverse + // if (!isRaiseHand && prevIsRaiseHand) { + // return [84, 65]; + // } + + return isMuted ? [22, 23] : [43, 44]; + // eslint-disable-next-line + }, [isMuted, shouldRaiseHand, isRaiseHand]); + + const microphoneColor: [number, number, number] | undefined = useMemo(() => { + return noColor ? [0xff, 0xff, 0xff] : ( + isRaiseHand ? [0x4d, 0xa6, 0xe0] + : (shouldRaiseHand || isMutedByMe ? [0xFF, 0x70, 0x6F] : ( + isSpeaking ? [0x57, 0xBC, 0x6C] : [0x84, 0x8D, 0x94] + )) + ); + }, [noColor, isRaiseHand, shouldRaiseHand, isMutedByMe, isSpeaking]); + + return ( + + ); +}; + +export default memo(OutlinedMicrophoneIcon); diff --git a/src/components/common/AnimatedIcon.tsx b/src/components/common/AnimatedIcon.tsx new file mode 100644 index 000000000..a8dfa76b2 --- /dev/null +++ b/src/components/common/AnimatedIcon.tsx @@ -0,0 +1,42 @@ +import React, { + FC, memo, useEffect, useState, +} from '../../lib/teact/teact'; + +import getAnimationData, { ANIMATED_STICKERS_PATHS } from './helpers/animatedAssets'; + +import AnimatedSticker from './AnimatedSticker'; + +type OwnProps = { + name: keyof typeof ANIMATED_STICKERS_PATHS; + size: number; + playSegment?: [number, number]; + color?: [number, number, number]; +}; + +const AnimatedIcon: FC = ({ + size, + name, + playSegment, + color, +}) => { + const [iconData, setIconData] = useState>(); + + useEffect(() => { + getAnimationData(name).then(setIconData); + }, [name]); + + return ( + + ); +}; + +export default memo(AnimatedIcon); diff --git a/src/components/common/AnimatedSticker.tsx b/src/components/common/AnimatedSticker.tsx index e7cb68181..e6ffe5e67 100644 --- a/src/components/common/AnimatedSticker.tsx +++ b/src/components/common/AnimatedSticker.tsx @@ -19,6 +19,7 @@ type OwnProps = { quality?: number; isLowPriority?: boolean; onLoad?: NoneToVoidFunction; + color?: [number, number, number]; }; type RLottieClass = typeof import('../../lib/rlottie/RLottie').default; @@ -52,6 +53,7 @@ const AnimatedSticker: FC = ({ quality, isLowPriority, onLoad, + color, }) => { const [animation, setAnimation] = useState(); // eslint-disable-next-line no-null/no-null @@ -85,6 +87,7 @@ const AnimatedSticker: FC = ({ isLowPriority, }, onLoad, + color, ); if (speed) { @@ -105,7 +108,13 @@ const AnimatedSticker: FC = ({ }); }); } - }, [animation, animationData, id, isLowPriority, noLoop, onLoad, quality, size, speed]); + }, [color, animation, animationData, id, isLowPriority, noLoop, onLoad, quality, size, speed]); + + useEffect(() => { + if (!animation) return; + + animation.setColor(color); + }, [color, animation]); useEffect(() => { return () => { @@ -183,6 +192,13 @@ const AnimatedSticker: FC = ({ } }, [animation, play, playSegment, noLoop, playAnimation, pauseAnimation]); + useEffect(() => { + if (animation) { + animation.changeData(animationData); + playAnimation(); + } + }, [playAnimation, animation, animationData]); + useHeavyAnimationCheck(freezeAnimation, unfreezeAnimation); // Pausing frame may not happen in background // so we need to make sure it happens right after focusing, diff --git a/src/components/common/GroupCallLink.tsx b/src/components/common/GroupCallLink.tsx new file mode 100644 index 000000000..9eeac9354 --- /dev/null +++ b/src/components/common/GroupCallLink.tsx @@ -0,0 +1,41 @@ +import React, { FC, useCallback } from '../../lib/teact/teact'; +import { withGlobal } from '../../lib/teact/teactn'; + +import { GlobalActions } from '../../global/types'; +import { ApiGroupCall } from '../../api/types'; + +import { pick } from '../../util/iteratees'; +import buildClassName from '../../util/buildClassName'; + +import Link from '../ui/Link'; + +type OwnProps = { + className?: string; + groupCall?: Partial; + children: any; +}; + +type DispatchProps = Pick; + +const GroupCallLink: FC = ({ + className, groupCall, joinGroupCall, children, +}) => { + const handleClick = useCallback(() => { + if (groupCall) { + joinGroupCall({ id: groupCall.id, accessHash: groupCall.accessHash }); + } + }, [groupCall, joinGroupCall]); + + if (!groupCall) { + return children; + } + + return ( + {children} + ); +}; + +export default withGlobal( + undefined, + (setGlobal, actions): DispatchProps => pick(actions, ['joinGroupCall']), +)(GroupCallLink); diff --git a/src/components/common/helpers/animatedAssets.ts b/src/components/common/helpers/animatedAssets.ts index 34819ec53..b7dc7743f 100644 --- a/src/components/common/helpers/animatedAssets.ts +++ b/src/components/common/helpers/animatedAssets.ts @@ -16,6 +16,22 @@ import FoldersAll from '../../../assets/FoldersAll.tgs'; import FoldersNew from '../../../assets/FoldersNew.tgs'; // @ts-ignore import DiscussionGroups from '../../../assets/DiscussionGroupsDucks.tgs'; +// @ts-ignore +import CameraFlip from '../../../assets/animatedIcons/CameraFlip.tgs'; +// @ts-ignore +import HandFilled from '../../../assets/animatedIcons/HandFilled.tgs'; +// @ts-ignore +import HandOutline from '../../../assets/animatedIcons/HandOutline.tgs'; +// @ts-ignore +import Speaker from '../../../assets/animatedIcons/Speaker.tgs'; +// @ts-ignore +import VoiceAllowTalk from '../../../assets/animatedIcons/VoiceAllowTalk.tgs'; +// @ts-ignore +import VoiceMini from '../../../assets/animatedIcons/VoiceMini.tgs'; +// @ts-ignore +import VoiceMuted from '../../../assets/animatedIcons/VoiceMuted.tgs'; +// @ts-ignore +import VoiceOutlined from '../../../assets/animatedIcons/VoiceOutlined.tgs'; export const ANIMATED_STICKERS_PATHS = { MonkeyIdle, @@ -25,6 +41,14 @@ export const ANIMATED_STICKERS_PATHS = { FoldersAll, FoldersNew, DiscussionGroups, + CameraFlip, + HandFilled, + HandOutline, + Speaker, + VoiceAllowTalk, + VoiceMini, + VoiceMuted, + VoiceOutlined, }; export default function getAnimationData(name: keyof typeof ANIMATED_STICKERS_PATHS) { diff --git a/src/components/common/helpers/renderActionMessageText.tsx b/src/components/common/helpers/renderActionMessageText.tsx index c02adcca0..c2f1ae4f7 100644 --- a/src/components/common/helpers/renderActionMessageText.tsx +++ b/src/components/common/helpers/renderActionMessageText.tsx @@ -1,6 +1,8 @@ import React from '../../../lib/teact/teact'; -import { ApiChat, ApiMessage, ApiUser } from '../../../api/types'; +import { + ApiChat, ApiMessage, ApiUser, ApiGroupCall, +} from '../../../api/types'; import { LangFn } from '../../../hooks/useLang'; import { getChatTitle, @@ -17,6 +19,7 @@ import renderText from './renderText'; import UserLink from '../UserLink'; import MessageLink from '../MessageLink'; import ChatLink from '../ChatLink'; +import GroupCallLink from '../GroupCallLink'; interface ActionMessageTextOptions { maxTextLength?: number; @@ -39,7 +42,7 @@ export function renderActionMessageText( return []; } const { - text, translationValues, amount, currency, + text, translationValues, amount, currency, call, } = message.content.action; const content: TextPart[] = []; const textOptions: ActionMessageTextOptions = { ...options, maxTextLength: 32 }; @@ -115,6 +118,10 @@ export function renderActionMessageText( return content.join('').trim(); } + if (call) { + return renderGroupCallContent(call, content); + } + return content; } @@ -172,6 +179,14 @@ function renderOriginContent(lang: LangFn, origin: ApiUser | ApiChat, asPlain?: : renderChatContent(lang, origin as ApiChat, asPlain); } +function renderGroupCallContent(groupCall: Partial, text: TextPart[]): string | TextPart | undefined { + return ( + + {text} + + ); +} + function renderUserContent(sender: ApiUser, asPlain?: boolean): string | TextPart | undefined { const text = trimText(getUserFullName(sender)); diff --git a/src/components/left/main/Chat.scss b/src/components/left/main/Chat.scss index 2e6d1c824..621e9e3d6 100644 --- a/src/components/left/main/Chat.scss +++ b/src/components/left/main/Chat.scss @@ -76,6 +76,7 @@ } .status { + position: relative; flex-shrink: 0; } diff --git a/src/components/left/main/Chat.tsx b/src/components/left/main/Chat.tsx index 9d33640c4..2ff43451e 100644 --- a/src/components/left/main/Chat.tsx +++ b/src/components/left/main/Chat.tsx @@ -51,6 +51,7 @@ import DeleteChatModal from '../../common/DeleteChatModal'; import ListItem from '../../ui/ListItem'; import Badge from './Badge'; import ChatFolderModal from '../ChatFolderModal.async'; +import ChatCallStatus from './ChatCallStatus'; import './Chat.scss'; @@ -285,6 +286,9 @@ const Chat: FC = ({ isSavedMessages={privateChatUser?.isSelf} lastSyncTime={lastSyncTime} /> + {chat.isCallActive && chat.isCallNotEmpty && ( + + )}
diff --git a/src/components/left/main/ChatCallStatus.tsx b/src/components/left/main/ChatCallStatus.tsx index 6d1478ad3..f6ba47ad0 100644 --- a/src/components/left/main/ChatCallStatus.tsx +++ b/src/components/left/main/ChatCallStatus.tsx @@ -1,6 +1,8 @@ import React, { FC, memo } from '../../../lib/teact/teact'; import buildClassName from '../../../util/buildClassName'; +import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment'; + import './ChatCallStatus.scss'; type OwnProps = { @@ -13,7 +15,12 @@ const ChatCallStatus: FC = ({ isActive, }) => { return ( -
+
diff --git a/src/components/main/Main.scss b/src/components/main/Main.scss index fd9e82448..e8c053f02 100644 --- a/src/components/main/Main.scss +++ b/src/components/main/Main.scss @@ -18,6 +18,14 @@ } } +.has-group-call-header { + --group-call-header-height: 2rem; + #LeftColumn, #MiddleColumn, #RightColumn-wrapper { + height: calc(100% - 2rem); + margin-top: 2rem; + } +} + #LeftColumn { min-width: 12rem; width: 33vw; diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index d1b8ef474..821b70e25 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -44,6 +44,8 @@ import ForwardPicker from './ForwardPicker.async'; import SafeLinkModal from './SafeLinkModal.async'; import HistoryCalendar from './HistoryCalendar.async'; import StickerSetModal from '../common/StickerSetModal.async'; +import GroupCall from '../calls/group/GroupCall.async'; +import ActiveCallHeader from '../calls/ActiveCallHeader.async'; import './Main.scss'; @@ -60,6 +62,7 @@ type StateProps = { isHistoryCalendarOpen: boolean; shouldSkipHistoryAnimations?: boolean; openedStickerSetShortName?: string; + activeGroupCallId?: string; isServiceChatReady?: boolean; animationLevel: number; language?: LangCode; @@ -88,6 +91,7 @@ const Main: FC = ({ hasNotifications, hasDialogs, audioMessage, + activeGroupCallId, safeLinkModalUrl, isHistoryCalendarOpen, shouldSkipHistoryAnimations, @@ -271,6 +275,12 @@ const Main: FC = ({ onClose={handleStickerSetModalClose} stickerSetShortName={openedStickerSetShortName} /> + {activeGroupCallId && ( + <> + + + + )}
); @@ -319,6 +329,7 @@ export default memo(withGlobal( shouldSkipHistoryAnimations: global.shouldSkipHistoryAnimations, openedStickerSetShortName: global.openedStickerSetShortName, isServiceChatReady: selectIsServiceChatReady(global), + activeGroupCallId: global.groupCalls.activeGroupCallId, animationLevel, language, wasTimeFormatSetManually, diff --git a/src/components/middle/HeaderActions.tsx b/src/components/middle/HeaderActions.tsx index 7c9812855..2b68ee308 100644 --- a/src/components/middle/HeaderActions.tsx +++ b/src/components/middle/HeaderActions.tsx @@ -11,9 +11,9 @@ import { GlobalActions, MessageListType } from '../../global/types'; import { MAIN_THREAD_ID } from '../../api/types'; import { IAnchorPosition } from '../../types'; -import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment'; +import { ARE_CALLS_SUPPORTED, IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment'; import { pick } from '../../util/iteratees'; -import { isChatChannel, isChatSuperGroup } from '../../modules/helpers'; +import { isChatBasicGroup, isChatChannel, isChatSuperGroup } from '../../modules/helpers'; import { selectChat, selectChatBot, @@ -45,6 +45,8 @@ interface StateProps { canSearch?: boolean; canMute?: boolean; canLeave?: boolean; + canEnterVoiceChat?: boolean; + canCreateVoiceChat?: boolean; } type DispatchProps = Pick; @@ -63,6 +65,8 @@ const HeaderActions: FC = ({ canSearch, canMute, canLeave, + canEnterVoiceChat, + canCreateVoiceChat, isRightColumnShown, canExpandActions, joinChannel, @@ -191,6 +195,8 @@ const HeaderActions: FC = ({ canSearch={canSearch} canMute={canMute} canLeave={canLeave} + canEnterVoiceChat={canEnterVoiceChat} + canCreateVoiceChat={canCreateVoiceChat} onSubscribeChannel={handleSubscribeClick} onSearchClick={handleSearchClick} onClose={handleHeaderMenuClose} @@ -226,6 +232,9 @@ export default memo(withGlobal( const canSearch = isMainThread || isDiscussionThread; const canMute = isMainThread && !isChatWithSelf && !canSubscribe; const canLeave = isMainThread && !canSubscribe; + const canEnterVoiceChat = ARE_CALLS_SUPPORTED && chat && chat.isCallActive; + const canCreateVoiceChat = ARE_CALLS_SUPPORTED && chat && !chat.isCallActive + && (chat.adminRights?.manageCall || (chat.isCreator && isChatBasicGroup(chat))); return { noMenu: false, @@ -237,6 +246,8 @@ export default memo(withGlobal( canSearch, canMute, canLeave, + canEnterVoiceChat, + canCreateVoiceChat, }; }, (setGlobal, actions): DispatchProps => pick(actions, [ diff --git a/src/components/middle/HeaderMenuContainer.tsx b/src/components/middle/HeaderMenuContainer.tsx index 84d6c812e..494f60173 100644 --- a/src/components/middle/HeaderMenuContainer.tsx +++ b/src/components/middle/HeaderMenuContainer.tsx @@ -27,7 +27,8 @@ import DeleteChatModal from '../common/DeleteChatModal'; import './HeaderMenuContainer.scss'; type DispatchProps = Pick; export type OwnProps = { @@ -43,6 +44,8 @@ export type OwnProps = { canSearch?: boolean; canMute?: boolean; canLeave?: boolean; + canEnterVoiceChat?: boolean; + canCreateVoiceChat?: boolean; onSubscribeChannel: () => void; onSearchClick: () => void; onClose: () => void; @@ -70,6 +73,8 @@ const HeaderMenuContainer: FC = ({ canSearch, canMute, canLeave, + canEnterVoiceChat, + canCreateVoiceChat, chat, isPrivate, isMuted, @@ -84,6 +89,8 @@ const HeaderMenuContainer: FC = ({ enterMessageSelectMode, sendBotCommand, restartBot, + joinGroupCall, + createGroupCall, openLinkedChat, addContact, }) => { @@ -121,6 +128,20 @@ const HeaderMenuContainer: FC = ({ closeMenu(); }, [chatId, closeMenu, isMuted, updateChatMutedState]); + const handleEnterVoiceChatClick = useCallback(() => { + if (canCreateVoiceChat) { + // TODO show popup to schedule + createGroupCall({ + chatId, + }); + } else { + joinGroupCall({ + chatId, + }); + } + closeMenu(); + }, [closeMenu, canCreateVoiceChat, chatId, joinGroupCall, createGroupCall]); + const handleLinkedChatClick = useCallback(() => { openLinkedChat({ id: chatId }); closeMenu(); @@ -211,6 +232,14 @@ const HeaderMenuContainer: FC = ({ {lang(isMuted ? 'ChatsUnmute' : 'ChatsMute')} )} + {(canEnterVoiceChat || canCreateVoiceChat) && ( + + {lang(canCreateVoiceChat ? 'StartVoipChat' : 'VoipGroupJoinCall')} + + )} {hasLinkedChat && ( ( 'enterMessageSelectMode', 'sendBotCommand', 'restartBot', + 'joinGroupCall', + 'createGroupCall', 'openLinkedChat', 'addContact', ]), diff --git a/src/components/middle/MiddleHeader.scss b/src/components/middle/MiddleHeader.scss index dfe436a5e..daa2f48e4 100644 --- a/src/components/middle/MiddleHeader.scss +++ b/src/components/middle/MiddleHeader.scss @@ -302,8 +302,9 @@ .Avatar { margin-right: .625rem; - width: 2.5rem; - height: 2.5rem; + // TODO For some reason webpack imports `Audio.scss` second time when loading calls bundle + width: 2.5rem !important; + height: 2.5rem !important; font-size: 1.0625rem; } diff --git a/src/components/middle/MiddleHeader.tsx b/src/components/middle/MiddleHeader.tsx index 8842f412f..11bda9429 100644 --- a/src/components/middle/MiddleHeader.tsx +++ b/src/components/middle/MiddleHeader.tsx @@ -61,6 +61,7 @@ import Button from '../ui/Button'; import HeaderActions from './HeaderActions'; import HeaderPinnedMessage from './HeaderPinnedMessage'; import AudioPlayer from './AudioPlayer'; +import GroupCallTopPane from '../calls/group/GroupCallTopPane'; import './MiddleHeader.scss'; @@ -404,6 +405,14 @@ const MiddleHeader: FC = ({ {renderInfo} + + {shouldRenderPinnedMessage && renderingPinnedMessage && ( = ({ onChange={handlePermissionChange} />
+
+ +
{!isChannel && (
void; onCloseAnimationEnd?: () => void; onEnter?: () => void; + dialogRef?: RefObject; }; type StateProps = { @@ -38,6 +40,7 @@ type StateProps = { }; const Modal: FC = ({ + dialogRef, title, className, isOpen, @@ -78,7 +81,6 @@ const Modal: FC = ({ useEffectWithPrevDeps(([prevIsOpen]) => { document.body.classList.toggle('has-open-dialog', isOpen); - if (isOpen || (!isOpen && prevIsOpen !== undefined)) { dispatchHeavyAnimationEvent(ANIMATION_DURATION); } @@ -138,7 +140,7 @@ const Modal: FC = ({ >
-
+
{renderHeader()}
{children} diff --git a/src/components/ui/RangeSlider.scss b/src/components/ui/RangeSlider.scss index af1d8a56b..6f0ad9ed4 100644 --- a/src/components/ui/RangeSlider.scss +++ b/src/components/ui/RangeSlider.scss @@ -1,3 +1,5 @@ +@import '../../styles/mixins'; + @mixin thumb-styles() { background: var(--slider-color); border: none; @@ -72,43 +74,7 @@ } // Reset range input browser styles - input[type="range"] { - -webkit-appearance: none; - display: block; - width: 100%; - height: 0.75rem; - margin-bottom: 0.5rem; - background: transparent; - - &:focus { - outline: none; - } - - &::-ms-track { - width: 100%; - cursor: pointer; - - background: transparent; - border-color: transparent; - color: transparent; - } - - &::-webkit-slider-thumb { - -webkit-appearance: none; - } - - &::-moz-slider-thumb { - -moz-appearance: none; - } - - &::-webkit-slider-runnable-track { - cursor: pointer; - } - - &::-moz-range-track, &::-moz-range-progress { - cursor: pointer; - } - } + @include reset-range(); // Apply custom styles input[type="range"] { diff --git a/src/config.ts b/src/config.ts index 3d67e975b..cb23399c2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -58,6 +58,7 @@ export const BLOCKED_LIST_LIMIT = 100; export const PROFILE_PHOTOS_LIMIT = 40; export const PROFILE_SENSITIVE_AREA = 500; export const COMMON_CHATS_LIMIT = 100; +export const GROUP_CALL_PARTICIPANTS_LIMIT = 100; export const TOP_CHAT_MESSAGES_PRELOAD_LIMIT = 20; export const ALL_CHATS_PRELOAD_DISABLED = false; @@ -170,3 +171,8 @@ export const LIGHT_THEME_BG_COLOR = '#A2AF8E'; export const DARK_THEME_BG_COLOR = '#0F0F0F'; export const DARK_THEME_PATTERN_COLOR = '#0a0a0a8c'; export const DEFAULT_PATTERN_COLOR = 'rgba(90, 110, 70, 0.6)'; + +// Group calls +export const GROUP_CALL_VOLUME_MULTIPLIER = 100; +export const GROUP_CALL_DEFAULT_VOLUME = 100 * GROUP_CALL_VOLUME_MULTIPLIER; +export const ENABLE_THUMBNAIL_VIDEO = false; diff --git a/src/global/cache.ts b/src/global/cache.ts index 2bc9908e5..61ce53f10 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -189,6 +189,10 @@ function migrateCache(cached: GlobalState, initialState: GlobalState) { if (cached.audioPlayer.playbackRate === undefined) { cached.audioPlayer.playbackRate = DEFAULT_PLAYBACK_RATE; } + + if (cached.groupCalls === undefined) { + cached.groupCalls = initialState.groupCalls; + } } function updateCache() { diff --git a/src/global/initial.ts b/src/global/initial.ts index 7abdc61b2..9c5453a72 100644 --- a/src/global/initial.ts +++ b/src/global/initial.ts @@ -44,6 +44,10 @@ export const INITIAL_STATE: GlobalState = { messageLists: [], }, + groupCalls: { + byId: {}, + }, + scheduledMessages: { byChatId: {}, }, diff --git a/src/global/types.ts b/src/global/types.ts index 559cc9a25..4a6126016 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -21,6 +21,7 @@ import { ApiInviteInfo, ApiCountryCode, ApiCountry, + ApiGroupCall, } from '../api/types'; import { FocusDirection, @@ -163,6 +164,12 @@ export type GlobalState = { }; }; + groupCalls: { + byId: Record; + activeGroupCallId?: string; + isGroupCallPanelHidden?: boolean; + }; + scheduledMessages: { byChatId: Record; @@ -539,7 +546,12 @@ export type ActionTypes = ( // payment 'openPaymentModal' | 'closePaymentModal' | 'addPaymentError' | 'validateRequestedInfo' | 'setPaymentStep' | 'sendPaymentForm' | 'getPaymentForm' | 'getReceipt' | - 'sendCredentialsInfo' | 'setInvoiceMessageInfo' | 'clearPaymentError' | 'clearReceipt' + 'sendCredentialsInfo' | 'setInvoiceMessageInfo' | 'clearPaymentError' | 'clearReceipt' | + // calls + 'joinGroupCall' | 'toggleGroupCallMute' | 'toggleGroupCallPresentation' | 'leaveGroupCall' | + 'toggleGroupCallVideo' | 'requestToSpeak' | 'setGroupCallParticipantVolume' | 'toggleGroupCallPanel' | + 'createGroupCall' | 'joinVoiceChatByLink' | 'subscribeToGroupCallUpdates' | 'createGroupCallInviteLink' | + 'loadMoreGroupCallParticipants' | 'connectToActiveGroupCall' | 'playGroupCallSound' ); export type GlobalActions = Record void>; diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 2a225c36e..85d1cbb35 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -1100,4 +1100,15 @@ langpack.getLangPack#f2f2330a lang_pack:string lang_code:string = LangPackDiffer langpack.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector = Vector; langpack.getLanguages#42c6978f lang_pack:string = Vector; folders.editPeerFolders#6847d0ab folder_peers:Vector = Updates; +phone.createGroupCall#48cdc6d8 flags:# peer:InputPeer random_id:int title:flags.0?string schedule_date:flags.1?int = Updates; +phone.joinGroupCall#b132ff7b flags:# muted:flags.0?true video_stopped:flags.2?true call:InputGroupCall join_as:InputPeer invite_hash:flags.1?string params:DataJSON = Updates; +phone.leaveGroupCall#500377f9 call:InputGroupCall source:int = Updates; +phone.discardGroupCall#7a777135 call:InputGroupCall = Updates; +phone.getGroupCall#41845db call:InputGroupCall limit:int = phone.GroupCall; +phone.getGroupParticipants#c558d8ab call:InputGroupCall ids:Vector sources:Vector offset:string limit:int = phone.GroupParticipants; +phone.editGroupCallParticipant#a5273abf flags:# call:InputGroupCall participant:InputPeer muted:flags.0?Bool volume:flags.1?int raise_hand:flags.2?Bool video_stopped:flags.3?Bool video_paused:flags.4?Bool presentation_paused:flags.5?Bool = Updates; +phone.exportGroupCallInvite#e6aa647f flags:# can_self_unmute:flags.0?true call:InputGroupCall = phone.ExportedGroupCallInvite; +phone.toggleGroupCallStartSubscription#219c34e6 call:InputGroupCall subscribed:Bool = Updates; +phone.joinGroupCallPresentation#cbea6bc4 call:InputGroupCall params:DataJSON = Updates; +phone.leaveGroupCallPresentation#1c50d144 call:InputGroupCall = Updates; `; \ No newline at end of file diff --git a/src/lib/gramjs/tl/static/api.reduced.tl b/src/lib/gramjs/tl/static/api.reduced.tl index bf5914323..9f3d06c1d 100644 --- a/src/lib/gramjs/tl/static/api.reduced.tl +++ b/src/lib/gramjs/tl/static/api.reduced.tl @@ -1101,4 +1101,15 @@ langpack.getLangPack#f2f2330a lang_pack:string lang_code:string = LangPackDiffer langpack.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector = Vector; langpack.getLanguages#42c6978f lang_pack:string = Vector; folders.editPeerFolders#6847d0ab folder_peers:Vector = Updates; +phone.createGroupCall#48cdc6d8 flags:# peer:InputPeer random_id:int title:flags.0?string schedule_date:flags.1?int = Updates; +phone.joinGroupCall#b132ff7b flags:# muted:flags.0?true video_stopped:flags.2?true call:InputGroupCall join_as:InputPeer invite_hash:flags.1?string params:DataJSON = Updates; +phone.leaveGroupCall#500377f9 call:InputGroupCall source:int = Updates; +phone.discardGroupCall#7a777135 call:InputGroupCall = Updates; +phone.getGroupCall#41845db call:InputGroupCall limit:int = phone.GroupCall; +phone.getGroupParticipants#c558d8ab call:InputGroupCall ids:Vector sources:Vector offset:string limit:int = phone.GroupParticipants; +phone.editGroupCallParticipant#a5273abf flags:# call:InputGroupCall participant:InputPeer muted:flags.0?Bool volume:flags.1?int raise_hand:flags.2?Bool video_stopped:flags.3?Bool video_paused:flags.4?Bool presentation_paused:flags.5?Bool = Updates; +phone.exportGroupCallInvite#e6aa647f flags:# can_self_unmute:flags.0?true call:InputGroupCall = phone.ExportedGroupCallInvite; +phone.toggleGroupCallStartSubscription#219c34e6 call:InputGroupCall subscribed:Bool = Updates; +phone.joinGroupCallPresentation#cbea6bc4 call:InputGroupCall params:DataJSON = Updates; +phone.leaveGroupCallPresentation#1c50d144 call:InputGroupCall = Updates; // LAYER 133 diff --git a/src/lib/rlottie/RLottie.ts b/src/lib/rlottie/RLottie.ts index 992d0a33d..c264ca7cd 100644 --- a/src/lib/rlottie/RLottie.ts +++ b/src/lib/rlottie/RLottie.ts @@ -89,6 +89,7 @@ class RLottie { private animationData: AnyLiteral, private params: Params = {}, private onLoad?: () => void, + private customColor?: [number, number, number], ) { this.initContainer(); this.initConfig(); @@ -199,6 +200,46 @@ class RLottie { this.canvas.remove(); } + private onChangeData(framesCount: number) { + this.isWaiting = false; + this.framesCount = framesCount; + this.chunksCount = Math.ceil(framesCount / this.chunkSize); + this.isAnimating = false; + + this.doPlay(); + } + + setColor(newColor: [number, number, number] | undefined) { + this.customColor = newColor; + if (this.customColor) { + const imageData = this.ctx.getImageData(0, 0, this.imgSize, this.imgSize); + const arr = imageData.data; + for (let i = 0; i < arr.length; i += 4) { + /* eslint-disable prefer-destructuring */ + arr[i] = this.customColor[0]; + arr[i + 1] = this.customColor[1]; + arr[i + 2] = this.customColor[2]; + /* eslint-enable prefer-destructuring */ + } + this.ctx.putImageData(imageData, 0, 0); + } + } + + changeData(animationData: AnyLiteral) { + this.pause(); + this.animationData = animationData; + this.initConfig(); + + workers[this.workerIndex].request({ + name: 'changeData', + args: [ + this.key, + this.animationData, + this.onChangeData.bind(this), + ], + }); + } + private initRenderer() { this.workerIndex = cycleRestrict(MAX_WORKERS, ++lastWorkerIndex); @@ -264,7 +305,8 @@ class RLottie { const frameIndex = Math.round(this.approxFrameIndex); const chunkIndex = this.getChunkIndex(frameIndex); const chunk = this.chunks[chunkIndex]; - if (!chunk) { + + if (!chunk || chunk.length === 0) { this.requestChunk(chunkIndex); this.isAnimating = false; this.isWaiting = true; @@ -285,7 +327,17 @@ class RLottie { return false; } - const imageData = new ImageData(new Uint8ClampedArray(frame), this.imgSize, this.imgSize); + const arr = new Uint8ClampedArray(frame); + if (this.customColor) { + for (let i = 0; i < arr.length; i += 4) { + /* eslint-disable prefer-destructuring */ + arr[i] = this.customColor[0]; + arr[i + 1] = this.customColor[1]; + arr[i + 2] = this.customColor[2]; + /* eslint-enable prefer-destructuring */ + } + } + const imageData = new ImageData(arr, this.imgSize, this.imgSize); this.ctx.putImageData(imageData, 0, 0); if (this.onLoad && !this.isOnLoadFired) { @@ -373,7 +425,7 @@ class RLottie { } private requestChunk(chunkIndex: number) { - if (this.chunks[chunkIndex]) { + if (this.chunks[chunkIndex] && this.chunks[chunkIndex]?.length !== 0) { return; } diff --git a/src/lib/rlottie/rlottie.worker.ts b/src/lib/rlottie/rlottie.worker.ts index 246063062..a976bec39 100644 --- a/src/lib/rlottie/rlottie.worker.ts +++ b/src/lib/rlottie/rlottie.worker.ts @@ -56,6 +56,23 @@ async function init( onInit(Math.ceil(framesCount / reduceFactor)); } +async function changeData( + key: string, + animationData: AnyLiteral, + onInit: CancellableCallback, +) { + if (!rLottieApi) { + await rLottieApiPromise; + } + + const json = JSON.stringify(animationData); + const { reduceFactor, handle } = renderers.get(key)!; + + const stringOnWasmHeap = allocate(intArrayFromString(json), 'i8', 0); + const framesCount = rLottieApi.loadFromData(handle, stringOnWasmHeap); + onInit(Math.ceil(framesCount / reduceFactor)); +} + async function renderFrames( key: string, fromIndex: number, toIndex: number, onProgress: CancellableCallback, ) { @@ -86,6 +103,7 @@ function destroy(key: string) { createWorkerInterface({ init, + changeData, renderFrames, destroy, }); diff --git a/src/lib/secret-sauce/blacksilence.d.ts b/src/lib/secret-sauce/blacksilence.d.ts new file mode 100644 index 000000000..828b76642 --- /dev/null +++ b/src/lib/secret-sauce/blacksilence.d.ts @@ -0,0 +1,5 @@ +export declare const silence: (ctx: AudioContext) => MediaStream; +export declare const black: ({ width, height }?: { + width?: number | undefined; + height?: number | undefined; +}) => MediaStream; diff --git a/src/lib/secret-sauce/buildSdp.d.ts b/src/lib/secret-sauce/buildSdp.d.ts new file mode 100644 index 000000000..f2e1d31f8 --- /dev/null +++ b/src/lib/secret-sauce/buildSdp.d.ts @@ -0,0 +1,21 @@ +import { GroupCallTransport, PayloadType, RTPExtension, SsrcGroup } from './types'; +export declare type Conference = { + sessionId: number; + audioExtensions: RTPExtension[]; + videoExtensions: RTPExtension[]; + audioPayloadTypes: PayloadType[]; + videoPayloadTypes: PayloadType[]; + ssrcs: Ssrc[]; + transport: GroupCallTransport; +}; +export declare type Ssrc = { + userId: string; + endpoint: string; + isMain: boolean; + isRemoved?: boolean; + isVideo: boolean; + isPresentation?: boolean; + sourceGroups: SsrcGroup[]; +}; +declare const _default: (conference: Conference, isAnswer?: boolean, isPresentation?: boolean) => string; +export default _default; diff --git a/src/lib/secret-sauce/colibriClass.d.ts b/src/lib/secret-sauce/colibriClass.d.ts new file mode 100644 index 000000000..80bcc3aaa --- /dev/null +++ b/src/lib/secret-sauce/colibriClass.d.ts @@ -0,0 +1,36 @@ +export declare type EndpointConnectivityStatusChangeEvent = { + colibriClass: 'EndpointConnectivityStatusChangeEvent'; + endpoint: string; + active: boolean; +}; +export declare type DominantSpeakerEndpointChangeEvent = { + colibriClass: 'DominantSpeakerEndpointChangeEvent'; + dominantSpeakerEndpoint: string; + previousSpeakers: string[]; +}; +export declare type SenderVideoConstraints = { + colibriClass: 'SenderVideoConstraints'; + videoConstraints: { + idealHeight: number; + }; +}; +export declare type DebugMessage = { + colibriClass: 'DebugMessage'; + message: string; +}; +export declare type LastNEndpointsChangeEvent = { + colibriClass: 'LastNEndpointsChangeEvent'; + lastNEndpoints: string[]; +}; +export declare type ReceiverVideoConstraints = { + colibriClass: 'ReceiverVideoConstraints'; + defaultConstraints: { + maxHeight: number; + }; + constraints: Record; + onStageEndpoints: string[]; +}; +export declare type ColibriClass = (LastNEndpointsChangeEvent | DebugMessage | EndpointConnectivityStatusChangeEvent | SenderVideoConstraints | DominantSpeakerEndpointChangeEvent | ReceiverVideoConstraints); diff --git a/src/lib/secret-sauce/index.d.ts b/src/lib/secret-sauce/index.d.ts new file mode 100644 index 000000000..f887e1781 --- /dev/null +++ b/src/lib/secret-sauce/index.d.ts @@ -0,0 +1,3 @@ +export { handleUpdateGroupCallConnection, startSharingScreen, joinGroupCall, getDevices, getUserStreams, setVolume, isStreamEnabled, toggleStream, leaveGroupCall, handleUpdateGroupCallParticipants, switchCameraInput, toggleSpeaker, toggleNoiseSuppression, } from './secretsauce'; +export { IS_SCREENSHARE_SUPPORTED, THRESHOLD, } from './utils'; +export * from './types'; diff --git a/src/lib/secret-sauce/index.js b/src/lib/secret-sauce/index.js new file mode 100644 index 000000000..8f63f62df --- /dev/null +++ b/src/lib/secret-sauce/index.js @@ -0,0 +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:f,fingerprints:m,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 v=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)},S=()=>{r(`a=ice-ufrag:${l}`),r(`a=ice-pwd:${f}`),m.forEach((e=>{r(`a=fingerprint:${e.hash} ${e.fingerprint}`),r("a=setup:passive")})),g.forEach(v)},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"):(S(),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"),S(),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:()=>f,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&&(f("audio",!1),f("video",!1),f("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");m("connecting");var i=t.createMediaStreamDestination();return n.srcObject=i.stream,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(v,1e3),audioElement:n,destination:i,audioContext:t},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 f(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 m(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(),m("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 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 S(e){if(o&&o.audioElement&&o.destination&&o.audioContext){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.audioContext,i=e.createMediaStreamSource(t),r=e.createGain();r.gain.value=(n?.volume||1e4)/1e4;const c=e.createGain();r.gain.value=1;const d=e.createAnalyser();d.minDecibels=-100,d.maxDecibels=-30,d.smoothingTimeConstant=.05,d.fftSize=1024,i.connect(d).connect(c).connect(r).connect(o.destination);const p=new Audio;p.srcObject=i.mediaStream,p.muted=!0,p.remove(),o={...o,participantFunctions:{...o.participantFunctions,[a]:{...o.participantFunctions?.[a],setVolume:e=>{r.gain.value=1{c.gain.value=e?0:1},getCurrentAmplitude:()=>{var e=new Uint8Array(d.frequencyBinCount);return d.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?m("connected"):"checking"===e||"new"===e?m("connecting"):"disconnected"===i.iceConnectionState&&m("reconnecting")}),i.ontrack=S,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 diff --git a/src/lib/secret-sauce/index.js.LICENSE.txt b/src/lib/secret-sauce/index.js.LICENSE.txt new file mode 100644 index 000000000..1d5cd7da2 --- /dev/null +++ b/src/lib/secret-sauce/index.js.LICENSE.txt @@ -0,0 +1,39 @@ +/*! ./blacksilence */ + +/*! ./buildSdp */ + +/*! ./parseSdp */ + +/*! ./secretsauce */ + +/*! ./types */ + +/*! ./utils */ + +/*!**********************!*\ + !*** ./src/index.ts ***! + \**********************/ + +/*!**********************!*\ + !*** ./src/types.ts ***! + \**********************/ + +/*!**********************!*\ + !*** ./src/utils.ts ***! + \**********************/ + +/*!*************************!*\ + !*** ./src/buildSdp.ts ***! + \*************************/ + +/*!*************************!*\ + !*** ./src/parseSdp.ts ***! + \*************************/ + +/*!****************************!*\ + !*** ./src/secretsauce.ts ***! + \****************************/ + +/*!*****************************!*\ + !*** ./src/blacksilence.ts ***! + \*****************************/ diff --git a/src/lib/secret-sauce/parseSdp.d.ts b/src/lib/secret-sauce/parseSdp.d.ts new file mode 100644 index 000000000..d789bd62d --- /dev/null +++ b/src/lib/secret-sauce/parseSdp.d.ts @@ -0,0 +1,3 @@ +import { JoinGroupCallPayload } from './types'; +declare const _default: (sessionDescription: RTCSessionDescriptionInit) => JoinGroupCallPayload; +export default _default; diff --git a/src/lib/secret-sauce/secretsauce.d.ts b/src/lib/secret-sauce/secretsauce.d.ts new file mode 100644 index 000000000..8d3829af9 --- /dev/null +++ b/src/lib/secret-sauce/secretsauce.d.ts @@ -0,0 +1,20 @@ +import { GroupCallConnectionData, GroupCallParticipant, JoinGroupCallPayload } from './types'; +declare type StreamType = 'audio' | 'video' | 'presentation'; +export declare function getDevices(streamType: StreamType, isInput?: boolean): Promise; +export declare function toggleSpeaker(): void; +export declare function toggleNoiseSuppression(): void; +export declare function getUserStreams(userId: string): { + audio?: MediaStream | undefined; + video?: MediaStream | undefined; + presentation?: MediaStream | undefined; +} | undefined; +export declare function setVolume(userId: string, volume: number): void; +export declare function isStreamEnabled(streamType: StreamType, userId?: string): boolean; +export declare function switchCameraInput(): Promise; +export declare function toggleStream(streamType: StreamType, value?: boolean | undefined): Promise; +export declare function leaveGroupCall(): void; +export declare function handleUpdateGroupCallParticipants(updatedParticipants: GroupCallParticipant[]): Promise; +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 new file mode 100644 index 000000000..57029540f --- /dev/null +++ b/src/lib/secret-sauce/types.d.ts @@ -0,0 +1,100 @@ +export interface GroupCallParticipant { + isSelf?: boolean; + isMuted?: boolean; + isLeft?: boolean; + isUser?: boolean; + canSelfUnmute?: boolean; + hasJustJoined?: boolean; + isVideoJoined?: boolean; + isMutedByMe?: boolean; + isVolumeByAdmin?: boolean; + isMin?: boolean; + isVersioned?: boolean; + source: number; + date: Date; + id: string; + volume?: number; + about?: string; + video?: GroupCallParticipantVideo; + presentation?: GroupCallParticipantVideo; + raiseHandRating?: string; + hasAudioStream?: boolean; + hasVideoStream?: boolean; + hasPresentationStream?: boolean; + amplitude?: number; +} +export declare type GroupCallConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting' | 'discarded'; +export interface GroupCallParticipantVideo { + endpoint: string; + isPaused?: boolean; + sourceGroups: SsrcGroup[]; + audioSource?: number; +} +export declare type Fingerprint = { + hash: string; + setup: string; + fingerprint: string; +}; +export declare type SsrcGroup = { + semantics: string; + sources: number[]; +}; +export declare type Candidate = { + generation: string; + component: string; + protocol: string; + port: string; + ip: string; + foundation: string; + id: string; + priority: string; + type: string; + network: string; + 'rel-addr': string; + 'rel-port': string; +}; +export declare type JoinGroupCallPayload = { + ufrag: string; + pwd: string; + fingerprints: Fingerprint[]; + ssrc?: number; + 'ssrc-groups'?: SsrcGroup[]; +}; +export interface RTPExtension { + id: number; + uri: string; +} +export interface RTCPFeedbackParam { + type: string; + subtype?: string; +} +export interface PayloadType { + id: number; + name: string; + clockrate: number; + channels: number; + parameters?: Record; + 'rtcp-fbs'?: RTCPFeedbackParam[]; +} +export interface GroupCallTransport { + candidates: Candidate[]; + pwd: string; + ufrag: string; + fingerprints: Fingerprint[]; + 'rtcp-mux': boolean; + xmlns: string; +} +export interface GroupCallConnectionData { + transport: GroupCallTransport; + audio: { + 'payload-types': PayloadType[]; + 'rtp-hdrexts': RTPExtension[]; + }; + video: { + endpoint: string; + 'payload-types': PayloadType[]; + 'rtp-hdrexts': RTPExtension[]; + server_sources: number[]; + }; + stream?: boolean; +} diff --git a/src/lib/secret-sauce/utils.d.ts b/src/lib/secret-sauce/utils.d.ts new file mode 100644 index 000000000..2540de229 --- /dev/null +++ b/src/lib/secret-sauce/utils.d.ts @@ -0,0 +1,11 @@ +export declare function toTelegramSource(source: number): number; +export declare function fromTelegramSource(source: number): number; +export declare function getAmplitude(array: Uint8Array, scale?: number): number; +export declare function getPlatform(): "Windows" | "macOS" | "iOS" | "Android" | "Linux" | undefined; +export declare const THRESHOLD = 0.1; +export declare const PLATFORM_ENV: "Windows" | "macOS" | "iOS" | "Android" | "Linux" | undefined; +export declare const IS_MAC_OS: boolean; +export declare const IS_IOS: boolean; +export declare const IS_SCREENSHARE_SUPPORTED: boolean; +export declare const IS_ECHO_CANCELLATION_SUPPORTED: boolean | undefined; +export declare const IS_NOISE_SUPPRESSION_SUPPORTED: any; diff --git a/src/modules/actions/all.ts b/src/modules/actions/all.ts index ad3188e0d..4b2f03d5e 100644 --- a/src/modules/actions/all.ts +++ b/src/modules/actions/all.ts @@ -8,6 +8,7 @@ import './ui/users'; import './ui/settings'; import './ui/misc'; import './ui/payments'; +import './ui/calls'; import './api/initial'; import './api/chats'; @@ -31,3 +32,4 @@ import './apiUpdaters/symbols'; import './apiUpdaters/misc'; import './apiUpdaters/settings'; import './apiUpdaters/twoFaSettings'; +import './apiUpdaters/calls'; diff --git a/src/modules/actions/api/calls.async.ts b/src/modules/actions/api/calls.async.ts new file mode 100644 index 000000000..5b00a7abf --- /dev/null +++ b/src/modules/actions/api/calls.async.ts @@ -0,0 +1,283 @@ +import { + joinGroupCall, + startSharingScreen, + leaveGroupCall, + toggleStream, + isStreamEnabled, + setVolume, + handleUpdateGroupCallParticipants, handleUpdateGroupCallConnection, +} from '../../../lib/secret-sauce'; +import { addReducer, getGlobal, setGlobal } from '../../../lib/teact/teactn'; +import { callApi } from '../../../api/gramjs'; +import { selectChat, selectUser } from '../../selectors'; +import { + selectActiveGroupCall, + selectGroupCallParticipant, +} from '../../selectors/calls'; +import { + removeGroupCall, + updateActiveGroupCall, + updateGroupCall, + updateGroupCallParticipant, +} from '../../reducers/calls'; +import { ApiUpdate } from '../../../api/types'; +import { GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config'; +import { omit } from '../../../util/iteratees'; +import { getGroupCallAudioContext, getGroupCallAudioElement, removeGroupCallAudioElement } from '../ui/calls'; +import { loadFullChat } from './chats'; + +addReducer('apiUpdate', (global, actions, update: ApiUpdate) => { + const { activeGroupCallId } = global.groupCalls; + + switch (update['@type']) { + case 'updateGroupCallLeavePresentation': { + actions.toggleGroupCallPresentation({ value: false }); + break; + } + case 'updateGroupCallStreams': { + if (!update.userId || !activeGroupCallId) break; + if (!selectGroupCallParticipant(global, activeGroupCallId, update.userId)) break; + + return updateGroupCallParticipant(global, activeGroupCallId, update.userId, omit(update, ['@type', 'userId'])); + } + case 'updateGroupCallConnectionState': { + if (!activeGroupCallId) break; + + if (update.connectionState === 'disconnected') { + actions.leaveGroupCall({ isFromLibrary: true }); + break; + } + + return updateGroupCall(global, activeGroupCallId, { + connectionState: update.connectionState, + isSpeakerDisabled: update.isSpeakerDisabled, + }); + } + case 'updateGroupCallParticipants': { + const { groupCallId, participants } = update; + if (activeGroupCallId === groupCallId) { + void handleUpdateGroupCallParticipants(participants); + } + break; + } + case 'updateGroupCallConnection': { + if (update.data.stream) { + actions.showNotification({ message: 'Big live streams are not yet supported' }); + actions.leaveGroupCall(); + break; + } + void handleUpdateGroupCallConnection(update.data, update.presentation); + + const groupCall = selectActiveGroupCall(global); + if (groupCall?.participants && Object.keys(groupCall.participants).length > 0) { + void handleUpdateGroupCallParticipants(Object.values(groupCall.participants)); + } + break; + } + } + + return undefined; +}); + +addReducer('leaveGroupCall', (global, actions, payload) => { + const { + isFromLibrary, shouldDiscard, shouldRemove, rejoin, + } = payload || {}; + const groupCall = selectActiveGroupCall(global); + if (!groupCall) { + return; + } + + global = updateActiveGroupCall(global, { + connectionState: 'disconnected', + }, groupCall.participantsCount - 1); + + (async () => { + await callApi('leaveGroupCall', { + call: groupCall, + }); + + if (shouldDiscard) { + await callApi('discardGroupCall', { + call: groupCall, + }); + } + + global = getGlobal(); + if (shouldRemove) { + global = removeGroupCall(global, groupCall.id); + } + + removeGroupCallAudioElement(); + + setGlobal({ + ...global, + groupCalls: { + ...global.groupCalls, + isGroupCallPanelHidden: true, + activeGroupCallId: undefined, + }, + }); + + if (!isFromLibrary) { + leaveGroupCall(); + } + + if (rejoin) { + actions.joinGroupCall(rejoin); + } + })(); +}); + +addReducer('toggleGroupCallVideo', (global) => { + const groupCall = selectActiveGroupCall(global); + const user = selectUser(global, global.currentUserId!); + if (!user || !groupCall) { + return; + } + + (async () => { + await toggleStream('video'); + + await callApi('editGroupCallParticipant', { + call: groupCall, + videoStopped: !isStreamEnabled('video'), + participant: user, + }); + })(); +}); + +addReducer('requestToSpeak', (global, actions, payload) => { + const { value } = payload || { value: true }; + const groupCall = selectActiveGroupCall(global); + const user = selectUser(global, global.currentUserId!); + if (!user || !groupCall) { + return; + } + + void callApi('editGroupCallParticipant', { + call: groupCall, + raiseHand: value, + participant: user, + }); +}); + +addReducer('setGroupCallParticipantVolume', (global, actions, payload) => { + const { participantId, volume } = payload!; + + const groupCall = selectActiveGroupCall(global); + const user = selectUser(global, participantId); + if (!user || !groupCall) { + return; + } + + setVolume(participantId, Math.floor(volume / GROUP_CALL_VOLUME_MULTIPLIER) / 100); + + void callApi('editGroupCallParticipant', { + call: groupCall, + volume: Number(volume), + participant: user, + }); +}); + +addReducer('toggleGroupCallMute', (global, actions, payload) => { + const { participantId, value } = payload || {}; + const groupCall = selectActiveGroupCall(global); + const user = selectUser(global, participantId || global.currentUserId!); + if (!user || !groupCall) { + return; + } + + (async () => { + const muted = value === undefined ? isStreamEnabled('audio', user.id) : value; + + if (!participantId) { + await toggleStream('audio'); + } else { + setVolume(participantId, muted ? 0 : 1); + } + + await callApi('editGroupCallParticipant', { + call: groupCall, + muted, + participant: user, + }); + })(); +}); + +addReducer('toggleGroupCallPresentation', (global, actions, payload) => { + const groupCall = selectActiveGroupCall(global); + const user = selectUser(global, global.currentUserId!); + if (!user || !groupCall) { + return; + } + + (async () => { + const value = payload?.value !== undefined ? payload?.value : !isStreamEnabled('presentation'); + if (value) { + const params = await startSharingScreen(); + if (!params) { + return; + } + + await callApi('joinGroupCallPresentation', { + call: groupCall, + params, + }); + } else { + await toggleStream('presentation', false); + await callApi('leaveGroupCallPresentation', { + call: groupCall, + }); + } + + await callApi('editGroupCallParticipant', { + call: groupCall, + presentationPaused: !isStreamEnabled('presentation'), + participant: user, + }); + })(); +}); + +addReducer('connectToActiveGroupCall', (global, actions) => { + const groupCall = selectActiveGroupCall(global); + if (!groupCall) return; + + if (groupCall.connectionState === 'discarded') { + actions.showNotification({ message: 'This voice chat is not active' }); + return; + } + + const audioElement = getGroupCallAudioElement(); + const audioContext = getGroupCallAudioContext(); + + if (!audioElement || !audioContext) { + return; + } + + const { + currentUserId, + } = global; + + if (!currentUserId) return; + + (async () => { + const params = await joinGroupCall(currentUserId, audioContext, audioElement, actions.apiUpdate); + + const result = await callApi('joinGroupCall', { + call: groupCall, + params, + inviteHash: groupCall.inviteHash, + }); + + if (!result) return; + + actions.loadMoreGroupCallParticipants(); + + if (groupCall.chatId) { + const chat = selectChat(getGlobal(), groupCall.chatId); + if (!chat) return; + await loadFullChat(chat); + } + })(); +}); diff --git a/src/modules/actions/api/chats.ts b/src/modules/actions/api/chats.ts index 5c359efec..0eec22192 100644 --- a/src/modules/actions/api/chats.ts +++ b/src/modules/actions/api/chats.ts @@ -42,12 +42,14 @@ import { selectCurrentMessageList, selectThreadInfo, selectCurrentChat, selectLastServiceNotification, } from '../../selectors'; -import { buildCollectionByKey } from '../../../util/iteratees'; +import { buildCollectionByKey, omit } from '../../../util/iteratees'; import { debounce, pause, throttle } from '../../../util/schedulers'; import { isChatSummaryOnly, isChatArchived, prepareChatList, isChatBasicGroup, } from '../../helpers'; import { processDeepLink } from '../../../util/deeplink'; +import { updateGroupCall } from '../../reducers/calls'; +import { selectGroupCall } from '../../selectors/calls'; const TOP_CHAT_MESSAGES_PRELOAD_INTERVAL = 100; const CHATS_PRELOAD_INTERVAL = 300; @@ -567,7 +569,13 @@ addReducer('openTelegramLink', (global, actions, payload) => { const chatOrChannelPostId = part2 ? Number(part2) : undefined; const messageId = part3 ? Number(part3) : undefined; const commentId = params.comment ? Number(params.comment) : undefined; - if (part1 === 'c' && chatOrChannelPostId && messageId) { + + if (params.hasOwnProperty('voicechat') || params.hasOwnProperty('livestream')) { + actions.joinVoiceChatByLink({ + username: part1, + inviteHash: params.voicechat || params.livestream, + }); + } else if (part1 === 'c' && chatOrChannelPostId && messageId) { actions.focusMessage({ chatId: -chatOrChannelPostId, messageId, @@ -869,7 +877,7 @@ addReducer('linkDiscussionGroup', (global, actions, payload) => { fullInfo = fullChat.fullInfo; } - if (fullInfo.isPreHistoryHidden) { + if (fullInfo!.isPreHistoryHidden) { await callApi('togglePreHistoryHidden', { chat, isEnabled: false }); } @@ -1031,21 +1039,35 @@ async function loadChats(listType: 'active' | 'archived', offsetId?: string, off setGlobal(global); } -async function loadFullChat(chat: ApiChat) { +export async function loadFullChat(chat: ApiChat) { const result = await callApi('fetchFullChat', chat); if (!result) { - return; + return undefined; } - const { users, fullInfo } = result; + const { users, fullInfo, groupCall } = result; let global = getGlobal(); if (users) { global = addUsers(global, buildCollectionByKey(users, 'id')); } + + if (groupCall) { + const existingGroupCall = selectGroupCall(global, groupCall.id!); + global = updateGroupCall( + global, + groupCall.id!, + omit(groupCall, ['connectionState']), + undefined, + existingGroupCall ? undefined : groupCall.participantsCount, + ); + } + global = updateChat(global, chat.id, { fullInfo }); setGlobal(global); + + return result; } async function createChannel(title: string, users: ApiUser[], about?: string, photo?: File) { @@ -1203,7 +1225,7 @@ async function deleteChatFolder(id: number) { await callApi('deleteChatFolder', id); } -async function fetchChatByUsername( +export async function fetchChatByUsername( username: string, ) { const global = getGlobal(); diff --git a/src/modules/actions/apiUpdaters/calls.ts b/src/modules/actions/apiUpdaters/calls.ts new file mode 100644 index 000000000..7ebcff41f --- /dev/null +++ b/src/modules/actions/apiUpdaters/calls.ts @@ -0,0 +1,60 @@ +import { addReducer, getGlobal } from '../../../lib/teact/teactn'; +import { ApiUpdate } from '../../../api/types'; +import { removeGroupCall, updateGroupCall, updateGroupCallParticipant } from '../../reducers/calls'; +import { omit } from '../../../util/iteratees'; +import { selectChat } from '../../selectors'; +import { updateChat } from '../../reducers'; + +addReducer('apiUpdate', (global, actions, update: ApiUpdate) => { + switch (update['@type']) { + case 'updateGroupCall': { + if (update.call.connectionState === 'discarded') { + if (global.groupCalls.activeGroupCallId) { + actions.leaveGroupCall({ shouldRemove: true }); + return undefined; + } else { + return removeGroupCall(global, update.call.id); + } + } + + return updateGroupCall(global, + update.call.id, + omit(update.call, ['connectionState']), + undefined, + update.call.participantsCount); + } + case 'updateGroupCallChatId': { + const chat = selectChat(global, update.chatId); + if (chat) { + global = updateChat(global, update.chatId, { + fullInfo: { + ...chat.fullInfo, + groupCallId: update.call.id, + }, + }); + } + return global; + } + case 'updateGroupCallParticipants': { + const { groupCallId, participants, nextOffset } = update; + const { currentUserId } = global; + + // `secret-sauce` should disconnect if the participant is us but from another device + global = getGlobal(); + participants.forEach((participant) => { + if (participant.id) { + global = updateGroupCallParticipant(global, groupCallId, participant.id, participant, + !!nextOffset || currentUserId === participant.id); + } + }); + if (nextOffset) { + global = updateGroupCall(global, groupCallId, { + nextOffset, + }); + } + return global; + } + } + + return undefined; +}); diff --git a/src/modules/actions/calls.ts b/src/modules/actions/calls.ts new file mode 100644 index 000000000..5828e6c0a --- /dev/null +++ b/src/modules/actions/calls.ts @@ -0,0 +1 @@ +import './api/calls.async'; diff --git a/src/modules/actions/ui/calls.ts b/src/modules/actions/ui/calls.ts new file mode 100644 index 000000000..2f07fcc0b --- /dev/null +++ b/src/modules/actions/ui/calls.ts @@ -0,0 +1,311 @@ +import { addReducer, getGlobal, setGlobal } from '../../../lib/teact/teactn'; +import { selectActiveGroupCall, selectChatGroupCall, selectGroupCall } from '../../selectors/calls'; +import { callApi } from '../../../api/gramjs'; +import { selectChat } from '../../selectors'; +import { copyTextToClipboard } from '../../../util/clipboard'; +import { ApiGroupCall } from '../../../api/types'; +import { updateGroupCall } from '../../reducers/calls'; +import { buildCollectionByKey, omit } from '../../../util/iteratees'; +import { addChats, addUsers } from '../../reducers'; +import { fetchChatByUsername, loadFullChat } from '../api/chats'; +import safePlay from '../../../util/safePlay'; +import { ARE_CALLS_SUPPORTED } from '../../../util/environment'; +import * as langProvider from '../../../util/langProvider'; + +// 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 initializationPromise: Promise | undefined = Promise.resolve(); + +const initializeSoundsForSafari = () => { + if (!initializationPromise) return Promise.resolve(); + + initializationPromise = Promise.all(Object.values(sounds).map((l) => { + l.muted = true; + l.volume = 0.0001; + return l.play().then(() => { + l.pause(); + l.volume = 1; + l.currentTime = 0; + l.muted = false; + }); + })).then(() => { + initializationPromise = undefined; + }); + + return initializationPromise; +}; + +async function fetchGroupCall(groupCall: Partial) { + const result = await callApi('getGroupCall', { + call: groupCall, + }); + + if (!result) return undefined; + + let global = getGlobal(); + + const existingGroupCall = selectGroupCall(global, groupCall.id!); + + global = updateGroupCall(global, + groupCall.id!, + omit(result.groupCall, ['connectionState']), + undefined, + existingGroupCall?.isLoaded ? undefined : result.groupCall.participantsCount); + global = addUsers(global, buildCollectionByKey(result.users, 'id')); + global = addChats(global, buildCollectionByKey(result.chats, 'id')); + + setGlobal(global); + + return result.groupCall; +} + +async function fetchGroupCallParticipants(groupCall: Partial, nextOffset?: string) { + const result = await callApi('fetchGroupCallParticipants', { + call: groupCall as ApiGroupCall, + offset: nextOffset, + }); + + if (!result) return; + + let global = getGlobal(); + + global = addUsers(global, buildCollectionByKey(result.users, 'id')); + global = addChats(global, buildCollectionByKey(result.chats, 'id')); + + setGlobal(global); +} + +addReducer('toggleGroupCallPanel', (global) => { + return { + ...global, + groupCalls: { + ...global.groupCalls, + isGroupCallPanelHidden: !global.groupCalls.isGroupCallPanelHidden, + }, + }; +}); + +addReducer('subscribeToGroupCallUpdates', (global, actions, payload) => { + const { subscribed, id } = payload!; + const groupCall = selectGroupCall(global, id); + + if (!groupCall) return; + + (async () => { + if (subscribed) { + await fetchGroupCall(groupCall); + await fetchGroupCallParticipants(groupCall); + } + + await callApi('toggleGroupCallStartSubscription', { + subscribed, + call: groupCall, + }); + })(); +}); + +addReducer('createGroupCall', (global, actions, payload) => { + const { chatId } = payload; + + const chat = selectChat(global, chatId); + if (!chat) { + return; + } + + (async () => { + const result = await callApi('createGroupCall', { + peer: chat, + }); + + if (!result) return; + + global = getGlobal(); + setGlobal(updateGroupCall(global, result.id, { + ...result, + chatId, + })); + + actions.joinGroupCall({ id: result.id, accessHash: result.accessHash }); + })(); +}); + +addReducer('createGroupCallInviteLink', (global, actions) => { + const groupCall = selectActiveGroupCall(global); + + if (!groupCall) { + return; + } + + (async () => { + const result = await callApi('exportGroupCallInvite', { + call: groupCall, + canSelfUnmute: false, + }); + + if (!result) return; + + copyTextToClipboard(result); + actions.showNotification({ + message: 'Link copied to clipboard', + }); + })(); +}); + +addReducer('joinVoiceChatByLink', (global, actions, payload) => { + const { username, inviteHash } = payload!; + + (async () => { + const chat = await fetchChatByUsername(username); + + if (!chat) { + actions.showNotification({ message: langProvider.getTranslation('NoUsernameFound') }); + return; + } + + const full = await loadFullChat(chat); + + if (full?.groupCall) { + actions.joinGroupCall({ id: full.groupCall.id, accessHash: full.groupCall.accessHash, inviteHash }); + } + })(); +}); + +addReducer('joinGroupCall', (global, actions, payload) => { + if (!ARE_CALLS_SUPPORTED) return; + + const { + chatId, id, accessHash, inviteHash, + } = payload; + + createAudioElement(); + + (async () => { + await initializeSoundsForSafari(); + const { groupCalls: { activeGroupCallId } } = global; + let groupCall = id ? selectGroupCall(global, id) : selectChatGroupCall(global, chatId); + + if (groupCall?.id === activeGroupCallId) { + actions.toggleGroupCallPanel(); + return; + } + + if (activeGroupCallId) { + actions.leaveGroupCall({ + rejoin: payload, + }); + return; + } + + if (groupCall && activeGroupCallId === groupCall.id) { + actions.toggleGroupCallPanel(); + return; + } + + if (!groupCall && (!id || !accessHash)) { + groupCall = await fetchGroupCall({ + id, + accessHash, + }); + } + + if (!groupCall) return; + + global = getGlobal(); + + global = updateGroupCall( + global, + groupCall.id, + { + ...groupCall, + inviteHash, + }, + undefined, + groupCall.participantsCount + 1, + ); + + setGlobal({ + ...global, + groupCalls: { + ...global.groupCalls, + activeGroupCallId: groupCall.id, + isGroupCallPanelHidden: false, + }, + }); + })(); +}); + +addReducer('playGroupCallSound', (global, actions, payload) => { + const { sound } = payload!; + + if (!sounds[sound]) { + return; + } + + if (initializationPromise) { + initializationPromise.then(() => { + safePlay(sounds[sound]); + }); + } else { + if (sound !== 'connecting') { + sounds.connecting.pause(); + } + safePlay(sounds[sound]); + } +}); + +addReducer('loadMoreGroupCallParticipants', (global) => { + const groupCall = selectActiveGroupCall(global); + if (!groupCall) { + return; + } + + void fetchGroupCallParticipants(groupCall, groupCall.nextOffset); +}); + +function createAudioContext() { + return (new (window.AudioContext || (window as any).webkitAudioContext)()); +} + +const silence = (ctx: AudioContext) => { + const oscillator = ctx.createOscillator(); + const dst = oscillator.connect(ctx.createMediaStreamDestination()); + oscillator.start(); + return new MediaStream([Object.assign((dst as any).stream.getAudioTracks()[0], { enabled: false })]); +}; + +function createAudioElement() { + const ctx = createAudioContext(); + audioElement = new Audio(); + audioContext = ctx; + audioElement.srcObject = silence(ctx); + safePlay(audioElement); +} + +export function getGroupCallAudioElement() { + return audioElement; +} + +export function getGroupCallAudioContext() { + return audioContext; +} + +export function removeGroupCallAudioElement() { + audioElement?.pause(); + audioContext = undefined; + audioElement = undefined; +} diff --git a/src/modules/reducers/calls.ts b/src/modules/reducers/calls.ts new file mode 100644 index 000000000..41250737f --- /dev/null +++ b/src/modules/reducers/calls.ts @@ -0,0 +1,115 @@ +import { GroupCallParticipant } from '../../lib/secret-sauce'; +import { GlobalState } from '../../global/types'; +import { ApiGroupCall } from '../../api/types'; +import { selectGroupCall } from '../selectors/calls'; +import { omit } from '../../util/iteratees'; +import { updateChat } from './chats'; +import { selectChat } from '../selectors'; + +export function updateGroupCall( + global: GlobalState, + groupCallId: string, + groupCallUpdate: Partial, + addToParticipantCount?: number, + resetParticipantCount?: number, +): GlobalState { + const unfiltered = Object.values({ + ...global.groupCalls.byId[groupCallId]?.participants, + ...groupCallUpdate.participants, + }); + const filtered = unfiltered.filter((l) => !l.isLeft); + const participants = filtered.reduce((acc: Record, el) => { + acc[el.id] = el; + return acc; + }, {}); + + return { + ...global, + groupCalls: { + ...global.groupCalls, + byId: { + ...global.groupCalls.byId, + [groupCallId]: { + ...global.groupCalls.byId[groupCallId], + ...omit(groupCallUpdate, ['participantsCount']), + ...(addToParticipantCount && { + participantsCount: global.groupCalls.byId[groupCallId].participantsCount + addToParticipantCount, + }), + ...(resetParticipantCount !== undefined && { + participantsCount: resetParticipantCount, + }), + participants, + }, + }, + }, + }; +} + +export function removeGroupCall( + global: GlobalState, + groupCallId: string, +): GlobalState { + const groupCall = selectGroupCall(global, groupCallId); + if (groupCall && groupCall.chatId) { + const chat = selectChat(global, groupCall.chatId); + if (chat) { + global = updateChat(global, groupCall.chatId, { + fullInfo: { + ...chat.fullInfo, + groupCallId: undefined, + }, + }); + } + } + + return { + ...global, + groupCalls: { + ...global.groupCalls, + byId: { + ...omit(global.groupCalls.byId, [groupCallId.toString()]), + }, + }, + }; +} + +export function updateActiveGroupCall( + global: GlobalState, + groupCallUpdate: Partial, + resetParticipantCount?: number, +): GlobalState { + if (!global.groupCalls.activeGroupCallId) { + return global; + } + + return updateGroupCall(global, + global.groupCalls.activeGroupCallId, + groupCallUpdate, + undefined, + resetParticipantCount); +} + +export function updateGroupCallParticipant( + global: GlobalState, + groupCallId: string, + userId: string, + participantUpdate: Partial, + noUpdateCount = false, +) { + const groupCall = selectGroupCall(global, groupCallId); + if (!groupCall) { + return global; + } + + return updateGroupCall(global, groupCallId, { + participants: { + ...groupCall.participants, + [userId]: { + ...groupCall.participants[userId], + ...participantUpdate, + }, + }, + }, participantUpdate.isLeft + ? (noUpdateCount ? 0 : -1) + : (groupCall.participants[userId] || noUpdateCount ? 0 : 1)); +} diff --git a/src/modules/selectors/calls.ts b/src/modules/selectors/calls.ts new file mode 100644 index 000000000..7061f2136 --- /dev/null +++ b/src/modules/selectors/calls.ts @@ -0,0 +1,38 @@ +import { GlobalState } from '../../global/types'; +import { selectChat } from './chats'; +import { isChatBasicGroup } from '../helpers'; + +export function selectChatGroupCall(global: GlobalState, chatId: string) { + const chat = selectChat(global, chatId); + if (!chat || !chat.fullInfo || !chat.fullInfo.groupCallId) return undefined; + + return selectGroupCall(global, chat.fullInfo.groupCallId); +} + +export function selectGroupCall(global: GlobalState, groupCallId: string) { + return global.groupCalls.byId[groupCallId]; +} + +export function selectGroupCallParticipant(global: GlobalState, groupCallId: string, participantId: string) { + return selectGroupCall(global, groupCallId)?.participants[participantId]; +} + +export function selectIsAdminInActiveGroupCall(global: GlobalState): boolean { + const chatId = selectActiveGroupCall(global)?.chatId; + + if (!chatId) return false; + + const chat = selectChat(global, chatId); + if (!chat) return false; + + return (isChatBasicGroup(chat) && chat.isCreator) || !!chat.adminRights?.manageCall; +} + +export function selectActiveGroupCall(global: GlobalState) { + const { groupCalls: { activeGroupCallId } } = global; + if (!activeGroupCallId) { + return undefined; + } + + return selectGroupCall(global, activeGroupCallId); +} diff --git a/src/styles/Telegram T.json b/src/styles/Telegram T.json index 68ad76f86..e8ffbd808 100644 --- a/src/styles/Telegram T.json +++ b/src/styles/Telegram T.json @@ -2,57 +2,57 @@ "metadata": { "name": "Telegram T", "lastOpened": 0, - "created": 1635725227633 + "created": 1637672665426 }, "iconSets": [ { "selection": [ { "order": 673, - "id": 5, "name": "loop", "prevSize": 32, - "code": 59777, + "id": 0, + "code": 59788, "tempChar": "" }, { "order": 672, - "id": 4, "name": "skip-next", "prevSize": 32, - "code": 59778, + "id": 1, + "code": 59789, "tempChar": "" }, { "order": 671, - "id": 3, "name": "skip-previous", "prevSize": 32, - "code": 59779, + "id": 2, + "code": 59790, "tempChar": "" }, { "order": 674, - "id": 2, "name": "volume-1", "prevSize": 32, - "code": 59780, + "id": 3, + "code": 59791, "tempChar": "" }, { "order": 669, - "id": 1, "name": "volume-2", "prevSize": 32, - "code": 59781, + "id": 4, + "code": 59792, "tempChar": "" }, { "order": 676, - "id": 0, "name": "volume-3", "prevSize": 32, - "code": 59782, + "id": 5, + "code": 59793, "tempChar": "" } ], @@ -68,108 +68,198 @@ "prevSize": 32, "icons": [ { - "id": 5, + "id": 0, "paths": [ "M298.667 298.667h426.667v128l170.667-170.667-170.667-170.667v128h-512v256h85.333v-170.667zM725.333 725.333h-426.667v-128l-170.667 170.667 170.667 170.667v-128h512v-256h-85.333v170.667z" ], - "attrs": [ - {} - ], + "attrs": [], "isMulticolor": false, "isMulticolor2": false, - "grid": 24, "tags": [ "loop" - ] - }, - { - "id": 4, - "paths": [ - "M256 768l362.667-256-362.667-256v512zM682.667 256v512h85.333v-512h-85.333z" ], - "attrs": [ - {} - ], - "isMulticolor": false, - "isMulticolor2": false, - "grid": 24, - "tags": [ - "skip-next" - ] - }, - { - "id": 3, - "paths": [ - "M256 256h85.333v512h-85.333zM405.333 512l362.667 256v-512z" - ], - "attrs": [ - {} - ], - "isMulticolor": false, - "isMulticolor2": false, - "grid": 24, - "tags": [ - "skip-previous" - ] - }, - { - "id": 2, - "paths": [ - "M558.623 229.209c5.383 8.831 8.569 19.512 8.569 30.939v509.203c0 33.212-26.923 60.135-60.135 60.135-11.427 0-22.108-3.187-31.207-8.72l0.267 0.149-160.939-96.567c-29.352-17.832-48.66-49.631-48.66-85.94v-247.323c0-36.309 19.309-68.108 48.217-85.692l0.443-0.251 160.939-96.565c28.48-17.087 65.419-7.853 82.505 20.627v0.005zM146.653 334.345c33.212 0 60.135 26.923 60.135 60.135v240.54c0 33.212-26.923 60.135-60.135 60.135s-60.135-26.923-60.135-60.135v-240.54c0-33.212 26.923-60.135 60.135-60.135v0zM554.485 623.967h-0.040c-3.997 0.005-7.956-0.781-11.648-2.313s-7.045-3.78-9.864-6.613l-0.001-0.001c-11.909-11.909-11.909-187.531 0-199.44 11.911-11.911 31.219-11.911 43.107 0 26.637 26.636 41.295 62.059 41.295 99.697 0 37.688-14.657 73.084-41.293 99.72-2.823 2.841-6.181 5.095-9.88 6.631-3.699 1.537-7.665 2.325-11.671 2.32h-0.004z" - ], - "attrs": [ - {} - ], - "grid": 24, - "tags": [ - "volume-1" - ], - "isMulticolor": false, - "isMulticolor2": false + "defaultCode": 59777, + "grid": 24 }, { "id": 1, + "paths": [ + "M256 768l362.667-256-362.667-256v512zM682.667 256v512h85.333v-512h-85.333z" + ], + "attrs": [], + "isMulticolor": false, + "isMulticolor2": false, + "tags": [ + "skip-next" + ], + "defaultCode": 59778, + "grid": 24 + }, + { + "id": 2, + "paths": [ + "M256 256h85.333v512h-85.333zM405.333 512l362.667 256v-512z" + ], + "attrs": [], + "isMulticolor": false, + "isMulticolor2": false, + "tags": [ + "skip-previous" + ], + "defaultCode": 59779, + "grid": 24 + }, + { + "id": 3, + "paths": [ + "M558.623 229.209c5.383 8.831 8.569 19.512 8.569 30.939v509.203c0 33.212-26.923 60.135-60.135 60.135-11.427 0-22.108-3.187-31.207-8.72l0.267 0.149-160.939-96.567c-29.352-17.832-48.66-49.631-48.66-85.94v-247.323c0-36.309 19.309-68.108 48.217-85.692l0.443-0.251 160.939-96.565c28.48-17.087 65.419-7.853 82.505 20.627v0.005zM146.653 334.345c33.212 0 60.135 26.923 60.135 60.135v240.54c0 33.212-26.923 60.135-60.135 60.135s-60.135-26.923-60.135-60.135v-240.54c0-33.212 26.923-60.135 60.135-60.135v0zM554.485 623.967h-0.040c-3.997 0.005-7.956-0.781-11.648-2.313s-7.045-3.78-9.864-6.613l-0.001-0.001c-11.909-11.909-11.909-187.531 0-199.44 11.911-11.911 31.219-11.911 43.107 0 26.637 26.636 41.295 62.059 41.295 99.697 0 37.688-14.657 73.084-41.293 99.72-2.823 2.841-6.181 5.095-9.88 6.631-3.699 1.537-7.665 2.325-11.671 2.32h-0.004z" + ], + "attrs": [], + "isMulticolor": false, + "isMulticolor2": false, + "tags": [ + "volume-1" + ], + "defaultCode": 59780, + "grid": 24 + }, + { + "id": 4, "paths": [ "M558.623 229.209c5.383 8.831 8.569 19.512 8.569 30.939v509.203c0 33.212-26.923 60.135-60.135 60.135-11.427 0-22.108-3.187-31.207-8.72l0.267 0.149-160.939-96.567c-29.352-17.832-48.66-49.631-48.66-85.94v-247.323c0-36.309 19.309-68.108 48.217-85.692l0.443-0.251 160.939-96.565c28.48-17.087 65.419-7.853 82.505 20.627v0.005zM146.653 334.345c33.212 0 60.135 26.923 60.135 60.135v240.54c0 33.212-26.923 60.135-60.135 60.135s-60.135-26.923-60.135-60.135v-240.54c0-33.212 26.923-60.135 60.135-60.135zM554.485 623.967h-0.040c-0.012 0-0.026 0-0.040 0-8.391 0-15.985-3.413-21.47-8.925l-0.001-0.001-0.001-0.001c-11.909-11.909-11.909-187.531 0-199.44 11.911-11.911 31.219-11.911 43.107 0 26.637 26.636 41.295 62.059 41.295 99.697 0 37.688-14.657 73.084-41.293 99.72-5.493 5.529-13.101 8.951-21.508 8.951-0.015 0-0.030-0-0.045-0l0.002 0h-0.004zM659.088 707.369h-0.024c-0.012 0-0.027 0-0.041 0-9.339 0-17.792-3.794-23.902-9.926l-0.001-0.001c-13.221-13.245-13.221-34.696 0-47.941 74.168-74.14 74.139-194.815 0-268.96-13.247-13.245-13.247-34.695 0-47.94 13.221-13.247 34.695-13.247 47.941 0 100.605 100.607 100.605 264.259 0.024 364.812-6.663 6.637-15.315 9.956-23.997 9.956z" ], - "attrs": [ - {} - ], - "grid": 24, + "attrs": [], + "isMulticolor": false, + "isMulticolor2": false, "tags": [ "volume-2" ], - "isMulticolor": false, - "isMulticolor2": false + "defaultCode": 59781, + "grid": 24 }, { - "id": 0, + "id": 5, "paths": [ "M558.623 229.209c5.383 8.831 8.569 19.512 8.569 30.939v509.203c0 33.212-26.923 60.135-60.135 60.135-11.427 0-22.108-3.187-31.207-8.72l0.267 0.149-160.939-96.567c-29.352-17.832-48.66-49.631-48.66-85.94v-247.323c0-36.309 19.309-68.108 48.217-85.692l0.443-0.251 160.939-96.565c28.48-17.087 65.419-7.853 82.505 20.627v0.005zM146.653 334.345c33.212 0 60.135 26.923 60.135 60.135v240.54c0 33.212-26.923 60.135-60.135 60.135s-60.135-26.923-60.135-60.135v-240.54c0-33.212 26.923-60.135 60.135-60.135zM554.485 623.967h-0.040c-0.012 0-0.026 0-0.040 0-8.391 0-15.985-3.413-21.47-8.925l-0.001-0.001-0.001-0.001c-11.909-11.909-11.909-187.531 0-199.44 11.911-11.911 31.219-11.911 43.107 0 26.637 26.636 41.295 62.059 41.295 99.697 0 37.688-14.657 73.084-41.293 99.72-5.493 5.529-13.101 8.951-21.508 8.951-0.015 0-0.030-0-0.045-0l0.002 0h-0.004zM659.088 707.369h-0.024c-0.012 0-0.027 0-0.041 0-9.339 0-17.792-3.794-23.902-9.926l-0.001-0.001c-13.221-13.245-13.221-34.696 0-47.941 74.168-74.14 74.139-194.815 0-268.96-13.247-13.245-13.247-34.695 0-47.94 13.221-13.247 34.695-13.247 47.941 0 100.605 100.607 100.605 264.259 0.024 364.812-6.663 6.637-15.315 9.956-23.997 9.956zM740.708 792.089c-13.341-13.339-13.341-34.941 0-48.283 61.275-61.275 95.013-142.712 95.013-229.413s-33.769-168.164-95.067-229.439c-13.317-13.34-13.317-34.943 0-48.283 13.368-13.34 34.967-13.34 48.307 0 74.187 74.187 115.039 172.789 115.039 277.721s-40.821 203.533-115.013 277.667c-6.167 6.195-14.699 10.029-24.128 10.029h-0.037c-8.715 0-17.429-3.344-24.113-10z" ], - "attrs": [ - {} - ], - "grid": 24, + "attrs": [], + "isMulticolor": false, + "isMulticolor2": false, "tags": [ "volume-3" ], - "isMulticolor": false, - "isMulticolor2": false + "defaultCode": 59782, + "grid": 24 } ], - "invisible": false, - "colorThemes": [] + "colorThemes": [], + "colorThemeIdx": 0 }, { "selection": [ + { + "order": 693, + "id": 46, + "name": "sidebar", + "prevSize": 32, + "code": 59794, + "tempChar": "" + }, + { + "order": 690, + "id": 45, + "name": "video-stop", + "prevSize": 32, + "code": 59787, + "tempChar": "" + }, + { + "order": 678, + "id": 44, + "name": "speaker", + "prevSize": 32, + "code": 59777, + "tempChar": "" + }, + { + "order": 679, + "id": 43, + "name": "speaker-outline", + "prevSize": 32, + "code": 59778, + "tempChar": "" + }, + { + "order": 680, + "id": 42, + "name": "phone-discard-outline", + "prevSize": 32, + "code": 59779, + "tempChar": "" + }, + { + "order": 681, + "id": 41, + "name": "allow-speak", + "prevSize": 32, + "code": 59780, + "tempChar": "" + }, + { + "order": 682, + "id": 40, + "name": "stop-raising-hand", + "prevSize": 32, + "code": 59781, + "tempChar": "" + }, + { + "order": 683, + "id": 39, + "name": "share-screen", + "prevSize": 32, + "code": 59782, + "tempChar": "" + }, + { + "order": 684, + "id": 38, + "name": "voice-chat", + "prevSize": 32, + "code": 59783, + "tempChar": "" + }, + { + "order": 689, + "id": 37, + "name": "video", + "prevSize": 32, + "code": 59784, + "tempChar": "" + }, + { + "order": 686, + "id": 36, + "name": "noise-suppression", + "prevSize": 32, + "code": 59785, + "tempChar": "" + }, + { + "order": 688, + "id": 35, + "name": "phone-discard", + "prevSize": 32, + "code": 59786, + "tempChar": "" + }, { "order": 667, "id": 34, "name": "bot-commands-filled", "prevSize": 32, "code": 59775, - "tempChar": "" + "tempChar": "" }, { "order": 664, @@ -177,7 +267,7 @@ "name": "reply-filled", "prevSize": 32, "code": 59776, - "tempChar": "" + "tempChar": "" }, { "order": 656, @@ -185,7 +275,7 @@ "name": "bug", "prevSize": 32, "code": 59774, - "tempChar": "" + "tempChar": "" }, { "order": 619, @@ -193,7 +283,7 @@ "name": "data", "prevSize": 32, "code": 59773, - "tempChar": "" + "tempChar": "" }, { "order": 622, @@ -201,7 +291,7 @@ "name": "darkmode", "prevSize": 32, "code": 59769, - "tempChar": "" + "tempChar": "" }, { "order": 623, @@ -209,7 +299,7 @@ "name": "animations", "prevSize": 32, "code": 59770, - "tempChar": "" + "tempChar": "" }, { "order": 626, @@ -217,7 +307,7 @@ "name": "enter", "prevSize": 32, "code": 59771, - "tempChar": "" + "tempChar": "" }, { "order": 627, @@ -225,7 +315,7 @@ "name": "fontsize", "prevSize": 32, "code": 59772, - "tempChar": "" + "tempChar": "" }, { "order": 630, @@ -233,7 +323,7 @@ "name": "permissions", "prevSize": 32, "code": 59766, - "tempChar": "" + "tempChar": "" }, { "order": 631, @@ -241,7 +331,7 @@ "name": "card", "prevSize": 32, "code": 59767, - "tempChar": "" + "tempChar": "" }, { "order": 634, @@ -249,7 +339,7 @@ "name": "truck", "prevSize": 32, "code": 59768, - "tempChar": "" + "tempChar": "" }, { "order": 663, @@ -257,7 +347,7 @@ "name": "share-filled", "prevSize": 32, "code": 59738, - "tempChar": "" + "tempChar": "" }, { "order": 638, @@ -265,7 +355,7 @@ "name": "bold", "prevSize": 32, "code": 59745, - "tempChar": "" + "tempChar": "" }, { "order": 639, @@ -273,7 +363,7 @@ "name": "bot-command", "prevSize": 32, "code": 59746, - "tempChar": "" + "tempChar": "" }, { "order": 642, @@ -281,7 +371,7 @@ "name": "calendar-filter", "prevSize": 32, "code": 59747, - "tempChar": "" + "tempChar": "" }, { "order": 643, @@ -289,7 +379,7 @@ "name": "comments", "prevSize": 32, "code": 59748, - "tempChar": "" + "tempChar": "" }, { "order": 645, @@ -297,7 +387,7 @@ "name": "comments-sticker", "prevSize": 32, "code": 59749, - "tempChar": "" + "tempChar": "" }, { "order": 646, @@ -305,15 +395,15 @@ "name": "arrow-down", "prevSize": 32, "code": 59750, - "tempChar": "" + "tempChar": "" }, { - "order": 647, + "order": 668, "id": 14, "name": "email", "prevSize": 32, "code": 59751, - "tempChar": "" + "tempChar": "" }, { "order": 648, @@ -321,7 +411,7 @@ "name": "italic", "prevSize": 32, "code": 59752, - "tempChar": "" + "tempChar": "" }, { "order": 620, @@ -329,7 +419,7 @@ "name": "link", "prevSize": 32, "code": 59753, - "tempChar": "" + "tempChar": "" }, { "order": 621, @@ -337,7 +427,7 @@ "name": "mention", "prevSize": 32, "code": 59754, - "tempChar": "" + "tempChar": "" }, { "order": 624, @@ -345,7 +435,7 @@ "name": "monospace", "prevSize": 32, "code": 59755, - "tempChar": "" + "tempChar": "" }, { "order": 625, @@ -353,7 +443,7 @@ "name": "next", "prevSize": 32, "code": 59756, - "tempChar": "" + "tempChar": "" }, { "order": 628, @@ -361,7 +451,7 @@ "name": "password-off", "prevSize": 32, "code": 59757, - "tempChar": "" + "tempChar": "" }, { "order": 629, @@ -369,7 +459,7 @@ "name": "pin-list", "prevSize": 32, "code": 59758, - "tempChar": "" + "tempChar": "" }, { "order": 632, @@ -377,7 +467,7 @@ "name": "previous", "prevSize": 32, "code": 59759, - "tempChar": "" + "tempChar": "" }, { "order": 633, @@ -385,7 +475,7 @@ "name": "replace", "prevSize": 32, "code": 59760, - "tempChar": "" + "tempChar": "" }, { "order": 636, @@ -393,23 +483,23 @@ "name": "schedule", "prevSize": 32, "code": 59761, - "tempChar": "" + "tempChar": "" }, { - "order": 637, + "order": 691, "id": 3, "name": "strikethrough", "prevSize": 32, "code": 59762, - "tempChar": "" + "tempChar": "" }, { - "order": 640, + "order": 692, "id": 2, "name": "underlined", "prevSize": 32, "code": 59763, - "tempChar": "" + "tempChar": "" }, { "order": 641, @@ -417,7 +507,7 @@ "name": "zoom-in", "prevSize": 32, "code": 59764, - "tempChar": "" + "tempChar": "" }, { "order": 649, @@ -425,20 +515,250 @@ "name": "zoom-out", "prevSize": 32, "code": 59765, - "tempChar": "" + "tempChar": "" } ], "id": 2, "metadata": { "name": "Untitled Set", "importSize": { - "width": 22, - "height": 22 + "width": 768, + "height": 768 } }, "height": 1024, "prevSize": 32, "icons": [ + { + "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" + ], + "attrs": [ + {}, + {}, + {}, + {}, + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "sidebar" + ] + }, + { + "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" + ], + "attrs": [ + {}, + {}, + {}, + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "video-stop" + ] + }, + { + "id": 44, + "paths": [ + "M781.227 139.947c146.773 69.973 241.493 220.16 241.493 383.147 0 162.56-94.72 312.747-241.92 382.72-5.547 2.987-11.947 4.267-17.92 4.267-16.213 0-31.573-8.96-38.827-24.32-10.24-21.333-0.853-46.507 20.053-56.747 117.333-55.893 193.28-176.213 193.28-305.92 0-130.133-75.947-250.027-193.28-305.92-20.907-10.24-30.293-35.84-20.053-57.173s35.413-30.293 57.173-20.053z", + "M757.333 525.227c0-48.213-21.333-93.013-58.88-123.733-18.347-14.507-21.333-41.387-6.4-59.733s41.813-21.333 60.16-6.4c57.6 46.507 90.453 116.053 90.453 189.867 0 70.827-30.293 137.813-84.053 184.32-8.107 6.827-17.92 10.667-27.733 10.667-11.947 0-23.893-5.12-32.427-14.933-15.36-17.493-13.653-44.8 4.267-60.16 34.987-30.293 54.613-73.813 54.613-119.893z", + "M586.667 182.613v672.853l-5.973 10.24c-26.88 46.080-60.587 60.16-84.48 64-5.12 0.853-10.667 1.28-15.787 1.28-46.080 0-80.64-30.293-84.907-34.133l-149.76-149.76h-112.64c-70.4 0-128-57.173-128-127.573v-200.533c0-70.4 57.6-128 128-128h112.64l148.053-148.053c6.4-5.973 49.067-42.667 102.4-34.133 23.893 3.84 57.6 17.92 84.48 64l5.973 9.813z" + ], + "attrs": [ + {}, + {}, + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "speaker" + ] + }, + { + "id": 43, + "paths": [ + "M733.013 709.12c-11.947 0-23.893-5.12-32-14.507-15.36-17.92-13.653-44.8 4.267-60.16 34.56-30.293 54.613-73.813 54.613-119.893 0-48.213-21.333-93.44-58.88-123.733-18.347-14.933-21.333-41.813-6.4-60.16s41.813-21.333 60.16-6.4c57.6 46.507 90.88 116.053 90.88 189.867 0 70.827-30.72 137.813-84.053 184.32-8.96 7.253-18.773 10.667-28.587 10.667z", + "M764.587 898.987c-15.787 0-31.147-8.96-38.4-24.32-10.24-21.333-1.28-46.507 20.053-56.747 117.333-55.893 193.28-176.213 193.28-305.92 0-130.133-75.947-250.027-193.28-305.92-21.333-10.24-30.293-35.413-20.053-56.747s35.413-30.293 56.747-20.053c146.773 69.973 241.92 220.587 241.92 383.147s-94.72 313.173-241.92 383.147c-5.973 2.133-11.947 3.413-18.347 3.413z", + "M582.827 161.707c-26.88-46.080-60.587-60.16-84.48-64-53.333-8.107-96.427 28.587-102.4 34.133l-148.48 148.48h-112.213c-70.827 0-128 57.173-128 128v200.107c0 70.4 57.173 128 128 128h112.213l149.76 149.76c4.267 3.84 39.253 33.707 85.333 33.707 5.12 0 10.24-0.427 15.787-1.28 23.893-3.413 57.6-17.92 84.48-64l5.973-9.813v-672.853l-5.973-10.24zM503.467 820.48c-5.12 6.827-11.52 12.8-17.92 13.653-11.093 1.707-26.027-7.253-29.44-9.813l-173.227-173.227h-147.627c-23.467 0-42.667-19.2-42.667-42.667v-200.107c0-23.893 19.2-42.667 42.667-42.667h147.627l171.093-171.52c5.547-4.693 20.48-14.080 31.573-11.947 6.4 1.28 12.8 7.253 17.92 13.653v624.64z" + ], + "attrs": [ + {}, + {}, + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "speaker-outline" + ] + }, + { + "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" + ], + "attrs": [ + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "phone-discard-outline" + ] + }, + { + "id": 41, + "paths": [ + "M370.773 638.293c-139.52 0-253.013-113.493-253.013-253.013s113.493-253.013 253.013-253.013 253.013 113.493 253.013 253.013-113.493 253.013-253.013 253.013zM370.773 217.173c-92.587 0-167.68 75.093-167.68 167.68s75.093 167.68 167.68 167.68 167.68-75.093 167.68-167.68-75.52-167.68-167.68-167.68z", + "M724.48 603.733c-11.947 0-23.893-5.12-32-14.507-15.36-17.92-13.653-44.8 4.267-60.16 34.56-30.293 54.613-73.813 54.613-119.893 0-48.213-21.333-93.44-58.88-123.733-18.347-14.933-21.333-41.813-6.4-60.16s41.813-21.333 60.16-6.4c57.6 46.507 90.88 116.053 90.88 189.867 0 70.827-30.72 137.813-84.053 184.32-8.533 7.253-18.773 10.667-28.587 10.667z", + "M850.773 727.040c-11.947 0-23.893-5.12-32.427-14.933-15.36-17.92-13.227-44.8 4.693-60.16 72.533-61.867 113.92-151.467 113.92-245.333 0-92.16-40.107-180.48-110.080-241.92-17.92-15.787-19.2-42.667-3.84-60.16 15.787-17.493 42.667-19.2 60.16-3.84 88.32 77.653 138.667 189.44 138.667 305.92 0 119.040-52.48 232.107-143.787 310.187-7.68 6.827-17.493 10.24-27.307 10.24z", + "M707.413 965.547c-23.467 0-42.667-19.2-42.667-42.667 0-69.12-56.32-125.44-125.44-125.44h-330.667c-69.12 0-125.44 56.32-125.44 125.44 0 23.467-19.2 42.667-42.667 42.667s-42.667-19.2-42.667-42.667c0-116.48 94.72-210.773 210.773-210.773h330.24c116.48 0 210.773 94.72 210.773 210.773 0.427 23.467-18.773 42.667-42.24 42.667z" + ], + "attrs": [ + {}, + {}, + {}, + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "allow-speak" + ] + }, + { + "id": 40, + "paths": [ + "M512 457.813c-123.307 0-224-100.267-224-224s100.693-223.573 224-223.573 224 100.267 224 224-100.693 223.573-224 223.573zM512 95.573c-76.373 0-138.667 62.293-138.667 138.667s62.293 138.24 138.667 138.24 138.667-62.293 138.667-138.667-62.293-138.24-138.667-138.24z", + "M134.827 1010.347c-23.467 0-42.667-19.2-42.667-42.667 0-194.987 39.68-333.227 118.187-411.307 70.827-70.827 151.467-70.4 177.493-70.4h135.253c23.467 0 42.667 19.2 42.667 42.667s-19.2 42.667-42.667 42.667h-133.12c-23.467-0.853-73.813-0.427-119.467 45.227-61.013 60.587-93.013 182.187-93.013 351.147 0 23.467-19.2 42.667-42.667 42.667z", + "M523.093 1003.093c-198.4 0-220.16-118.187-221.013-123.307l-0.427-7.253v-340.907c0-23.467 19.2-42.667 42.667-42.667s42.667 19.2 42.667 42.667v335.36c3.413 8.533 26.453 50.347 136.533 50.347 23.467 0 42.667 19.2 42.667 42.667s-19.627 43.093-43.093 43.093z", + "M911.36 1010.347c-23.467 0-42.667-19.2-42.667-42.667 0-168.96-32-290.133-93.013-351.147-45.653-45.653-95.147-45.227-116.907-45.227h-135.68c-23.467 0-42.667-19.2-42.667-42.667s19.2-42.667 42.667-42.667h133.12c28.16 0 108.8-0.427 179.627 70.4 78.507 78.080 118.187 216.747 118.187 411.307 0 23.467-19.2 42.667-42.667 42.667z", + "M523.093 1003.093c-23.467 0-42.667-19.2-42.667-42.667s19.2-42.667 42.667-42.667c107.52 0 131.84-40.107 136.533-51.2v-334.507c0-23.467 19.2-42.667 42.667-42.667s42.667 19.2 42.667 42.667l-0.427 347.733c-1.28 5.12-23.467 123.307-221.44 123.307z" + ], + "attrs": [ + {}, + {}, + {}, + {}, + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "stop-raising-hand" + ] + }, + { + "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" + ], + "attrs": [ + {}, + {}, + {}, + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "share-screen" + ] + }, + { + "id": 38, + "paths": [ + "M661.76 570.027c-23.467 0-42.667-19.2-42.667-42.667v-197.547c0-23.467 19.2-42.667 42.667-42.667s42.667 19.2 42.667 42.667v197.12c0 23.893-18.773 43.093-42.667 43.093z", + "M316.587 609.28c-23.467 0-42.667-19.2-42.667-42.667v-251.733c0-23.467 19.2-42.667 42.667-42.667s42.667 19.2 42.667 42.667v251.733c0 23.467-18.773 42.667-42.667 42.667z", + "M488.533 654.507c-23.467 0-42.667-19.2-42.667-42.667v-354.987c0-23.467 19.2-42.667 42.667-42.667s42.667 19.2 42.667 42.667v354.987c0 23.893-18.773 42.667-42.667 42.667z", + "M346.88 1017.6c-50.773 0-79.787-40.96-80.64-79.36l0.427-20.907c0.427-29.44 1.28-59.733-11.947-73.387-10.667-11.093-34.133-16.64-67.413-16.64-96.427 0-174.507-78.080-174.507-174.507v-465.493c0-96.427 78.080-174.507 174.507-174.507h607.573c96 0 174.507 78.080 174.507 174.507v465.067c0 96-78.080 174.507-174.507 174.507h-1.707c-0.853 0-90.453-3.84-161.707-3.84-27.307 0.427-114.347 75.093-155.733 111.36-69.547 59.307-98.987 83.2-128.853 83.2zM338.773 933.547v0 0zM186.88 98.133c-49.067 0-89.173 40.107-89.173 89.173v465.067c0 49.067 40.107 89.173 89.173 89.173 58.453 0 100.267 13.653 128.427 42.24 38.4 39.253 37.547 92.587 36.267 134.827v6.4c17.493-12.8 45.653-37.12 67.84-55.893 92.587-79.787 157.44-131.84 211.627-131.84 69.547 0 153.6 3.413 164.267 3.84 48.64-0.427 88.32-40.107 88.32-89.173v-464.64c0-49.067-40.107-89.173-89.173-89.173h-607.573z" + ], + "attrs": [ + {}, + {}, + {}, + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "voice-chat" + ] + }, + { + "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" + ], + "attrs": [ + {}, + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "video" + ] + }, + { + "id": 36, + "paths": [ + "M327.253 922.88c-46.080 0-51.627-29.013-97.28-255.147-4.267-21.333-8.96-45.227-13.653-68.267-32.427 66.56-50.347 96.853-84.48 96.853h-87.467c-23.467 0-42.667-19.2-42.667-42.667s19.2-42.667 42.667-42.667h69.547c9.813-16.64 25.6-48.64 35.413-69.547 35.413-73.387 47.36-98.133 81.067-98.133 41.813 0 43.947 11.52 83.2 207.36 2.987 14.080 5.973 29.867 9.387 46.080 5.973-35.84 12.373-73.387 17.92-108.373 75.947-456.533 85.333-474.027 135.68-474.027 50.773 0 58.88 36.267 108.373 321.28 14.080 80.213 32.427 187.307 49.067 258.987 13.227-50.773 26.88-117.333 37.12-164.693 35.84-172.373 44.373-213.333 91.733-213.333 42.667 0 56.747 40.107 94.293 146.347 17.92 50.347 49.92 141.227 69.973 164.693h56.747c23.467 0 42.667 19.2 42.667 42.667s-19.2 42.667-42.667 42.667h-65.707c-60.16 0-91.307-80.213-141.227-221.44-2.133-5.973-4.267-11.947-6.4-18.347-5.547 24.747-10.667 50.773-15.787 73.813-45.653 219.307-64.427 295.253-123.307 295.253-57.173 0-73.387-64.427-130.133-392.107-7.68-43.52-16.213-93.867-24.747-139.52-17.067 87.467-35.84 202.667-50.773 291.84-53.333 320.427-53.333 320.427-98.56 320.427z" + ], + "attrs": [ + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "noise-suppression" + ] + }, + { + "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" + ], + "attrs": [ + {} + ], + "grid": 24, + "tags": [ + "phone-discard" + ], + "isMulticolor": false, + "isMulticolor2": false + }, { "id": 34, "paths": [ @@ -2367,7 +2687,7 @@ ], "grid": 24, "tags": [ - "muted" + "muted-chat" ], "isMulticolor": false, "isMulticolor2": false, @@ -2491,7 +2811,7 @@ "name": "select", "prevSize": 32, "code": 59744, - "tempChar": "" + "tempChar": "" }, { "order": 480, @@ -2499,7 +2819,7 @@ "name": "folder", "prevSize": 32, "code": 59667, - "tempChar": "" + "tempChar": "" }, { "order": 481, @@ -2507,7 +2827,7 @@ "name": "bots", "prevSize": 32, "code": 59669, - "tempChar": "" + "tempChar": "" }, { "order": 482, @@ -2515,7 +2835,7 @@ "name": "calendar", "prevSize": 32, "code": 59670, - "tempChar": "" + "tempChar": "" }, { "order": 483, @@ -2523,7 +2843,7 @@ "name": "cloud-download", "prevSize": 32, "code": 59671, - "tempChar": "" + "tempChar": "" }, { "order": 484, @@ -2531,7 +2851,7 @@ "name": "colorize", "prevSize": 32, "code": 59672, - "tempChar": "" + "tempChar": "" }, { "order": 651, @@ -2539,7 +2859,7 @@ "name": "forward", "prevSize": 32, "code": 59687, - "tempChar": "" + "tempChar": "" }, { "order": 650, @@ -2547,7 +2867,7 @@ "name": "reply", "prevSize": 32, "code": 59719, - "tempChar": "" + "tempChar": "" }, { "order": 487, @@ -2555,7 +2875,7 @@ "name": "help", "prevSize": 32, "code": 59690, - "tempChar": "" + "tempChar": "" }, { "order": 488, @@ -2563,7 +2883,7 @@ "name": "info", "prevSize": 32, "code": 59691, - "tempChar": "" + "tempChar": "" }, { "order": 489, @@ -2571,7 +2891,7 @@ "name": "info-filled", "prevSize": 32, "code": 59675, - "tempChar": "" + "tempChar": "" }, { "order": 490, @@ -2579,7 +2899,7 @@ "name": "delete-filled", "prevSize": 32, "code": 59676, - "tempChar": "" + "tempChar": "" }, { "order": 491, @@ -2587,7 +2907,7 @@ "name": "delete", "prevSize": 32, "code": 59677, - "tempChar": "" + "tempChar": "" }, { "order": 492, @@ -2595,7 +2915,7 @@ "name": "edit", "prevSize": 32, "code": 59683, - "tempChar": "" + "tempChar": "" }, { "order": 493, @@ -2603,7 +2923,7 @@ "name": "new-chat-filled", "prevSize": 32, "code": 59705, - "tempChar": "" + "tempChar": "" }, { "order": 494, @@ -2611,7 +2931,7 @@ "name": "send", "prevSize": 32, "code": 59722, - "tempChar": "" + "tempChar": "" }, { "order": 495, @@ -2619,7 +2939,7 @@ "name": "send-outline", "prevSize": 32, "code": 59723, - "tempChar": "" + "tempChar": "" }, { "order": 496, @@ -2627,7 +2947,7 @@ "name": "add-user-filled", "prevSize": 32, "code": 59652, - "tempChar": "" + "tempChar": "" }, { "order": 497, @@ -2635,7 +2955,7 @@ "name": "add-user", "prevSize": 32, "code": 59653, - "tempChar": "" + "tempChar": "" }, { "order": 498, @@ -2643,7 +2963,7 @@ "name": "delete-user", "prevSize": 32, "code": 59678, - "tempChar": "" + "tempChar": "" }, { "order": 499, @@ -2651,7 +2971,7 @@ "name": "microphone", "prevSize": 32, "code": 59701, - "tempChar": "" + "tempChar": "" }, { "order": 500, @@ -2659,7 +2979,7 @@ "name": "microphone-alt", "prevSize": 32, "code": 59707, - "tempChar": "" + "tempChar": "" }, { "order": 501, @@ -2667,7 +2987,7 @@ "name": "poll", "prevSize": 32, "code": 59704, - "tempChar": "" + "tempChar": "" }, { "order": 502, @@ -2675,7 +2995,7 @@ "name": "revote", "prevSize": 32, "code": 59706, - "tempChar": "" + "tempChar": "" }, { "order": 503, @@ -2683,7 +3003,7 @@ "name": "photo", "prevSize": 32, "code": 59712, - "tempChar": "" + "tempChar": "" }, { "order": 504, @@ -2691,7 +3011,7 @@ "name": "document", "prevSize": 32, "code": 59679, - "tempChar": "" + "tempChar": "" }, { "order": 505, @@ -2699,7 +3019,7 @@ "name": "camera", "prevSize": 32, "code": 59662, - "tempChar": "" + "tempChar": "" }, { "order": 506, @@ -2707,7 +3027,7 @@ "name": "camera-add", "prevSize": 32, "code": 59663, - "tempChar": "" + "tempChar": "" }, { "order": 507, @@ -2715,7 +3035,7 @@ "name": "logout", "prevSize": 32, "code": 59698, - "tempChar": "" + "tempChar": "" }, { "order": 508, @@ -2723,7 +3043,7 @@ "name": "saved-messages", "prevSize": 32, "code": 59720, - "tempChar": "" + "tempChar": "" }, { "order": 509, @@ -2731,7 +3051,7 @@ "name": "settings", "prevSize": 32, "code": 59726, - "tempChar": "" + "tempChar": "" }, { "order": 652, @@ -2739,7 +3059,7 @@ "name": "phone", "prevSize": 32, "code": 59711, - "tempChar": "" + "tempChar": "" }, { "order": 653, @@ -2747,7 +3067,7 @@ "name": "attach", "prevSize": 32, "code": 59657, - "tempChar": "" + "tempChar": "" }, { "order": 512, @@ -2755,7 +3075,7 @@ "name": "copy", "prevSize": 32, "code": 59674, - "tempChar": "" + "tempChar": "" }, { "order": 513, @@ -2763,7 +3083,7 @@ "name": "channel", "prevSize": 32, "code": 59665, - "tempChar": "" + "tempChar": "" }, { "order": 514, @@ -2771,7 +3091,7 @@ "name": "group", "prevSize": 32, "code": 59689, - "tempChar": "" + "tempChar": "" }, { "order": 515, @@ -2779,7 +3099,7 @@ "name": "user", "prevSize": 32, "code": 59737, - "tempChar": "" + "tempChar": "" }, { "order": 516, @@ -2787,7 +3107,7 @@ "name": "non-contacts", "prevSize": 32, "code": 59688, - "tempChar": "" + "tempChar": "" }, { "order": 517, @@ -2795,7 +3115,7 @@ "name": "active-sessions", "prevSize": 32, "code": 59650, - "tempChar": "" + "tempChar": "" }, { "order": 518, @@ -2803,7 +3123,7 @@ "name": "admin", "prevSize": 32, "code": 59654, - "tempChar": "" + "tempChar": "" }, { "order": 519, @@ -2811,7 +3131,7 @@ "name": "download", "prevSize": 32, "code": 59681, - "tempChar": "" + "tempChar": "" }, { "order": 520, @@ -2819,7 +3139,7 @@ "name": "location", "prevSize": 32, "code": 59696, - "tempChar": "" + "tempChar": "" }, { "order": 521, @@ -2827,7 +3147,7 @@ "name": "stop", "prevSize": 32, "code": 59730, - "tempChar": "" + "tempChar": "" }, { "order": 523, @@ -2835,7 +3155,7 @@ "name": "archive", "prevSize": 32, "code": 59656, - "tempChar": "" + "tempChar": "" }, { "order": 524, @@ -2843,7 +3163,7 @@ "name": "unarchive", "prevSize": 32, "code": 59731, - "tempChar": "" + "tempChar": "" }, { "order": 525, @@ -2851,7 +3171,7 @@ "name": "readchats", "prevSize": 32, "code": 59699, - "tempChar": "" + "tempChar": "" }, { "order": 526, @@ -2859,7 +3179,7 @@ "name": "unread", "prevSize": 32, "code": 59735, - "tempChar": "" + "tempChar": "" }, { "order": 654, @@ -2867,7 +3187,7 @@ "name": "message", "prevSize": 32, "code": 59700, - "tempChar": "" + "tempChar": "" }, { "order": 659, @@ -2875,7 +3195,7 @@ "name": "lock", "prevSize": 32, "code": 59697, - "tempChar": "" + "tempChar": "" }, { "order": 529, @@ -2883,7 +3203,7 @@ "name": "unlock", "prevSize": 32, "code": 59732, - "tempChar": "" + "tempChar": "" }, { "order": 530, @@ -2891,7 +3211,7 @@ "name": "mute", "prevSize": 32, "code": 59703, - "tempChar": "" + "tempChar": "" }, { "order": 531, @@ -2899,7 +3219,7 @@ "name": "unmute", "prevSize": 32, "code": 59733, - "tempChar": "" + "tempChar": "" }, { "order": 532, @@ -2907,7 +3227,7 @@ "name": "pin", "prevSize": 32, "code": 59713, - "tempChar": "" + "tempChar": "" }, { "order": 533, @@ -2915,7 +3235,7 @@ "name": "unpin", "prevSize": 32, "code": 59734, - "tempChar": "" + "tempChar": "" }, { "order": 534, @@ -2923,7 +3243,7 @@ "name": "smallscreen", "prevSize": 32, "code": 59742, - "tempChar": "" + "tempChar": "" }, { "order": 535, @@ -2931,7 +3251,7 @@ "name": "fullscreen", "prevSize": 32, "code": 59743, - "tempChar": "" + "tempChar": "" }, { "order": 536, @@ -2939,7 +3259,7 @@ "name": "large-pause", "prevSize": 32, "code": 59694, - "tempChar": "" + "tempChar": "" }, { "order": 537, @@ -2947,7 +3267,7 @@ "name": "large-play", "prevSize": 32, "code": 59695, - "tempChar": "" + "tempChar": "" }, { "order": 538, @@ -2955,7 +3275,7 @@ "name": "pause", "prevSize": 32, "code": 59709, - "tempChar": "" + "tempChar": "" }, { "order": 539, @@ -2963,7 +3283,7 @@ "name": "play", "prevSize": 32, "code": 59715, - "tempChar": "" + "tempChar": "" }, { "order": 540, @@ -2971,7 +3291,7 @@ "name": "channelviews", "prevSize": 32, "code": 59666, - "tempChar": "" + "tempChar": "" }, { "order": 541, @@ -2979,7 +3299,7 @@ "name": "message-succeeded", "prevSize": 32, "code": 59648, - "tempChar": "" + "tempChar": "" }, { "order": 657, @@ -2987,7 +3307,7 @@ "name": "message-read", "prevSize": 32, "code": 59649, - "tempChar": "" + "tempChar": "" }, { "order": 543, @@ -2995,7 +3315,7 @@ "name": "message-pending", "prevSize": 32, "code": 59724, - "tempChar": "" + "tempChar": "" }, { "order": 544, @@ -3003,7 +3323,7 @@ "name": "message-failed", "prevSize": 32, "code": 59725, - "tempChar": "" + "tempChar": "" }, { "order": 545, @@ -3011,7 +3331,7 @@ "name": "favorite", "prevSize": 32, "code": 59710, - "tempChar": "" + "tempChar": "" }, { "order": 546, @@ -3019,7 +3339,7 @@ "name": "keyboard", "prevSize": 32, "code": 59716, - "tempChar": "" + "tempChar": "" }, { "order": 547, @@ -3027,7 +3347,7 @@ "name": "delete-left", "prevSize": 32, "code": 59717, - "tempChar": "" + "tempChar": "" }, { "order": 548, @@ -3035,7 +3355,7 @@ "name": "recent", "prevSize": 32, "code": 59718, - "tempChar": "" + "tempChar": "" }, { "order": 549, @@ -3043,7 +3363,7 @@ "name": "gifs", "prevSize": 32, "code": 59727, - "tempChar": "" + "tempChar": "" }, { "order": 550, @@ -3051,7 +3371,7 @@ "name": "stickers", "prevSize": 32, "code": 59739, - "tempChar": "" + "tempChar": "" }, { "order": 551, @@ -3059,7 +3379,7 @@ "name": "smile", "prevSize": 32, "code": 59728, - "tempChar": "" + "tempChar": "" }, { "order": 552, @@ -3067,7 +3387,7 @@ "name": "animals", "prevSize": 32, "code": 59655, - "tempChar": "" + "tempChar": "" }, { "order": 553, @@ -3075,7 +3395,7 @@ "name": "eats", "prevSize": 32, "code": 59682, - "tempChar": "" + "tempChar": "" }, { "order": 554, @@ -3083,7 +3403,7 @@ "name": "sport", "prevSize": 32, "code": 59729, - "tempChar": "" + "tempChar": "" }, { "order": 555, @@ -3091,7 +3411,7 @@ "name": "car", "prevSize": 32, "code": 59664, - "tempChar": "" + "tempChar": "" }, { "order": 556, @@ -3099,7 +3419,7 @@ "name": "lamp", "prevSize": 32, "code": 59692, - "tempChar": "" + "tempChar": "" }, { "order": 557, @@ -3107,7 +3427,7 @@ "name": "language", "prevSize": 32, "code": 59693, - "tempChar": "" + "tempChar": "" }, { "order": 558, @@ -3115,7 +3435,7 @@ "name": "flag", "prevSize": 32, "code": 59686, - "tempChar": "" + "tempChar": "" }, { "order": 559, @@ -3123,7 +3443,7 @@ "name": "more", "prevSize": 32, "code": 59702, - "tempChar": "" + "tempChar": "" }, { "order": 560, @@ -3131,7 +3451,7 @@ "name": "search", "prevSize": 32, "code": 59721, - "tempChar": "" + "tempChar": "" }, { "order": 561, @@ -3139,7 +3459,7 @@ "name": "remove", "prevSize": 32, "code": 59740, - "tempChar": "" + "tempChar": "" }, { "order": 562, @@ -3147,7 +3467,7 @@ "name": "add", "prevSize": 32, "code": 59651, - "tempChar": "" + "tempChar": "" }, { "order": 563, @@ -3155,7 +3475,7 @@ "name": "check", "prevSize": 32, "code": 59668, - "tempChar": "" + "tempChar": "" }, { "order": 564, @@ -3163,7 +3483,7 @@ "name": "close", "prevSize": 32, "code": 59673, - "tempChar": "" + "tempChar": "" }, { "order": 610, @@ -3171,7 +3491,7 @@ "name": "arrow-left", "prevSize": 32, "code": 59661, - "tempChar": "" + "tempChar": "" }, { "order": 566, @@ -3179,7 +3499,7 @@ "name": "arrow-right", "prevSize": 32, "code": 59708, - "tempChar": "" + "tempChar": "" }, { "order": 567, @@ -3187,7 +3507,7 @@ "name": "down", "prevSize": 32, "code": 59680, - "tempChar": "" + "tempChar": "" }, { "order": 568, @@ -3195,7 +3515,7 @@ "name": "up", "prevSize": 32, "code": 59736, - "tempChar": "" + "tempChar": "" }, { "order": 569, @@ -3203,7 +3523,7 @@ "name": "eye-closed", "prevSize": 32, "code": 59685, - "tempChar": "" + "tempChar": "" }, { "order": 570, @@ -3211,15 +3531,15 @@ "name": "eye", "prevSize": 32, "code": 59684, - "tempChar": "" + "tempChar": "" }, { - "order": 675, + "order": 571, "id": 4, - "name": "muted", + "name": "muted-chat", "prevSize": 32, "code": 59741, - "tempChar": "" + "tempChar": "" }, { "order": 572, @@ -3227,7 +3547,7 @@ "name": "avatar-archived-chats", "prevSize": 32, "code": 59658, - "tempChar": "" + "tempChar": "" }, { "order": 573, @@ -3235,7 +3555,7 @@ "name": "avatar-deleted-account", "prevSize": 32, "code": 59659, - "tempChar": "" + "tempChar": "" }, { "order": 574, @@ -3243,7 +3563,7 @@ "name": "avatar-saved-messages", "prevSize": 32, "code": 59660, - "tempChar": "" + "tempChar": "" }, { "order": 575, @@ -3251,7 +3571,7 @@ "name": "pinned-chat", "prevSize": 32, "code": 59714, - "tempChar": "" + "tempChar": "" } ], "prevSize": 32, @@ -3299,4 +3619,4 @@ "showLiga": false }, "uid": -1 -} +} \ No newline at end of file diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss index 1b34ff0a0..afd0416d9 100644 --- a/src/styles/_mixins.scss +++ b/src/styles/_mixins.scss @@ -13,3 +13,43 @@ overflow-x: hidden; } } + +@mixin reset-range() { + input[type="range"] { + -webkit-appearance: none; + display: block; + width: 100%; + height: 0.75rem; + margin-bottom: 0.5rem; + background: transparent; + + &:focus { + outline: none; + } + + &::-ms-track { + width: 100%; + cursor: pointer; + + background: transparent; + border-color: transparent; + color: transparent; + } + + &::-webkit-slider-thumb { + -webkit-appearance: none; + } + + &::-moz-slider-thumb { + -moz-appearance: none; + } + + &::-webkit-slider-runnable-track { + cursor: pointer; + } + + &::-moz-range-track, &::-moz-range-progress { + cursor: pointer; + } + } +} diff --git a/src/styles/icons.scss b/src/styles/icons.scss index e442748c6..7668eb52f 100644 --- a/src/styles/icons.scss +++ b/src/styles/icons.scss @@ -33,23 +33,59 @@ } .icon-loop:before { - content: "\e981"; + content: "\e98c"; } .icon-skip-next:before { - content: "\e982"; + content: "\e98d"; } .icon-skip-previous:before { - content: "\e983"; + content: "\e98e"; } .icon-volume-1:before { - content: "\e984"; + content: "\e98f"; } .icon-volume-2:before { - content: "\e985"; + content: "\e990"; } .icon-volume-3:before { + content: "\e991"; +} +.icon-sidebar:before { + content: "\e992"; +} +.icon-video-stop:before { + content: "\e98b"; +} +.icon-speaker:before { + content: "\e981"; +} +.icon-speaker-outline:before { + content: "\e982"; +} +.icon-phone-discard-outline:before { + content: "\e983"; +} +.icon-allow-speak:before { + content: "\e984"; +} +.icon-stop-raising-hand:before { + content: "\e985"; +} +.icon-share-screen:before { content: "\e986"; } +.icon-voice-chat:before { + content: "\e987"; +} +.icon-video:before { + content: "\e988"; +} +.icon-noise-suppression:before { + content: "\e989"; +} +.icon-phone-discard:before { + content: "\e98a"; +} .icon-bot-commands-filled:before { content: "\e97f"; } @@ -422,7 +458,7 @@ .icon-eye:before { content: "\e924"; } -.icon-muted:before { +.icon-muted-chat:before { content: "\e95d"; } .icon-avatar-archived-chats:before { diff --git a/src/util/deeplink.ts b/src/util/deeplink.ts index f94da80fd..321cccef7 100644 --- a/src/util/deeplink.ts +++ b/src/util/deeplink.ts @@ -13,6 +13,7 @@ export const processDeepLink = (url: string) => { openChatByUsername, openStickerSetShortName, focusMessage, + joinVoiceChatByLink, } = getDispatch(); const method = pathname.replace(/^\/\//, '') as DeepLinkMethod; @@ -23,14 +24,23 @@ export const processDeepLink = (url: string) => { switch (method) { case 'resolve': { - const { domain, post, comment } = params; + const { + domain, post, comment, voicechat, livestream, + } = params; if (domain !== 'telegrampassport') { - openChatByUsername({ - username: domain, - messageId: Number(post), - commentId: Number(comment), - }); + if (params.hasOwnProperty('voicechat') || params.hasOwnProperty('livestream')) { + joinVoiceChatByLink({ + username: domain, + inviteHash: voicechat || livestream, + }); + } else { + openChatByUsername({ + username: domain, + messageId: Number(post), + commentId: Number(comment), + }); + } } break; } diff --git a/src/util/environment.ts b/src/util/environment.ts index 48f0c8eae..84d4651ae 100644 --- a/src/util/environment.ts +++ b/src/util/environment.ts @@ -67,6 +67,8 @@ export const IS_OPUS_SUPPORTED = Boolean((new Audio()).canPlayType('audio/ogg; c export const IS_CANVAS_FILTER_SUPPORTED = ( !IS_TEST && 'filter' in (document.createElement('canvas').getContext('2d') || {}) ); +export const IS_REQUEST_FULLSCREEN_SUPPORTED = 'requestFullscreen' in document.createElement('div'); +export const ARE_CALLS_SUPPORTED = !navigator.userAgent.includes('Firefox'); export const LAYERS_ANIMATION_NAME = IS_ANDROID ? 'slide-fade' : IS_IOS ? 'slide-layers' : 'push-slide'; const TEST_VIDEO = document.createElement('video'); diff --git a/src/util/moduleLoader.ts b/src/util/moduleLoader.ts index 0b8e87968..0a8135155 100644 --- a/src/util/moduleLoader.ts +++ b/src/util/moduleLoader.ts @@ -4,12 +4,14 @@ export enum Bundles { Auth, Main, Extra, + Calls, } interface ImportedBundles { [Bundles.Auth]: typeof import('../bundles/auth'); [Bundles.Main]: typeof import('../bundles/main'); [Bundles.Extra]: typeof import('../bundles/extra'); + [Bundles.Calls]: typeof import('../bundles/calls'); } type BundlePromises = { @@ -38,6 +40,9 @@ export async function loadModule>( case Bundles.Extra: LOAD_PROMISES[Bundles.Extra] = import('../bundles/extra'); break; + case Bundles.Calls: + LOAD_PROMISES[Bundles.Calls] = import('../bundles/calls'); + break; } (LOAD_PROMISES[bundleName] as Promise).then(handleBundleLoad); diff --git a/src/util/vibrate.ts b/src/util/vibrate.ts new file mode 100644 index 000000000..2af6a7bf0 --- /dev/null +++ b/src/util/vibrate.ts @@ -0,0 +1,3 @@ +export const vibrateShort = () => { + navigator.vibrate?.(50); +}; diff --git a/webpack.config.js b/webpack.config.js index b267c859a..d82a0c10d 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -25,6 +25,7 @@ module.exports = (env = {}, argv = {}) => { path.resolve(__dirname, 'node_modules/opus-recorder/dist'), path.resolve(__dirname, 'src/lib/webp'), path.resolve(__dirname, 'src/lib/rlottie'), + path.resolve(__dirname, 'src/lib/secret-sauce'), ], port: 1234, host: '0.0.0.0',