const DEFAULT_COLORS = [ '#FF4A80', '#FF7070', '#FA8E4B', '#FEE440', '#5FFF77', '#00F5D4', '#00BBF9', '#4371FB', '#9B5DE5', '#F670DD', ] let FieldData = {} const Widget = { width: 0, height: 0, cooldown: false, raidActive: false, raidTimer: null, userMessageCount: {}, soundEffects: [], messageCount: 0, pronouns: {}, pronounsCache: {}, channel: {}, service: '', followCache: {}, globalEmotes: {}, } const PRONOUNS_API_BASE = 'https://pronouns.alejo.io/api' const PRONOUNS_API = { user: username => `${PRONOUNS_API_BASE}/users/${username}`, pronouns: `${PRONOUNS_API_BASE}/pronouns`, } const DEC_API_BASE = 'https://decapi.me/twitch' const DEC_API = { followedSeconds: username => `${DEC_API_BASE}/followed/${Widget.channel.username}/${username}?format=U`, } const GLOBAL_EMOTES = { ffz: { api: 'https://api2.frankerfacez.com/v1/set/global', transformer: response => { const { default_sets, sets } = response const emoteNames = [] for (const set of default_sets) { const { emoticons } = sets[set] for (const emote of emoticons) { emoteNames.push(emote.name) } } return emoteNames }, }, bttv: { api: 'https://api.betterttv.net/3/cached/emotes/global', transformer: response => { return response.map(emote => emote.code) }, }, '7tv': { api: 'https://api.7tv.app/v2/emotes/global', transformer: response => { return response.map(emote => emote.name) }, }, } // --------------------------- // Widget Initialization // --------------------------- window.addEventListener('onWidgetLoad', async obj => { Widget.channel = obj.detail.channel loadFieldData(obj.detail.fieldData) loadGlobalEmotes() const { isEditorMode } = await SE_API.getOverlayStatus() conditionalMainClass('editor', isEditorMode) conditionalMainClass('dark-mode', FieldData.darkMode) conditionalMainClass( 'custom-message-colors', FieldData.useCustomMessageColors, ) conditionalMainClass('custom-border-colors', FieldData.useCustomBorderColors) conditionalMainClass( 'custom-pronouns-badge-colors', FieldData.pronounsBadgeCustomColors, ) if (FieldData.pronounsMode !== 'off') { await getPronouns() } if (FieldData.previewMode && isEditorMode) sendTestMessage(5, 500) }) function loadFieldData(data) { FieldData = data const specificUsersSoundGroups = Array(10) .fill('specificUsersSoundGroup') .map((text, i) => `${text}${i + 1}`) processFieldData( value => stringToArray(value), 'ignoreUserList', 'ignorePrefixList', 'allowUserList', 'allowedStrings', ...specificUsersSoundGroups, ) processFieldData( value => value === 'true', 'includeEveryone', 'includeSubs', 'includeVIPs', 'includeMods', 'emoteOnly', 'highlightOnly', 'darkMode', 'useCustomMessageColors', 'useCustomBorderColors', 'previewMode', 'largeEmotes', 'showBadges', 'fixedWidth', 'pronounsLowercase', 'pronounsBadgeCustomColors', 'includeFollowers', 'ffzGlobal', 'bttvGlobal', 'topEdge', 'bottomEdge', 'leftEdge', 'rightEdge', 'hideOutOfBounds', ) processFieldData(value => (value ? value : 1), 'delay') const soundData = {} for (let i = 1; i <= 10; i++) { const group = FieldData[`soundGroup${i}`] const specificUsers = FieldData[`specificUsersSoundGroup${i}`] const isSpecific = specificUsers.length > 0 // specific-index so multiple specifics don't override each other const userLevel = isSpecific ? `specific-${i}` : FieldData[`userLevelSoundGroup${i}`] const messageType = FieldData[`messageTypeSoundGroup${i}`] if (group && group.length > 0) { if (!soundData[userLevel]) { soundData[userLevel] = {} } if (isSpecific) { soundData[userLevel].users = specificUsers } if (!soundData[userLevel][messageType]) { soundData[userLevel][messageType] = [] } soundData[userLevel][messageType].push(...group) } } Widget.soundEffects = Object.entries(soundData) .reduce((acc, entry) => { const [userLevel, { users, ...messageTypes }] = entry for (const [messageType, soundEffects] of Object.entries(messageTypes)) { acc.push({ userLevel, messageType, soundEffects, users, order: soundSortOrder(userLevel, messageType), }) } return [...acc] }, []) .sort(({ order: a }, { order: b }) => { // sort by userLevel (0) then by messageType (1) if (a[0] !== b[0]) return b[0] - a[0] else return b[1] - a[1] }) } function processFieldData(process, ...keys) { for (const key of keys) { FieldData[key] = process(FieldData[key]) } } function stringToArray(string = '', separator = ',') { return string.split(separator).reduce((acc, value) => { const trimmed = value.trim() if (trimmed !== '') acc.push(trimmed) return acc }, []) } function conditionalMainClass(className, condition = true) { const main = $('main') if (condition) main.addClass(className) else main.removeClass(className) } function soundSortOrder(userLevel, messageType) { return [userLevelSortOrder(userLevel), messageTypeSortOrder(messageType)] } function userLevelSortOrder(userLevel) { switch (userLevel) { case 'everyone': return 0 case 'subs': return 100 case 'vips': return 200 case 'mods': return 300 default: return 1000 // assume specific } } function messageTypeSortOrder(messageType) { switch (messageType) { case 'highlight': return 1000 case 'action': return 500 case 'default': return 100 default: return 0 // assume all } } async function loadGlobalEmotes() { for (const [key, value] of Object.entries(GLOBAL_EMOTES)) { const { api, transformer } = value const response = await get(api) if (response != null) { Widget.globalEmotes[key] = transformer(response) } } } // -------------------- // Event Handlers // -------------------- window.addEventListener('onEventReceived', obj => { const { listener, event } = obj.detail switch (listener) { case 'message': onMessage(event) break case 'raid-latest': onRaid(event) break case 'delete-message': deleteMessage(event.msgId) break case 'delete-messages': deleteMessages(event.userId) break case 'event:test': onButton(event) break default: return } }) // --------------------- // Event Functions // --------------------- async function onMessage(event, testMessage = false) { const { service } = event Widget.service = service const { // facebook attachment, // trovo content_data, messageId, content, // general badges = [], userId = '', nick: username = '', displayName = '', } = event.data let { emotes = [], text = '', msgId = '', displayColor: color } = event.data let pronouns = null const allPronounKeys = Object.keys(Widget.pronouns) if (FieldData.pronounsMode !== 'off' && allPronounKeys.length > 0) { if (testMessage) { const randomPronounKey = allPronounKeys[random(0, allPronounKeys.length - 1)] pronouns = Widget.pronouns[randomPronounKey] } else if (service === 'twitch') { pronouns = await getUserPronoun(username) } } if (pronouns && FieldData.pronounsLowercase) { pronouns = pronouns.toLowerCase() } // handle facebook if (service === 'facebook' && attachment && attachment.type === 'sticker') { const { url, target } = attachment text = 'sticker' emotes.push({ type: 'sticker', name: text, id: target.id, gif: false, urls: { 1: url, 2: url, 4: url, }, start: 0, end: text.length, }) } // handle trovo if (service === 'trovo') { // remove messages from before the widget was loaded... idk why trovo sends these if (!content_data) return msgId = messageId text = content color = undefined } // Filters if (FieldData.raidCooldown > 0 && !Widget.raidActive) return if (FieldData.raidCooldown < 0 && Widget.raidActive) return if (hasIgnoredPrefix(text)) return if (!passedMinMessageThreshold(userId)) return if ( FieldData.allowUserList.length && !userListIncludes(FieldData.allowUserList, displayName, username) ) return if (userListIncludes(FieldData.ignoreUserList, displayName, username)) return const permittedUserLevel = await hasIncludedBadge(badges, username) if (!permittedUserLevel) return if ( FieldData.allowedStrings.length && !FieldData.allowedStrings.includes(text) ) return const messageType = getMessageType(event.data) if (FieldData.highlightOnly && messageType !== 'highlight') return const parsedText = parse(htmlEncode(text), emotes) const emoteSize = calcEmoteSize(parsedText) if (FieldData.emoteOnly && emoteSize === 1) return if (FieldData.messageCooldown) { if (Widget.cooldown) { return } else { Widget.cooldown = true window.setTimeout(() => { Widget.cooldown = false }, FieldData.messageCooldown * 1000) } } const elementData = { parsedText, name: displayName, emoteSize, messageType, msgId, userId, color, badges, pronouns, } // Render Bubble if (FieldData.positionMode !== 'list') { $('main').append(BubbleComponent(elementData)) } else { $('main').prepend(BubbleComponent(elementData)) } const currentMessage = `.bubble[data-message-id="${msgId}"]` // Calcute Bubble Position window.setTimeout(_ => { const height = $(currentMessage).outerHeight() let maxWidth = FieldData.fixedWidth || FieldData.theme.includes('.css') ? FieldData.maxWidth : $(`${currentMessage} .message-wrapper`).width() + 1 const minWidth = $(`${currentMessage} .username`).outerWidth() $(`${currentMessage} .message`).css({ '--dynamicWidth': Math.max(minWidth, maxWidth), }) if (FieldData.positionMode !== 'list') { // I'm not entirely sure why the + 30 is necessary, // but it makes the calculations work correctly let xMax = Math.max(minWidth, maxWidth) + 30 if (FieldData.theme === 'animal-crossing') { xMax += 15 // due to margin-left 15 on .message } const { left, top, right, bottom } = calcPosition(xMax, height) window.setTimeout(_ => { $(currentMessage).css({ left, top, right, bottom }) }, 300) } }, 300) // Get Sound let sound = null const soundUrls = getSound(username, displayName, badges, messageType) if (soundUrls) { sound = new Audio(soundUrls[random(0, soundUrls.length - 1)]) sound.volume = parseInt(FieldData.volume) / 100 } // Show Bubble and Play Sound window.setTimeout(_ => { Widget.messageCount++ if (soundUrls) sound.play() $(currentMessage).addClass('animate') $(currentMessage).addClass(FieldData.animation) if (FieldData.positionMode === 'list') $(currentMessage).css({ position: 'relative' }) const getOldest = () => { const oldestMsgId = FieldData.positionMode !== 'list' ? $('.bubble:not(.expired)').first().attr('data-message-id') : $('.bubble:not(.expired)').last().attr('data-message-id') return [`.bubble[data-message-id="${oldestMsgId}"]`, oldestMsgId] } const earlyDelete = (selector, id) => { $(selector).addClass('expired') $(selector).fadeOut(400, _ => deleteMessage(id)) } // Max message handling if ( FieldData.maxMessages > 0 && Widget.messageCount > FieldData.maxMessages ) { const [selector, id] = getOldest() earlyDelete(selector, id) } if (FieldData.hideOutOfBounds && FieldData.positionMode === 'list') { let hideDelay = 0 if (FieldData.animation === 'dynamic') { if ( FieldData.listDirection === 'left' || FieldData.listDirection === 'right' ) hideDelay = 200 if ( FieldData.listDirection === 'top' || FieldData.listDirection === 'bottom' ) hideDelay = 1000 } window.setTimeout(_ => { let tryDelete = true while (tryDelete) { const [selector, id] = getOldest() const { left, top } = $(selector).position() const height = $(selector).outerHeight() const width = $(selector).outerWidth() const widgetWidth = $('main').innerWidth() const widgetHeight = $('main').innerHeight() switch (FieldData.listDirection) { case 'bottom': if (top < FieldData.padding) earlyDelete(selector, id) else tryDelete = false break case 'top': if (top > widgetHeight - FieldData.padding - height) earlyDelete(selector, id) else tryDelete = false break case 'left': if (left > widgetWidth - FieldData.padding - width) earlyDelete(selector, id) else tryDelete = false break case 'right': if (left < FieldData.padding) earlyDelete(selector, id) else tryDelete = false break default: // nothing } } }, hideDelay) } if (FieldData.lifetime > 0) { window.setTimeout(_ => { deleteMessage(msgId) }, FieldData.lifetime * 1000) } }, FieldData.delay * 1000) } function onRaid(event) { if (FieldData.raidCooldown === 0) return if (event.amount < FieldData.raidMin) return // Reset timer if another raid happens during an active raid timer clearTimeout(Widget.raidTimer) Widget.raidActive = true Widget.raidTimer = window.setTimeout(() => { Widget.raidActive = false }, Math.abs(FieldData.raidCooldown) * 1000) } function deleteMessage(msgId) { const messages = $(`.bubble[data-message-id="${msgId}"]`) Widget.messageCount -= messages.length messages.remove() } function deleteMessages(userId) { // userId is undefined when clear chat is used // when userId is defined, that user has been banned or timed out let selector = '.bubble' if (userId) { selector = `.bubble[data-user-id="${userId}"]` Widget.messageCount -= $(selector).length } else { Widget.messageCount = 0 } $(selector).remove() } function onButton(event) { const { listener, field, value } = event if (listener !== 'widget-button' || value !== 'zaytri_dynamicchatbubbles') return switch (field) { case 'testMessageButton': sendTestMessage() break default: return } } const TEST_USER_TYPES = [ { name: 'User', badges: [] }, { name: 'Moderator', badges: [ { type: 'moderator', url: 'https://static-cdn.jtvnw.net/badges/v1/3267646d-33f0-4b17-b3df-f923a41db1d0/3', }, ], }, { name: 'VIP', badges: [ { type: 'vip', url: 'https://static-cdn.jtvnw.net/badges/v1/b817aba4-fad8-49e2-b88a-7cc744dfa6ec/3', }, ], }, ] function sendTestMessage(amount = 1, delay = 250) { for (let i = 0; i < amount; i++) { window.setTimeout(_ => { const number = numbered.stringify(random(1, 10)) const userType = TEST_USER_TYPES[random(0, TEST_USER_TYPES.length - 1)] const name = `${userType.name}_${numbered.stringify(random(1, 10))}` const event = { data: { userId: name, tags: {}, text: 'test', displayName: random(0, 1) ? name : name.toLowerCase(), nick: '', msgId: `${name}_${Date.now()}`, badges: userType.badges, }, } const previewMessage = FieldData.previewMessage.trim() if (previewMessage !== '') { event.data.text = previewMessage } else { const [text, emotes] = TEST_MESSAGES[random(0, TEST_MESSAGES.length - 1)] event.data.text = text event.data.emotes = emotes } let messageType = 1 switch (FieldData.previewType) { case 'random': messageType = random(1, 3) break case 'action': messageType = 2 break case 'highlight': messageType = 3 break default: messageType = 1 } if (messageType === 2) { event.data.isAction = true } else if (messageType === 3) { event.data.tags['msg-id'] = 'highlighted-message' } onMessage(event, true) }, i * delay) } } // ------------------------- // Component Functions // ------------------------- function BubbleComponent(props) { const { parsedText, emoteSize, messageType, msgId, userId, color: userColor, badges, pronouns, } = props let { name } = props if (FieldData.pronounsMode === 'suffix' && pronouns) { name = `${name} (${pronouns})` } const color = userColor || generateColor(name) const tColor = tinycolor(color) const darkerColor = tinycolor .mix( FieldData.useCustomBorderColors ? FieldData.borderColor : color, 'black', 25, ) .toString() // based on https://stackoverflow.com/a/69869976 const isDark = tColor.getLuminance() < 0.4 const parsedElements = parsedText.map(({ type, data }) => { switch (type) { case 'emote': return EmoteComponent(data) case 'text': default: return TextComponent(data) } }) let containerClasses = [ 'bubble', `emote-${FieldData.largeEmotes ? emoteSize : 1}`, ] switch (messageType) { case 'highlight': { if (FieldData.highlightStyle === 'rainbow') containerClasses.push('highlight') break } case 'action': { if (FieldData.actionStyle === 'italics') containerClasses.push('action') break } default: // nothing } if (isDark && !FieldData.theme.includes('.css')) containerClasses.push('user-color-dark') let usernameChildren = [] if (FieldData.showBadges) { usernameChildren = BadgesComponent(badges) } if (FieldData.pronounsMode === 'badge' && pronouns) { usernameChildren.push(PronounsBadgeComponent(pronouns)) } usernameChildren.push(name) const usernameProps = {} if (!FieldData.useCustomBorderColors && !FieldData.theme.includes('.css')) { usernameProps.style = { color: isDark ? tinycolor.mix(color, 'white', 85).toString() : tinycolor.mix(color, 'black', 85).toString(), } } const usernameBoxProps = {} if (FieldData.theme.includes('.css')) { usernameChildren.push(SpacerComponent()) usernameChildren.push( Component('div', { class: 'title-bar-controls', children: [ Component('button', { 'aria-label': 'Minimize' }), Component('button', { 'aria-label': 'Maximize' }), Component('button', { 'aria-label': 'Close' }), ], }), ) containerClasses.push('window') usernameBoxProps.class = 'title-bar' } const bubbleChildren = [ UsernameBoxComponent( UsernameComponent(usernameChildren, usernameProps), usernameBoxProps, ), MessageComponent(MessageWrapperComponent(parsedElements)), ] if (FieldData.theme === 'default') { bubbleChildren.unshift(BackgroundComponent()) } return Component('section', { class: containerClasses, style: { '--userColor': color, '--darkerColor': darkerColor }, 'data-message-id': msgId, 'data-user-id': userId, children: bubbleChildren, }) } function BadgesComponent(badges) { return badges.map(badge => Component('img', { class: 'badge', src: badge.url, alt: badge.type }), ) } function TextComponent(text) { return Component('span', { class: 'text', children: text }) } function EmoteComponent({ urls, name }) { let url = urls[4] if (!url) url = urls[2] if (!url) url = urls[1] return Component('img', { class: ['emote'], src: url, alt: name }) } const ClassComponent = (tag, className) => (children, props = {}) => { const { class: classNames, ...rest } = props return Component(tag, { children, class: [joinIfArray(classNames), className], ...rest, }) } const BackgroundComponent = ClassComponent('div', 'bubble-background') const UsernameBoxComponent = ClassComponent('div', 'username-box') const UsernameComponent = ClassComponent('div', 'username') const PronounsBadgeComponent = ClassComponent('span', 'pronouns-badge') const MessageComponent = ClassComponent('div', 'message') const MessageWrapperComponent = ClassComponent('span', 'message-wrapper') const SpacerComponent = ClassComponent('span', 'spacer') function Component(tag, props) { const { children, class: classes, style, ...rest } = props if (classes) rest.class = joinIfArray(classes, ' ') if (style) rest.style = Object.entries(style) .map(([key, value]) => `${key}: ${value}`) .join(';') const attributes = Object.entries(rest).reduce( (acc, [attr, value]) => `${acc} ${attr}='${value}'`, '', ) return `<${tag}${attributes}>${ children !== undefined ? joinIfArray(children) : '' }` } // ---------------------------- // Pronouns API Functions // ---------------------------- async function getPronouns() { const res = await get(PRONOUNS_API.pronouns) if (res) { res.forEach(pronoun => { Widget.pronouns[pronoun.name] = pronoun.display }) } } async function getUserPronoun(username) { const lowercaseUsername = username.toLowerCase() let pronouns = Widget.pronounsCache[lowercaseUsername] if (!pronouns || pronouns.expire < Date.now()) { const res = await get(PRONOUNS_API.user(lowercaseUsername)) const [newPronouns] = res Widget.pronounsCache[lowercaseUsername] = { ...newPronouns, expire: Date.now() + 1000 * 60 * 5, // 5 minutes in the future } pronouns = Widget.pronounsCache[lowercaseUsername] } if (!pronouns.pronoun_id) { return null } return Widget.pronouns[pronouns.pronoun_id] } // --------------------- // Helper Functions // --------------------- async function get(URL) { return await fetch(URL) .then(async res => { if (!res.ok) return null return res.json() }) .catch(error => null) } async function getFollowDate(username) { let followData = Widget.followCache[username] if (!followData || followData.expire < Date.now()) { const data = await get(DEC_API.followedSeconds(username)) const seconds = parseInt(data) if (isNaN(seconds)) return null date = new Date(seconds * 1000) // convert to milliseconds then date Widget.followCache[username] = { date, expire: Date.now() + 1000 * 60 * 60, // 1 hour in the future } followData = Widget.followCache[username] } return followData.date } async function followCheck(username) { if ( Widget.service !== 'twitch' || // only works on twitch Widget.channel.username.toLowerCase() === username.toLowerCase() // is broadcaster ) { return true } const followDate = await getFollowDate(username) if (!followDate) return false // convert minFollowTime from days to milliseconds const minFollowTime = 1000 * 60 * 60 * 24 * FieldData.minFollowTime return Date.now() - followDate >= minFollowTime } function hasIgnoredPrefix(text) { for (const prefix of FieldData.ignorePrefixList) { if (text.startsWith(prefix)) return true } return false } function passedMinMessageThreshold(userId) { if (FieldData.minMessages === 0) return true // begin counting if (!Widget.userMessageCount[userId]) Widget.userMessageCount[userId] = 0 Widget.userMessageCount[userId]++ return Widget.userMessageCount[userId] > FieldData.minMessages } function userListIncludes(userList, ...names) { const lowercaseNames = names.map(name => name.toLowerCase()) return userList.some(user => lowercaseNames.includes(user.toLowerCase())) } async function hasIncludedBadge(badges = [], username) { const codeBadges = [...badges] if (FieldData.includeEveryone) return true const includedBadges = ['broadcaster'] if (FieldData.includeFollowers) { includedBadges.push('follower') const isFollower = await followCheck(username) if (isFollower) { codeBadges.push({ type: 'follower' }) } } if (!codeBadges.length) return false if (FieldData.includeSubs) includedBadges.push('subscriber', 'founder') if (FieldData.includeVIPs) includedBadges.push('vip') if (FieldData.includeMods) includedBadges.push('moderator') return hasBadge(codeBadges, ...includedBadges) } function isMod(badges = []) { return hasBadge(badges, 'moderator', 'broadcaster') } function isVIP(badges = []) { return hasBadge(badges, 'vip', 'broadcaster') } function isSub(badges = []) { return hasBadge(badges, 'subscriber', 'founder', 'broadcaster') } function hasBadge(userBadges = [], ...badgeTypes) { return userBadges.some(({ type }) => badgeTypes.includes(type)) } function getMessageType(data) { if (data.isAction) return 'action' if (data.tags && data.tags['msg-id'] === 'highlighted-message') return 'highlight' return 'default' } function getSound(nick, name, badges, messageType) { for (const soundGroup of Widget.soundEffects) { const { userLevel, messageType: soundMessageType, users = [], soundEffects, } = soundGroup if (soundMessageType === 'all' || soundMessageType === messageType) { switch (userLevel) { case 'everyone': return soundEffects case 'subs': if (isSub(badges)) return soundEffects break case 'vips': if (isVIP(badges)) return soundEffects break case 'mods': if (isMod(badges)) return soundEffects break // assume specific default: if (userListIncludes(users, nick, name)) return soundEffects break } } } return null } function parse(text, emotes) { const filteredEmotes = emotes.filter(emote => { const { name, type } = emote if ( (type === 'ffz' && FieldData.ffzGlobal) || (type === 'bttv' && FieldData.bttvGlobal) ) return true const globalEmotes = Widget.globalEmotes[type] if (!globalEmotes) return true return !globalEmotes.includes(name) }) if (!filteredEmotes || filteredEmotes.length === 0) { return [{ type: 'text', data: text }] } const regex = createRegex(filteredEmotes.map(e => htmlEncode(e.name))) const textObjs = text .split(regex) .map(string => ({ type: 'text', data: string })) const last = textObjs.pop() const parsedText = textObjs.reduce((acc, textObj, index) => { return [...acc, textObj, { type: 'emote', data: filteredEmotes[index] }] }, []) parsedText.push(last) return parsedText } function calcEmoteSize(parsedText) { let emotesFound = 0 for (const { type, data } of parsedText) { if (type === 'emote') { emotesFound++ } else if (data.trim() !== '') return 1 } if (emotesFound > 1) return 2 return 4 } // I have no idea how this works anymore but it does // Regex is so useful but it's so confusing // This is all to parse out the emote text const createRegex = strings => { const regexStrings = strings .sort() .reverse() .map(string => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) const regex = `(?<=\\s|^)(?:${regexStrings.join('|')})(?=\\s|$|[.,!])` return new RegExp(regex, 'g') } function generateColor(name) { if (!name) return DEFAULT_COLORS[0] const value = name .split('') .reduce((sum, letter) => sum + letter.charCodeAt(0), 0) return DEFAULT_COLORS[value % DEFAULT_COLORS.length] } function random(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min } function calcPosition(width, height) { const main = $('main') const widgetWidth = main.innerWidth() const widgetHeight = main.innerHeight() const { padding } = FieldData // edge testing /*-* return [ random(0, 1) ? padding : Math.max(padding, widgetWidth - padding - width), random(0, 1) ? padding : Math.max(padding, widgetHeight - padding - height), ] /*-*/ const minX = padding const maxX = Math.max(padding, widgetWidth - padding - width) const minY = padding const maxY = Math.max(padding, widgetHeight - padding - height) const randomX = random(minX, maxX) const randomY = random(minY, maxY) if (FieldData.positionMode === 'random') { return { top: randomY, left: randomX } } else { const possibleCoords = [] const deviation = random(0, FieldData.edgeDeviation) if (FieldData.topEdge) { possibleCoords.push({ top: minY + deviation, left: randomX }) } if (FieldData.bottomEdge) { possibleCoords.push({ bottom: minY + deviation, left: randomX }) } if (FieldData.leftEdge) { possibleCoords.push({ left: minX + deviation, top: randomY }) } if (FieldData.rightEdge) { possibleCoords.push({ right: minX + deviation, top: randomY }) } // no edges chosen so just put all chats in the middle as an easter egg if (possibleCoords.length === 0) { return { left: (minX + maxX) / 2, top: (minY + maxY) / 2 } } return possibleCoords[random(0, possibleCoords.length - 1)] } } function joinIfArray(possibleArray, delimiter = '') { if (Array.isArray(possibleArray)) return possibleArray.join(delimiter) return possibleArray } const TEST_MESSAGES = [ ['HYPE'], ['uwu'], [ 'popCat', [ { type: 'bttv', name: 'popCat', id: '60d5abc38ed8b373e421952f', gif: true, urls: { 1: 'https://cdn.betterttv.net/emote/60d5abc38ed8b373e421952f/1x', 2: 'https://cdn.betterttv.net/emote/60d5abc38ed8b373e421952f/2x', 4: 'https://cdn.betterttv.net/emote/60d5abc38ed8b373e421952f/3x', }, start: 0, end: 6, }, ], ], [ 'catHYPE hypeE catHYPE', [ { type: 'bttv', name: 'catHYPE', id: '6090e9cc39b5010444d0b3ff', gif: true, urls: { 1: 'https://cdn.betterttv.net/emote/6090e9cc39b5010444d0b3ff/1x', 2: 'https://cdn.betterttv.net/emote/6090e9cc39b5010444d0b3ff/2x', 4: 'https://cdn.betterttv.net/emote/6090e9cc39b5010444d0b3ff/3x', }, start: 0, end: 7, }, { type: 'bttv', name: 'hypeE', id: '5b6ded5560d17f4657e1319e', gif: true, urls: { 1: 'https://cdn.betterttv.net/emote/5b6ded5560d17f4657e1319e/1x', 2: 'https://cdn.betterttv.net/emote/5b6ded5560d17f4657e1319e/2x', 4: 'https://cdn.betterttv.net/emote/5b6ded5560d17f4657e1319e/3x', }, start: 8, end: 13, }, { type: 'bttv', name: 'catHYPE', id: '6090e9cc39b5010444d0b3ff', gif: true, urls: { 1: 'https://cdn.betterttv.net/emote/6090e9cc39b5010444d0b3ff/1x', 2: 'https://cdn.betterttv.net/emote/6090e9cc39b5010444d0b3ff/2x', 4: 'https://cdn.betterttv.net/emote/6090e9cc39b5010444d0b3ff/3x', }, start: 14, end: 21, }, ], ], [ 'zaytriLOVE', [ { type: 'twitch', name: 'zaytriLOVE', id: '307974105', gif: false, urls: { 1: 'https://static-cdn.jtvnw.net/emoticons/v2/307974105/default/dark/1.0', 2: 'https://static-cdn.jtvnw.net/emoticons/v2/307974105/default/dark/2.0', 4: 'https://static-cdn.jtvnw.net/emoticons/v2/307974105/default/dark/3.0', }, start: 0, end: 9, }, ], ], [ 'D: D: D:', [ { type: 'bttv', name: 'D:', id: '55028cd2135896936880fdd7', gif: false, urls: { 1: 'https://cdn.betterttv.net/emote/55028cd2135896936880fdd7/1x', 2: 'https://cdn.betterttv.net/emote/55028cd2135896936880fdd7/2x', 4: 'https://cdn.betterttv.net/emote/55028cd2135896936880fdd7/3x', }, start: 0, end: 2, }, { type: 'bttv', name: 'D:', id: '55028cd2135896936880fdd7', gif: false, urls: { 1: 'https://cdn.betterttv.net/emote/55028cd2135896936880fdd7/1x', 2: 'https://cdn.betterttv.net/emote/55028cd2135896936880fdd7/2x', 4: 'https://cdn.betterttv.net/emote/55028cd2135896936880fdd7/3x', }, start: 3, end: 5, }, { type: 'bttv', name: 'D:', id: '55028cd2135896936880fdd7', gif: false, urls: { 1: 'https://cdn.betterttv.net/emote/55028cd2135896936880fdd7/1x', 2: 'https://cdn.betterttv.net/emote/55028cd2135896936880fdd7/2x', 4: 'https://cdn.betterttv.net/emote/55028cd2135896936880fdd7/3x', }, start: 6, end: 8, }, ], ], [ 'SCREME', [ { type: 'bttv', name: 'SCREME', id: '5fea41766b06e834ffd76103', gif: true, urls: { 1: 'https://cdn.betterttv.net/emote/5fea41766b06e834ffd76103/1x', 2: 'https://cdn.betterttv.net/emote/5fea41766b06e834ffd76103/2x', 4: 'https://cdn.betterttv.net/emote/5fea41766b06e834ffd76103/3x', }, start: 0, end: 6, }, ], ], [ 'toad sings but make it nightcore zaytriSCREME', [ { type: 'twitch', name: 'zaytriSCREME', id: '305161229', gif: false, urls: { 1: 'https://static-cdn.jtvnw.net/emoticons/v2/305161229/default/dark/1.0', 2: 'https://static-cdn.jtvnw.net/emoticons/v2/305161229/default/dark/2.0', 4: 'https://static-cdn.jtvnw.net/emoticons/v2/305161229/default/dark/3.0', }, start: 33, end: 44, }, ], ], [ 'bobDance bobDance bobDance', [ { type: 'bttv', name: 'bobDance', id: '5e2a1da9bca2995f13fc0261', gif: true, urls: { 1: 'https://cdn.betterttv.net/emote/5e2a1da9bca2995f13fc0261/1x', 2: 'https://cdn.betterttv.net/emote/5e2a1da9bca2995f13fc0261/2x', 4: 'https://cdn.betterttv.net/emote/5e2a1da9bca2995f13fc0261/3x', }, start: 0, end: 8, }, { type: 'bttv', name: 'bobDance', id: '5e2a1da9bca2995f13fc0261', gif: true, urls: { 1: 'https://cdn.betterttv.net/emote/5e2a1da9bca2995f13fc0261/1x', 2: 'https://cdn.betterttv.net/emote/5e2a1da9bca2995f13fc0261/2x', 4: 'https://cdn.betterttv.net/emote/5e2a1da9bca2995f13fc0261/3x', }, start: 9, end: 17, }, { type: 'bttv', name: 'bobDance', id: '5e2a1da9bca2995f13fc0261', gif: true, urls: { 1: 'https://cdn.betterttv.net/emote/5e2a1da9bca2995f13fc0261/1x', 2: 'https://cdn.betterttv.net/emote/5e2a1da9bca2995f13fc0261/2x', 4: 'https://cdn.betterttv.net/emote/5e2a1da9bca2995f13fc0261/3x', }, start: 18, end: 26, }, ], ], [ 'bongoTap', [ { type: 'bttv', name: 'bongoTap', id: '5ba6d5ba6ee0c23989d52b10', gif: true, urls: { 1: 'https://cdn.betterttv.net/emote/5ba6d5ba6ee0c23989d52b10/1x', 2: 'https://cdn.betterttv.net/emote/5ba6d5ba6ee0c23989d52b10/2x', 4: 'https://cdn.betterttv.net/emote/5ba6d5ba6ee0c23989d52b10/3x', }, start: 0, end: 8, }, ], ], [ 'VoHiYo hello!', [ { type: 'twitch', name: 'VoHiYo', id: '81274', gif: false, urls: { 1: 'https://static-cdn.jtvnw.net/emoticons/v2/81274/default/dark/1.0', 2: 'https://static-cdn.jtvnw.net/emoticons/v2/81274/default/dark/2.0', 4: 'https://static-cdn.jtvnw.net/emoticons/v2/81274/default/dark/3.0', }, start: 0, end: 5, }, ], ], [ 'TwitchUnity', [ { type: 'twitch', name: 'TwitchUnity', id: '196892', gif: false, urls: { 1: 'https://static-cdn.jtvnw.net/emoticons/v2/196892/default/dark/1.0', 2: 'https://static-cdn.jtvnw.net/emoticons/v2/196892/default/dark/2.0', 4: 'https://static-cdn.jtvnw.net/emoticons/v2/196892/default/dark/3.0', }, start: 0, end: 10, }, ], ], [ 'MercyWing1 PinkMercy MercyWing2', [ { type: 'twitch', name: 'MercyWing1', id: '1003187', gif: false, urls: { 1: 'https://static-cdn.jtvnw.net/emoticons/v1/1003187/1.0', 2: 'https://static-cdn.jtvnw.net/emoticons/v1/1003187/1.0', 4: 'https://static-cdn.jtvnw.net/emoticons/v1/1003187/3.0', }, start: 0, end: 9, }, { type: 'twitch', name: 'PinkMercy', id: '1003190', gif: false, urls: { 1: 'https://static-cdn.jtvnw.net/emoticons/v1/1003190/1.0', 2: 'https://static-cdn.jtvnw.net/emoticons/v1/1003190/1.0', 4: 'https://static-cdn.jtvnw.net/emoticons/v1/1003190/3.0', }, start: 11, end: 19, }, { type: 'twitch', name: 'MercyWing2', id: '1003189', gif: false, urls: { 1: 'https://static-cdn.jtvnw.net/emoticons/v1/1003189/1.0', 2: 'https://static-cdn.jtvnw.net/emoticons/v1/1003189/1.0', 4: 'https://static-cdn.jtvnw.net/emoticons/v1/1003189/3.0', }, start: 21, end: 30, }, ], ], ] function htmlEncode(text) { return text.replace(/[\<\>\"\'\^\=]/g, char => `&#${char.charCodeAt(0)};`) }