You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
341 lines
10 KiB
341 lines
10 KiB
// 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;
|
|
}
|
|
}
|
|
}
|