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.
347 lines
12 KiB
347 lines
12 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 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 = `<video playsinline autoplay muted loop preload="auto"><source src="/sticker/${sticker._id}/media"></source></video>`;
|
|
break;
|
|
case 'image/png':
|
|
chatContent.innerHTML = `<img src="/sticker/${sticker._id}/media" height="100" />`;
|
|
break;
|
|
case 'image/jpeg':
|
|
chatContent.innerHTML = `<img src="/sticker/${sticker._id}/media" height="100" />`;
|
|
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;
|
|
}
|
|
}
|