'use strict'; /** * Copyright 2009 Google Inc., 2011 Peter 'Pita' Martischka (Primary Technology Ltd) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS-IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const ChatMessage = require('./ChatMessage'); const padutils = require('./pad_utils').padutils; const padcookie = require('./pad_cookie').padcookie; const Tinycon = require('tinycon/tinycon'); const hooks = require('./pluginfw/hooks'); const padeditor = require('./pad_editor').padeditor; // Removes diacritics and lower-cases letters. https://stackoverflow.com/a/37511463 const normalize = (s) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); const chat = (() => { let isStuck = false; let userAndChat = false; let chatMentions = 0; return { show() { $('#chaticon').removeClass('visible'); $('#chatbox').addClass('visible'); this.scrollDown(true); chatMentions = 0; Tinycon.setBubble(0); $('.chat-gritter-msg').each(function () { $.gritter.remove(this.id); }); }, focus: () => { setTimeout(() => { $('#chatinput').focus(); }, 100); }, // Make chat stick to right hand side of screen stickToScreen(fromInitialCall) { if (pad.settings.hideChat) { return; } this.show(); isStuck = (!isStuck || fromInitialCall); $('#chatbox').hide(); // Add timeout to disable the chatbox animations setTimeout(() => { $('#chatbox, .sticky-container').toggleClass('stickyChat', isStuck); $('#chatbox').css('display', 'flex'); }, 0); padcookie.setPref('chatAlwaysVisible', isStuck); $('#options-stickychat').prop('checked', isStuck); }, chatAndUsers(fromInitialCall) { const toEnable = $('#options-chatandusers').is(':checked'); if (toEnable || !userAndChat || fromInitialCall) { this.stickToScreen(true); $('#options-stickychat').prop('checked', true); $('#options-chatandusers').prop('checked', true); $('#options-stickychat').prop('disabled', 'disabled'); userAndChat = true; } else { $('#options-stickychat').prop('disabled', false); userAndChat = false; } padcookie.setPref('chatAndUsers', userAndChat); $('#users, .sticky-container') .toggleClass('chatAndUsers popup-show stickyUsers', userAndChat); $('#chatbox').toggleClass('chatAndUsersChat', userAndChat); }, reduce() { // decide on hide logic based on chat window being maximized or not if ($('#options-stickychat').prop('checked')) { this.stickToScreen(); $('#options-stickychat').prop('checked', false); } else { $('#chatcounter').text('0'); // It usually is not necessary to call .show() because .hide() is only normally called when // the pad is loaded and showChat=false. When showChat=false, there are no chat UI elements // so there's nothing to click on to get the chatbox to display. However, there are other // ways to get the chatbox to display: // * A plugin might call `chat.show()`. // * The user can hit Alt-C (assuming the shortcut is enabled). // * The user can run `chat.show()` in the developer console. // In all cases, reducing the shown chatbox should cause it to minimize to an icon, not // vanish completely. $('#chaticon').show(); $('#chaticon').addClass('visible'); $('#chatbox').removeClass('visible'); } }, minimize() { if ($('#options-stickychat').prop('checked')) this.reduce(); this.reduce(); }, hide() { this.minimize(); $('#chaticon').hide(); }, scrollDown(force) { if ($('#chatbox').hasClass('visible')) { if (force || !this.lastMessage || !this.lastMessage.position() || this.lastMessage.position().top < ($('#chattext').outerHeight() + 20)) { // if we use a slow animate here we can have a race condition // when a users focus can not be moved away from the last message recieved. $('#chattext').animate( {scrollTop: $('#chattext')[0].scrollHeight}, {duration: 400, queue: false}); this.lastMessage = $('#chattext > p').eq(-1); } } }, async send() { const text = $('#chatinput').val(); if (text.replace(/\s+/, '').length === 0) return; const message = new ChatMessage(text); await hooks.aCallAll('chatSendMessage', Object.freeze({message})); this._pad.collabClient.sendMessage({type: 'CHAT_MESSAGE', message}); $('#chatinput').val(''); }, async addMessage(msg, increment, isHistoryAdd) { msg = ChatMessage.fromObject(msg); // correct the time msg.time += this._pad.clientTimeOffset; if (!msg.authorId) { /* * If, for a bug or a database corruption, the message coming from the * server does not contain the authorId field (see for example #3731), * let's be defensive and replace it with "unknown". */ msg.authorId = 'unknown'; console.warn( 'The "authorId" field of a chat message coming from the server was not present. ' + 'Replacing with "unknown". This may be a bug or a database corruption.'); } const authorClass = (authorId) => `author-${authorId.replace(/[^a-y0-9]/g, (c) => { if (c === '.') return '-'; return `z${c.charCodeAt(0)}z`; })}`; // the hook args const ctx = { authorName: msg.displayName != null ? msg.displayName : html10n.get('pad.userlist.unnamed'), author: msg.authorId, text: padutils.escapeHtmlWithClickableLinks(msg.text, '_blank'), message: msg, rendered: null, sticky: false, timestamp: msg.time, timeStr: (() => { let minutes = `${new Date(msg.time).getMinutes()}`; let hours = `${new Date(msg.time).getHours()}`; if (minutes.length === 1) minutes = `0${minutes}`; if (hours.length === 1) hours = `0${hours}`; return `${hours}:${minutes}`; })(), duration: 4000, }; // is the users focus already in the chatbox? const alreadyFocused = $('#chatinput').is(':focus'); // does the user already have the chatbox open? const chatOpen = $('#chatbox').hasClass('visible'); // does this message contain this user's name? (is the current user mentioned?) const wasMentioned = msg.authorId !== window.clientVars.userId && ctx.authorName !== html10n.get('pad.userlist.unnamed') && normalize(ctx.text).includes(normalize(ctx.authorName)); // If the user was mentioned, make the message sticky if (wasMentioned && !alreadyFocused && !isHistoryAdd && !chatOpen) { chatMentions++; Tinycon.setBubble(chatMentions); ctx.sticky = true; } await hooks.aCallAll('chatNewMessage', ctx); const cls = authorClass(ctx.author); const chatMsg = ctx.rendered != null ? $(ctx.rendered) : $('

') .attr('data-authorId', ctx.author) .addClass(cls) .append($('').text(`${ctx.authorName}:`)) .append($('') .addClass('time') .addClass(cls) // Hook functions are trusted to not introduce an XSS vulnerability by adding // unescaped user input to ctx.timeStr. .html(ctx.timeStr)) .append(' ') // ctx.text was HTML-escaped before calling the hook. Hook functions are trusted to not // introduce an XSS vulnerability by adding unescaped user input. .append($('

').html(ctx.text).contents()); if (isHistoryAdd) chatMsg.insertAfter('#chatloadmessagesbutton'); else $('#chattext').append(chatMsg); chatMsg.each((i, e) => html10n.translateElement(html10n.translations, e)); // should we increment the counter?? if (increment && !isHistoryAdd) { // Update the counter of unread messages let count = Number($('#chatcounter').text()); count++; $('#chatcounter').text(count); if (!chatOpen && ctx.duration > 0) { const text = $('

') .append($('').addClass('author-name').text(ctx.authorName)) // ctx.text was HTML-escaped before calling the hook. Hook functions are trusted // to not introduce an XSS vulnerability by adding unescaped user input. .append($('

').html(ctx.text).contents()); text.each((i, e) => html10n.translateElement(html10n.translations, e)); $.gritter.add({ text, sticky: ctx.sticky, time: ctx.duration, position: 'bottom', class_name: 'chat-gritter-msg', }); } } if (!isHistoryAdd) this.scrollDown(); }, init(pad) { this._pad = pad; $('#options-stickychat').on('click', () => this.stickToScreen()); $('#options-chatandusers').on('click', () => this.chatAndUsers()); $('#chaticon').on('click', () => { this.show(); return false; }); $('#titlecross').on('click', () => { this.reduce(); return false; }); $('#titlesticky').on('click', () => { this.stickToScreen(true); return false; }); $('#chatinput').on('keydown', (evt) => { // If the event is Alt C or Escape & we're already in the chat menu // Send the users focus back to the pad if ((evt.altKey === true && evt.which === 67) || evt.which === 27) { // If we're in chat already.. $(':focus').blur(); // required to do not try to remove! padeditor.ace.focus(); // Sends focus back to pad evt.preventDefault(); return false; } }); // Clear the chat mentions when the user clicks on the chat input box $('#chatinput').click(() => { chatMentions = 0; Tinycon.setBubble(0); }); $('#chatinput').keypress((evt) => { // if the user typed enter, fire the send if (evt.key === 'Enter' && !evt.shiftKey) { evt.preventDefault(); this.send(); } }); // initial messages are loaded in pad.js' _afterHandshake $('#chatcounter').text(0); $('#chatloadmessagesbutton').click(() => { const start = Math.max(this.historyPointer - 20, 0); const end = this.historyPointer; if (start === end) return; // nothing to load $('#chatloadmessagesbutton').css('display', 'none'); $('#chatloadmessagesball').css('display', 'block'); pad.collabClient.sendMessage({type: 'GET_CHAT_MESSAGES', start, end}); this.historyPointer = start; }); const {searchParams} = new URL(window.location.href); const {showChat = true, alwaysShowChat = false, chatAndUsers = false} = clientVars.padOptions; const settings = this._pad.settings; settings.hideChat = showChat.toString() === 'false'; if (settings.hideChat) this.hide(); if (alwaysShowChat.toString() === 'true' && !settings.hideChat) this.stickToScreen(); if (chatAndUsers.toString() === 'true') this.chatAndUsers(); settings.hideChat = searchParams.get('showChat') === 'false'; if (settings.hideChat) this.hide(); if (searchParams.get('alwaysShowChat') === 'true' && !settings.hideChat) this.stickToScreen(); if (searchParams.get('chatAndUsers') === 'true') this.chatAndUsers(); const chatVisCookie = !!padcookie.getPref('chatAlwaysVisible'); if (chatVisCookie) this.stickToScreen(true); $('#options-stickychat').prop('checked', chatVisCookie); const chatAUVisCookie = !!padcookie.getPref('chatAndUsersVisible'); if (chatAUVisCookie) this.chatAndUsers(true); $('#options-chatandusers').prop('checked', chatAUVisCookie); }, }; })(); Object.defineProperty(exports, 'chat', { get: () => { padutils.warnDeprecated( 'chat.chat is deprecated and will be removed in a future version of Etherpad'); return chat; }, }); exports.aceKeyEvent = (hookName, {evt}) => { const {altC} = window.clientVars.padShortcutEnabled; if (evt.type !== 'keydown' || !evt.altKey || evt.keyCode !== 67 || !altC) return; evt.target.blur(); chat.show(); chat.focus(); evt.preventDefault(); return true; }; exports.handleClientMessage_CHAT_MESSAGE = (hookName, {msg}) => { chat.addMessage(msg.message, true, false); }; exports.handleClientMessage_CHAT_MESSAGES = (hookName, {msg}) => { for (let i = msg.messages.length - 1; i >= 0; i--) { chat.addMessage(msg.messages[i], true, true); } if (!chat.gotInitalMessages) { chat.scrollDown(); chat.gotInitalMessages = true; chat.historyPointer = clientVars.chatHead - msg.messages.length; } // messages are loaded, so hide the loading-ball $('#chatloadmessagesball').css('display', 'none'); // there are less than 100 messages or we reached the top if (chat.historyPointer <= 0) { $('#chatloadmessagesbutton').css('display', 'none'); } else { // there are still more messages, re-show the load-button $('#chatloadmessagesbutton').css('display', 'block'); } }; exports.postAceInit = async (hookName, {clientVars, pad}) => { chat.init(pad); if (padcookie.getPref('chatAlwaysVisible')) { chat.stickToScreen(true); $('#options-stickychat').prop('checked', true); } if (padcookie.getPref('chatAndUsers')) { chat.chatAndUsers(true); $('#options-chatandusers').prop('checked', true); } // Prevent sticky chat or chat and users to be checked for mobiles const checkChatAndUsersVisibility = (x) => { if (!x.matches) return; $('#options-chatandusers:checked').click(); $('#options-stickychat:checked').click(); }; const mobileMatch = window.matchMedia('(max-width: 800px)'); mobileMatch.addListener(checkChatAndUsersVisibility); setTimeout(() => { checkChatAndUsersVisibility(mobileMatch); }, 0); if (clientVars.chatHead !== -1) { const chatHead = clientVars.chatHead; const start = Math.max(chatHead - 100, 0); pad.collabClient.sendMessage({type: 'GET_CHAT_MESSAGES', start, end: chatHead}); } else { $('#chatloadmessagesbutton').css('display', 'none'); } if (clientVars.readonly) { chat.hide(); $('#chatinput').attr('disabled', true); $('#options-chatandusers').parent().hide(); $('#options-stickychat').parent().hide(); } else if (!pad.settings.hideChat) { $('#chaticon').show(); } };