Last active
December 22, 2022 18:38
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==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