diff --git a/src/resources/js/meet/room.js b/src/resources/js/meet/room.js index c702618d..d5bb6174 100644 --- a/src/resources/js/meet/room.js +++ b/src/resources/js/meet/room.js @@ -1,1097 +1,1097 @@ 'use strict' import anchorme from 'anchorme' import { Client } from './client.js' import { Roles } from './constants.js' import { Dropdown } from 'bootstrap' import { library } from '@fortawesome/fontawesome-svg-core' function Room(container) { let sessionData // Room session metadata let peers = {} // Participants in the session (including self) let publishersContainer // Container element for publishers let subscribersContainer // Container element for subscribers let selfId // peer Id of the current user let chatCount = 0 let scrollStop let $t let $toast const client = new Client() // Disconnect participant when browser's window close window.addEventListener('beforeunload', () => { leaveRoom() }) window.addEventListener('resize', resize) // Public methods this.isScreenSharingSupported = isScreenSharingSupported this.joinRoom = joinRoom this.leaveRoom = leaveRoom this.raiseHand = raiseHand this.setupStart = setupStart this.setupStop = setupStop this.setupSetAudioDevice = setupSetAudioDevice this.setupSetVideoDevice = setupSetVideoDevice this.switchAudio = switchAudio this.switchChannel = switchChannel this.switchScreen = switchScreen this.switchVideo = switchVideo /** * Join the room session * * @param data Session metadata and event handlers: * token - A token for the main connection, * nickname - Participant name, * languages - Supported languages (code-to-label map) * chatElement - DOM element for the chat widget, * counterElement - DOM element for the participants counter, * menuElement - DOM element of the room toolbar, * queueElement - DOM element for the Q&A queue (users with a raised hand) * onSuccess - Callback for session connection (join) success * onError - Callback for session connection (join) error * onDestroy - Callback for session disconnection event, * onMediaSetup - Called when user clicks the Media setup button * onUpdate - Callback for current user/session update, * toast - Toast widget * translate - Translation function */ function joinRoom(data) { // Create a container for subscribers and publishers publishersContainer = $('
').appendTo(container).get(0) subscribersContainer = $('
').appendTo(container).get(0) resize() $t = data.translate $toast = data.toast // Make sure all supported callbacks exist, so we don't have to check // their existence everywhere anymore let events = ['Success', 'Error', 'Destroy', 'Update', 'MediaSetup'] events.map(event => 'on' + event).forEach(event => { if (!data[event]) { data[event] = () => {} } }) sessionData = data // Handle new participants (including self) client.on('addPeer', (event) => { if (event.isSelf) { selfId = event.id } peers[event.id] = event event.element = participantCreate(event, event.videoElement) if (event.raisedHand) { peerHandUp(event) } }) // Handle removed participants client.on('removePeer', (peerId) => { let peer = peers[peerId] if (peer) { // Remove elements related to the participant peerHandDown(peer) $(peer.element).remove() if (peer.screen) { $(peer.screen).remove() } delete peers[peerId] } resize() }) // Participant properties changed e.g. audio/video muted/unmuted client.on('updatePeer', (event, changed) => { let peer = peers[event.id] if (!peer) { return } event.element = peer.element event.screen = peer.screen // Video element added or removed if (event.videoElement && event.videoElement.parentNode != event.element) { $(event.element).prepend(event.videoElement) } else if (!event.videoElement) { $(event.element).find('video').remove() } // Video element of the shared screen added or removed if (event.screenVideoElement && !event.screen) { const screen = { id: event.id, role: event.role | Roles.SCREEN, nickname: event.nickname } event.screen = participantCreate(screen, event.screenVideoElement) } else if (!event.screenVideoElement && event.screen) { $(event.screen).remove() event.screen = null resize() } peers[event.id] = event if (changed && changed.length) { if (changed && changed.includes('nickname')) { nicknameUpdate(event.nickname, event.id) } if (changed.includes('raisedHand')) { if (event.raisedHand) { peerHandUp(event) } else { peerHandDown(event) } } if (changed && changed.includes('screenWidth')) { resize() return } } if (changed && changed.includes('interpreterRole') && !event.isSelf && $(event.element).find('video').length ) { // Publisher-to-interpreter or vice-versa, move element to the subscribers list or vice-versa, // but keep the existing video element let wrapper = participantCreate(event, $(event.element).find('video')) event.element.remove() event.element = wrapper } else if (changed && changed.includes('publisherRole') && !event.language) { // Handle publisher-to-subscriber and subscriber-to-publisher change event.element.remove() event.element = participantCreate(event, event.videoElement) } else { participantUpdate(event.element, event) } // It's me, got publisher role if (event.isSelf && (event.role & Roles.PUBLISHER) && changed && changed.includes('publisherRole')) { // Open the media setup dialog sessionData.onMediaSetup() } if (changed && changed.includes('moderatorRole')) { participantUpdateAll() } }) // Handle successful connection to the room client.on('joinSuccess', () => { data.onSuccess() client.media.setupStop() }) // Handle join requests from other users (knocking to the room) client.on('joinRequest', event => { joinRequest(event) }) // Handle session disconnection events client.on('closeSession', event => { // Notify the UI data.onDestroy(event) // Remove all participant elements Object.keys(peers).forEach(peerId => { $(peers[peerId].element).remove() }) peers = {} // refresh the matrix resize() }) // Handle session update events (e.g. channel, channels list changes) client.on('updateSession', event => { // Inform the vue component, so it can update some UI controls sessionData.onUpdate(event) }) const { audioSource, videoSource } = client.media.setupData() // Start the session client.joinSession(data.token, { videoSource, audioSource, nickname: data.nickname }) // Prepare the chat initChat() } /** * Leave the room (disconnect) */ function leaveRoom(forced) { client.closeSession(forced) peers = {} } /** * Handler for an event received by the moderator when a participant * is asking for a permission to join the room */ function joinRequest(data) { const id = data.requestId // The toast for this user request already exists, ignore // It's not really needed as we do this on server-side already if ($('#i' + id).length) { return } const body = $( `
` + `
` + `
` + `

` + `
` + `` + `` ) $toast.message({ className: 'join-request', icon: 'user', timeout: 0, title: $t('meet.join-request'), // titleClassName: '', body: body.html(), onShow: element => { $(element).find('p').text($t('meet.join-requested', { user: data.nickname || '' })) // add id attribute, so we can identify it $(element).attr('id', 'i' + id) // add action to the buttons .find('button.accept,button.deny').on('click', e => { const action = $(e.target).is('.accept') ? 'Accept' : 'Deny' client['joinRequest' + action](id) $('#i' + id).remove() }) } }) } /** * Raise or lower the hand * * @param status Hand raised or not */ async function raiseHand(status) { return await client.raiseHand(status) } /** * Sets the audio and video devices for the session. * This will ask user for permission to access media devices. * * @param props Setup properties (videoElement, volumeElement, onSuccess, onError) */ function setupStart(props) { client.media.setupStart(props) // When setting up devices while the session is ongoing we have to // disable currently selected devices (temporarily) otherwise e.g. // changing a mic or camera to another device will not be possible. if (client.isJoined()) { client.setMic('') client.setCamera('') } } /** * Stop the setup "process", cleanup after it. */ async function setupStop() { client.media.setupStop() // Apply device changes to the client const { audioSource, videoSource } = client.media.setupData() await client.setMic(audioSource) await client.setCamera(videoSource) } /** * Change the publisher audio device * * @param deviceId Device identifier string */ async function setupSetAudioDevice(deviceId) { return await client.media.setupSetAudio(deviceId) } /** * Change the publisher video device * * @param deviceId Device identifier string */ async function setupSetVideoDevice(deviceId) { return await client.media.setupSetVideo(deviceId) } /** * Setup the chat UI */ function initChat() { // Handle arriving chat messages client.on('chatMessage', pushChatMessage) // The UI elements are created in the vue template // Here we add a logic for how they work const chat = $(sessionData.chatElement).find('.chat').get(0) const textarea = $(sessionData.chatElement).find('textarea') const button = $(sessionData.menuElement).find('.link-chat') textarea.on('keydown', e => { if (e.keyCode == 13 && !e.shiftKey) { if (textarea.val().length) { client.chatMessage(textarea.val()) textarea.val('') } return false } }) // Add an element for the count of unread messages on the chat button - button.append('') + button.append('') .on('click', () => { button.find('.badge').text('') chatCount = 0 // When opening the chat scroll it to the bottom, or we shouldn't? scrollStop = false chat.scrollTop = chat.scrollHeight }) $(chat).on('scroll', event => { // Detect manual scrollbar moves, disable auto-scrolling until // the scrollbar is positioned on the element bottom again scrollStop = chat.scrollTop + chat.offsetHeight < chat.scrollHeight }) } /** * Add a message to the chat * * @param data Object with a message, nickname, id (of the connection, empty for self) */ function pushChatMessage(data) { let message = $('').text(data.message).text() // make the message secure // Format the message, convert emails and urls to links message = anchorme({ input: message, options: { attributes: { target: "_blank" }, // any link above 20 characters will be truncated // to 20 characters and ellipses at the end truncate: 20, // characters will be taken out of the middle middleTruncation: true } // TODO: anchorme is extensible, we could support // github/phabricator's markup e.g. backticks for code samples }) message = message.replace(/\r?\n/, '
') // Display the message let chat = $(sessionData.chatElement).find('.chat') let box = chat.find('.message').last() message = $('
').html(message) message.find('a').attr('rel', 'noreferrer') if (box.length && box.data('id') == data.peerId) { // A message from the same user as the last message, no new box needed message.appendTo(box) } else { box = $('
').data('id', data.peerId) .append($('
').text(data.nickname || '')) .append(message) .appendTo(chat) if (data.isSelf) { box.addClass('self') } } // Count unread messages if (!$(sessionData.chatElement).is('.open')) { if (!data.isSelf) { chatCount++ } } else { chatCount = 0 } $(sessionData.menuElement).find('.link-chat .badge').text(chatCount ? chatCount : '') // Scroll the chat element to the end if (!scrollStop) { chat.get(0).scrollTop = chat.get(0).scrollHeight } } /** * Switch interpreted language channel * * @param channel Two-letter language code */ function switchChannel(channel) { client.setLanguageChannel(channel) } /** * Mute/Unmute audio for current session publisher */ async function switchAudio() { const isActive = client.micStatus() if (isActive) { return await client.micMute() } else { return await client.micUnmute() } } /** * Mute/Unmute video for current session publisher */ async function switchVideo() { const isActive = client.camStatus() if (isActive) { return await client.camMute() } else { return await client.camUnmute() } } /** * Switch on/off screen sharing */ async function switchScreen() { const isActive = client.screenStatus() if (isActive) { return await client.screenUnshare() } else { return await client.screenShare() } } /** * Detect if screen sharing is supported by the browser */ function isScreenSharingSupported() { return !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia) } /** * Handler for Hand-Up "signal" */ function peerHandUp(peer) { let element = $(nicknameWidget(peer)) participantUpdate(element, peer) element.attr('id', 'qa' + peer.id).appendTo($(sessionData.queueElement).show()) setTimeout(() => element.addClass('wiggle'), 50) } /** * Handler for Hand-Down "signal" */ function peerHandDown(peer) { let list = $(sessionData.queueElement) list.find('#qa' + peer.id).remove() if (!list.find('.meet-nickname').length) { list.hide() } } /** * Update participant nickname in the UI * * @param nickname Nickname * @param peerId Connection identifier of the user */ function nicknameUpdate(nickname, peerId) { if (peerId) { $(sessionData.chatElement).find('.chat').find('.message').each(function() { let elem = $(this) if (elem.data('id') == peerId) { elem.find('.nickname').text(nickname || '') } }) $(sessionData.queueElement).find('#qa' + peerId + ' .content').text(nickname || '') // Also update the nickname for the shared screen as we do not call // participantUpdate() for this element $('#screen-' + peerId).find('.meet-nickname .content').text(nickname || '') } } /** * Create a participant element in the matrix. Depending on the peer role * parameter it will be a video element wrapper inside the matrix or a simple * tag-like element on the subscribers list. * * @param params Peer metadata/params * @param content Optional content to prepend to the element, e.g. video element * * @return The element */ function participantCreate(params, content) { let element if ((!params.language && params.role & Roles.PUBLISHER) || params.role & Roles.SCREEN) { // publishers and shared screens element = publisherCreate(params, content) } else { // subscribers and language interpreters element = subscriberCreate(params, content) } setTimeout(resize, 50) return element } /** * Create a