· last year · Nov 04, 2023, 02:05 PM
1const DEFAULT_COLORS = [
2 '#FF4A80',
3 '#FF7070',
4 '#FA8E4B',
5 '#FEE440',
6 '#5FFF77',
7 '#00F5D4',
8 '#00BBF9',
9 '#4371FB',
10 '#9B5DE5',
11 '#F670DD',
12]
13
14let FieldData = {}
15const 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
32const PRONOUNS_API_BASE = 'https://pronouns.alejo.io/api'
33const PRONOUNS_API = {
34 user: username => `${PRONOUNS_API_BASE}/users/${username}`,
35 pronouns: `${PRONOUNS_API_BASE}/pronouns`,
36}
37
38const DEC_API_BASE = 'https://decapi.me/twitch'
39const DEC_API = {
40 followedSeconds: username =>
41 `${DEC_API_BASE}/followed/${Widget.channel.username}/${username}?format=U`,
42}
43
44const 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
77window.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
103function 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
195function processFieldData(process, ...keys) {
196 for (const key of keys) {
197 FieldData[key] = process(FieldData[key])
198 }
199}
200
201function 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
209function conditionalMainClass(className, condition = true) {
210 const main = $('main')
211
212 if (condition) main.addClass(className)
213 else main.removeClass(className)
214}
215
216function soundSortOrder(userLevel, messageType) {
217 return [userLevelSortOrder(userLevel), messageTypeSortOrder(messageType)]
218}
219
220function 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
235function 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
248async 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
262window.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
289async 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
537function 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
550function deleteMessage(msgId) {
551 const messages = $(`.bubble[data-message-id="${msgId}"]`)
552 Widget.messageCount -= messages.length
553 messages.remove()
554}
555
556function 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
571function 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
586const 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
608function 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
665function 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
782function BadgesComponent(badges) {
783 return badges.map(badge =>
784 Component('img', { class: 'badge', src: badge.url, alt: badge.type }),
785 )
786}
787
788function TextComponent(text) {
789 return Component('span', { class: 'text', children: text })
790}
791
792function 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
799const 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 }
809const BackgroundComponent = ClassComponent('div', 'bubble-background')
810const UsernameBoxComponent = ClassComponent('div', 'username-box')
811const UsernameComponent = ClassComponent('div', 'username')
812const PronounsBadgeComponent = ClassComponent('span', 'pronouns-badge')
813const MessageComponent = ClassComponent('div', 'message')
814const MessageWrapperComponent = ClassComponent('span', 'message-wrapper')
815const SpacerComponent = ClassComponent('span', 'spacer')
816
817function 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// ----------------------------
839async 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
848async 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// ---------------------
872async 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
881async 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
901async 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
917function hasIgnoredPrefix(text) {
918 for (const prefix of FieldData.ignorePrefixList) {
919 if (text.startsWith(prefix)) return true
920 }
921 return false
922}
923
924function 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
934function userListIncludes(userList, ...names) {
935 const lowercaseNames = names.map(name => name.toLowerCase())
936 return userList.some(user => lowercaseNames.includes(user.toLowerCase()))
937}
938
939async 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
963function isMod(badges = []) {
964 return hasBadge(badges, 'moderator', 'broadcaster')
965}
966
967function isVIP(badges = []) {
968 return hasBadge(badges, 'vip', 'broadcaster')
969}
970
971function isSub(badges = []) {
972 return hasBadge(badges, 'subscriber', 'founder', 'broadcaster')
973}
974
975function hasBadge(userBadges = [], ...badgeTypes) {
976 return userBadges.some(({ type }) => badgeTypes.includes(type))
977}
978
979function 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
986function 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
1017function 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
1051function 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
1065const 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
1074function 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
1082function random(min, max) {
1083 return Math.floor(Math.random() * (max - min + 1)) + min
1084}
1085
1086function 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
1138function joinIfArray(possibleArray, delimiter = '') {
1139 if (Array.isArray(possibleArray)) return possibleArray.join(delimiter)
1140 return possibleArray
1141}
1142
1143const 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
1450function htmlEncode(text) {
1451 return text.replace(/[\<\>\"\'\^\=]/g, char => `&#${char.charCodeAt(0)};`)
1452}