// site-chat.js // Copyright (C) 2022 DTP Technologies, LLC // License: Apache-2.0 'use strict'; const DTP_COMPONENT = { name: 'Site Chat', slug: 'site-chat' }; const dtp = window.dtp = window.dtp || { }; // jshint ignore:line const EMOJI_EXPLOSION_DURATION = 8000; const EMOJI_EXPLOSION_INTERVAL = 100; import DtpLog from 'dtp/dtp-log.js'; import SiteReactions from './site-reactions.js'; export default class SiteChat { constructor (app) { this.app = app; this.log = new DtpLog(DTP_COMPONENT); this.ui = { form: document.querySelector('#chat-input-form'), messageList: document.querySelector('#chat-message-list'), messages: [ ], messageMenu: document.querySelector('.chat-message-menu'), input: document.querySelector('#chat-input-text'), isAtBottom: true, isModifying: false, }; if (this.ui.messageList) { this.ui.messageList.addEventListener('scroll', this.onChatMessageListScroll.bind(this)); this.updateTimestamps(); setTimeout(( ) => { this.log.info('constructor', 'scrolling chat', { top: this.ui.messageList.scrollHeight }); this.ui.messageList.scrollTo({ top: this.ui.messageList.scrollHeight, behavior: 'instant' }); }, 100); this.ui.reactions = new SiteReactions(); this.lastReaction = new Date(); } if (this.ui.input) { this.ui.input.addEventListener('keydown', this.onChatInputKeyDown.bind(this)); } this.lastReaction = new Date(); if (window.localStorage) { this.mutedUsers = window.localStorage.mutedUsers ? JSON.parse(window.localStorage.mutedUsers) : [ ]; this.filterChatView(); } } async filterChatView ( ) { this.mutedUsers.forEach((block) => { document.querySelectorAll(`.chat-message[data-author-id="${block.userId}"]`).forEach((message) => { message.parentElement.removeChild(message); }); }); } async toggleChatInput (event) { event.preventDefault(); event.stopPropagation(); this.ui.input.toggleAttribute('hidden'); if (this.ui.input.getAttribute('hidden')) { this.ui.input.focus(); } return true; } async openChatInput ( ) { if (this.ui.input.hasAttribute('hidden')) { this.ui.input.removeAttribute('hidden'); } return true; } async onChatInputKeyDown (event) { if (event.key === 'Enter' && !event.shiftKey) { return this.sendUserChat(event); } } async onChatMessageListScroll (/* event */) { const prevBottom = this.ui.isAtBottom; const scrollPos = this.ui.messageList.scrollTop + this.ui.messageList.clientHeight; this.ui.isAtBottom = scrollPos >= (this.ui.messageList.scrollHeight - 8); if (this.ui.isAtBottom !== prevBottom) { this.log.info('onChatMessageListScroll', 'at-bottom status change', { atBottom: this.ui.isAtBottom }); if (this.ui.isAtBottom) { this.ui.messageMenu.classList.remove('chat-menu-visible'); } else { this.ui.messageMenu.classList.add('chat-menu-visible'); } } } async resumeChatScroll ( ) { this.ui.messageList.scrollTop = this.ui.messageList.scrollHeight; } async sendUserChat (event) { event.preventDefault(); if (!dtp.room || !dtp.room._id) { UIkit.modal.alert('There is a problem with Chat. Please refresh the page.'); return; } const roomId = dtp.room._id; const content = this.ui.input.value; this.ui.input.value = ''; if (content.length === 0) { return true; } this.log.info('sendUserChat', 'sending chat message', { roomId, content }); this.app.socket.emit('user-chat', { channelType: 'ChatRoom', channel: roomId, content, }); // set focus back to chat input this.ui.input.focus(); return true; } async sendReaction (event) { const NOW = new Date(); if (NOW - this.lastReaction < 1000) { return; } this.lastReaction = NOW; const target = event.currentTarget || event.target; if (!target) { return; } const reaction = target.getAttribute('data-reaction'); this.log.info('sendReaction', 'sending user reaction', { reaction }); this.app.socket.emit('user-react', { subjectType: 'ChatRoom', subject: dtp.room._id, reaction, }); } async appendUserChat (message) { const isAtBottom = this.ui.isAtBottom; this.log.info('appendUserChat', 'message received', { user: message.user, content: message.content }); if (this.mutedUsers.find((block) => block.userId === message.user._id)) { this.log.info('appendUserChat', 'message is from blocked user', { _id: message.user._id, username: message.user.username, }); return; // sender is blocked by local user on this device } const chatMessage = document.createElement('div'); chatMessage.setAttribute('data-message-id', message._id); chatMessage.setAttribute('data-author-id', message.user._id); chatMessage.classList.add('uk-margin-small'); chatMessage.classList.add('chat-message'); const userGrid = document.createElement('div'); userGrid.setAttribute('uk-grid', ''); userGrid.classList.add('uk-grid-small'); userGrid.classList.add('uk-flex-middle'); chatMessage.appendChild(userGrid); const usernameColumn = document.createElement('div'); usernameColumn.classList.add('uk-width-expand'); userGrid.appendChild(usernameColumn); const chatUser = document.createElement('div'); const authorName = message.user.displayName || message.user.username; chatUser.classList.add('uk-text-small'); chatUser.classList.add('chat-username'); chatUser.textContent = authorName; usernameColumn.appendChild(chatUser); if (message.user.picture && message.user.picture.small) { const chatUserPictureColumn = document.createElement('div'); chatUserPictureColumn.classList.add('uk-width-auto'); userGrid.appendChild(chatUserPictureColumn); const chatUserPicture = document.createElement('img'); chatUserPicture.classList.add('chat-author-image'); chatUserPicture.setAttribute('src', `/image/${message.user.picture.small._id}`); chatUserPicture.setAttribute('alt', `${authorName}'s profile picture`); chatUserPictureColumn.appendChild(chatUserPicture); } if (dtp.user && (dtp.user._id !== message.user._id)) { const menuColumn = document.createElement('div'); menuColumn.classList.add('uk-width-auto'); menuColumn.classList.add('chat-user-menu'); userGrid.appendChild(menuColumn); const menuButton = document.createElement('button'); menuButton.setAttribute('type', 'button'); menuButton.classList.add('uk-button'); menuButton.classList.add('uk-button-link'); menuButton.classList.add('uk-button-small'); menuColumn.appendChild(menuButton); const menuIcon = document.createElement('i'); menuIcon.classList.add('fas'); menuIcon.classList.add('fa-ellipsis-h'); menuButton.appendChild(menuIcon); const menuDropdown = document.createElement('div'); menuDropdown.setAttribute('data-message-id', message._id); menuDropdown.setAttribute('uk-dropdown', 'mode: click'); menuColumn.appendChild(menuDropdown); const dropdownList = document.createElement('ul'); dropdownList.classList.add('uk-nav'); dropdownList.classList.add('uk-dropdown-nav'); menuDropdown.appendChild(dropdownList); let dropdownListItem = document.createElement('li'); dropdownList.appendChild(dropdownListItem); let link = document.createElement('a'); link.setAttribute('href', ''); link.setAttribute('data-message-id', message._id); link.setAttribute('data-user-id', message.user._id); link.setAttribute('data-username', message.user.username); link.setAttribute('onclick', "return dtp.app.muteChatUser(event);"); link.textContent = `Mute ${message.user.displayName || message.user.username}`; dropdownListItem.appendChild(link); } const chatContent = document.createElement('div'); chatContent.classList.add('chat-content'); chatContent.classList.add('uk-text-break'); chatContent.innerHTML = message.content; chatMessage.appendChild(chatContent); const chatTimestamp = document.createElement('div'); chatTimestamp.classList.add('chat-timestamp'); chatTimestamp.classList.add('uk-text-small'); chatTimestamp.textContent = moment(message.created).format('hh:mm:ss a'); chatMessage.appendChild(chatTimestamp); if (Array.isArray(message.stickers) && message.stickers.length) { message.stickers.forEach((sticker) => { const chatContent = document.createElement('div'); chatContent.classList.add('chat-sticker'); chatContent.setAttribute('title', `:${sticker.slug}:`); chatContent.setAttribute('data-sticker-id', sticker._id); switch (sticker.encoded.type) { case 'video/mp4': chatContent.innerHTML = ``; break; case 'image/png': chatContent.innerHTML = ``; break; case 'image/jpeg': chatContent.innerHTML = ``; break; } chatMessage.appendChild(chatContent); }); } this.ui.isModifying = true; this.ui.messageList.appendChild(chatMessage); this.ui.messages.push(chatMessage); while (this.ui.messages.length > 50) { const message = this.ui.messages.shift(); this.ui.messageList.removeChild(message); } if (isAtBottom) { /* * This is jank. I don't know why I had to add this jank, but it is jank. * The browser started emitting a scroll event *after* I issue this scroll * command to return to the bottom of the view. So, I have to issue the * scroll, let it fuck up, and issue the scroll again. I don't care why. */ this.ui.messageList.scrollTo(0, this.ui.messageList.scrollHeight); setTimeout(( ) => { this.ui.isAtBottom = true; this.ui.messageList.scrollTo(0, this.ui.messageList.scrollHeight); this.ui.isModifying = false; }, 25); } } updateTimestamps ( ) { const timestamps = document.querySelectorAll('div.chat-timestamp[data-created]'); timestamps.forEach((timestamp) => { const created = timestamp.getAttribute('data-created'); timestamp.textContent = moment(created).format('hh:mm:ss a'); }); } createEmojiReact (message) { this.ui.reactions.create(message.reaction); } triggerEmojiExplosion ( ) { const reactions = ['happy', 'angry', 'honk', 'clap', 'fire', 'laugh']; const stopHandler = this.stopEmojiExplosion.bind(this); if (this.emojiExplosionTimeout && this.emojiExplosionInterval) { clearTimeout(this.emojiExplosionTimeout); this.emojiExplosionTimeout = setTimeout(stopHandler, EMOJI_EXPLOSION_DURATION); return; } // spawn 10 emoji reacts per second until told to stop this.emojiExplosionInterval = setInterval(( ) => { // choose a random reaction from the list of available reactions and // spawn it. const reaction = reactions[Math.floor(Math.random() * reactions.length)]; this.ui.reactions.create({ reaction }); }, EMOJI_EXPLOSION_INTERVAL); // set a timeout to stop the explosion this.emojiExplosionTimeout = setTimeout(stopHandler, EMOJI_EXPLOSION_DURATION); } stopEmojiExplosion ( ) { if (!this.emojiExplosionTimeout || !this.emojiExplosionInterval) { return; } clearTimeout(this.emojiExplosionTimeout); delete this.emojiExplosionTimeout; clearInterval(this.emojiExplosionInterval); delete this.emojiExplosionInterval; } }