// 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 UIkit from 'uikit'; import SiteReactions from './site-reactions.js'; import * as picmo from 'picmo'; export default class SiteChat { constructor (app) { this.app = app; this.log = new DtpLog(DTP_COMPONENT); this.ui = { menu: document.querySelector('#chat-room-menu'), 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'), emojiPicker: document.querySelector('#site-emoji-picker'), isAtBottom: true, isModifying: false, }; if (this.ui.messageList) { this.ui.messageList.addEventListener('scroll', this.onChatMessageListScroll.bind(this)); 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)); } if (this.ui.emojiPicker) { this.ui.picmo = picmo.createPicker({ rootElement: this.ui.emojiPicker, theme: picmo.darkTheme, }); this.ui.picmo.addEventListener('emoji:select', this.onEmojiSelected.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; 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 fragment = document.createDocumentFragment(); fragment.innerHTML = message.html; this.ui.isModifying = true; this.ui.messageList.insertAdjacentHTML('beforeend', message.html); this.trimMessages(); this.app.updateTimestamps(); 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); } } async appendSystemMessage (message) { this.log.debug('appendSystemMessage', 'received system message', { message }); const systemMessage = document.createElement('div'); systemMessage.setAttribute('data-message-type', message.type); systemMessage.classList.add('uk-margin-small'); systemMessage.classList.add('chat-message'); systemMessage.classList.add('system-message'); const chatContent = document.createElement('div'); chatContent.classList.add('chat-content'); chatContent.classList.add('uk-text-break'); chatContent.innerHTML = message.content; systemMessage.appendChild(chatContent); const chatTimestamp = document.createElement('div'); chatTimestamp.classList.add('chat-timestamp'); chatTimestamp.classList.add('uk-text-small'); chatTimestamp.innerHTML = moment(message.created).format('hh:mm:ss a'); systemMessage.appendChild(chatTimestamp); this.ui.messageList.appendChild(systemMessage); this.trimMessages(); this.app.updateTimestamps(); if (this.ui.isAtBottom) { this.ui.messageList.scrollTo(0, this.ui.messageList.scrollHeight); } } trimMessages ( ) { while (this.ui.messageList.childNodes.length > 50) { this.ui.messageList.removeChild(this.ui.messageList.childNodes.item(0)); } } 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; } async showForm (event, roomId, formName) { try { UIkit.dropdown(this.ui.menu).hide(false); await this.app.showForm(event, `/chat/room/${roomId}/form/${formName}`); } catch (error) { UIkit.modal.alert(`Failed to display form: ${error.message}`); } } async toggleEmojiPicker (/* event */) { this.ui.emojiPicker.classList.toggle('picker-open'); } async onEmojiSelected (event) { this.ui.emojiPicker.classList.remove('picker-open'); return this.insertContentAtCursor(event.emoji); } async insertContentAtCursor (content) { this.ui.input.focus(); if (document.selection) { let sel = document.selection.createRange(); sel.text = content; } else if (this.ui.input.selectionStart || (this.ui.input.selectionStart === 0)) { let startPos = this.ui.input.selectionStart; let endPos = this.ui.input.selectionEnd; let oldLength = this.ui.input.value.length; this.ui.input.value = this.ui.input.value.substring(0, startPos) + content + this.ui.input.value.substring(endPos, this.ui.input.value.length); this.ui.input.selectionStart = startPos + (this.ui.input.value.length - oldLength); this.ui.input.selectionEnd = this.ui.input.selectionStart; } else { this.ui.input.value += content; } } async deleteInvite (event) { const target = event.currentTarget || event.target; const roomId = target.getAttribute('data-room-id'); const inviteId = target.getAttribute('data-invite-id'); try { const response = await fetch(`/chat/room/${roomId}/invite/${inviteId}`, { method: 'DELETE' }); await this.app.processResponse(response); } catch (error) { console.log('delete canceled', error); return; } } async deleteChatRoom (event) { const target = event.currentTarget || event.target; const roomId = target.getAttribute('data-room-id'); try { const response = await fetch(`/chat/room/${roomId}`, { method: 'DELETE' }); await this.app.processResponse(response); } catch (error) { console.log('delete canceled', error); return; } } }