diff --git a/src/resources/js/app.js b/src/resources/js/app.js index cd6e4b94..b27fdb0c 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,525 +1,525 @@ /** * First we will load all of this project's JavaScript dependencies which * includes Vue and other libraries. It is a great starting point when * building robust, powerful web applications using Vue and Laravel. */ require('./bootstrap') import AppComponent from '../vue/App' import MenuComponent from '../vue/Widgets/Menu' import SupportForm from '../vue/Widgets/SupportForm' import store from './store' import { Tab } from 'bootstrap' import { loadLangAsync, i18n } from './locale' const loader = '
Loading
' let isLoading = 0 // Lock the UI with the 'loading...' element const startLoading = () => { isLoading++ let loading = $('#app > .app-loader').removeClass('fadeOut') if (!loading.length) { $('#app').append($(loader)) } } // Hide "loading" overlay const stopLoading = () => { if (isLoading > 0) { $('#app > .app-loader').addClass('fadeOut') isLoading--; } } let loadingRoute // Note: This has to be before the app is created // Note: You cannot use app inside of the function window.router.beforeEach((to, from, next) => { // check if the route requires authentication and user is not logged in if (to.meta.requiresAuth && !store.state.isLoggedIn) { // remember the original request, to use after login store.state.afterLogin = to; // redirect to login page next({ name: 'login' }) return } if (to.meta.loading) { startLoading() loadingRoute = to.name } next() }) window.router.afterEach((to, from) => { if (to.name && loadingRoute === to.name) { stopLoading() loadingRoute = null } // When changing a page remove old: // - error page // - modal backdrop $('#error-page,.modal-backdrop.show').remove() $('body').css('padding', 0) // remove padding added by unclosed modal }) const app = new Vue({ components: { AppComponent, MenuComponent, }, i18n, store, router: window.router, data() { return { isUser: !window.isAdmin && !window.isReseller, appName: window.config['app.name'], appUrl: window.config['app.url'], themeDir: '/themes/' + window.config['app.theme'] } }, methods: { // Clear (bootstrap) form validation state clearFormValidation(form) { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, hasPermission(type) { const authInfo = store.state.authInfo const key = 'enable' + type.charAt(0).toUpperCase() + type.slice(1) return !!(authInfo && authInfo.statusInfo[key]) }, hasRoute(name) { return this.$router.resolve({ name: name }).resolved.matched.length > 0 }, hasSKU(name) { const authInfo = store.state.authInfo return authInfo.statusInfo.skus && authInfo.statusInfo.skus.indexOf(name) != -1 }, isController(wallet_id) { if (wallet_id && store.state.authInfo) { let i for (i = 0; i < store.state.authInfo.wallets.length; i++) { if (wallet_id == store.state.authInfo.wallets[i].id) { return true } } for (i = 0; i < store.state.authInfo.accounts.length; i++) { if (wallet_id == store.state.authInfo.accounts[i].id) { return true } } } return false }, // Set user state to "logged in" loginUser(response, dashboard, update) { if (!update) { store.commit('logoutUser') // destroy old state data store.commit('loginUser') } localStorage.setItem('token', response.access_token) localStorage.setItem('refreshToken', response.refresh_token) axios.defaults.headers.common.Authorization = 'Bearer ' + response.access_token if (response.email) { store.state.authInfo = response } if (dashboard !== false) { this.$router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null // Refresh the token before it expires let timeout = response.expires_in || 0 // We'll refresh 60 seconds before the token expires if (timeout > 60) { timeout -= 60 } // TODO: We probably should try a few times in case of an error // TODO: We probably should prevent axios from doing any requests // while the token is being refreshed this.refreshTimeout = setTimeout(() => { axios.post('/api/auth/refresh', {'refresh_token': response.refresh_token}).then(response => { this.loginUser(response.data, false, true) }) }, timeout * 1000) }, // Set user state to "not logged in" logoutUser(redirect) { store.commit('logoutUser') localStorage.setItem('token', '') localStorage.setItem('refreshToken', '') delete axios.defaults.headers.common.Authorization if (redirect !== false) { this.$router.push({ name: 'login' }) } clearTimeout(this.refreshTimeout) }, logo(mode) { let src = this.appUrl + this.themeDir + '/images/logo_' + (mode || 'header') + '.png' return `${this.appName}` }, // Display "loading" overlay inside of the specified element addLoader(elem, small = true, style = null) { if (style) { $(elem).css(style) } else { $(elem).css('position', 'relative') } $(elem).append(small ? $(loader).addClass('small') : $(loader)) }, // Remove loader element added in addLoader() removeLoader(elem) { $(elem).find('.app-loader').remove() }, startLoading, stopLoading, isLoading() { return isLoading > 0 }, tab(e) { e.preventDefault() new Tab(e.target).show() }, errorPage(code, msg, hint) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side // effects: it changes the URL and adds the error page to browser history. // For now we'll be replacing current view with error page "manually". if (!msg) msg = this.$te('error.' + code) ? this.$t('error.' + code) : this.$t('error.unknown') if (!hint) hint = '' const error_page = '
' + `
${code}
${msg}
${hint}
` + '
' $('#error-page').remove() $('#app').append(error_page) app.updateBodyClass('error') }, errorHandler(error) { this.stopLoading() if (!error.response) { // TODO: probably network connection error } else if (error.response.status === 401) { // Remember requested route to come back to it after log in if (this.$route.meta.requiresAuth) { store.state.afterLogin = this.$route this.logoutUser() } else { this.logoutUser(false) } } else { this.errorPage(error.response.status, error.response.statusText) } }, downloadFile(url) { // TODO: This might not be a best way for big files as the content // will be stored (temporarily) in browser memory // TODO: This method does not show the download progress in the browser // but it could be implemented in the UI, axios has 'progress' property axios.get(url, { responseType: 'blob' }) .then(response => { const link = document.createElement('a') const contentDisposition = response.headers['content-disposition'] let filename = 'unknown' if (contentDisposition) { const match = contentDisposition.match(/filename="(.+)"/); if (match.length === 2) { filename = match[1]; } } link.href = window.URL.createObjectURL(response.data) link.download = filename link.click() }) }, price(price, currency) { // TODO: Set locale argument according to the currently used locale return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) }, - priceLabel(cost, discount) { + priceLabel(cost, discount, currency) { let index = '' if (discount) { cost = Math.floor(cost * ((100 - discount) / 100)) index = '\u00B9' } - return this.price(cost) + '/' + this.$t('wallet.month') + index + return this.price(cost, currency) + '/' + this.$t('wallet.month') + index }, clickRecord(event) { if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) { $(event.target).closest('tr').find('a').trigger('click') } }, domainStatusClass(domain) { if (domain.isDeleted) { return 'text-muted' } if (domain.isSuspended) { return 'text-warning' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'text-danger' } return 'text-success' }, domainStatusText(domain) { if (domain.isDeleted) { return this.$t('status.deleted') } if (domain.isSuspended) { return this.$t('status.suspended') } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return this.$t('status.notready') } return this.$t('status.active') }, distlistStatusClass(list) { if (list.isDeleted) { return 'text-muted' } if (list.isSuspended) { return 'text-warning' } if (!list.isLdapReady) { return 'text-danger' } return 'text-success' }, distlistStatusText(list) { if (list.isDeleted) { return this.$t('status.deleted') } if (list.isSuspended) { return this.$t('status.suspended') } if (!list.isLdapReady) { return this.$t('status.notready') } return this.$t('status.active') }, pageName(path) { let page = this.$route.path // check if it is a "menu page", find the page name // otherwise we'll use the real path as page name window.config.menu.every(item => { if (item.location == page && item.page) { page = item.page return false } }) page = page.replace(/^\//, '') return page ? page : '404' }, supportDialog(container) { let dialog = $('#support-dialog')[0] if (!dialog) { // FIXME: Find a nicer way of doing this SupportForm.i18n = i18n let form = new Vue(SupportForm) form.$mount($('
').appendTo(container)[0]) form.$root = this form.$toast = this.$toast dialog = form.$el } dialog.__vue__.showDialog() }, userStatusClass(user) { if (user.isDeleted) { return 'text-muted' } if (user.isSuspended) { return 'text-warning' } if (!user.isImapReady || !user.isLdapReady) { return 'text-danger' } return 'text-success' }, userStatusText(user) { if (user.isDeleted) { return this.$t('status.deleted') } if (user.isSuspended) { return this.$t('status.suspended') } if (!user.isImapReady || !user.isLdapReady) { return this.$t('status.notready') } return this.$t('status.active') }, updateBodyClass(name) { // Add 'class' attribute to the body, different for each page // so, we can apply page-specific styles document.body.className = 'page-' + (name || this.pageName()).replace(/\/.*$/, '') } } }) // Fetch the locale file and the start the app loadLangAsync().then(() => app.$mount('#app')) // Add a axios request interceptor window.axios.interceptors.request.use( config => { // This is the only way I found to change configuration options // on a running application. We need this for browser testing. config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider return config }, error => { // Do something with request error return Promise.reject(error) } ) // Add a axios response interceptor for general/validation error handler window.axios.interceptors.response.use( response => { if (response.config.onFinish) { response.config.onFinish() } return response }, error => { let error_msg let status = error.response ? error.response.status : 200 // Do not display the error in a toast message, pass the error as-is if (error.config.ignoreErrors) { return Promise.reject(error) } if (error.config.onFinish) { error.config.onFinish() } if (error.response && status == 422) { error_msg = "Form validation error" const modal = $('div.modal.show') $(modal.length ? modal : 'form').each((i, form) => { form = $(form) $.each(error.response.data.errors || {}, (idx, msg) => { const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx let input = form.find('#' + input_name) if (!input.length) { input = form.find('[name="' + input_name + '"]'); } if (input.length) { // Create an error message // API responses can use a string, array or object let msg_text = '' if (typeof(msg) !== 'string') { $.each(msg, (index, str) => { msg_text += str + ' ' }) } else { msg_text = msg } let feedback = $('
').text(msg_text) if (input.is('.list-input')) { // List input widget let controls = input.children(':not(:first-child)') if (!controls.length && typeof msg == 'string') { // this is an empty list (the main input only) // and the error message is not an array input.find('.main-input').addClass('is-invalid') } else { controls.each((index, element) => { if (msg[index]) { $(element).find('input').addClass('is-invalid') } }) } input.addClass('is-invalid').next('.invalid-feedback').remove() input.after(feedback) } else { // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() input.parent().append(feedback) } } }) form.find('.is-invalid:not(.listinput-widget)').first().focus() }) } else if (error.response && error.response.data) { error_msg = error.response.data.message } else { error_msg = error.request ? error.request.statusText : error.message } app.$toast.error(error_msg || app.$t('error.server')) // Pass the error as-is return Promise.reject(error) } ) diff --git a/src/resources/js/meet/app.js b/src/resources/js/meet/app.js index 1ce44d8b..91741595 100644 --- a/src/resources/js/meet/app.js +++ b/src/resources/js/meet/app.js @@ -1,1726 +1,1726 @@ import anchorme from 'anchorme' import { Dropdown } from 'bootstrap' import { library } from '@fortawesome/fontawesome-svg-core' import { OpenVidu } from 'openvidu-browser' class Roles { static get SUBSCRIBER() { return 1 << 0; } static get PUBLISHER() { return 1 << 1; } static get MODERATOR() { return 1 << 2; } static get SCREEN() { return 1 << 3; } static get OWNER() { return 1 << 4; } } // Disable jsnlog's error handlers added in OpenVidu 2.18 // https://github.com/OpenVidu/openvidu/issues/631 window.onerror = () => { return false } window.onunhandledrejection = () => { return false } function Meet(container) { let OV // OpenVidu object to initialize a session let session // Session object where the user will connect let publisher // Publisher object which the user will publish let audioActive = false // True if the audio track of the publisher is active let videoActive = false // True if the video track of the publisher is active let audioSource = '' // Currently selected microphone let videoSource = '' // Currently selected camera let sessionData // Room session metadata let screenOV // OpenVidu object to initialize a screen sharing session let screenSession // Session object where the user will connect for screen sharing let screenPublisher // Publisher object which the user will publish the screen sharing let publisherDefaults = { publishAudio: true, // Whether to start publishing with your audio unmuted or not publishVideo: true, // Whether to start publishing with your video enabled or not resolution: '640x480', // The resolution of your video frameRate: 30, // The frame rate of your video mirror: true // Whether to mirror your local video or not } let cameras = [] // List of user video devices let microphones = [] // List of user audio devices let connections = {} // Connected users in the session let chatCount = 0 let volumeElement let publishersContainer let subscribersContainer let scrollStop let $t OV = ovInit() // 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.setupStart = setupStart this.setupStop = setupStop this.setupSetAudioDevice = setupSetAudioDevice this.setupSetVideoDevice = setupSetVideoDevice this.switchAudio = switchAudio this.switchChannel = switchChannel this.switchScreen = switchScreen this.switchVideo = switchVideo this.updateSession = updateSession /** * Initialize OpenVidu instance */ function ovInit() { let ov = new OpenVidu() // If there's anything to do, do it here. //ov.setAdvancedConfiguration(config) // Disable all logging except errors // ov.enableProdMode() return ov } /** * Join the room session * * @param data Session metadata and event handlers: * token - OpenVidu token for the main connection, * shareToken - OpenVidu token for screen-sharing connection, * nickname - Participant name, * role - connection (participant) role(s), * connections - Optional metadata for other users connections (current state), * channel - Selected interpreted language channel (two-letter language code) * 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, * onDismiss - Callback for Dismiss action, * onJoinRequest - Callback for join request, * onConnectionChange - Callback for participant changes, e.g. role update, * onSessionDataUpdate - Callback for current user connection update, * onMediaSetup - Called when user clicks the Media setup button * translate - Translation function */ function joinRoom(data) { // Create a container for subscribers and publishers publishersContainer = $('
').appendTo(container).get(0) subscribersContainer = $('
').appendTo(container).get(0) resize(); volumeMeterStop() data.params = { nickname: data.nickname, // user nickname // avatar: undefined // avatar image } $t = data.translate // Make sure all supported callbacks exist, so we don't have to check // their existence everywhere anymore let events = ['Success', 'Error', 'Destroy', 'Dismiss', 'JoinRequest', 'ConnectionChange', 'SessionDataUpdate', 'MediaSetup'] events.map(event => 'on' + event).forEach(event => { if (!data[event]) { data[event] = () => {} } }) sessionData = data // Init a session session = OV.initSession() // Handle connection creation events session.on('connectionCreated', event => { // Ignore the current user connection if (event.connection.role) { return } // This is the first event executed when a user joins in. // We'll create the video wrapper here, which can be re-used // in 'streamCreated' event handler. let metadata = connectionData(event.connection) const connId = metadata.connectionId // The connection metadata here is the initial metadata set on // connection initialization. There's no way to update it via OpenVidu API. // So, we merge the initial connection metadata with up-to-dated one that // we got from our database. if (sessionData.connections && connId in sessionData.connections) { Object.assign(metadata, sessionData.connections[connId]) } metadata.element = participantCreate(metadata) connections[connId] = metadata }) session.on('connectionDestroyed', event => { let connectionId = event.connection.connectionId let conn = connections[connectionId] if (conn) { // Remove elements related to the participant connectionHandDown(connectionId) $(conn.element).remove() delete connections[connectionId] } resize() }) // On every new Stream received... session.on('streamCreated', event => { let connectionId = event.stream.connection.connectionId let metadata = connections[connectionId] let props = { // Prepend the video element so it is always before the watermark element insertMode: 'PREPEND' } // Subscribe to the Stream to receive it let subscriber = session.subscribe(event.stream, metadata.element, props); Object.assign(metadata, { audioActive: event.stream.audioActive, videoActive: event.stream.videoActive, videoDimensions: event.stream.videoDimensions }) subscriber.on('videoElementCreated', event => { $(event.element).prop({ tabindex: -1 }) resize() }) // Update the wrapper controls/status participantUpdate(metadata.element, metadata) // Send the current user status to the connecting user // otherwise e.g. nickname might be not up to date signalUserUpdate(event.stream.connection) }) // Stream properties changes e.g. audio/video muted/unmuted session.on('streamPropertyChanged', event => { let connectionId = event.stream.connection.connectionId let metadata = connections[connectionId] if (session.connection.connectionId == connectionId) { metadata = sessionData metadata.audioActive = audioActive metadata.videoActive = videoActive } if (metadata) { metadata[event.changedProperty] = event.newValue if (event.changedProperty == 'videoDimensions') { resize() } else { participantUpdate(metadata.element, metadata) } } }) // Handle session disconnection events session.on('sessionDisconnected', event => { data.onDestroy(event) session = null resize() }) // Handle signals from all participants session.on('signal', signalEventHandler) // Connect with the token session.connect(data.token, data.params) .then(() => { data.onSuccess() let params = { connectionId: session.connection.connectionId, role: data.role, audioActive, videoActive } params = Object.assign({}, data.params, params) publisher.on('videoElementCreated', event => { $(event.element).prop({ muted: true, // Mute local video to avoid feedback disablePictureInPicture: true, // this does not work in Firefox tabindex: -1 }) resize() }) let wrapper = participantCreate(params) if (data.role & Roles.PUBLISHER) { publisher.createVideoElement(wrapper, 'PREPEND') session.publish(publisher) } sessionData.element = wrapper // Create Q&A queue from the existing connections with rised hand. // Here we expect connections in a proper queue order Object.keys(data.connections || {}).forEach(key => { let conn = data.connections[key] if (conn.hand) { conn.connectionId = key connectionHandUp(conn) } }) sessionData.channels = getChannels(data.connections) // Inform the vue component, so it can update some UI controls if (sessionData.channels.length) { sessionData.onSessionDataUpdate(sessionData) } }) .catch(error => { console.error('There was an error connecting to the session: ', error.message); data.onError(error) }) // Prepare the chat setupChat() } /** * Leave the room (disconnect) */ function leaveRoom() { if (publisher) { volumeMeterStop() // Release any media let mediaStream = publisher.stream.getMediaStream() if (mediaStream) { mediaStream.getTracks().forEach(track => track.stop()) } publisher = null } if (session) { session.disconnect(); session = null } if (screenSession) { screenSession.disconnect(); screenSession = null } } /** * 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) { // Note: After changing media permissions in Chrome/Firefox a page refresh is required. // That means that in a scenario where you first blocked access to media devices // and then allowed it we can't ask for devices list again and expect a different // result than before. // That's why we do not bother, and return ealy when we open the media setup dialog. if (publisher) { volumeMeterStart() return } publisher = OV.initPublisher(undefined, publisherDefaults) publisher.once('accessDenied', error => { props.onError(error) }) publisher.once('accessAllowed', async () => { let mediaStream = publisher.stream.getMediaStream() let videoStream = mediaStream.getVideoTracks()[0] let audioStream = mediaStream.getAudioTracks()[0] audioActive = !!audioStream videoActive = !!videoStream volumeElement = props.volumeElement publisher.addVideoElement(props.videoElement) volumeMeterStart() const devices = await OV.getDevices() devices.forEach(device => { // device's props: deviceId, kind, label if (device.kind == 'videoinput') { cameras.push(device) if (videoStream && videoStream.label == device.label) { videoSource = device.deviceId } } else if (device.kind == 'audioinput') { microphones.push(device) if (audioStream && audioStream.label == device.label) { audioSource = device.deviceId } } }) props.onSuccess({ microphones, cameras, audioSource, videoSource, audioActive, videoActive }) }) } /** * Stop the setup "process", cleanup after it. */ function setupStop() { volumeMeterStop() } /** * Change the publisher audio device * * @param deviceId Device identifier string */ async function setupSetAudioDevice(deviceId) { if (!deviceId) { publisher.publishAudio(false) volumeMeterStop() audioActive = false } else if (deviceId == audioSource) { publisher.publishAudio(true) volumeMeterStart() audioActive = true } else { const mediaStream = publisher.stream.mediaStream const properties = Object.assign({}, publisherDefaults, { publishAudio: true, publishVideo: videoActive, audioSource: deviceId, videoSource: videoSource }) volumeMeterStop() // Stop and remove the old track, otherwise you get "Concurrent mic process limit." error mediaStream.getAudioTracks().forEach(track => { track.stop() mediaStream.removeTrack(track) }) // TODO: Handle errors await OV.getUserMedia(properties) .then(async (newMediaStream) => { await replaceTrack(newMediaStream.getAudioTracks()[0]) volumeMeterStart() audioActive = true audioSource = deviceId }) } return audioActive } /** * Change the publisher video device * * @param deviceId Device identifier string */ async function setupSetVideoDevice(deviceId) { if (!deviceId) { publisher.publishVideo(false) videoActive = false } else if (deviceId == videoSource) { publisher.publishVideo(true) videoActive = true } else { const mediaStream = publisher.stream.mediaStream const properties = Object.assign({}, publisherDefaults, { publishAudio: audioActive, publishVideo: true, audioSource: audioSource, videoSource: deviceId }) volumeMeterStop() // Stop and remove the old track, otherwise you get "Concurrent mic process limit." error mediaStream.getVideoTracks().forEach(track => { track.stop() mediaStream.removeTrack(track) }) // TODO: Handle errors await OV.getUserMedia(properties) .then(async (newMediaStream) => { await replaceTrack(newMediaStream.getVideoTracks()[0]) volumeMeterStart() videoActive = true videoSource = deviceId }) } return videoActive } /** * A way to switch tracks in a stream. * Note: This is close to what publisher.replaceTrack() does but it does not * require the session. * Note: The old track needs to be removed before OV.getUserMedia() call, * otherwise we get "Concurrent mic process limit" error. */ function replaceTrack(track) { const stream = publisher.stream const replaceMediaStreamTrack = () => { stream.mediaStream.addTrack(track); if (session) { session.sendVideoData(publisher.stream.streamManager, 5, true, 5); } } // Fix a bug in Chrome where you would start hearing yourself after audio device change // https://github.com/OpenVidu/openvidu/issues/449 publisher.videoReference.muted = true return new Promise((resolve, reject) => { if (stream.isLocalStreamPublished) { // Only if the Publisher has been published it is necessary to call the native // Web API RTCRtpSender.replaceTrack() const senders = stream.getRTCPeerConnection().getSenders() let sender if (track.kind === 'video') { sender = senders.find(s => !!s.track && s.track.kind === 'video') } else { sender = senders.find(s => !!s.track && s.track.kind === 'audio') } if (!sender) return sender.replaceTrack(track).then(() => { replaceMediaStreamTrack() resolve() }).catch(error => { reject(error) }) } else { // Publisher not published. Simply modify local MediaStream tracks replaceMediaStreamTrack() resolve() } }) } /** * Setup the chat UI */ function setupChat() { // 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) { signalChat(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 }) } /** * Signal events handler */ function signalEventHandler(signal) { let conn, data let connId = signal.from ? signal.from.connectionId : null switch (signal.type) { case 'signal:userChanged': // TODO: Use 'signal:connectionUpdate' for nickname updates? if (conn = connections[connId]) { data = JSON.parse(signal.data) conn.nickname = data.nickname participantUpdate(conn.element, conn) nicknameUpdate(data.nickname, connId) } break case 'signal:chat': data = JSON.parse(signal.data) data.id = connId pushChatMessage(data) break case 'signal:joinRequest': // accept requests from the server only if (!connId) { sessionData.onJoinRequest(JSON.parse(signal.data)) } break case 'signal:connectionUpdate': // accept requests from the server only if (!connId) { data = JSON.parse(signal.data) connectionUpdate(data) } break } } /** * Send the chat message to other participants * * @param message Message string */ function signalChat(message) { let data = { nickname: sessionData.params.nickname, message } session.signal({ data: JSON.stringify(data), type: 'chat' }) } /** * 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 isSelf = data.id == session.connectionId 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.id) { // A message from the same user as the last message, no new box needed message.appendTo(box) } else { box = $('
').data('id', data.id) .append($('
').text(data.nickname || '')) .append(message) .appendTo(chat) if (isSelf) { box.addClass('self') } } // Count unread messages if (!$(sessionData.chatElement).is('.open')) { if (!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 } } /** * Send the user properties update signal to other participants * * @param connection Optional connection to which the signal will be sent * If not specified the signal is sent to all participants */ function signalUserUpdate(connection) { let data = { nickname: sessionData.params.nickname } session.signal({ data: JSON.stringify(data), type: 'userChanged', to: connection ? [connection] : undefined }) // The same nickname for screen sharing session if (screenSession) { screenSession.signal({ data: JSON.stringify(data), type: 'userChanged', to: connection ? [connection] : undefined }) } } /** * Switch interpreted language channel * * @param channel Two-letter language code */ function switchChannel(channel) { sessionData.channel = channel // Mute/unmute all connections depending on the selected channel participantUpdateAll() } /** * Mute/Unmute audio for current session publisher */ function switchAudio() { // TODO: If user has no devices or denied access to them in the setup, // the button will just not work. Find a way to make it working // after user unlocks his devices. For now he has to refresh // the page and join the room again. if (microphones.length) { try { publisher.publishAudio(!audioActive) audioActive = !audioActive } catch (e) { console.error(e) } } return audioActive } /** * Mute/Unmute video for current session publisher */ function switchVideo() { // TODO: If user has no devices or denied access to them in the setup, // the button will just not work. Find a way to make it working // after user unlocks his devices. For now he has to refresh // the page and join the room again. if (cameras.length) { try { publisher.publishVideo(!videoActive) videoActive = !videoActive } catch (e) { console.error(e) } } return videoActive } /** * Switch on/off screen sharing */ function switchScreen(callback) { if (screenPublisher) { // Note: This is what the original openvidu-call app does. // It is probably better for performance reasons to close the connection, // than to use unpublish() and keep the connection open. screenSession.disconnect() screenSession = null screenPublisher = null if (callback) { // Note: Disconnecting invalidates the token, we have to inform the vue component // to update UI state (and be prepared to request a new token). callback(false) } return } screenConnect(callback) } /** * Detect if screen sharing is supported by the browser */ function isScreenSharingSupported() { return !!OV.checkScreenSharingCapabilities(); } /** * Update participant connection state */ function connectionUpdate(data) { let conn = connections[data.connectionId] let refresh = false let handUpdate = conn => { if ('hand' in data && data.hand != conn.hand) { if (data.hand) { connectionHandUp(conn) } else { connectionHandDown(data.connectionId) } } } // It's me if (session.connection.connectionId == data.connectionId) { const rolePublisher = data.role && data.role & Roles.PUBLISHER const roleModerator = data.role && data.role & Roles.MODERATOR const isPublisher = sessionData.role & Roles.PUBLISHER const isModerator = sessionData.role & Roles.MODERATOR // demoted to a subscriber if ('role' in data && isPublisher && !rolePublisher) { session.unpublish(publisher) // FIXME: There's a reference in OpenVidu to a video element that should not // exist anymore. It causes issues when we try to do publish/unpublish // sequence multiple times in a row. So, we're clearing the reference here. let videos = publisher.stream.streamManager.videos publisher.stream.streamManager.videos = videos.filter(video => video.video.parentNode != null) } handUpdate(sessionData) // merge the changed data into internal session metadata object sessionData = Object.assign({}, sessionData, data, { audioActive, videoActive }) // update the participant element sessionData.element = participantUpdate(sessionData.element, sessionData) // promoted/demoted to/from a moderator if ('role' in data) { // Update all participants, to enable/disable the popup menu refresh = (!isModerator && roleModerator) || (isModerator && !roleModerator) } // promoted to a publisher if ('role' in data && !isPublisher && rolePublisher) { publisher.createVideoElement(sessionData.element, 'PREPEND') session.publish(publisher).then(() => { sessionData.audioActive = publisher.stream.audioActive sessionData.videoActive = publisher.stream.videoActive sessionData.onSessionDataUpdate(sessionData) }) // Open the media setup dialog // Note: If user didn't give permission to media before joining the room // he will not be able to use them now. Changing permissions requires // a page refresh. // Note: In Firefox I'm always being asked again for media permissions. // It does not happen in Chrome. In Chrome the cam/mic will be just re-used. // I.e. streaming starts automatically. // It might make sense to not start streaming automatically in any cirmustances, // display the dialog and wait until user closes it, but this would be // a bigger refactoring. sessionData.onMediaSetup() } } else if (conn) { handUpdate(conn) // merge the changed data into internal session metadata object Object.keys(data).forEach(key => { conn[key] = data[key] }) conn.element = participantUpdate(conn.element, conn) } // Update channels list sessionData.channels = getChannels(connections) // The channel user was using has been removed (or rather the participant stopped being an interpreter) if (sessionData.channel && !sessionData.channels.includes(sessionData.channel)) { sessionData.channel = null refresh = true } if (refresh) { participantUpdateAll() } // Inform the vue component, so it can update some UI controls sessionData.onSessionDataUpdate(sessionData) } /** * Handler for Hand-Up "signal" */ function connectionHandUp(connection) { connection.isSelf = session.connection.connectionId == connection.connectionId let element = $(nicknameWidget(connection)) participantUpdate(element, connection) element.attr('id', 'qa' + connection.connectionId) .appendTo($(sessionData.queueElement).show()) setTimeout(() => element.addClass('widdle'), 50) } /** * Handler for Hand-Down "signal" */ function connectionHandDown(connectionId) { let list = $(sessionData.queueElement) list.find('#qa' + connectionId).remove(); if (!list.find('.meet-nickname').length) { list.hide(); } } /** * Update participant nickname in the UI * * @param nickname Nickname * @param connectionId Connection identifier of the user */ function nicknameUpdate(nickname, connectionId) { if (connectionId) { $(sessionData.chatElement).find('.chat').find('.message').each(function() { let elem = $(this) if (elem.data('id') == connectionId) { elem.find('.nickname').text(nickname || '') } }) $(sessionData.queueElement).find('#qa' + connectionId + ' .content').text(nickname || '') } } /** * Create a participant element in the matrix. Depending on the connection role * parameter it will be a video element wrapper inside the matrix or a simple * tag-like element on the subscribers list. * * @param params Connection metadata/params * @param content Optional content to prepend to the element * * @return The element */ function participantCreate(params, content) { let element params.isSelf = params.isSelf || session.connection.connectionId == params.connectionId 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