Skip to content

Instantly share code, notes, and snippets.

@Pixoll
Last active December 22, 2022 18:38
Show Gist options
  • Save Pixoll/a67ee501ee650c9a943d4f5ecf4cc717 to your computer and use it in GitHub Desktop.
Save Pixoll/a67ee501ee650c9a943d4f5ecf4cc717 to your computer and use it in GitHub Desktop.
Tampermonkey userscript. Track full list of attendees inside of a Caracal room with the "Attendees List" button on the bottom-left corner.
// ==UserScript==
// @name Caracal Attendees Tracker
// @namespace Pixoll
// @author Pixoll
// @version 1.4.4
// @description Track full list of attendees inside of a Caracal room.
// @icon https://www.google.com/s2/favicons?sz=64&domain=caracal.club
// @match https://caracal.club/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// ==/UserScript==
//#region Constants
const version = '1.4.4';
/** @type {Window & typeof globalThis} */
const globalVars = (function () { return this; })();
const ids = /** @type {const} */ ({
attendeesList: 'cat-attendees-list',
copyButton: 'cat-copy-button',
copyButtonIcon: 'cat-copy-button-icon',
HEMTable: 'cat-hem-table',
menu: 'cat-menu',
menuBg: 'cat-menu-bg',
menuButton: 'cat-menu-button',
resetButton: 'cat-reset-button',
versionTag: 'cat-version',
});
const classes = /** @type {const} */ ({
buttonIcon: 'cat-button-icon',
errorToast: 'cat-error-toast',
HEMTableCell: 'cat-hem-table-cell',
HEMTextInput: 'cat-hem-text-input',
HEMCheckbox: 'cat-hem-checkbox',
imageButton: 'cat-image-button',
toast: 'cat-toast',
popup: 'popup',
pointer: 'pointer',
warnToast: 'cat-warn-toast',
});
const styleVars = /** @type {const} */ ({
color: {
red: '#a93e3e',
lightGray: '#606060',
gray: '#333',
gold: '#e68a19',
},
borderRadius: '3px',
fontSize: '18px',
});
const toastAnimations = /** @type {const} */ ({
fadeInTop: 'fadeintop',
fadeOutTop: 'fadeouttop',
});
const toastAnimationTimes = /** @type {const} */ ({
default: 2.5,
warn: 5,
error: 10,
});
/** @type {Record<IDs | Classes, CSSStyle> | Record<Animations, AnimationData>} */
const stylesObj = {
[ids.menuBg]: {
top: '0px',
},
[ids.menu]: {
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
},
[ids.menuButton]: {
marginRight: '12px',
},
[ids.copyButton]: {
position: important('static'),
marginLeft: '15px',
marginTop: '10px',
},
[ids.copyButtonIcon]: {
height: '30px',
},
[ids.attendeesList]: {
overflow: 'auto',
maxWidth: '400px',
maxHeight: '200px',
marginBlockStart: '0px',
marginBlockEnd: '0px',
fontSize: styleVars.fontSize,
marginBottom: '10px',
backgroundColor: styleVars.color.gray,
borderRadius: styleVars.borderRadius,
padding: '5px',
},
[ids.HEMTable]: {
marginBottom: '10px',
},
[ids.resetButton]: {
right: '126px',
backgroundColor: styleVars.color.red,
},
[ids.versionTag]: {
position: 'absolute',
left: '10px',
fontSize: '15px',
color: 'darkgray',
},
[classes.imageButton]: {
background: important('none'),
border: important('none'),
padding: important('0'),
width: important('unset'),
},
[classes.buttonIcon]: {
margin: important('0'),
padding: important('0'),
},
[classes.HEMTableCell]: {
padding: styleVars.borderRadius,
},
[classes.HEMTextInput]: {
backgroundColor: styleVars.color.lightGray,
color: 'white',
border: '0',
height: '24px',
padding: '5px',
borderRadius: styleVars.borderRadius,
fontSize: styleVars.fontSize,
maxWidth: '175px',
},
[classes.HEMCheckbox]: {
marginLeft: important('15px'),
marginRight: important('5px'),
height: '12px',
width: '12px',
},
[classes.toast]: {
animation: animationString({ name: 'fadeInTop' }, { name: 'fadeOutTop' }),
position: 'absolute',
left: '50%',
top: '30px',
transform: 'translate(-50%, 0)',
minWidth: important('200px'),
backgroundColor: '#333',
color: '#fff',
textAlign: 'center',
padding: important('16px'),
fontSize: styleVars.fontSize,
},
[classes.errorToast]: {
backgroundColor: important(styleVars.color.red),
animation: important(
animationString({ name: 'fadeInTop' }, { name: 'fadeOutTop' }, toastAnimationTimes.error)
),
},
[classes.warnToast]: {
backgroundColor: important(styleVars.color.gold),
animation: important(
animationString({ name: 'fadeInTop' }, { name: 'fadeOutTop' }, toastAnimationTimes.warn)
),
},
[toastAnimations.fadeInTop]: {
from: { top: '0', opacity: '0' },
to: { top: '30px', opacity: '1' },
},
[toastAnimations.fadeOutTop]: {
from: { top: '30px', opacity: '1' },
to: { top: '0', opacity: '0' },
},
};
const styles = Object.entries(stylesObj)
.map(([name, style]) => parseStyleEntry(name, style))
.join('\n\n');
GM_addStyle(styles);
//#endregion
//#region Util functions
/**
* @param {string} cssValue
* @returns {`${cssValue} !important`}
*/
function important(cssValue) {
return `${cssValue} !important`;
}
/** @param {string} name */
function stylePrefix(name) {
if (objHasValue(ids, name)) return '#';
if (objHasValue(classes, name)) return '.';
if (objHasValue(toastAnimations, name)) return '@keyframes ';
return '';
}
/**
* @param {object} obj
* @param {string} val
*/
function objHasValue(obj, val) {
return Object.values(obj).some(v => v === val);
}
/**
* @param {string} name
* @param {CSSStyleDeclaration|AnimationData} style
*/
function parseStyleEntry(name, style) {
const prefix = stylePrefix(name);
const styleName = prefix + name;
if (prefix === '@keyframes ' && 'from' in style) {
return `${styleName} {\n${parseAnimationObj(style)}\n}`;
}
const entries = Object.entries(style)
.map(([k, s]) => `\t${parseStyleName(k)}: ${s}`)
.join(';\n');
return `${styleName} {\n${entries};\n}`;
}
/**
* @param {AnimationData} animation
*/
function parseAnimationObj(animation) {
return Object.entries(animation)
.map(([name, style]) => {
const entries = Object.entries(style)
.map(([k, s]) => `\t\t${parseStyleName(k)}: ${s}`)
.join(';\n');
return `\t${name} {\n${entries};\n\t}`;
})
.join('\n');
}
/**
* @param {AnimationStringOptions} first
* @param {AnimationStringOptions} second
* @param {number} duration In seconds
*/
function animationString(first, second, duration = toastAnimationTimes.default) {
const firstAnimation = `${toastAnimations[first.name]} ${first.duration || 0.5}s`;
const secondAnimation = `${toastAnimations[second.name]} ${second.duration || 0.5}s`;
return `${firstAnimation}, ${secondAnimation} ${duration}s`;
}
/**
* @param {ClassName[]} classNames
*/
function multiClassName(...classNames) {
return pick(classes, classNames, true).join(' ');
}
/**
* @template {Record<unknown, unknown>} T
* @template {keyof T} K
* @template {boolean} [B=false]
* @param {T} obj
* @param {K[]} keys
* @param {B} [valuesOnly]
* @returns {B extends true ? TuplifyUnion<PropertiesOf<Pick<T, K>>> : Pick<T, K>}
*/
function pick(obj, keys, valuesOnly = false) {
const picked = Object.fromEntries(Object.entries(obj)
.filter(([key]) => keys.includes(key))
);
if (valuesOnly) return Object.values(picked);
return picked;
}
/** @param {string} name */
function parseStyleName(name) {
if (name.startsWith('webkit')) name = `-${name}`;
return name.replace(/[A-Z]+/g, '-$&').toLowerCase();
}
/**
* @param {CaracalAttendee[]} current
* @param {CaracalAttendee[]} newAttendees
*/
function getNewAttendees(current, newAttendees) {
const map1 = new Map();
current.forEach(e => map1.set(e.username, true));
const added = newAttendees.filter(e => !map1.has(e.username));
return added;
}
/** @param {CSSStyle} styles */
const parseLogStyles = styles => Object.entries(styles)
.map(([key, val]) => `${parseStyleName(key)}: ${val}`)
.join('; ');
/** @param {string} color */
const logStyles = color => parseLogStyles({
color: '#fff',
backgroundColor: color,
padding: '2px 4px',
borderRadius: '2px',
});
/**
* @template T
* @param {T[]} arr
* @param {object} [options]
* @param {boolean} [options.quotes]
* @param {string} [options.key]
*/
function listFrom(arr, options = {}) {
if (arr.length === 0) return 'EMPTY';
const { quotes = true, key = null } = options;
return arr.map(el => {
let val = el;
if (key) val = val[key];
return quotes ? `"${val}"` : val;
}).join(', ');
}
/** @param {CaracalAttendee[]} arr */
function removeRepeatedUsers(arr) {
const stringified = arr.map(user => JSON.stringify(user));
/** @type {CaracalAttendee[]} */
const parsed = Array.from(new Set(stringified)).map(user => JSON.parse(user));
return parsed;
}
/**
* @param {Element} newNode
* @param {'before'|'after'} mode
* @param {Element} referenceNode
*/
function insert(newNode, mode, referenceNode) {
const { parentNode, nextSibling, previousSibling } = referenceNode;
parentNode?.insertBefore(newNode, mode === 'after' ? nextSibling : previousSibling);
}
/**
* @param {string} query Element query (`document.querySelector`).
* @param {object} [options]
* @param {number} [options.minChildCount] Expected min. child count of element.
* @param {number} [options.waitTimeout] Time out promise after the specified ms. Default is `10_000`.
*/
async function waitForElement(query, options = {}) {
const { minChildCount = null, waitTimeout = 10 * 1000 } = options;
await new Promise((resolve, reject) => {
const timer = setInterval(() => {
const element = document.querySelector(query);
if (element) {
if (minChildCount && element.childElementCount < minChildCount) return;
clearTimeout(timeoutTimer);
clearInterval(timer);
resolve(undefined);
}
}, 100);
const timeoutTimer = setTimeout(() => {
reject(new Error(`Promise timed out after ${waitTimeout} ms`));
}, waitTimeout);
});
}
/**
* @template {keyof HTMLElementTagNameMap} T
* @param {Element[]} elements
* @param {T} type
* @returns {Array<HTMLElementTagNameMap[T]>}
*/
function filterElementsByType(elements, type) {
return elements.filter(/** @returns {el is HTMLElementTagNameMap[T]} */ el => el.tagName.toLowerCase() === type);
}
/**
* @param {Element} element
* @returns {Element[]}
*/
function allChildrenFrom(element) {
if (element.childElementCount === 0) return [];
const elements = Array.from(element.children);
const children = elements.flatMap(allChildrenFrom);
return [...elements, ...children];
}
/**
* @param {string} query
* @param {number} [start]
* @param {number} [end]
*/
function queryChildrenFrom(query, start = 0, end = undefined) {
const children = Array.from(document.querySelector(query).children);
return children.slice(start, end);
}
/**
* @template {keyof HTMLElementTagNameMap} K
* @param {K} tagName
* @param {Partial<Omit<HTMLElementTagNameMap[K], 'style'>>} data
* @returns {HTMLElementTagNameMap[K]}
*/
function createElement(tagName, data = {}) {
const element = document.createElement(tagName);
Object.assign(element, data);
return element;
}
/**
* @template {keyof HTMLElementTagNameMap} K
* @template {number} N
* @param {K} tagName
* @param {N} amount
* @returns {Tuple<HTMLElementTagNameMap[K], N>}
*/
function createMultipleElements(tagName, amount) {
const elements = [];
for (let n = 0; n < amount; n++) {
const element = document.createElement(tagName);
elements.push(element);
}
return elements;
}
/**
* @param {string} string
* @param {number} number
*/
function pluralize(string, number) {
if (number === 1) return `${number} ${string}`;
let es;
for (const end of ['ch', 'sh', 's', 'x', 'z']) {
if (string.endsWith(end)) es = true;
}
return `${number} ${string}${es ? 'es' : 's'}`;
}
/**
* @template T
* @param {T} data
* @returns {T}
*/
function deepCopy(data) {
return JSON.parse(JSON.stringify(data));
}
//#endregion
class CaracalAttendeesTracker {
/**
* @type {CaracalAttendee[]}
* @private
*/
attendees;
/**
* @type {CaracalAttendee[]}
* @private
*/
displayedAttendees;
/**
* @type {Record<string, HTMLDivElement>}
* @private
*/
elements;
/**
* @type {MutationObserver}
* @private
*/
newAttendeesObserver;
/**
* @type {boolean}
* @private
*/
registeredInterface;
/**
* @type {boolean}
* @private
*/
started;
/**
* @type {boolean}
* @private
*/
testEnv;
/**
* @type {RegExp}
* @private
*/
workingUrlRegex;
/** @param {CaracalAttendeesTrackerOptions} [options] */
constructor(options = {}) {
Object.assign(this, options);
if (this.testEnv) {
this.log('Detected development environment.', '#1385bc');
globalVars.CAT = this;
}
if (!GM_getValue('attendees')) this.storeAttendees([]);
this.attendees = GM_getValue('attendees') ?? [];
this.displayedAttendees = this.attendeesCopy;
this.elements = {};
this.newAttendeesObserver = new MutationObserver(this.handleNewAttendees.bind(this));
this.registeredInterface = false;
this.started = false;
this.workingUrlRegex ??=
/https:\/\/caracal.club\/(?!create|find|sign(ed)?_(?:out|in|up)|profile|subscribe|about|faq|tos)[\w-]+/;
}
/** @private */
get attendeesCopy() {
return deepCopy(this.attendees);
}
/**
* @returns {CaracalHost}
* @private
*/
get host() {
const { attendees } = this;
/** @type {string} */
const roomOwner = globalVars.room.owner;
return {
username: roomOwner,
attendeeIndex: attendees.map(user => user.username).indexOf(roomOwner),
};
}
async start() {
try {
if (this.started) {
this.log('Client has already started.', 'red');
return;
}
this.log('Starting...');
if (!this.workingUrlRegex.test(window.location.href)) {
this.log('Detected disabled URL.', 'red');
this.stop();
return;
}
await this.checkIfNewRoom();
await this.registerInterface();
this.initAttendeesObserver();
this.started = true;
} catch (err) {
this.handleError(err);
}
}
stop() {
this.log('Stopping...');
this.newAttendeesObserver.disconnect();
}
/**
* @param {string|string[]} message
* @private
*/
log(message, color = 'green') {
if (Array.isArray(message)) {
message = listFrom(message);
}
console.log(`%cCaracal Attendees Tracker: ${message}`, logStyles(color));
}
/**
* @param {Error} err
* @private
*/
handleError(err) {
console.error(err);
const toastMessage = `An unexpected ${err.name} occurred: "${err.message}".`;
this.sendToast({
message: toastMessage,
extraClasses: ['errorToast'],
duration: toastAnimationTimes.error,
});
}
/**
* @param {MutationRecord[]} mutations
* @private
*/
handleNewAttendees(mutations) {
for (const { addedNodes } of mutations) {
if (addedNodes.length === 0) continue;
/** @type {CaracalAttendee[]} */
const newUsers = [];
for (const userImg of addedNodes) {
newUsers.push(this.parseAttendee(userImg));
}
const { attendees, displayedAttendees } = this;
const newAttendees = getNewAttendees(attendees, removeRepeatedUsers(newUsers));
this.log(`${newAttendees.length} new attendees`, '#1385bc');
if (newAttendees.length === 0) return;
attendees.push(...newAttendees);
const newDisplayedAttendees = getNewAttendees(displayedAttendees, removeRepeatedUsers(newUsers));
displayedAttendees.push(...deepCopy(newDisplayedAttendees));
this.storeAttendees(attendees);
this.refreshTextArea(displayedAttendees);
}
}
/**
* @param {HTMLImageElement} userImg
* @returns {CaracalAttendee}
* @private
*/
parseAttendee(userImg) {
const username = userImg.alt;
const isGuest = this.checkIfGuest(userImg);
return { username, isGuest };
}
/**
* @param {HTMLImageElement} userImg
* @private
*/
checkIfGuest(userImg) {
return /\/random\d+/.test(userImg.src)
&& !/[^a-z\d]+/i.test(userImg.alt)
&& userImg.alt.length === 10;
}
/** @private */
initAttendeesObserver() {
this.newAttendeesObserver.observe(document.querySelector('#users'), {
subtree: true,
childList: true,
});
}
/**
* @param {boolean} [shouldUpdateTextArea]
* @private
*/
getCurrentAttendees(shouldUpdateTextArea = true) {
const currentAttendees = queryChildrenFrom('#users', 1)
.map(userImg => this.parseAttendee(userImg));
const { attendees, displayedAttendees } = this;
const newAttendees = getNewAttendees(attendees, removeRepeatedUsers(currentAttendees));
this.log(pluralize('new user', newAttendees.length), '#1385bc');
if (newAttendees.length === 0) return;
attendees.push(...newAttendees);
const newDisplayedAttendees = getNewAttendees(displayedAttendees, removeRepeatedUsers(newAttendees));
displayedAttendees.push(...deepCopy(newDisplayedAttendees));
this.storeAttendees(attendees);
if (shouldUpdateTextArea) this.refreshTextArea(displayedAttendees);
}
/**
* @param {CaracalAttendee[]} attendees
* @private
*/
storeAttendees(attendees) {
GM_setValue('attendees', attendees);
this.attendees = attendees;
if (attendees.length === 0) {
this.log('Cleared data.', 'DarkOrange');
return;
}
this.log(`Stored attendees: ${listFrom(attendees, { key: 'username' })}`, 'DarkOrange');
}
/** @private */
async registerInterface() {
if (this.registeredInterface) return;
this.registerPopupMenu();
await this.registerButton();
this.registeredInterface = true;
}
/** @private */
registerPopupMenu() {
const menuBg = createElement('div', {
id: ids.menuBg,
className: 'block',
onclick: () => this.toggleMenu(),
});
const menu = createElement('div', {
id: ids.menu,
className: classes.popup,
});
const versionTag = createElement('div', {
id: ids.versionTag,
innerText: `v${version}`,
});
menu.append(versionTag);
/** @type {HTMLElement[]} */
const menuElements = [];
const title = createElement('div', {
className: 'centered',
});
title.append(createElement('h2', {
innerText: 'Attendees List',
}));
const copyButton = createElement('button', {
id: ids.copyButton,
className: multiClassName('pointer', 'imageButton'),
ariaLabel: 'Copy to clipboard',
src: 'https://i.ibb.co/sQL1bG1/copy.png',
onclick: () => {
navigator.clipboard.writeText(attendeesArea.innerText);
this.sendToast({
message: 'Copied attendees list to clipboard.',
});
},
});
const copyButtonIcon = createElement('img', {
id: ids.copyButtonIcon,
className: classes.buttonIcon,
src: 'https://i.ibb.co/sQL1bG1/copy.png',
});
copyButton.append(copyButtonIcon);
title.append(copyButton);
menuElements.push(title);
const attendeesArea = createElement('p', {
id: ids.attendeesList,
innerText: listFrom(this.attendees, { key: 'username', quotes: false }),
});
menuElements.push(attendeesArea);
if (this.isCurrentUserHost()) {
const hostExclusiveMenu = this.registerHostExclusiveMenu();
menuElements.push(hostExclusiveMenu);
}
const refreshBtn = createElement('button', {
className: classes.pointer,
innerText: 'Refresh',
onclick: () => this.getCurrentAttendees(),
});
menuElements.push(refreshBtn);
const resetBtn = createElement('button', {
id: ids.resetButton,
className: classes.pointer,
innerText: 'Reset',
onclick: () => {
const confirmed = confirm('This will delete all saved data. Are you sure you want to do this?');
if (!confirmed) return;
this.storeAttendees([]);
this.displayedAttendees = this.attendeesCopy;
refreshBtn.onclick();
this.updateHostIndex();
},
});
menuElements.push(resetBtn);
menu.append(...menuElements);
this.elements = { bg: menuBg, menu, _textArea: attendeesArea };
this.log('Registered popup menu.');
}
/** @private */
registerHostExclusiveMenu() {
const hemTable = createElement('table', {
id: ids.HEMTable,
});
const hemRows = createMultipleElements('tr', 2);
hemTable.append(...hemRows);
this.populateHEMInputRow(hemRows[0], {
name: 'Your username',
value: this.host.username,
checkboxFunction: this.hostUsernameCheckbox,
});
this.populateHEMInputRow(hemRows[1], {
name: 'Guests\' usernames',
value: 'Guest',
checkboxFunction: this.guestUsernameCheckbox,
});
return hemTable;
}
/**
* @param {HTMLTableRowElement} row
* @param {HEMRowOptions} options
* @private
*/
populateHEMInputRow(row, options) {
const rowName = createElement('td', {
className: classes.HEMTableCell,
innerText: options.name,
});
const inputBox = createElement('td', {
className: classes.HEMTableCell,
});
row.append(rowName, inputBox);
const textInput = createElement('input', {
className: classes.HEMTextInput,
value: options.value,
defaultValue: options.defaultValue ?? options.value,
});
// Function looses its original context when specified in the options
const boundFunction = options.checkboxFunction.bind(this);
const checkbox = createElement('input', {
className: multiClassName('HEMCheckbox', 'pointer'),
onclick: () => boundFunction(checkbox, textInput),
});
checkbox.setAttribute('type', 'checkbox');
inputBox.append(textInput, checkbox);
}
/**
* @param {HTMLInputElement} checkboxElement
* @param {HTMLInputElement} inputElement
* @private
*/
hostUsernameCheckbox(checkboxElement, inputElement) {
this.updateHostIndex();
const { attendeesCopy, host, displayedAttendees } = this;
/** @type {string|null} */
const newUsername = checkboxElement.checked ? (inputElement.value || null) : null;
if (!newUsername) {
displayedAttendees[host.attendeeIndex].username = host.username;
return this.refreshTextArea(displayedAttendees);
}
const attendeesUsernames = attendeesCopy.map(user => user.username);
if (attendeesUsernames.some(username => username === newUsername && newUsername !== host.username)) {
this.sendToast({
message: 'Someone in the attendees list already has this username. Please choose another one.',
extraClasses: ['warnToast'],
duration: toastAnimationTimes.warn,
});
inputElement.value = '';
return;
}
displayedAttendees[host.attendeeIndex].username = newUsername ?? host.username;
this.refreshTextArea(displayedAttendees);
}
/**
* @param {HTMLInputElement} checkboxElement
* @param {HTMLInputElement} inputElement
* @private
*/
guestUsernameCheckbox(checkboxElement, inputElement) {
this.updateHostIndex();
const { attendeesCopy, displayedAttendees, host } = this;
/** @type {string|null} */
const newUsername = checkboxElement.checked ? (inputElement.value || null) : null;
if (!newUsername) {
attendeesCopy[host.attendeeIndex].username = displayedAttendees[host.attendeeIndex].username;
this.displayedAttendees = attendeesCopy;
return this.refreshTextArea(attendeesCopy);
}
let guestIndex = 1;
for (const user of displayedAttendees) {
if (!user.isGuest) continue;
user.username = `${newUsername}${guestIndex}`;
guestIndex++;
}
this.refreshTextArea(displayedAttendees);
}
/** @private */
isCurrentUserHost() {
const { testEnv, host } = this;
if (testEnv) return true;
/** @type {string} */
const currentUser = document.querySelector('.profilelink')?.innerText;
return host.username === currentUser;
}
/** @private */
updateHostIndex() {
const { host, attendees } = this;
const attendeesUsernames = attendees.map(user => user.username);
const hostIndex = attendeesUsernames.indexOf(host.username);
host.attendeeIndex = hostIndex;
}
/**
* @param {CaracalAttendee[]} attendees
* @private
*/
refreshTextArea(attendees) {
const { _textArea } = this.elements;
if (!_textArea) return;
_textArea.innerText = listFrom(attendees, { key: 'username', quotes: false });
}
/** @private */
async registerButton() {
const button = createElement('button', {
id: ids.menuButton,
className: multiClassName('pointer', 'imageButton'),
ariaLabel: 'Attendees List',
onclick: () => this.toggleMenu(),
});
const buttonIcon = createElement('img', {
className: classes.buttonIcon,
src: 'https://i.ibb.co/Hq9bzst/person.png',
});
button.append(buttonIcon);
await waitForElement('#buttonfw');
const indexReference = document.querySelector('#buttonfw');
insert(button, 'before', indexReference);
this.log('Registered button.');
}
/** @private */
toggleMenu() {
const { elements, attendeesCopy } = this;
const menu = Object.entries(elements).filter(([key]) => !key.startsWith('_')).map(([, val]) => val);
const body = document.querySelector('body');
const visible = queryChildrenFrom('body').some(el => el.id === ids.menu);
if (visible) {
for (const element of menu) {
body.removeChild(element);
}
this.resetInputElements(menu);
this.refreshTextArea(attendeesCopy);
} else {
this.getCurrentAttendees();
body.append(...menu);
}
}
/**
* @param {HTMLDivElement[]} menu
* @private
*/
resetInputElements(menu) {
const allInputElements = filterElementsByType(menu.map(allChildrenFrom).flat(), 'input');
const nonDefaultCheckboxes = allInputElements.filter(el => {
if (!el.className.includes(classes.HEMCheckbox)) return false;
return el.checked !== el.defaultChecked;
});
const nonDefaultTextBoxes = allInputElements.filter(el => {
if (!el.className.includes(classes.HEMTextInput)) return false;
return el.value !== el.defaultValue;
});
for (const checkbox of nonDefaultCheckboxes) {
checkbox.checked = checkbox.defaultChecked;
}
for (const textBox of nonDefaultTextBoxes) {
textBox.value = textBox.defaultValue;
}
}
/** @private */
async checkIfNewRoom() {
await waitForElement('#users', { minChildCount: 2 });
const userCount = document.querySelector('#users').childElementCount - 1;
this.log(`User count is ${userCount}`, '#1385bc');
if (userCount === 1) this.storeAttendees([]);
this.getCurrentAttendees(false);
}
/**
* @param {SendToastOptions} options
* @private
*/
sendToast({
message,
extraClasses = [],
id = '',
duration = toastAnimationTimes.default,
}) {
const body = document.querySelector('body');
const toast = createElement('div', {
className: multiClassName('toast', 'popup', ...extraClasses),
innerHTML: message,
});
if (id) toast.id = id;
body.append(toast);
setTimeout(() => body.removeChild(toast), duration * 1_000 + 500);
}
}
const tracker = new CaracalAttendeesTracker({
testEnv: false,
});
tracker.start();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment