MrKakeVT

js chat bot

Nov 4th, 2023
684
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
  1. const DEFAULT_COLORS = [
  2.   '#FF4A80',
  3.   '#FF7070',
  4.   '#FA8E4B',
  5.   '#FEE440',
  6.   '#5FFF77',
  7.   '#00F5D4',
  8.   '#00BBF9',
  9.   '#4371FB',
  10.   '#9B5DE5',
  11.   '#F670DD',
  12. ]
  13.  
  14. let FieldData = {}
  15. const Widget = {
  16.   width: 0,
  17.   height: 0,
  18.   cooldown: false,
  19.   raidActive: false,
  20.   raidTimer: null,
  21.   userMessageCount: {},
  22.   soundEffects: [],
  23.   messageCount: 0,
  24.   pronouns: {},
  25.   pronounsCache: {},
  26.   channel: {},
  27.   service: '',
  28.   followCache: {},
  29.   globalEmotes: {},
  30. }
  31.  
  32. const PRONOUNS_API_BASE = 'https://pronouns.alejo.io/api'
  33. const PRONOUNS_API = {
  34.   user: username => `${PRONOUNS_API_BASE}/users/${username}`,
  35.   pronouns: `${PRONOUNS_API_BASE}/pronouns`,
  36. }
  37.  
  38. const DEC_API_BASE = 'https://decapi.me/twitch'
  39. const DEC_API = {
  40.   followedSeconds: username =>
  41.     `${DEC_API_BASE}/followed/${Widget.channel.username}/${username}?format=U`,
  42. }
  43.  
  44. const GLOBAL_EMOTES = {
  45.   ffz: {
  46.     api: 'https://api2.frankerfacez.com/v1/set/global',
  47.     transformer: response => {
  48.       const { default_sets, sets } = response
  49.       const emoteNames = []
  50.       for (const set of default_sets) {
  51.         const { emoticons } = sets[set]
  52.         for (const emote of emoticons) {
  53.           emoteNames.push(emote.name)
  54.         }
  55.       }
  56.       return emoteNames
  57.     },
  58.   },
  59.   bttv: {
  60.     api: 'https://api.betterttv.net/3/cached/emotes/global',
  61.     transformer: response => {
  62.       return response.map(emote => emote.code)
  63.     },
  64.   },
  65.   '7tv': {
  66.     api: 'https://api.7tv.app/v2/emotes/global',
  67.     transformer: response => {
  68.       return response.map(emote => emote.name)
  69.     },
  70.   },
  71. }
  72.  
  73. // ---------------------------
  74. //    Widget Initialization
  75. // ---------------------------
  76.  
  77. window.addEventListener('onWidgetLoad', async obj => {
  78.   Widget.channel = obj.detail.channel
  79.   loadFieldData(obj.detail.fieldData)
  80.   loadGlobalEmotes()
  81.  
  82.   const { isEditorMode } = await SE_API.getOverlayStatus()
  83.   conditionalMainClass('editor', isEditorMode)
  84.  
  85.   conditionalMainClass('dark-mode', FieldData.darkMode)
  86.   conditionalMainClass(
  87.     'custom-message-colors',
  88.     FieldData.useCustomMessageColors,
  89.   )
  90.   conditionalMainClass('custom-border-colors', FieldData.useCustomBorderColors)
  91.   conditionalMainClass(
  92.     'custom-pronouns-badge-colors',
  93.     FieldData.pronounsBadgeCustomColors,
  94.   )
  95.  
  96.   if (FieldData.pronounsMode !== 'off') {
  97.     await getPronouns()
  98.   }
  99.  
  100.   if (FieldData.previewMode && isEditorMode) sendTestMessage(5, 500)
  101. })
  102.  
  103. function loadFieldData(data) {
  104.   FieldData = data
  105.  
  106.   const specificUsersSoundGroups = Array(10)
  107.     .fill('specificUsersSoundGroup')
  108.     .map((text, i) => `${text}${i + 1}`)
  109.   processFieldData(
  110.     value => stringToArray(value),
  111.     'ignoreUserList',
  112.     'ignorePrefixList',
  113.     'allowUserList',
  114.     'allowedStrings',
  115.     ...specificUsersSoundGroups,
  116.   )
  117.  
  118.   processFieldData(
  119.     value => value === 'true',
  120.     'includeEveryone',
  121.     'includeSubs',
  122.     'includeVIPs',
  123.     'includeMods',
  124.     'emoteOnly',
  125.     'highlightOnly',
  126.     'darkMode',
  127.     'useCustomMessageColors',
  128.     'useCustomBorderColors',
  129.     'previewMode',
  130.     'largeEmotes',
  131.     'showBadges',
  132.     'fixedWidth',
  133.     'pronounsLowercase',
  134.     'pronounsBadgeCustomColors',
  135.     'includeFollowers',
  136.     'ffzGlobal',
  137.     'bttvGlobal',
  138.     'topEdge',
  139.     'bottomEdge',
  140.     'leftEdge',
  141.     'rightEdge',
  142.     'hideOutOfBounds',
  143.   )
  144.  
  145.   processFieldData(value => (value ? value : 1), 'delay')
  146.  
  147.   const soundData = {}
  148.   for (let i = 1; i <= 10; i++) {
  149.     const group = FieldData[`soundGroup${i}`]
  150.     const specificUsers = FieldData[`specificUsersSoundGroup${i}`]
  151.     const isSpecific = specificUsers.length > 0
  152.     // specific-index so multiple specifics don't override each other
  153.     const userLevel = isSpecific
  154.       ? `specific-${i}`
  155.       : FieldData[`userLevelSoundGroup${i}`]
  156.     const messageType = FieldData[`messageTypeSoundGroup${i}`]
  157.     if (group && group.length > 0) {
  158.       if (!soundData[userLevel]) {
  159.         soundData[userLevel] = {}
  160.       }
  161.  
  162.       if (isSpecific) {
  163.         soundData[userLevel].users = specificUsers
  164.       }
  165.  
  166.       if (!soundData[userLevel][messageType]) {
  167.         soundData[userLevel][messageType] = []
  168.       }
  169.  
  170.       soundData[userLevel][messageType].push(...group)
  171.     }
  172.   }
  173.  
  174.   Widget.soundEffects = Object.entries(soundData)
  175.     .reduce((acc, entry) => {
  176.       const [userLevel, { users, ...messageTypes }] = entry
  177.       for (const [messageType, soundEffects] of Object.entries(messageTypes)) {
  178.         acc.push({
  179.           userLevel,
  180.           messageType,
  181.           soundEffects,
  182.           users,
  183.           order: soundSortOrder(userLevel, messageType),
  184.         })
  185.       }
  186.       return [...acc]
  187.     }, [])
  188.     .sort(({ order: a }, { order: b }) => {
  189.       // sort by userLevel (0) then by messageType (1)
  190.       if (a[0] !== b[0]) return b[0] - a[0]
  191.       else return b[1] - a[1]
  192.     })
  193. }
  194.  
  195. function processFieldData(process, ...keys) {
  196.   for (const key of keys) {
  197.     FieldData[key] = process(FieldData[key])
  198.   }
  199. }
  200.  
  201. function stringToArray(string = '', separator = ',') {
  202.   return string.split(separator).reduce((acc, value) => {
  203.     const trimmed = value.trim()
  204.     if (trimmed !== '') acc.push(trimmed)
  205.     return acc
  206.   }, [])
  207. }
  208.  
  209. function conditionalMainClass(className, condition = true) {
  210.   const main = $('main')
  211.  
  212.   if (condition) main.addClass(className)
  213.   else main.removeClass(className)
  214. }
  215.  
  216. function soundSortOrder(userLevel, messageType) {
  217.   return [userLevelSortOrder(userLevel), messageTypeSortOrder(messageType)]
  218. }
  219.  
  220. function userLevelSortOrder(userLevel) {
  221.   switch (userLevel) {
  222.     case 'everyone':
  223.       return 0
  224.     case 'subs':
  225.       return 100
  226.     case 'vips':
  227.       return 200
  228.     case 'mods':
  229.       return 300
  230.     default:
  231.       return 1000 // assume specific
  232.   }
  233. }
  234.  
  235. function messageTypeSortOrder(messageType) {
  236.   switch (messageType) {
  237.     case 'highlight':
  238.       return 1000
  239.     case 'action':
  240.       return 500
  241.     case 'default':
  242.       return 100
  243.     default:
  244.       return 0 // assume all
  245.   }
  246. }
  247.  
  248. async function loadGlobalEmotes() {
  249.   for (const [key, value] of Object.entries(GLOBAL_EMOTES)) {
  250.     const { api, transformer } = value
  251.     const response = await get(api)
  252.     if (response != null) {
  253.       Widget.globalEmotes[key] = transformer(response)
  254.     }
  255.   }
  256. }
  257.  
  258. // --------------------
  259. //    Event Handlers
  260. // --------------------
  261.  
  262. window.addEventListener('onEventReceived', obj => {
  263.   const { listener, event } = obj.detail
  264.   switch (listener) {
  265.     case 'message':
  266.       onMessage(event)
  267.       break
  268.     case 'raid-latest':
  269.       onRaid(event)
  270.       break
  271.     case 'delete-message':
  272.       deleteMessage(event.msgId)
  273.       break
  274.     case 'delete-messages':
  275.       deleteMessages(event.userId)
  276.       break
  277.     case 'event:test':
  278.       onButton(event)
  279.       break
  280.     default:
  281.       return
  282.   }
  283. })
  284.  
  285. // ---------------------
  286. //    Event Functions
  287. // ---------------------
  288.  
  289. async function onMessage(event, testMessage = false) {
  290.   const { service } = event
  291.   Widget.service = service
  292.   const {
  293.     // facebook
  294.     attachment,
  295.     // trovo
  296.     content_data,
  297.     messageId,
  298.     content,
  299.     // general
  300.     badges = [],
  301.     userId = '',
  302.     nick: username = '',
  303.     displayName = '',
  304.   } = event.data
  305.  
  306.   let { emotes = [], text = '', msgId = '', displayColor: color } = event.data
  307.  
  308.   let pronouns = null
  309.   const allPronounKeys = Object.keys(Widget.pronouns)
  310.   if (FieldData.pronounsMode !== 'off' && allPronounKeys.length > 0) {
  311.     if (testMessage) {
  312.       const randomPronounKey =
  313.         allPronounKeys[random(0, allPronounKeys.length - 1)]
  314.       pronouns = Widget.pronouns[randomPronounKey]
  315.     } else if (service === 'twitch') {
  316.       pronouns = await getUserPronoun(username)
  317.     }
  318.   }
  319.  
  320.   if (pronouns && FieldData.pronounsLowercase) {
  321.     pronouns = pronouns.toLowerCase()
  322.   }
  323.  
  324.   // handle facebook
  325.   if (service === 'facebook' && attachment && attachment.type === 'sticker') {
  326.     const { url, target } = attachment
  327.     text = 'sticker'
  328.     emotes.push({
  329.       type: 'sticker',
  330.       name: text,
  331.       id: target.id,
  332.       gif: false,
  333.       urls: {
  334.         1: url,
  335.         2: url,
  336.         4: url,
  337.       },
  338.       start: 0,
  339.       end: text.length,
  340.     })
  341.   }
  342.  
  343.   // handle trovo
  344.   if (service === 'trovo') {
  345.     // remove messages from before the widget was loaded... idk why trovo sends these
  346.     if (!content_data) return
  347.  
  348.     msgId = messageId
  349.     text = content
  350.     color = undefined
  351.   }
  352.  
  353.   // Filters
  354.   if (FieldData.raidCooldown > 0 && !Widget.raidActive) return
  355.   if (FieldData.raidCooldown < 0 && Widget.raidActive) return
  356.   if (hasIgnoredPrefix(text)) return
  357.   if (!passedMinMessageThreshold(userId)) return
  358.   if (
  359.     FieldData.allowUserList.length &&
  360.     !userListIncludes(FieldData.allowUserList, displayName, username)
  361.   )
  362.     return
  363.   if (userListIncludes(FieldData.ignoreUserList, displayName, username)) return
  364.  
  365.   const permittedUserLevel = await hasIncludedBadge(badges, username)
  366.   if (!permittedUserLevel) return
  367.   if (
  368.     FieldData.allowedStrings.length &&
  369.     !FieldData.allowedStrings.includes(text)
  370.   )
  371.     return
  372.  
  373.   const messageType = getMessageType(event.data)
  374.   if (FieldData.highlightOnly && messageType !== 'highlight') return
  375.  
  376.   const parsedText = parse(htmlEncode(text), emotes)
  377.   const emoteSize = calcEmoteSize(parsedText)
  378.   if (FieldData.emoteOnly && emoteSize === 1) return
  379.  
  380.   if (FieldData.messageCooldown) {
  381.     if (Widget.cooldown) {
  382.       return
  383.     } else {
  384.       Widget.cooldown = true
  385.       window.setTimeout(() => {
  386.         Widget.cooldown = false
  387.       }, FieldData.messageCooldown * 1000)
  388.     }
  389.   }
  390.  
  391.   const elementData = {
  392.     parsedText,
  393.     name: displayName,
  394.     emoteSize,
  395.     messageType,
  396.     msgId,
  397.     userId,
  398.     color,
  399.     badges,
  400.     pronouns,
  401.   }
  402.  
  403.   // Render Bubble
  404.   if (FieldData.positionMode !== 'list') {
  405.     $('main').append(BubbleComponent(elementData))
  406.   } else {
  407.     $('main').prepend(BubbleComponent(elementData))
  408.   }
  409.   const currentMessage = `.bubble[data-message-id="${msgId}"]`
  410.  
  411.   // Calcute Bubble Position
  412.   window.setTimeout(_ => {
  413.     const height = $(currentMessage).outerHeight()
  414.     let maxWidth =
  415.       FieldData.fixedWidth || FieldData.theme.includes('.css')
  416.         ? FieldData.maxWidth
  417.         : $(`${currentMessage} .message-wrapper`).width() + 1
  418.     const minWidth = $(`${currentMessage} .username`).outerWidth()
  419.  
  420.     $(`${currentMessage} .message`).css({
  421.       '--dynamicWidth': Math.max(minWidth, maxWidth),
  422.     })
  423.  
  424.     if (FieldData.positionMode !== 'list') {
  425.       // I'm not entirely sure why the + 30 is necessary,
  426.       // but it makes the calculations work correctly
  427.       let xMax = Math.max(minWidth, maxWidth) + 30
  428.  
  429.       if (FieldData.theme === 'animal-crossing') {
  430.         xMax += 15 // due to margin-left 15 on .message
  431.       }
  432.  
  433.       const { left, top, right, bottom } = calcPosition(xMax, height)
  434.  
  435.       window.setTimeout(_ => {
  436.         $(currentMessage).css({ left, top, right, bottom })
  437.       }, 300)
  438.     }
  439.   }, 300)
  440.  
  441.   // Get Sound
  442.   let sound = null
  443.   const soundUrls = getSound(username, displayName, badges, messageType)
  444.   if (soundUrls) {
  445.     sound = new Audio(soundUrls[random(0, soundUrls.length - 1)])
  446.     sound.volume = parseInt(FieldData.volume) / 100
  447.   }
  448.  
  449.   // Show Bubble and Play Sound
  450.   window.setTimeout(_ => {
  451.     Widget.messageCount++
  452.     if (soundUrls) sound.play()
  453.     $(currentMessage).addClass('animate')
  454.     $(currentMessage).addClass(FieldData.animation)
  455.     if (FieldData.positionMode === 'list')
  456.       $(currentMessage).css({ position: 'relative' })
  457.  
  458.     const getOldest = () => {
  459.       const oldestMsgId =
  460.         FieldData.positionMode !== 'list'
  461.           ? $('.bubble:not(.expired)').first().attr('data-message-id')
  462.           : $('.bubble:not(.expired)').last().attr('data-message-id')
  463.       return [`.bubble[data-message-id="${oldestMsgId}"]`, oldestMsgId]
  464.     }
  465.  
  466.     const earlyDelete = (selector, id) => {
  467.       $(selector).addClass('expired')
  468.       $(selector).fadeOut(400, _ => deleteMessage(id))
  469.     }
  470.  
  471.     // Max message handling
  472.     if (
  473.       FieldData.maxMessages > 0 &&
  474.       Widget.messageCount > FieldData.maxMessages
  475.     ) {
  476.       const [selector, id] = getOldest()
  477.       earlyDelete(selector, id)
  478.     }
  479.  
  480.     if (FieldData.hideOutOfBounds && FieldData.positionMode === 'list') {
  481.       let hideDelay = 0
  482.       if (FieldData.animation === 'dynamic') {
  483.         if (
  484.           FieldData.listDirection === 'left' ||
  485.           FieldData.listDirection === 'right'
  486.         )
  487.           hideDelay = 200
  488.         if (
  489.           FieldData.listDirection === 'top' ||
  490.           FieldData.listDirection === 'bottom'
  491.         )
  492.           hideDelay = 1000
  493.       }
  494.       window.setTimeout(_ => {
  495.         let tryDelete = true
  496.         while (tryDelete) {
  497.           const [selector, id] = getOldest()
  498.           const { left, top } = $(selector).position()
  499.           const height = $(selector).outerHeight()
  500.           const width = $(selector).outerWidth()
  501.           const widgetWidth = $('main').innerWidth()
  502.           const widgetHeight = $('main').innerHeight()
  503.  
  504.           switch (FieldData.listDirection) {
  505.             case 'bottom':
  506.               if (top < FieldData.padding) earlyDelete(selector, id)
  507.               else tryDelete = false
  508.               break
  509.             case 'top':
  510.               if (top > widgetHeight - FieldData.padding - height)
  511.                 earlyDelete(selector, id)
  512.               else tryDelete = false
  513.               break
  514.             case 'left':
  515.               if (left > widgetWidth - FieldData.padding - width)
  516.                 earlyDelete(selector, id)
  517.               else tryDelete = false
  518.               break
  519.             case 'right':
  520.               if (left < FieldData.padding) earlyDelete(selector, id)
  521.               else tryDelete = false
  522.               break
  523.             default: // nothing
  524.           }
  525.         }
  526.       }, hideDelay)
  527.     }
  528.  
  529.     if (FieldData.lifetime > 0) {
  530.       window.setTimeout(_ => {
  531.         deleteMessage(msgId)
  532.       }, FieldData.lifetime * 1000)
  533.     }
  534.   }, FieldData.delay * 1000)
  535. }
  536.  
  537. function onRaid(event) {
  538.   if (FieldData.raidCooldown === 0) return
  539.   if (event.amount < FieldData.raidMin) return
  540.  
  541.   // Reset timer if another raid happens during an active raid timer
  542.   clearTimeout(Widget.raidTimer)
  543.  
  544.   Widget.raidActive = true
  545.   Widget.raidTimer = window.setTimeout(() => {
  546.     Widget.raidActive = false
  547.   }, Math.abs(FieldData.raidCooldown) * 1000)
  548. }
  549.  
  550. function deleteMessage(msgId) {
  551.   const messages = $(`.bubble[data-message-id="${msgId}"]`)
  552.   Widget.messageCount -= messages.length
  553.   messages.remove()
  554. }
  555.  
  556. function deleteMessages(userId) {
  557.   // userId is undefined when clear chat is used
  558.   // when userId is defined, that user has been banned or timed out
  559.   let selector = '.bubble'
  560.  
  561.   if (userId) {
  562.     selector = `.bubble[data-user-id="${userId}"]`
  563.     Widget.messageCount -= $(selector).length
  564.   } else {
  565.     Widget.messageCount = 0
  566.   }
  567.  
  568.   $(selector).remove()
  569. }
  570.  
  571. function onButton(event) {
  572.   const { listener, field, value } = event
  573.  
  574.   if (listener !== 'widget-button' || value !== 'zaytri_dynamicchatbubbles')
  575.     return
  576.  
  577.   switch (field) {
  578.     case 'testMessageButton':
  579.       sendTestMessage()
  580.       break
  581.     default:
  582.       return
  583.   }
  584. }
  585.  
  586. const TEST_USER_TYPES = [
  587.   { name: 'User', badges: [] },
  588.   {
  589.     name: 'Moderator',
  590.     badges: [
  591.       {
  592.         type: 'moderator',
  593.         url: 'https://static-cdn.jtvnw.net/badges/v1/3267646d-33f0-4b17-b3df-f923a41db1d0/3',
  594.       },
  595.     ],
  596.   },
  597.   {
  598.     name: 'VIP',
  599.     badges: [
  600.       {
  601.         type: 'vip',
  602.         url: 'https://static-cdn.jtvnw.net/badges/v1/b817aba4-fad8-49e2-b88a-7cc744dfa6ec/3',
  603.       },
  604.     ],
  605.   },
  606. ]
  607.  
  608. function sendTestMessage(amount = 1, delay = 250) {
  609.   for (let i = 0; i < amount; i++) {
  610.     window.setTimeout(_ => {
  611.       const number = numbered.stringify(random(1, 10))
  612.       const userType = TEST_USER_TYPES[random(0, TEST_USER_TYPES.length - 1)]
  613.       const name = `${userType.name}_${numbered.stringify(random(1, 10))}`
  614.       const event = {
  615.         data: {
  616.           userId: name,
  617.           tags: {},
  618.           text: 'test',
  619.           displayName: random(0, 1) ? name : name.toLowerCase(),
  620.           nick: '',
  621.           msgId: `${name}_${Date.now()}`,
  622.           badges: userType.badges,
  623.         },
  624.       }
  625.  
  626.       const previewMessage = FieldData.previewMessage.trim()
  627.       if (previewMessage !== '') {
  628.         event.data.text = previewMessage
  629.       } else {
  630.         const [text, emotes] =
  631.           TEST_MESSAGES[random(0, TEST_MESSAGES.length - 1)]
  632.         event.data.text = text
  633.         event.data.emotes = emotes
  634.       }
  635.  
  636.       let messageType = 1
  637.       switch (FieldData.previewType) {
  638.         case 'random':
  639.           messageType = random(1, 3)
  640.           break
  641.         case 'action':
  642.           messageType = 2
  643.           break
  644.         case 'highlight':
  645.           messageType = 3
  646.           break
  647.         default:
  648.           messageType = 1
  649.       }
  650.  
  651.       if (messageType === 2) {
  652.         event.data.isAction = true
  653.       } else if (messageType === 3) {
  654.         event.data.tags['msg-id'] = 'highlighted-message'
  655.       }
  656.       onMessage(event, true)
  657.     }, i * delay)
  658.   }
  659. }
  660.  
  661. // -------------------------
  662. //    Component Functions
  663. // -------------------------
  664.  
  665. function BubbleComponent(props) {
  666.   const {
  667.     parsedText,
  668.     emoteSize,
  669.     messageType,
  670.     msgId,
  671.     userId,
  672.     color: userColor,
  673.     badges,
  674.     pronouns,
  675.   } = props
  676.  
  677.   let { name } = props
  678.  
  679.   if (FieldData.pronounsMode === 'suffix' && pronouns) {
  680.     name = `${name} (${pronouns})`
  681.   }
  682.  
  683.   const color = userColor || generateColor(name)
  684.   const tColor = tinycolor(color)
  685.   const darkerColor = tinycolor
  686.     .mix(
  687.       FieldData.useCustomBorderColors ? FieldData.borderColor : color,
  688.       'black',
  689.       25,
  690.     )
  691.     .toString()
  692.  
  693.   // based on https://stackoverflow.com/a/69869976
  694.   const isDark = tColor.getLuminance() < 0.4
  695.  
  696.   const parsedElements = parsedText.map(({ type, data }) => {
  697.     switch (type) {
  698.       case 'emote':
  699.         return EmoteComponent(data)
  700.       case 'text':
  701.       default:
  702.         return TextComponent(data)
  703.     }
  704.   })
  705.  
  706.   let containerClasses = [
  707.     'bubble',
  708.     `emote-${FieldData.largeEmotes ? emoteSize : 1}`,
  709.   ]
  710.   switch (messageType) {
  711.     case 'highlight': {
  712.       if (FieldData.highlightStyle === 'rainbow')
  713.         containerClasses.push('highlight')
  714.       break
  715.     }
  716.     case 'action': {
  717.       if (FieldData.actionStyle === 'italics') containerClasses.push('action')
  718.       break
  719.     }
  720.     default: // nothing
  721.   }
  722.  
  723.   if (isDark && !FieldData.theme.includes('.css'))
  724.     containerClasses.push('user-color-dark')
  725.  
  726.   let usernameChildren = []
  727.   if (FieldData.showBadges) {
  728.     usernameChildren = BadgesComponent(badges)
  729.   }
  730.   if (FieldData.pronounsMode === 'badge' && pronouns) {
  731.     usernameChildren.push(PronounsBadgeComponent(pronouns))
  732.   }
  733.   usernameChildren.push(name)
  734.  
  735.   const usernameProps = {}
  736.   if (!FieldData.useCustomBorderColors && !FieldData.theme.includes('.css')) {
  737.     usernameProps.style = {
  738.       color: isDark
  739.         ? tinycolor.mix(color, 'white', 85).toString()
  740.         : tinycolor.mix(color, 'black', 85).toString(),
  741.     }
  742.   }
  743.  
  744.   const usernameBoxProps = {}
  745.   if (FieldData.theme.includes('.css')) {
  746.     usernameChildren.push(SpacerComponent())
  747.     usernameChildren.push(
  748.       Component('div', {
  749.         class: 'title-bar-controls',
  750.         children: [
  751.           Component('button', { 'aria-label': 'Minimize' }),
  752.           Component('button', { 'aria-label': 'Maximize' }),
  753.           Component('button', { 'aria-label': 'Close' }),
  754.         ],
  755.       }),
  756.     )
  757.     containerClasses.push('window')
  758.     usernameBoxProps.class = 'title-bar'
  759.   }
  760.  
  761.   const bubbleChildren = [
  762.     UsernameBoxComponent(
  763.       UsernameComponent(usernameChildren, usernameProps),
  764.       usernameBoxProps,
  765.     ),
  766.     MessageComponent(MessageWrapperComponent(parsedElements)),
  767.   ]
  768.  
  769.   if (FieldData.theme === 'default') {
  770.     bubbleChildren.unshift(BackgroundComponent())
  771.   }
  772.  
  773.   return Component('section', {
  774.     class: containerClasses,
  775.     style: { '--userColor': color, '--darkerColor': darkerColor },
  776.     'data-message-id': msgId,
  777.     'data-user-id': userId,
  778.     children: bubbleChildren,
  779.   })
  780. }
  781.  
  782. function BadgesComponent(badges) {
  783.   return badges.map(badge =>
  784.     Component('img', { class: 'badge', src: badge.url, alt: badge.type }),
  785.   )
  786. }
  787.  
  788. function TextComponent(text) {
  789.   return Component('span', { class: 'text', children: text })
  790. }
  791.  
  792. function EmoteComponent({ urls, name }) {
  793.   let url = urls[4]
  794.   if (!url) url = urls[2]
  795.   if (!url) url = urls[1]
  796.   return Component('img', { class: ['emote'], src: url, alt: name })
  797. }
  798.  
  799. const ClassComponent =
  800.   (tag, className) =>
  801.   (children, props = {}) => {
  802.     const { class: classNames, ...rest } = props
  803.     return Component(tag, {
  804.       children,
  805.       class: [joinIfArray(classNames), className],
  806.       ...rest,
  807.     })
  808.   }
  809. const BackgroundComponent = ClassComponent('div', 'bubble-background')
  810. const UsernameBoxComponent = ClassComponent('div', 'username-box')
  811. const UsernameComponent = ClassComponent('div', 'username')
  812. const PronounsBadgeComponent = ClassComponent('span', 'pronouns-badge')
  813. const MessageComponent = ClassComponent('div', 'message')
  814. const MessageWrapperComponent = ClassComponent('span', 'message-wrapper')
  815. const SpacerComponent = ClassComponent('span', 'spacer')
  816.  
  817. function Component(tag, props) {
  818.   const { children, class: classes, style, ...rest } = props
  819.  
  820.   if (classes) rest.class = joinIfArray(classes, ' ')
  821.  
  822.   if (style)
  823.     rest.style = Object.entries(style)
  824.       .map(([key, value]) => `${key}: ${value}`)
  825.       .join(';')
  826.  
  827.   const attributes = Object.entries(rest).reduce(
  828.     (acc, [attr, value]) => `${acc} ${attr}='${value}'`,
  829.     '',
  830.   )
  831.   return `<${tag}${attributes}>${
  832.     children !== undefined ? joinIfArray(children) : ''
  833.   }</${tag}>`
  834. }
  835.  
  836. // ----------------------------
  837. //    Pronouns API Functions
  838. // ----------------------------
  839. async function getPronouns() {
  840.   const res = await get(PRONOUNS_API.pronouns)
  841.   if (res) {
  842.     res.forEach(pronoun => {
  843.       Widget.pronouns[pronoun.name] = pronoun.display
  844.     })
  845.   }
  846. }
  847.  
  848. async function getUserPronoun(username) {
  849.   const lowercaseUsername = username.toLowerCase()
  850.   let pronouns = Widget.pronounsCache[lowercaseUsername]
  851.  
  852.   if (!pronouns || pronouns.expire < Date.now()) {
  853.     const res = await get(PRONOUNS_API.user(lowercaseUsername))
  854.     const [newPronouns] = res
  855.     Widget.pronounsCache[lowercaseUsername] = {
  856.       ...newPronouns,
  857.       expire: Date.now() + 1000 * 60 * 5, // 5 minutes in the future
  858.     }
  859.     pronouns = Widget.pronounsCache[lowercaseUsername]
  860.   }
  861.  
  862.   if (!pronouns.pronoun_id) {
  863.     return null
  864.   }
  865.  
  866.   return Widget.pronouns[pronouns.pronoun_id]
  867. }
  868.  
  869. // ---------------------
  870. //    Helper Functions
  871. // ---------------------
  872. async function get(URL) {
  873.   return await fetch(URL)
  874.     .then(async res => {
  875.       if (!res.ok) return null
  876.       return res.json()
  877.     })
  878.     .catch(error => null)
  879. }
  880.  
  881. async function getFollowDate(username) {
  882.   let followData = Widget.followCache[username]
  883.  
  884.   if (!followData || followData.expire < Date.now()) {
  885.     const data = await get(DEC_API.followedSeconds(username))
  886.     const seconds = parseInt(data)
  887.     if (isNaN(seconds)) return null
  888.  
  889.     date = new Date(seconds * 1000) // convert to milliseconds then date
  890.  
  891.     Widget.followCache[username] = {
  892.       date,
  893.       expire: Date.now() + 1000 * 60 * 60, // 1 hour in the future
  894.     }
  895.     followData = Widget.followCache[username]
  896.   }
  897.  
  898.   return followData.date
  899. }
  900.  
  901. async function followCheck(username) {
  902.   if (
  903.     Widget.service !== 'twitch' || // only works on twitch
  904.     Widget.channel.username.toLowerCase() === username.toLowerCase() // is broadcaster
  905.   ) {
  906.     return true
  907.   }
  908.  
  909.   const followDate = await getFollowDate(username)
  910.   if (!followDate) return false
  911.  
  912.   // convert minFollowTime from days to milliseconds
  913.   const minFollowTime = 1000 * 60 * 60 * 24 * FieldData.minFollowTime
  914.   return Date.now() - followDate >= minFollowTime
  915. }
  916.  
  917. function hasIgnoredPrefix(text) {
  918.   for (const prefix of FieldData.ignorePrefixList) {
  919.     if (text.startsWith(prefix)) return true
  920.   }
  921.   return false
  922. }
  923.  
  924. function passedMinMessageThreshold(userId) {
  925.   if (FieldData.minMessages === 0) return true
  926.  
  927.   // begin counting
  928.   if (!Widget.userMessageCount[userId]) Widget.userMessageCount[userId] = 0
  929.   Widget.userMessageCount[userId]++
  930.  
  931.   return Widget.userMessageCount[userId] > FieldData.minMessages
  932. }
  933.  
  934. function userListIncludes(userList, ...names) {
  935.   const lowercaseNames = names.map(name => name.toLowerCase())
  936.   return userList.some(user => lowercaseNames.includes(user.toLowerCase()))
  937. }
  938.  
  939. async function hasIncludedBadge(badges = [], username) {
  940.   const codeBadges = [...badges]
  941.  
  942.   if (FieldData.includeEveryone) return true
  943.  
  944.   const includedBadges = ['broadcaster']
  945.  
  946.   if (FieldData.includeFollowers) {
  947.     includedBadges.push('follower')
  948.     const isFollower = await followCheck(username)
  949.     if (isFollower) {
  950.       codeBadges.push({ type: 'follower' })
  951.     }
  952.   }
  953.  
  954.   if (!codeBadges.length) return false
  955.  
  956.   if (FieldData.includeSubs) includedBadges.push('subscriber', 'founder')
  957.   if (FieldData.includeVIPs) includedBadges.push('vip')
  958.   if (FieldData.includeMods) includedBadges.push('moderator')
  959.  
  960.   return hasBadge(codeBadges, ...includedBadges)
  961. }
  962.  
  963. function isMod(badges = []) {
  964.   return hasBadge(badges, 'moderator', 'broadcaster')
  965. }
  966.  
  967. function isVIP(badges = []) {
  968.   return hasBadge(badges, 'vip', 'broadcaster')
  969. }
  970.  
  971. function isSub(badges = []) {
  972.   return hasBadge(badges, 'subscriber', 'founder', 'broadcaster')
  973. }
  974.  
  975. function hasBadge(userBadges = [], ...badgeTypes) {
  976.   return userBadges.some(({ type }) => badgeTypes.includes(type))
  977. }
  978.  
  979. function getMessageType(data) {
  980.   if (data.isAction) return 'action'
  981.   if (data.tags && data.tags['msg-id'] === 'highlighted-message')
  982.     return 'highlight'
  983.   return 'default'
  984. }
  985.  
  986. function getSound(nick, name, badges, messageType) {
  987.   for (const soundGroup of Widget.soundEffects) {
  988.     const {
  989.       userLevel,
  990.       messageType: soundMessageType,
  991.       users = [],
  992.       soundEffects,
  993.     } = soundGroup
  994.     if (soundMessageType === 'all' || soundMessageType === messageType) {
  995.       switch (userLevel) {
  996.         case 'everyone':
  997.           return soundEffects
  998.         case 'subs':
  999.           if (isSub(badges)) return soundEffects
  1000.           break
  1001.         case 'vips':
  1002.           if (isVIP(badges)) return soundEffects
  1003.           break
  1004.         case 'mods':
  1005.           if (isMod(badges)) return soundEffects
  1006.           break
  1007.         // assume specific
  1008.         default:
  1009.           if (userListIncludes(users, nick, name)) return soundEffects
  1010.           break
  1011.       }
  1012.     }
  1013.   }
  1014.   return null
  1015. }
  1016.  
  1017. function parse(text, emotes) {
  1018.   const filteredEmotes = emotes.filter(emote => {
  1019.     const { name, type } = emote
  1020.     if (
  1021.       (type === 'ffz' && FieldData.ffzGlobal) ||
  1022.       (type === 'bttv' && FieldData.bttvGlobal)
  1023.     )
  1024.       return true
  1025.  
  1026.     const globalEmotes = Widget.globalEmotes[type]
  1027.     if (!globalEmotes) return true
  1028.  
  1029.     return !globalEmotes.includes(name)
  1030.   })
  1031.  
  1032.   if (!filteredEmotes || filteredEmotes.length === 0) {
  1033.     return [{ type: 'text', data: text }]
  1034.   }
  1035.  
  1036.   const regex = createRegex(filteredEmotes.map(e => htmlEncode(e.name)))
  1037.  
  1038.   const textObjs = text
  1039.     .split(regex)
  1040.     .map(string => ({ type: 'text', data: string }))
  1041.   const last = textObjs.pop()
  1042.  
  1043.   const parsedText = textObjs.reduce((acc, textObj, index) => {
  1044.     return [...acc, textObj, { type: 'emote', data: filteredEmotes[index] }]
  1045.   }, [])
  1046.  
  1047.   parsedText.push(last)
  1048.   return parsedText
  1049. }
  1050.  
  1051. function calcEmoteSize(parsedText) {
  1052.   let emotesFound = 0
  1053.   for (const { type, data } of parsedText) {
  1054.     if (type === 'emote') {
  1055.       emotesFound++
  1056.     } else if (data.trim() !== '') return 1
  1057.   }
  1058.   if (emotesFound > 1) return 2
  1059.   return 4
  1060. }
  1061.  
  1062. // I have no idea how this works anymore but it does
  1063. // Regex is so useful but it's so confusing
  1064. // This is all to parse out the emote text
  1065. const createRegex = strings => {
  1066.   const regexStrings = strings
  1067.     .sort()
  1068.     .reverse()
  1069.     .map(string => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
  1070.   const regex = `(?<=\\s|^)(?:${regexStrings.join('|')})(?=\\s|$|[.,!])`
  1071.   return new RegExp(regex, 'g')
  1072. }
  1073.  
  1074. function generateColor(name) {
  1075.   if (!name) return DEFAULT_COLORS[0]
  1076.   const value = name
  1077.     .split('')
  1078.     .reduce((sum, letter) => sum + letter.charCodeAt(0), 0)
  1079.   return DEFAULT_COLORS[value % DEFAULT_COLORS.length]
  1080. }
  1081.  
  1082. function random(min, max) {
  1083.   return Math.floor(Math.random() * (max - min + 1)) + min
  1084. }
  1085.  
  1086. function calcPosition(width, height) {
  1087.   const main = $('main')
  1088.   const widgetWidth = main.innerWidth()
  1089.   const widgetHeight = main.innerHeight()
  1090.   const { padding } = FieldData
  1091.  
  1092.   // edge testing
  1093.   /*-*
  1094.   return [
  1095.     random(0, 1) ? padding : Math.max(padding, widgetWidth - padding - width),
  1096.     random(0, 1) ? padding : Math.max(padding, widgetHeight - padding - height),
  1097.   ]
  1098.   /*-*/
  1099.   const minX = padding
  1100.   const maxX = Math.max(padding, widgetWidth - padding - width)
  1101.   const minY = padding
  1102.   const maxY = Math.max(padding, widgetHeight - padding - height)
  1103.  
  1104.   const randomX = random(minX, maxX)
  1105.   const randomY = random(minY, maxY)
  1106.  
  1107.   if (FieldData.positionMode === 'random') {
  1108.     return { top: randomY, left: randomX }
  1109.   } else {
  1110.     const possibleCoords = []
  1111.     const deviation = random(0, FieldData.edgeDeviation)
  1112.  
  1113.     if (FieldData.topEdge) {
  1114.       possibleCoords.push({ top: minY + deviation, left: randomX })
  1115.     }
  1116.  
  1117.     if (FieldData.bottomEdge) {
  1118.       possibleCoords.push({ bottom: minY + deviation, left: randomX })
  1119.     }
  1120.  
  1121.     if (FieldData.leftEdge) {
  1122.       possibleCoords.push({ left: minX + deviation, top: randomY })
  1123.     }
  1124.  
  1125.     if (FieldData.rightEdge) {
  1126.       possibleCoords.push({ right: minX + deviation, top: randomY })
  1127.     }
  1128.  
  1129.     // no edges chosen so just put all chats in the middle as an easter egg
  1130.     if (possibleCoords.length === 0) {
  1131.       return { left: (minX + maxX) / 2, top: (minY + maxY) / 2 }
  1132.     }
  1133.  
  1134.     return possibleCoords[random(0, possibleCoords.length - 1)]
  1135.   }
  1136. }
  1137.  
  1138. function joinIfArray(possibleArray, delimiter = '') {
  1139.   if (Array.isArray(possibleArray)) return possibleArray.join(delimiter)
  1140.   return possibleArray
  1141. }
  1142.  
  1143. const TEST_MESSAGES = [
  1144.   ['HYPE'],
  1145.   ['uwu'],
  1146.   [
  1147.     'popCat',
  1148.     [
  1149.       {
  1150.         type: 'bttv',
  1151.         name: 'popCat',
  1152.         id: '60d5abc38ed8b373e421952f',
  1153.         gif: true,
  1154.         urls: {
  1155.           1: 'https://cdn.betterttv.net/emote/60d5abc38ed8b373e421952f/1x',
  1156.           2: 'https://cdn.betterttv.net/emote/60d5abc38ed8b373e421952f/2x',
  1157.           4: 'https://cdn.betterttv.net/emote/60d5abc38ed8b373e421952f/3x',
  1158.         },
  1159.         start: 0,
  1160.         end: 6,
  1161.       },
  1162.     ],
  1163.   ],
  1164.   [
  1165.     'catHYPE hypeE catHYPE',
  1166.     [
  1167.       {
  1168.         type: 'bttv',
  1169.         name: 'catHYPE',
  1170.         id: '6090e9cc39b5010444d0b3ff',
  1171.         gif: true,
  1172.         urls: {
  1173.           1: 'https://cdn.betterttv.net/emote/6090e9cc39b5010444d0b3ff/1x',
  1174.           2: 'https://cdn.betterttv.net/emote/6090e9cc39b5010444d0b3ff/2x',
  1175.           4: 'https://cdn.betterttv.net/emote/6090e9cc39b5010444d0b3ff/3x',
  1176.         },
  1177.         start: 0,
  1178.         end: 7,
  1179.       },
  1180.       {
  1181.         type: 'bttv',
  1182.         name: 'hypeE',
  1183.         id: '5b6ded5560d17f4657e1319e',
  1184.         gif: true,
  1185.         urls: {
  1186.           1: 'https://cdn.betterttv.net/emote/5b6ded5560d17f4657e1319e/1x',
  1187.           2: 'https://cdn.betterttv.net/emote/5b6ded5560d17f4657e1319e/2x',
  1188.           4: 'https://cdn.betterttv.net/emote/5b6ded5560d17f4657e1319e/3x',
  1189.         },
  1190.         start: 8,
  1191.         end: 13,
  1192.       },
  1193.       {
  1194.         type: 'bttv',
  1195.         name: 'catHYPE',
  1196.         id: '6090e9cc39b5010444d0b3ff',
  1197.         gif: true,
  1198.         urls: {
  1199.           1: 'https://cdn.betterttv.net/emote/6090e9cc39b5010444d0b3ff/1x',
  1200.           2: 'https://cdn.betterttv.net/emote/6090e9cc39b5010444d0b3ff/2x',
  1201.           4: 'https://cdn.betterttv.net/emote/6090e9cc39b5010444d0b3ff/3x',
  1202.         },
  1203.         start: 14,
  1204.         end: 21,
  1205.       },
  1206.     ],
  1207.   ],
  1208.   [
  1209.     'zaytriLOVE',
  1210.     [
  1211.       {
  1212.         type: 'twitch',
  1213.         name: 'zaytriLOVE',
  1214.         id: '307974105',
  1215.         gif: false,
  1216.         urls: {
  1217.           1: 'https://static-cdn.jtvnw.net/emoticons/v2/307974105/default/dark/1.0',
  1218.           2: 'https://static-cdn.jtvnw.net/emoticons/v2/307974105/default/dark/2.0',
  1219.           4: 'https://static-cdn.jtvnw.net/emoticons/v2/307974105/default/dark/3.0',
  1220.         },
  1221.         start: 0,
  1222.         end: 9,
  1223.       },
  1224.     ],
  1225.   ],
  1226.   [
  1227.     'D: D: D:',
  1228.     [
  1229.       {
  1230.         type: 'bttv',
  1231.         name: 'D:',
  1232.         id: '55028cd2135896936880fdd7',
  1233.         gif: false,
  1234.         urls: {
  1235.           1: 'https://cdn.betterttv.net/emote/55028cd2135896936880fdd7/1x',
  1236.           2: 'https://cdn.betterttv.net/emote/55028cd2135896936880fdd7/2x',
  1237.           4: 'https://cdn.betterttv.net/emote/55028cd2135896936880fdd7/3x',
  1238.         },
  1239.         start: 0,
  1240.         end: 2,
  1241.       },
  1242.       {
  1243.         type: 'bttv',
  1244.         name: 'D:',
  1245.         id: '55028cd2135896936880fdd7',
  1246.         gif: false,
  1247.         urls: {
  1248.           1: 'https://cdn.betterttv.net/emote/55028cd2135896936880fdd7/1x',
  1249.           2: 'https://cdn.betterttv.net/emote/55028cd2135896936880fdd7/2x',
  1250.           4: 'https://cdn.betterttv.net/emote/55028cd2135896936880fdd7/3x',
  1251.         },
  1252.         start: 3,
  1253.         end: 5,
  1254.       },
  1255.       {
  1256.         type: 'bttv',
  1257.         name: 'D:',
  1258.         id: '55028cd2135896936880fdd7',
  1259.         gif: false,
  1260.         urls: {
  1261.           1: 'https://cdn.betterttv.net/emote/55028cd2135896936880fdd7/1x',
  1262.           2: 'https://cdn.betterttv.net/emote/55028cd2135896936880fdd7/2x',
  1263.           4: 'https://cdn.betterttv.net/emote/55028cd2135896936880fdd7/3x',
  1264.         },
  1265.         start: 6,
  1266.         end: 8,
  1267.       },
  1268.     ],
  1269.   ],
  1270.   [
  1271.     'SCREME',
  1272.     [
  1273.       {
  1274.         type: 'bttv',
  1275.         name: 'SCREME',
  1276.         id: '5fea41766b06e834ffd76103',
  1277.         gif: true,
  1278.         urls: {
  1279.           1: 'https://cdn.betterttv.net/emote/5fea41766b06e834ffd76103/1x',
  1280.           2: 'https://cdn.betterttv.net/emote/5fea41766b06e834ffd76103/2x',
  1281.           4: 'https://cdn.betterttv.net/emote/5fea41766b06e834ffd76103/3x',
  1282.         },
  1283.         start: 0,
  1284.         end: 6,
  1285.       },
  1286.     ],
  1287.   ],
  1288.   [
  1289.     'toad sings but make it nightcore zaytriSCREME',
  1290.     [
  1291.       {
  1292.         type: 'twitch',
  1293.         name: 'zaytriSCREME',
  1294.         id: '305161229',
  1295.         gif: false,
  1296.         urls: {
  1297.           1: 'https://static-cdn.jtvnw.net/emoticons/v2/305161229/default/dark/1.0',
  1298.           2: 'https://static-cdn.jtvnw.net/emoticons/v2/305161229/default/dark/2.0',
  1299.           4: 'https://static-cdn.jtvnw.net/emoticons/v2/305161229/default/dark/3.0',
  1300.         },
  1301.         start: 33,
  1302.         end: 44,
  1303.       },
  1304.     ],
  1305.   ],
  1306.   [
  1307.     'bobDance bobDance bobDance',
  1308.     [
  1309.       {
  1310.         type: 'bttv',
  1311.         name: 'bobDance',
  1312.         id: '5e2a1da9bca2995f13fc0261',
  1313.         gif: true,
  1314.         urls: {
  1315.           1: 'https://cdn.betterttv.net/emote/5e2a1da9bca2995f13fc0261/1x',
  1316.           2: 'https://cdn.betterttv.net/emote/5e2a1da9bca2995f13fc0261/2x',
  1317.           4: 'https://cdn.betterttv.net/emote/5e2a1da9bca2995f13fc0261/3x',
  1318.         },
  1319.         start: 0,
  1320.         end: 8,
  1321.       },
  1322.       {
  1323.         type: 'bttv',
  1324.         name: 'bobDance',
  1325.         id: '5e2a1da9bca2995f13fc0261',
  1326.         gif: true,
  1327.         urls: {
  1328.           1: 'https://cdn.betterttv.net/emote/5e2a1da9bca2995f13fc0261/1x',
  1329.           2: 'https://cdn.betterttv.net/emote/5e2a1da9bca2995f13fc0261/2x',
  1330.           4: 'https://cdn.betterttv.net/emote/5e2a1da9bca2995f13fc0261/3x',
  1331.         },
  1332.         start: 9,
  1333.         end: 17,
  1334.       },
  1335.       {
  1336.         type: 'bttv',
  1337.         name: 'bobDance',
  1338.         id: '5e2a1da9bca2995f13fc0261',
  1339.         gif: true,
  1340.         urls: {
  1341.           1: 'https://cdn.betterttv.net/emote/5e2a1da9bca2995f13fc0261/1x',
  1342.           2: 'https://cdn.betterttv.net/emote/5e2a1da9bca2995f13fc0261/2x',
  1343.           4: 'https://cdn.betterttv.net/emote/5e2a1da9bca2995f13fc0261/3x',
  1344.         },
  1345.         start: 18,
  1346.         end: 26,
  1347.       },
  1348.     ],
  1349.   ],
  1350.   [
  1351.     'bongoTap',
  1352.     [
  1353.       {
  1354.         type: 'bttv',
  1355.         name: 'bongoTap',
  1356.         id: '5ba6d5ba6ee0c23989d52b10',
  1357.         gif: true,
  1358.         urls: {
  1359.           1: 'https://cdn.betterttv.net/emote/5ba6d5ba6ee0c23989d52b10/1x',
  1360.           2: 'https://cdn.betterttv.net/emote/5ba6d5ba6ee0c23989d52b10/2x',
  1361.           4: 'https://cdn.betterttv.net/emote/5ba6d5ba6ee0c23989d52b10/3x',
  1362.         },
  1363.         start: 0,
  1364.         end: 8,
  1365.       },
  1366.     ],
  1367.   ],
  1368.   [
  1369.     'VoHiYo hello!',
  1370.     [
  1371.       {
  1372.         type: 'twitch',
  1373.         name: 'VoHiYo',
  1374.         id: '81274',
  1375.         gif: false,
  1376.         urls: {
  1377.           1: 'https://static-cdn.jtvnw.net/emoticons/v2/81274/default/dark/1.0',
  1378.           2: 'https://static-cdn.jtvnw.net/emoticons/v2/81274/default/dark/2.0',
  1379.           4: 'https://static-cdn.jtvnw.net/emoticons/v2/81274/default/dark/3.0',
  1380.         },
  1381.         start: 0,
  1382.         end: 5,
  1383.       },
  1384.     ],
  1385.   ],
  1386.   [
  1387.     'TwitchUnity',
  1388.     [
  1389.       {
  1390.         type: 'twitch',
  1391.         name: 'TwitchUnity',
  1392.         id: '196892',
  1393.         gif: false,
  1394.         urls: {
  1395.           1: 'https://static-cdn.jtvnw.net/emoticons/v2/196892/default/dark/1.0',
  1396.           2: 'https://static-cdn.jtvnw.net/emoticons/v2/196892/default/dark/2.0',
  1397.           4: 'https://static-cdn.jtvnw.net/emoticons/v2/196892/default/dark/3.0',
  1398.         },
  1399.         start: 0,
  1400.         end: 10,
  1401.       },
  1402.     ],
  1403.   ],
  1404.   [
  1405.     'MercyWing1 PinkMercy MercyWing2',
  1406.     [
  1407.       {
  1408.         type: 'twitch',
  1409.         name: 'MercyWing1',
  1410.         id: '1003187',
  1411.         gif: false,
  1412.         urls: {
  1413.           1: 'https://static-cdn.jtvnw.net/emoticons/v1/1003187/1.0',
  1414.           2: 'https://static-cdn.jtvnw.net/emoticons/v1/1003187/1.0',
  1415.           4: 'https://static-cdn.jtvnw.net/emoticons/v1/1003187/3.0',
  1416.         },
  1417.         start: 0,
  1418.         end: 9,
  1419.       },
  1420.       {
  1421.         type: 'twitch',
  1422.         name: 'PinkMercy',
  1423.         id: '1003190',
  1424.         gif: false,
  1425.         urls: {
  1426.           1: 'https://static-cdn.jtvnw.net/emoticons/v1/1003190/1.0',
  1427.           2: 'https://static-cdn.jtvnw.net/emoticons/v1/1003190/1.0',
  1428.           4: 'https://static-cdn.jtvnw.net/emoticons/v1/1003190/3.0',
  1429.         },
  1430.         start: 11,
  1431.         end: 19,
  1432.       },
  1433.       {
  1434.         type: 'twitch',
  1435.         name: 'MercyWing2',
  1436.         id: '1003189',
  1437.         gif: false,
  1438.         urls: {
  1439.           1: 'https://static-cdn.jtvnw.net/emoticons/v1/1003189/1.0',
  1440.           2: 'https://static-cdn.jtvnw.net/emoticons/v1/1003189/1.0',
  1441.           4: 'https://static-cdn.jtvnw.net/emoticons/v1/1003189/3.0',
  1442.         },
  1443.         start: 21,
  1444.         end: 30,
  1445.       },
  1446.     ],
  1447.   ],
  1448. ]
  1449.  
  1450. function htmlEncode(text) {
  1451.   return text.replace(/[\<\>\"\'\^\=]/g, char => `&#${char.charCodeAt(0)};`)
  1452. }
Advertisement
Add Comment
Please, Sign In to add comment