// ==UserScript==
// @name Epik Chat Room Hop Shortcuts
// @namespace dannysaurus.epik
// @version 0.0.2
// @license MIT License
//
// @match https://www.epikchat.com/chat*
//
// @require https://update.greasyfork.org/scripts/528456/1545566/UTILS_DOM%20Library.js
//
// @grant unsafeWindow
// @description adds Shortcuts for browsing rooms Ctrl + 0-9 and Ctrl + Q
// ==/UserScript==
/* jslint esversion: 11 */
/* global unsafeWindow */
(async () => {
'use strict';
/** @module UTILS */
const UTILS = (() => {
const {
trySelectElement,
getUsername,
areKeysPressed,
selectors,
} = unsafeWindow.dannysaurus_epik.libraries.UTILS_DOM;
return {
trySelectElement,
areKeysPressed,
selectors,
};
})();
/** @module CONFIG */
const CONFIG = (() => {
const ids = {
newContentSeparator: 'new-content-separator',
};
const classNames = {
numbered: 'numbered',
imgCircle: 'img-circle',
tab: 'tab',
notifyBadge: 'notifybadge',
notifyCount: 'notifycount',
roomTabShortcut: 'room-tab-shortcut',
avatarInitial: 'avatar-initial',
room: "room",
active: "active",
chatMessageItem: "chat-item",
message: "message",
chatItem: 'chat-item',
welcomeMessage: 'welcome-message',
welcomeMessageContent: 'welcome-message-content',
userRight: 'user-right',
};
const attributes = {
dataNumber: 'data-number',
dataType: 'data-type',
dataRid: 'data-rid',
dataItemId: 'data-item-id',
dataOriginalTitle: 'data-original-title',
};
const roomTabSelector = `.${classNames.tab}[${attributes.dataType}="room"]`;
const selectors = {
containers: {
ctabs: UTILS.selectors.querySelectors.ctabsContainer,
messages: UTILS.selectors.querySelectors.messagesLC,
},
roomTabContainer: roomTabSelector,
numbered: `.${classNames.numbered}`,
notifyBadge: `.${classNames.notifyBadge}`,
roomTabShortcut: `.${classNames.roomTabShortcut}`,
activeRoomTab: `li.${classNames.active} ${roomTabSelector}`,
chatMessage: `.${classNames.chatMessageItem}.${classNames.message}`,
};
const delays = {
/** Delay before a Keyboard Shortcut Badge is displayed. */
badgeChangeMs: 100,
/** Time a message can be considered as read after beeing displayed. */
messageReadDelayMs: 100,
};
const newContentSeparator = {
id: ids.newContentSeparator,
className: `${classNames.chatItem} ${classNames.welcomeMessage}`,
style: 'color: #202324;',
innerHTML: `<div class="${classNames.userRight}"><div class="${classNames.welcomeMessageContent}">------------------------------------------</div></div>`
};
const modifiers = { ctrlKey: true };
const keyShortcuts = {
modifiers,
hopToRoomWithLastMessageUpdate: {
...modifiers,
code: 'KeyQ',
},
};
return {
ids,
classNames,
attributes,
selectors,
keyShortcuts,
delays,
newContentSeparator,
};
})();
/**
* Deferred initialization of the containers for ctabs and messages.
* @property {HTMLElement|null} ctabs - The container element for ctabs.
* @property {HTMLElement|null} messages - The container element for messages.
*/
const containers = {};
try {
[containers.ctabs, containers.messages] = await Promise.all([
UTILS.trySelectElement({ selectors: CONFIG.selectors.containers.ctabs }),
UTILS.trySelectElement({ selectors: CONFIG.selectors.containers.messages }),
]);
} catch (error) {
console.error('Epik Chat Room Hop Shortcuts - Failed to select containers', error);
throw error;
}
/**
* This module observes changes in the chat rooms and keeps track of the last displayed message and the last message not yet displayed for each room.
*
* @module RoomsObserver
*/
const RoomsObserver = (() => {
/**
* Last displayed message for each room.
* @type {Object.<string, MessageObject>}
*/
const lastMessageDisplayed = {};
/**
* Notification info about the last not yet displayed message for each room.
* @type {Object.<string, MessageNotification>}
*/
const messagesNotDisplayed = {};
/**
* Listeners for new messages in the active room tab.
* @type {Set<function(MessageObject): void>}
*/
const listenersDisplayed = new Set();
/**
* Listeners for message update notifications in rooms of non active tabs.
* @type {Set<function(MessageNotification): void>}
*/
const listenersNotDisplayed = new Set();
/**
* Callback function to handle room tab change.
*
* @callback RoomTabChangeListener
* @param {Room} currentRoom - The currently active room.
* @param {Room} previousRoom - The previously active room.
* @returns {void}
*/
/**
* Listeners for message update notifications in rooms of non active tabs.
* @type {Set<RoomTabChangeListener>}
*/
const listenersActiveRoomTabChange = new Set();
/**
* Represents a message update displayed in the active tab or a notification about one not displayed yet.
* @class MessageNotificationObject
*/
class MessageNotification {
/**
* A notification about a message update in a non active room tab.
* @param {object} param0
* @param {string} param0.roomId - The room id.
* @param {number} param0.timeStamp - The timestamp when the message was received.
* @param {number} param0.notificationCount - The count of notifications not yet displayed for the room
*/
constructor({ roomId, notificationCount, roomTitle = '', timeStamp = Date.now() } = {}) {
if (!roomId) {
throw new Error('roomId is required');
}
if (typeof notificationCount !== 'number') {
throw new Error('notificationCount must be a number');
}
this.roomId = roomId;
this.roomTitle = roomTitle;
this.notificationCount = notificationCount;
this.timeStamp = timeStamp;
}
}
class MessageObject {
/**
* A new message displayed in the active room tab.
* @param {object} param0
* @param {string} param0.roomId - The room id.
* @param {string} param0.messageId - The message id.
* @param {number} param0.timeStamp=Date.now() - The timestamp when the message was received.
*/
constructor({ roomId, messageId, timeStamp = Date.now() } = {}) {
if (!roomId) {
throw new Error('roomId is required');
}
if (!messageId) {
throw new Error('messageId is required');
}
this.messageId = messageId;
this.timeStamp = timeStamp;
}
}
/**
* Keeps track of the last displayed message.
*/
const displayedMessagesObserver = new MutationObserver((mutationsList) => {
const activeRoomTab = containers.ctabs.querySelector(`${CONFIG.selectors.activeRoomTab}[${CONFIG.attributes.dataRid}]`);
if (!activeRoomTab) {
return;
}
const roomId = activeRoomTab.getAttribute(CONFIG.attributes.dataRid);
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
const messages = Array.from(mutation.addedNodes).filter(node => node.matches(CONFIG.selectors.chatMessage));
if (!messages.length) {
return;
}
const lastMessage = messages[messages.length - 1];
const messageId = lastMessage.getAttribute(CONFIG.attributes.dataItemId);
if (messageId && roomId) {
const messageObj = new MessageObject({ roomId, messageId });
setTimeout(() => {
lastMessageDisplayed[roomId] = messageObj;
}, CONFIG.delays.messageReadDelayMs);
listenersDisplayed.forEach(listener => listener(messageObj));
}
}
}
});
const tabObserver = (() => {
let lastActiveRoomId = 0;
return new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (
mutation.type === 'attributes'
&& mutation.attributeName === 'class'
&& mutation.target.classList.contains('active')
) {
const activeRoom = Room.getActive();
if (activeRoom && activeRoom.roomId !== lastActiveRoomId) {
const previousRoom = new Room(lastActiveRoomId);
listenersActiveRoomTabChange.forEach(listener => listener(activeRoom, previousRoom));
lastActiveRoomId = activeRoom.roomId;
return;
}
}
}
});
})();
/**
* Keeps track of the notification badges about new messages not yet displayed. (while the room tab isn't the active one)
*/
const unReadMessageCountObserver = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.TEXT_NODE && node.parentElement && node.parentElement.classList.contains(CONFIG.classNames.notifyCount)) {
const roomTabElement = node.parentElement.closest(CONFIG.selectors.roomTabContainer);
if (roomTabElement) {
const roomId = roomTabElement.getAttribute(CONFIG.attributes.dataRid);
const notificationCount = parseInt(node.textContent);
const roomTitle = roomTabElement.closest('li')?.getAttribute(CONFIG.attributes.dataOriginalTitle);
if (!roomId) {
return;
}
if (notificationCount === 0 && messagesNotDisplayed.hasOwnProperty(roomId)) {
// all messages displayed in the active room tab
delete messagesNotDisplayed[roomId];
return;
}
if (notificationCount > (messagesNotDisplayed[roomId]?.notificationCount || 0)) {
// notification about a new message, in a room tab that isn't the active one
const messageNotification = new MessageNotification({
roomId,
notificationCount,
roomTitle,
timeStamp: Date.now()
});
messagesNotDisplayed[roomId] = messageNotification;
listenersNotDisplayed.forEach(listener => listener(messageNotification));
}
}
}
});
}
}
});
// Start observing
displayedMessagesObserver.observe(containers.messages, { childList: true });
unReadMessageCountObserver.observe(containers.ctabs, { childList: true, subtree: true });
tabObserver.observe(containers.ctabs, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class']
});
return {
/**
* Gets the latest message notification of a not yet displayed message.
*
* @param {string} roomId
* @returns {MessageNotification|null} Info with timestamp and count of the last message not yet displayed for the specified room.
*/
getLastMessageNotification(roomId = null) {
if (roomId) {
return messagesNotDisplayed[roomId] || null;
}
return Object.values(messagesNotDisplayed).sort((a, b) => b.timeStamp - a.timeStamp)[0] || null;
},
/**
* Gets the latest message displayed in the active room tab.
*
* @param {string} roomId
* @returns {MessageObject|null}
*/
getLastDisplayedMessage(roomId = null) {
if (roomId) {
return lastMessageDisplayed[roomId] || null;
}
return Object.values(lastMessageDisplayed).sort((a, b) => b.timeStamp - a.timeStamp)[0] || null;
},
/**
* Registers a listener for notifications of not yet displayed messages.
*
* @param {function(MessageNotification): void} listener - The callback function to handle message notifications.
*/
registerListenerForNonDisplayedMessage(listener) {
if (!listenersNotDisplayed.has(listener)) {
listenersNotDisplayed.add(listener);
}
},
/**
* Registers a listener for messages being displayed.
*
* @param {function(MessageObject): void} listener - The callback function to handle message notifications.
*/
registerListenerForDisplayedMessage(listener) {
if (!listenersDisplayed.has(listener)) {
listenersDisplayed.add(listener);
}
},
/**
* Registers a listener for when a new room tab is activated.
*
* @param {RoomTabChangeListener} listener - The callback function to handle room tab change.
*/
registerListenerForActiveRoomTabChange(listener) {
if (!listenersActiveRoomTabChange.has(listener)) {
listenersActiveRoomTabChange.add(listener);
}
},
unregisterListener(listener) {
for (const listeners of [listenersDisplayed, listenersNotDisplayed, listenersActiveRoomTabChange]) {
listeners.delete(listener);
}
}
};
})();
class Room {
constructor(roomId) {
this.roomId = roomId;
}
/**
* @returns {Node|null} The room tab container node or null if the room tab container is not found.
*/
get tabContainer() {
return containers.ctabs.querySelector(`${CONFIG.selectors.roomTabContainer}[${CONFIG.attributes.dataRid}="${this.roomId}"]`) || null;
}
/**
* @returns {number} The index of the room tab container or -1 if the room tab container is not found.
*/
get tabIndex() {
const roomTabs = containers.ctabs.querySelectorAll(CONFIG.selectors.roomTabContainer);
return Array.from(roomTabs).findIndex(roomTab => roomTab.getAttribute(CONFIG.attributes.dataRid) === this.roomId);
}
/**
* @returns {boolean} True if this room is the one associate to the current active room tab, false otherwise.
*/
get isActive() {
return this.tabContainer && this.tabContainer.matches(CONFIG.selectors.activeRoomTab);
}
get lastDisplayMessageId() {
return RoomsObserver.getLastDisplayedMessage(this.roomId)?.messageId || null;
}
get lastDiplayedMessage() {
if (!this.lastDisplayMessageId) {
return null;
}
return containers.messages.querySelector(`.${CONFIG.classNames.chatMessageItem}[${CONFIG.attributes.dataItemId}="${this.lastDisplayMessageId}"]`);
}
static getActive() {
const activeRoomTab = containers.ctabs.querySelector(CONFIG.selectors.activeRoomTab);
if (!activeRoomTab) {
return null;
}
return new Room(activeRoomTab.getAttribute(CONFIG.attributes.dataRid));
}
static byIndex(index) {
if (index < 0) {
return null;
}
const allRooms = Room.forAllTabs();
return index <= allRooms.length ? allRooms[index] : null;
}
static forAllTabs() {
const roomTabs = containers.ctabs.querySelectorAll(CONFIG.selectors.roomTabContainer);
return Array.from(roomTabs).map(roomTab => new Room(roomTab.getAttribute(CONFIG.attributes.dataRid)));
}
/**
* Activates the room tab associated with this room.
*
* @returns {boolean} True if the room tab was found and got activated, false otherwise.
*/
hopTo() {
if (this.isActive) {
return true;
}
if (this.tabContainer) {
this.tabContainer.click();
return true;
}
return false;
}
}
/**
* RoomHop class for managing room hopping shortcuts.
* @class RoomHop
*/
const RoomHop = (() => {
let badgeCreationSemaphores = {};
const blockBadgeCreation = (roomHop, minTimeOutMs = 0) => badgeCreationSemaphores[roomHop.tabIndex] = {
isBlocked: true,
til: Date.now() + minTimeOutMs
};
const unblockBadgeCreation = (roomHop) => delete badgeCreationSemaphores[roomHop.tabIndex];
const isBadgeCreationInProgress = (roomHop) => badgeCreationSemaphores[roomHop.tabIndex]?.isBlocked || false;
const getBadgeCreationTimeout = (roomHop) => {
if (!isBadgeCreationInProgress(roomHop) || !badgeCreationSemaphores[roomHop.tabIndex]?.til) {
return 0;
}
return Math.max(badgeCreationSemaphores[roomHop.tabIndex].til - Date.now(), 0);
};
const createBadgeId = (roomHop) => `${CONFIG.classNames.roomTabShortcut}-${roomHop.hotkeyDigit}`;
return class RoomHop {
constructor(tabIndex, hotkeyDigit) {
this.tabIndex = tabIndex;
this.hotkeyDigit = hotkeyDigit;
}
getBadge() {
return document.getElementById(createBadgeId(this));
}
createBadge(timeOutMs = 0) {
if (isBadgeCreationInProgress(this) || this.getBadge()) {
return;
}
blockBadgeCreation(this);
setTimeout(() => {
// Create notify badge with the hotkey digit
const notifyBadge = document.createElement('div');
notifyBadge.id = createBadgeId(this);
notifyBadge.classList.add(CONFIG.classNames.roomTabShortcut, CONFIG.classNames.notifyBadge);
const span = document.createElement('span');
span.classList.add(CONFIG.classNames.imgCircle);
span.textContent = this.hotkeyDigit;
notifyBadge.appendChild(span);
const room = Room.byIndex(this.tabIndex);
if (room) {
room.tabContainer?.prepend(notifyBadge);
}
unblockBadgeCreation(this);
}, timeOutMs);
}
async removeBadge() {
while (isBadgeCreationInProgress(this)) {
await new Promise(resolve => setTimeout(resolve, getBadgeCreationTimeout(this)));
}
const roomHopBadge = this.getBadge();
if (roomHopBadge) {
roomHopBadge.remove();
}
}
hop() {
Room.byIndex(this.tabIndex)?.hopTo();
}
};
})();
/**
* Module for handling the room hopping shortcut for the room with the last undisplayed chat message update.
*/
const messageNotificationObserver = (() => {
const hotKeyChar = CONFIG.keyShortcuts.hopToRoomWithLastMessageUpdate.code.slice(-1);
let roomHopLastMessage = new RoomHop(-1, hotKeyChar);
/**
* Handler for a notification about a new message in a room tab that isn't the active one.
*
* @param {MessageNotification} messageNotification
*/
const displayRoomHopHotkey = (messageNotification) => {
if (messageNotification?.roomId && messageNotification?.notificationCount > 0) {
const tabIndex = new Room(messageNotification.roomId)?.tabIndex;
if (tabIndex !== roomHopLastMessage.tabIndex || !roomHopLastMessage.getBadge()) {
roomHopLastMessage.removeBadge();
roomHopLastMessage = new RoomHop(tabIndex, roomHopLastMessage.hotkeyDigit);
roomHopLastMessage.createBadge();
}
}
};
return {
registerListener: () => {
displayRoomHopHotkey(RoomsObserver.getLastMessageNotification());
RoomsObserver.registerListenerForNonDisplayedMessage(displayRoomHopHotkey);
},
unregisterListener: () => {
roomHopLastMessage.removeBadge();
RoomsObserver.unregisterListener(displayRoomHopHotkey);
},
get roomHop() {
return roomHopLastMessage;
}
};
})();
console.log('EpikChat Room Hop Shortcuts - is running');
// create fixed room hops 1 - 9
const roomHops = Array.from({ length: 10 }, (_, index) => {
const hotkeyDigit = (index + 1) % 10;
return new RoomHop(index, hotkeyDigit);
});
document.addEventListener('keydown', (event) => {
// add keyboard shortcut badges for indiced tabs
if (UTILS.areKeysPressed(event, CONFIG.keyShortcuts.modifiers)) {
roomHops.forEach((roomHop) => {
roomHop.createBadge(CONFIG.delays.badgeChangeMs);
});
// add keyboard shortcut badge that is live following the most recent message update of any inactive room tab
messageNotificationObserver.registerListener();
// perform room hopping if the right shortcut keys are pressed
for (let roomHop of [...roomHops, messageNotificationObserver.roomHop]) {
if (UTILS.areKeysPressed(event, { code: `Digit${roomHop.hotkeyDigit}` })
|| UTILS.areKeysPressed(event, { code: `Key${roomHop.hotkeyDigit}` })) {
event.preventDefault();
event.stopPropagation();
// click the room tab and restore the badge at the activated room tab
roomHop.hop();
roomHop.createBadge(CONFIG.delays.badgeChangeMs);
return;
}
}
}
});
document.addEventListener('keyup', (event) => {
// remove keyboard shortcut badges of indiced room tabs
if (!UTILS.areKeysPressed(event, CONFIG.keyShortcuts.modifiers)) {
event.preventDefault();
event.stopPropagation();
roomHops.forEach((roomHop) => {
roomHop.removeBadge();
});
// remove keyboard shortcut badge for the room tab with "most recent message update"
messageNotificationObserver.unregisterListener();
}
});
RoomsObserver.registerListenerForActiveRoomTabChange((currentRoom, _previousRoom) => {
const lastDisplayMessage = currentRoom.lastDiplayedMessage;
if (!lastDisplayMessage) {
return;
}
let separator = document.querySelector(`#${CONFIG.ids.newContentSeparator}`);
if (!separator) {
separator =
Object.assign(document.createElement('div'), CONFIG.newContentSeparator);
}
lastDisplayMessage.insertAdjacentElement('afterend', separator);
});
})();