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.

342 lines
11 KiB

// site-chat.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
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 {
static get COMPONENT ( ) { return { logId: 'site-chat', index: 'siteChat', className: 'DtpSiteChat' }; }
constructor (app) {
this.app = app;
this.log = new DtpLog(SiteChat.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;
}
}
}