481 lines
14 KiB
TypeScript
481 lines
14 KiB
TypeScript
import type { ApiGroupCall } from '../../../api/types';
|
|
import type { CallSound } from '../../../types';
|
|
import type { RequiredGlobalActions } from '../../index';
|
|
import type {
|
|
ActionReturnType, GlobalState, TabArgs,
|
|
} from '../../types';
|
|
|
|
import { requestNextMutation } from '../../../lib/fasterdom/fasterdom';
|
|
import { ARE_CALLS_SUPPORTED } from '../../../util/browser/windowEnvironment';
|
|
import { copyTextToClipboard } from '../../../util/clipboard';
|
|
import { getCurrentTabId } from '../../../util/establishMultitabRole';
|
|
import { omit } from '../../../util/iteratees';
|
|
import * as langProvider from '../../../util/oldLangProvider';
|
|
import safePlay from '../../../util/safePlay';
|
|
import { callApi } from '../../../api/gramjs';
|
|
import { getMainUsername } from '../../helpers';
|
|
import {
|
|
addActionHandler, getGlobal,
|
|
setGlobal,
|
|
} from '../../index';
|
|
import { updateGroupCall } from '../../reducers/calls';
|
|
import { updateTabState } from '../../reducers/tabs';
|
|
import {
|
|
selectChat, selectChatFullInfo, selectIsCurrentUserFrozen,
|
|
selectTabState, selectUser,
|
|
} from '../../selectors';
|
|
import { selectActiveGroupCall, selectChatGroupCall, selectGroupCall } from '../../selectors/calls';
|
|
import { fetchChatByUsername, loadFullChat } from '../api/chats';
|
|
|
|
// This is a tiny MP3 file that is silent - retrieved from https://bigsoundbank.com and then modified
|
|
// eslint-disable-next-line @stylistic/max-len
|
|
const silentSound = 'data:audio/mpeg;base64,SUQzBAAAAAABEVRYWFgAAAAtAAADY29tbWVudABCaWdTb3VuZEJhbmsuY29tIC8gTGFTb25vdGhlcXVlLm9yZwBURU5DAAAAHQAAA1N3aXRjaCBQbHVzIMKpIE5DSCBTb2Z0d2FyZQBUSVQyAAAABgAAAzIyMzUAVFNTRQAAAA8AAANMYXZmNTcuODMuMTAwAAAAAAAAAAAAAAD/80DEAAAAA0gAAAAATEFNRTMuMTAwVVVVVVVVVVVVVUxBTUUzLjEwMFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVf/zQsRbAAADSAAAAABVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVf/zQMSkAAADSAAAAABVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV';
|
|
|
|
let audioElement: HTMLAudioElement | undefined;
|
|
let audioContext: AudioContext | undefined;
|
|
let sounds: Record<CallSound, HTMLAudioElement>;
|
|
|
|
// Workaround: this function is called once on the first user interaction.
|
|
// After that, it will be possible to play the notification on iOS without problems.
|
|
// https://rosswintle.uk/2019/01/skirting-the-ios-safari-audio-auto-play-policy-for-ui-sound-effects/
|
|
export function initializeSoundsForSafari() {
|
|
initializeSounds();
|
|
|
|
return Promise.all(Object.values(sounds).map((sound) => {
|
|
const prevSrc = sound.src;
|
|
sound.src = silentSound;
|
|
sound.muted = true;
|
|
sound.volume = 0.0001;
|
|
return sound.play()
|
|
.then(() => {
|
|
sound.pause();
|
|
sound.volume = 1;
|
|
sound.currentTime = 0;
|
|
sound.muted = false;
|
|
|
|
requestNextMutation(() => {
|
|
sound.src = prevSrc;
|
|
});
|
|
});
|
|
}));
|
|
}
|
|
|
|
export function initializeSounds() {
|
|
if (sounds) {
|
|
return;
|
|
}
|
|
const joinAudio = new Audio('./voicechat_join.mp3');
|
|
const connectingAudio = new Audio('./voicechat_connecting.mp3');
|
|
connectingAudio.loop = true;
|
|
const leaveAudio = new Audio('./voicechat_leave.mp3');
|
|
const allowTalkAudio = new Audio('./voicechat_onallowtalk.mp3');
|
|
const busyAudio = new Audio('./call_busy.mp3');
|
|
const connectAudio = new Audio('./call_connect.mp3');
|
|
const endAudio = new Audio('./call_end.mp3');
|
|
const incomingAudio = new Audio('./call_incoming.mp3');
|
|
incomingAudio.loop = true;
|
|
const ringingAudio = new Audio('./call_ringing.mp3');
|
|
ringingAudio.loop = true;
|
|
|
|
sounds = {
|
|
join: joinAudio,
|
|
allowTalk: allowTalkAudio,
|
|
leave: leaveAudio,
|
|
connecting: connectingAudio,
|
|
incoming: incomingAudio,
|
|
end: endAudio,
|
|
connect: connectAudio,
|
|
busy: busyAudio,
|
|
ringing: ringingAudio,
|
|
};
|
|
}
|
|
|
|
async function fetchGroupCall<T extends GlobalState>(global: T, groupCall: Partial<ApiGroupCall>) {
|
|
if (selectIsCurrentUserFrozen(global)) return undefined;
|
|
const result = await callApi('getGroupCall', {
|
|
call: groupCall,
|
|
});
|
|
|
|
if (!result) return undefined;
|
|
|
|
global = getGlobal();
|
|
|
|
const existingGroupCall = selectGroupCall(global, groupCall.id!);
|
|
|
|
global = updateGroupCall(
|
|
global,
|
|
groupCall.id!,
|
|
omit(result.groupCall, ['connectionState']),
|
|
undefined,
|
|
existingGroupCall?.isLoaded ? undefined : result.groupCall.participantsCount,
|
|
);
|
|
|
|
setGlobal(global);
|
|
|
|
return result.groupCall;
|
|
}
|
|
|
|
function requestGroupCallParticipants(
|
|
groupCall: Partial<ApiGroupCall>, nextOffset?: string,
|
|
) {
|
|
return callApi('fetchGroupCallParticipants', {
|
|
call: groupCall as ApiGroupCall,
|
|
offset: nextOffset,
|
|
});
|
|
}
|
|
|
|
addActionHandler('toggleGroupCallPanel', (global, actions, payload): ActionReturnType => {
|
|
const { force, tabId = getCurrentTabId() } = payload || {};
|
|
return updateTabState(global, {
|
|
isCallPanelVisible: 'force' in (payload || {}) ? force : !selectTabState(global, tabId).isCallPanelVisible,
|
|
}, tabId);
|
|
});
|
|
|
|
addActionHandler('createGroupCall', async (global, actions, payload): Promise<void> => {
|
|
const { chatId, tabId = getCurrentTabId() } = payload;
|
|
|
|
const chat = selectChat(global, chatId);
|
|
if (!chat) {
|
|
return;
|
|
}
|
|
|
|
const result = await callApi('createGroupCall', {
|
|
peer: chat,
|
|
});
|
|
|
|
if (!result) return;
|
|
|
|
global = getGlobal();
|
|
global = updateGroupCall(global, result.id, {
|
|
...result,
|
|
chatId,
|
|
});
|
|
setGlobal(global);
|
|
|
|
actions.requestMasterAndJoinGroupCall({ id: result.id, accessHash: result.accessHash, tabId });
|
|
});
|
|
|
|
addActionHandler('createGroupCallInviteLink', async (global, actions, payload): Promise<void> => {
|
|
const { tabId = getCurrentTabId() } = payload || {};
|
|
const groupCall = selectActiveGroupCall(global);
|
|
|
|
if (!groupCall || !groupCall.chatId) {
|
|
return;
|
|
}
|
|
|
|
const chat = selectChat(global, groupCall.chatId);
|
|
if (!chat) {
|
|
return;
|
|
}
|
|
|
|
const hasPublicUsername = Boolean(getMainUsername(chat));
|
|
|
|
let inviteLink = selectChatFullInfo(global, chat.id)?.inviteLink;
|
|
if (hasPublicUsername) {
|
|
inviteLink = await callApi('exportGroupCallInvite', {
|
|
call: groupCall,
|
|
canSelfUnmute: false,
|
|
});
|
|
}
|
|
|
|
if (!inviteLink) {
|
|
return;
|
|
}
|
|
|
|
copyTextToClipboard(inviteLink);
|
|
actions.showNotification({
|
|
message: {
|
|
key: 'LinkCopied',
|
|
},
|
|
tabId,
|
|
});
|
|
});
|
|
|
|
addActionHandler('joinVoiceChatByLink', async (global, actions, payload): Promise<void> => {
|
|
const { username, inviteHash, tabId = getCurrentTabId() } = payload;
|
|
|
|
const chat = await fetchChatByUsername(global, username);
|
|
|
|
if (!chat) {
|
|
actions.showNotification({ message: langProvider.oldTranslate('NoUsernameFound'), tabId });
|
|
return;
|
|
}
|
|
|
|
global = getGlobal();
|
|
const full = await loadFullChat(global, actions, chat);
|
|
|
|
if (full?.groupCall) {
|
|
actions.requestMasterAndJoinGroupCall({
|
|
id: full.groupCall.id,
|
|
accessHash: full.groupCall.accessHash,
|
|
inviteHash,
|
|
tabId,
|
|
});
|
|
}
|
|
});
|
|
|
|
addActionHandler('requestMasterAndJoinGroupCall', (global, actions, payload): ActionReturnType => {
|
|
actions.requestMasterAndCallAction({
|
|
action: 'joinGroupCall',
|
|
payload,
|
|
tabId: payload.tabId || getCurrentTabId(),
|
|
});
|
|
});
|
|
|
|
addActionHandler('requestMasterAndAcceptCall', (global, actions, payload): ActionReturnType => {
|
|
actions.requestMasterAndCallAction({
|
|
action: 'acceptCall',
|
|
payload: undefined,
|
|
tabId: payload?.tabId || getCurrentTabId(),
|
|
});
|
|
});
|
|
|
|
addActionHandler('joinGroupCall', async (global, actions, payload): Promise<void> => {
|
|
const {
|
|
chatId, id, accessHash, inviteHash, tabId = getCurrentTabId(),
|
|
} = payload;
|
|
|
|
if (!ARE_CALLS_SUPPORTED) {
|
|
actions.showNotification({
|
|
message: 'Sorry, your browser doesn\'t support group calls',
|
|
tabId,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (global.phoneCall) {
|
|
actions.toggleGroupCallPanel({ tabId });
|
|
return;
|
|
}
|
|
|
|
createAudioElement();
|
|
|
|
initializeSounds();
|
|
global = getGlobal();
|
|
void checkNavigatorUserMediaPermissions(global, actions, true, tabId);
|
|
|
|
const { groupCalls: { activeGroupCallId } } = global;
|
|
let groupCall = id ? selectGroupCall(global, id) : selectChatGroupCall(global, chatId!);
|
|
|
|
if (groupCall && groupCall.id === activeGroupCallId) {
|
|
actions.toggleGroupCallPanel({ tabId });
|
|
return;
|
|
}
|
|
|
|
if (activeGroupCallId) {
|
|
if ('leaveGroupCall' in actions) {
|
|
actions.leaveGroupCall({
|
|
rejoin: payload,
|
|
tabId,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (groupCall && activeGroupCallId === groupCall.id) {
|
|
actions.toggleGroupCallPanel({ tabId });
|
|
return;
|
|
}
|
|
|
|
if (!groupCall && (!id || !accessHash) && chatId) {
|
|
const chat = selectChat(global, chatId);
|
|
|
|
if (!chat) return;
|
|
|
|
await loadFullChat(global, actions, chat);
|
|
global = getGlobal();
|
|
groupCall = selectChatGroupCall(global, chatId);
|
|
} else if (!groupCall && id && accessHash) {
|
|
groupCall = await fetchGroupCall(global, {
|
|
id,
|
|
accessHash,
|
|
});
|
|
}
|
|
|
|
if (!groupCall) return;
|
|
|
|
global = getGlobal();
|
|
global = updateGroupCall(
|
|
global,
|
|
groupCall.id,
|
|
{
|
|
...groupCall,
|
|
inviteHash,
|
|
},
|
|
undefined,
|
|
groupCall.participantsCount + 1,
|
|
);
|
|
global = {
|
|
...global,
|
|
groupCalls: {
|
|
...global.groupCalls,
|
|
activeGroupCallId: groupCall.id,
|
|
},
|
|
};
|
|
|
|
setGlobal(global);
|
|
|
|
actions.toggleGroupCallPanel({ force: false, tabId });
|
|
});
|
|
|
|
addActionHandler('playGroupCallSound', (global, actions, payload): ActionReturnType => {
|
|
const { sound } = payload;
|
|
|
|
if (!sounds?.[sound]) {
|
|
return;
|
|
}
|
|
|
|
const doPlay = () => {
|
|
if (sound !== 'connecting') {
|
|
sounds.connecting.pause();
|
|
}
|
|
if (sound !== 'incoming') {
|
|
sounds.incoming.pause();
|
|
}
|
|
if (sound !== 'ringing') {
|
|
sounds.ringing.pause();
|
|
}
|
|
safePlay(sounds[sound]);
|
|
};
|
|
|
|
doPlay();
|
|
});
|
|
|
|
addActionHandler('loadMoreGroupCallParticipants', (global): ActionReturnType => {
|
|
const groupCall = selectActiveGroupCall(global);
|
|
if (!groupCall) {
|
|
return;
|
|
}
|
|
|
|
void requestGroupCallParticipants(groupCall, groupCall.nextOffset);
|
|
});
|
|
|
|
addActionHandler('requestMasterAndRequestCall', (global, actions, payload): ActionReturnType => {
|
|
actions.requestMasterAndCallAction({
|
|
action: 'requestCall',
|
|
payload,
|
|
tabId: payload.tabId || getCurrentTabId(),
|
|
});
|
|
});
|
|
|
|
addActionHandler('requestCall', (global, actions, payload): ActionReturnType => {
|
|
const { userId, isVideo, tabId = getCurrentTabId() } = payload;
|
|
|
|
if (global.phoneCall) {
|
|
actions.toggleGroupCallPanel({ tabId });
|
|
return;
|
|
}
|
|
|
|
const user = selectUser(global, userId);
|
|
|
|
if (!user) {
|
|
return;
|
|
}
|
|
|
|
initializeSounds();
|
|
global = getGlobal();
|
|
void checkNavigatorUserMediaPermissions(global, actions, isVideo, tabId);
|
|
|
|
global = getGlobal();
|
|
global = {
|
|
...global,
|
|
phoneCall: {
|
|
id: '',
|
|
state: 'requesting',
|
|
participantId: userId,
|
|
isVideo,
|
|
adminId: global.currentUserId,
|
|
},
|
|
};
|
|
setGlobal(global);
|
|
|
|
actions.toggleGroupCallPanel({ force: false, tabId });
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
// This method is used instead of a navigator.permissions.query to determine permission to use a microphone,
|
|
// because Firefox does not have support for 'microphone' and 'camera' permissions
|
|
// https://github.com/mozilla/standards-positions/issues/19#issuecomment-370158947
|
|
export function checkNavigatorUserMediaPermissions<T extends GlobalState>(
|
|
global: T,
|
|
actions: RequiredGlobalActions, isVideo?: boolean,
|
|
...[tabId = getCurrentTabId()]: TabArgs<T>
|
|
) {
|
|
if (isVideo) {
|
|
navigator.mediaDevices.getUserMedia({ video: true })
|
|
.then((stream) => {
|
|
if (stream.getVideoTracks().length === 0) {
|
|
actions.showNotification({
|
|
message: langProvider.oldTranslate('Call.Camera.Error'),
|
|
tabId,
|
|
});
|
|
} else {
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
checkMicrophonePermission(global, actions, tabId);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
actions.showNotification({
|
|
message: langProvider.oldTranslate('Call.Camera.Error'),
|
|
tabId,
|
|
});
|
|
});
|
|
} else {
|
|
checkMicrophonePermission(global, actions, tabId);
|
|
}
|
|
}
|
|
|
|
function checkMicrophonePermission<T extends GlobalState>(
|
|
global: T, actions: RequiredGlobalActions, ...[tabId = getCurrentTabId()]: TabArgs<T>
|
|
) {
|
|
navigator.mediaDevices.getUserMedia({ audio: true })
|
|
.then((stream) => {
|
|
if (stream.getAudioTracks().length === 0) {
|
|
actions.showNotification({
|
|
message: langProvider.oldTranslate('RequestAcces.Error.HaveNotAccess.Call'),
|
|
tabId,
|
|
});
|
|
} else {
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
}
|
|
})
|
|
.catch(() => {
|
|
actions.showNotification({
|
|
message: langProvider.oldTranslate('RequestAcces.Error.HaveNotAccess.Call'),
|
|
tabId,
|
|
});
|
|
});
|
|
}
|