diff --git a/assets/chat/css/chat/_index.scss b/assets/chat/css/chat/_index.scss index 9c202515..82467ce2 100644 --- a/assets/chat/css/chat/_index.scss +++ b/assets/chat/css/chat/_index.scss @@ -9,6 +9,7 @@ @use 'scrollbar-theme'; @use 'toolbar'; @use 'window-select'; +@use 'event-bar'; #chat { min-width: a.$chat-min-width; diff --git a/assets/chat/css/chat/_output.scss b/assets/chat/css/chat/_output.scss index 89998b70..fa2a02a2 100644 --- a/assets/chat/css/chat/_output.scss +++ b/assets/chat/css/chat/_output.scss @@ -4,6 +4,7 @@ flex: 1; overflow: hidden; width: 100%; + position: relative; } .chat-output { @@ -15,6 +16,7 @@ font-family: a.$chat-lines-font; line-height: 1.65em; outline: 0 !important; + overflow-anchor: none; } .onstreamchat { diff --git a/assets/chat/css/chat/event-bar/_event-bar-event.scss b/assets/chat/css/chat/event-bar/_event-bar-event.scss new file mode 100644 index 00000000..37bb53f2 --- /dev/null +++ b/assets/chat/css/chat/event-bar/_event-bar-event.scss @@ -0,0 +1,148 @@ +@use '../../abstracts/' as a; +@use '../../messages/event/_donation' as donation; + +.event-bar-event { + position: relative; + cursor: pointer; + transition: transform 100ms; + + font-size: 1.1em; + border-radius: 10px; + border-style: solid; + border-color: unset; + border-width: 2px; + margin: a.$gutter-sm; + + .event-contents { + display: flex; + padding: 0 a.$gutter-xs 0 0.4em; + background-color: a.$color-chat-emphasize; + justify-content: space-between; + align-items: center; + border-radius: 10px; + } + + .event-info { + margin-right: a.$gutter-xs; + max-width: 12ch; + overflow: hidden; + } + + .event-icon { + width: 2em; + height: 2em; + border: 0.25em solid transparent; + opacity: 0.75; + + &.subscription { + @include a.icon-background('../img/sub-regular.svg'); + } + + &.giftsub { + filter: invert(100%); + + @include a.icon-background('../img/sub-gift.png'); + } + + &.massgift { + filter: invert(100%); + + @include a.icon-background('../img/sub-mass-gift.png'); + } + } + + .user { + font-weight: 500; + color: a.$color-label-user; + display: inline-block; + animation: scrolling-event-username 12s linear 3s infinite; + word-wrap: normal; + + &::before { + content: unset; + } + + &:hover { + text-decoration: none; + } + } + + &.amount-0 { + border-color: #1c5cdb; + } + + @each $amount, $color in donation.$amount-color-map { + &.amount-#{$amount} { + border-color: $color; + + .event-icon { + filter: invert(100%); + + @include a.icon-background('../img/donation-amount-#{$amount}.png'); + } + } + } + + &.rainbow-border { + background-clip: padding-box; + border: solid 2px transparent; + + &:before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + margin: -2px; + border-radius: inherit; + background: var(--rainbow-gradient); + } + } + + &:hover { + transform: scale(1.05); + } + + // Ensure `removed` can override `enter` because `enter` is not removed from + // the event after the animation completes. + &.enter { + animation: event-bar-appear 500ms linear; + } + + &.removed { + animation: event-bar-disappear 500ms linear; + } +} + +@keyframes event-bar-appear { + 0% { + transform: scale(0.1); + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes event-bar-disappear { + 0% { + opacity: 1; + } + + 100% { + transform: scale(0.1); + opacity: 0; + } +} + +@keyframes scrolling-event-username { + 25%, + 50% { + transform: translateX(calc(-1 * max(0px, 100% - 12ch))); + } + + 75%, + 100% { + transform: translateX(0%); + } +} diff --git a/assets/chat/css/chat/event-bar/_index.scss b/assets/chat/css/chat/event-bar/_index.scss new file mode 100644 index 00000000..6a29d7f8 --- /dev/null +++ b/assets/chat/css/chat/event-bar/_index.scss @@ -0,0 +1,49 @@ +@use '../../abstracts/' as a; + +@use 'event-bar-event'; + +#chat-event-bar { + &:empty { + display: none; + } + + display: inline-flex; + overflow-x: scroll; + background: #111113; + z-index: 6; + + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +} + +#chat-event-selected { + position: absolute; + z-index: 210; + inset: 0; + background-color: rgba(0, 0, 0, 0.5); + + .event-bar-selected-message { + margin: a.$gutter-sm; + + .focus:not(.watching-focus) & { + opacity: 1; + } + } + + &.hidden { + display: none; + } +} + +.onstreamchat { + #chat-event-bar { + display: none; + } + + #chat-event-selected, + #chat-pinned-message { + display: none; + } +} diff --git a/assets/chat/css/menus/_emote-list.scss b/assets/chat/css/menus/_emote-list.scss index a6382372..931a65e8 100644 --- a/assets/chat/css/menus/_emote-list.scss +++ b/assets/chat/css/menus/_emote-list.scss @@ -22,11 +22,6 @@ background: none; border-radius: 0; } - - .favorite { - flex: 0 88px; - border-top: 1px solid a.$color-surface-dark4; - } } .emote-group { @@ -36,6 +31,20 @@ margin: a.$gutter-lg; } +.favorite-emote { + position: relative; +} + +.favorite-emote:after { + content: url('../img/icon-pin.svg'); + position: absolute; + width: 1em; + height: 1em; + right: -0.5em; + top: -0.5em; + transform: rotate(45deg); +} + .emote-item { user-select: none; padding: a.$gutter-sm; diff --git a/assets/chat/css/menus/_event-action-menu.scss b/assets/chat/css/menus/_event-action-menu.scss new file mode 100644 index 00000000..b9190f6e --- /dev/null +++ b/assets/chat/css/menus/_event-action-menu.scss @@ -0,0 +1,23 @@ +@use '../abstracts/' as a; + +#event-action-menu { + height: fit-content; + width: fit-content; + min-width: 75px; + max-width: 250px; + z-index: 221; + + .chat-menu-inner { + background-color: a.$color-surface-dark3; + } + + .event-action { + transition: background-color 150ms ease; + color: a.$color-light; + padding: 0.5rem 1rem; + + &:hover { + background-color: a.$color-surface-dark4; + } + } +} diff --git a/assets/chat/css/menus/_index.scss b/assets/chat/css/menus/_index.scss index 30135d81..6d9af991 100644 --- a/assets/chat/css/menus/_index.scss +++ b/assets/chat/css/menus/_index.scss @@ -7,6 +7,7 @@ @use 'user-info'; @use 'user-list'; @use 'whispers-list'; +@use 'event-action-menu'; .chat-menu { display: none; diff --git a/assets/chat/css/messages/_index.scss b/assets/chat/css/messages/_index.scss index a8c3332a..77ddd9f8 100644 --- a/assets/chat/css/messages/_index.scss +++ b/assets/chat/css/messages/_index.scss @@ -1,9 +1,9 @@ @use '../abstracts' as a; @use 'base'; -@use 'modifiers'; @use 'ui'; @use 'user'; @use 'emote'; @use 'event'; @use 'pinned'; +@use 'modifiers'; diff --git a/assets/chat/css/messages/event/_event.scss b/assets/chat/css/messages/event/_event.scss index a80ead6d..28956bb1 100644 --- a/assets/chat/css/messages/event/_event.scss +++ b/assets/chat/css/messages/event/_event.scss @@ -32,8 +32,6 @@ } .event-icon { - width: 2.25em; - height: 2.25em; color: a.$color-chat-disabled; position: relative; text-decoration: none; @@ -41,6 +39,9 @@ border: 0.25em solid transparent; flex-shrink: 0; opacity: 0.75; + width: 100%; + height: 100%; + transition: background 200ms ease; } .event-bottom { @@ -50,6 +51,21 @@ border-bottom-left-radius: 10px; } + .event-button { + width: 2.25em; + height: 2.25em; + + &:hover:not(:disabled) { + .event-icon { + @include a.icon-background('../img/icon-ellipsis-vertical.svg'); + } + } + + &:disabled { + cursor: default; + } + } + &:not(:has(.event-bottom)) { .event-top { border-bottom-right-radius: 10px; diff --git a/assets/chat/css/messages/pinned/_frame.scss b/assets/chat/css/messages/pinned/_frame.scss index 68bab783..b7c5644d 100644 --- a/assets/chat/css/messages/pinned/_frame.scss +++ b/assets/chat/css/messages/pinned/_frame.scss @@ -1,6 +1,6 @@ @use '../../abstracts/' as a; -#chat-pinned-frame { +#chat-pinned-message { display: none; padding: 4px; position: absolute; @@ -38,7 +38,7 @@ } .onstreamchat { - #chat-pinned-frame { + #chat-pinned-message { display: none !important; } } diff --git a/assets/chat/img/icon-ellipsis-vertical.svg b/assets/chat/img/icon-ellipsis-vertical.svg new file mode 100644 index 00000000..1de61600 --- /dev/null +++ b/assets/chat/img/icon-ellipsis-vertical.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/assets/chat/js/chat.js b/assets/chat/js/chat.js index 46275847..40eb855a 100644 --- a/assets/chat/js/chat.js +++ b/assets/chat/js/chat.js @@ -31,7 +31,9 @@ import { ChatEmoteTooltip, ChatSettingsMenu, ChatUserInfoMenu, + ChatEventActionMenu, } from './menus'; +import ChatEventBar from './event-bar/EventBar'; import ChatAutoComplete from './autocomplete'; import ChatInputHistory from './history'; import ChatUserFocus from './focus'; @@ -56,6 +58,7 @@ import makeSafeForRegex, { import { HashLinkConverter, MISSING_ARG_ERROR } from './hashlinkconverter'; import ChatCommands from './commands'; import MessageTemplateHTML from '../../views/templates.html'; +import EventBarEvent from './event-bar/EventBarEvent'; class Chat { constructor(config) { @@ -150,6 +153,7 @@ class Chat { this.source.on('ADDPHRASE', (data) => this.onADDPHRASE(data)); this.source.on('REMOVEPHRASE', (data) => this.onREMOVEPHRASE(data)); this.source.on('DEATH', (data) => this.onDEATH(data)); + this.source.on('PAIDEVENTS', (data) => this.onPAIDEVENTS(data)); this.control.on('SEND', (data) => this.cmdSEND(data)); this.control.on('HINT', (data) => this.cmdHINT(data)); @@ -317,6 +321,11 @@ class Chat { this.mainwindow = new ChatWindow('main').into(this); this.mutedtimer = new MutedTimer(this); this.chatpoll = new ChatPoll(this); + + this.eventBar = new ChatEventBar(); + this.eventBar.on('eventSelected', () => this.onEVENTSELECTED()); + this.eventBar.on('eventUnselected', () => this.onEVENTUNSELECTED()); + this.pinnedMessage = null; this.windowToFront('main'); @@ -370,6 +379,18 @@ class Chat { ), ); + const eventActionMenu = new ChatEventActionMenu( + this.ui.find('#event-action-menu'), + this.ui.find('.msg-event .event-button'), + this, + ); + eventActionMenu.on('removeEvent', this.handleRemoveEvent.bind(this)); + eventActionMenu.on( + 'removeEvent', + this.eventBar.unselect.bind(this.eventBar), + ); + this.menus.set('event-action-menu', eventActionMenu); + this.autocomplete.bind(this); // Chat input @@ -983,7 +1004,7 @@ class Chat { } this.settings.set('favoriteemotes', [...this.favoriteemotes]); this.applySettings(); - this.menus.get('emotes').buildFavoriteEmoteMenu(); + this.menus.get('emotes').buildEmoteMenu(); return !exists; } @@ -1339,18 +1360,75 @@ class Chat { onSUBSCRIPTION(data) { MessageBuilder.subscription(data).into(this); + + // Don't add events when loading messages from history because the + // `PAIDEVENTS` payload will contain those events + if (!this.backlogloading) { + const eventBarEvent = new EventBarEvent( + this, + MessageTypes.SUBSCRIPTION, + data, + ); + this.eventBar.add(eventBarEvent); + if (this.eventBar.length === 1) { + this.mainwindow.update(); + } + } } onGIFTSUB(data) { MessageBuilder.gift(data).into(this); + + if (!this.backlogloading) { + const eventBarEvent = new EventBarEvent(this, MessageTypes.GIFTSUB, data); + this.eventBar.add(eventBarEvent); + if (this.eventBar.length === 1) { + this.mainwindow.update(); + } + } } onMASSGIFT(data) { MessageBuilder.massgift(data).into(this); + + if (!this.backlogloading) { + const eventBarEvent = new EventBarEvent( + this, + MessageTypes.MASSGIFT, + data, + ); + this.eventBar.add(eventBarEvent); + if (this.eventBar.length === 1) { + this.mainwindow.update(); + } + } } onDONATION(data) { MessageBuilder.donation(data).into(this); + + if (!this.backlogloading) { + const eventBarEvent = new EventBarEvent( + this, + MessageTypes.DONATION, + data, + ); + this.eventBar.add(eventBarEvent); + if (this.eventBar.length === 1) { + this.mainwindow.update(); + } + } + } + + onPAIDEVENTS(lines) { + const events = lines.map((l) => { + const { eventname, data } = this.source.parse({ data: l }); + return new EventBarEvent(this, eventname, data); + }); + this.eventBar.replaceEvents(events); + + this.mainwindow.update(); + this.eventBar.sort(); } onADDPHRASE(data) { @@ -1532,6 +1610,15 @@ class Chat { MessageBuilder.death(data.data, user, data.timestamp).into(this); } + onEVENTSELECTED() { + // Hide full pinned message interface to make everything look nice + if (this.pinnedMessage) this.pinnedMessage.hidden = true; + } + + onEVENTUNSELECTED() { + if (this.pinnedMessage) this.pinnedMessage.hidden = false; + } + cmdSHOWPOLL() { if (this.chatpoll.poll) { this.chatpoll.show(); @@ -2559,6 +2646,11 @@ class Chat { hostname = hostname.split('?')[0]; return hostname; } + + handleRemoveEvent(eventUuid) { + ChatMenu.closeMenus(this); + this.source.send('REMOVEEVENT', { data: eventUuid }); + } } export default Chat; diff --git a/assets/chat/js/const.js b/assets/chat/js/const.js index 7b518a4e..138a8004 100644 --- a/assets/chat/js/const.js +++ b/assets/chat/js/const.js @@ -106,6 +106,7 @@ const hintstrings = new Map( bigscreen: `Bigscreen! Did you know you can have the chat on the left or right side of the stream by clicking the swap icon in the top left?`, danisold: 'Destiny is an Amazon Associate. He earns a commission on qualifying purchases of any product on Amazon linked in Destiny.gg chat.', + cantremoveevent: 'This event could not be removed.', }), ); diff --git a/assets/chat/js/event-bar/EventBar.js b/assets/chat/js/event-bar/EventBar.js new file mode 100644 index 00000000..3755c12a --- /dev/null +++ b/assets/chat/js/event-bar/EventBar.js @@ -0,0 +1,165 @@ +import EventEmitter from '../emitter'; + +/** + * @typedef {import('../messages/ChatEventMessage').default & {expirationTimestamp: number}} ExpiringEvent + */ + +export default class ChatEventBar extends EventEmitter { + events = []; + + constructor() { + super(); + /** @type HTMLDivElement */ + this.eventBarUI = document.getElementById('chat-event-bar'); + /** @type HTMLDivElement */ + this.eventSelectUI = document.getElementById('chat-event-selected'); + + this.eventBarUI.addEventListener('wheel', (event) => { + if (event.deltaX === 0) { + event.preventDefault(); + this.eventBarUI.scrollBy({ + left: event.deltaY > 0 ? 30 : -30, + }); + } + }); + + this.eventSelectUI.addEventListener('click', (e) => { + // Don't unselect if the selected event message is clicked + if (e.target !== e.currentTarget) { + return; + } + + // Prevent the click from canceling focus, if enabled + e.stopPropagation(); + + this.unselect(); + }); + } + + /** + * Adds the event to the event bar. + * @param {EventBarEvent} event + * @param {boolean} animate Animate the addition of the event + */ + add(event, animate = true) { + if (!this.shouldEventBeDisplayed(event.data)) { + return; + } + + this.events.push(event); + + event.element.addEventListener('click', () => { + this.select(event.selectedElement); + }); + event.on('eventExpired', this.removeEvent.bind(this)); + + if (animate) { + event.element.classList.add('enter'); + } + + this.eventBarUI.prepend(event.element); + + // // Update chat window to fix the scroll position + // this.chat.mainwindow.update(); + // + event.startExpiry(); + } + + /** + * Unselects the currently highlighted event. + */ + unselect() { + if (this.eventSelectUI.hasChildNodes()) { + this.eventSelectUI.replaceChildren(); + this.eventSelectUI.classList.add('hidden'); + this.emit('eventUnselected'); + } + } + + /** + * Selects the specified event. + * @param {HTMLDivElement} event + */ + select(event) { + /** @type HTMLDivElement */ + event.classList.add('event-bar-selected-message'); + + this.eventSelectUI.replaceChildren(); + this.eventSelectUI.append(event); + this.eventSelectUI.classList.remove('hidden'); + + this.emit('eventSelected'); + } + + /** + * Checks if the specified event is already in the event bar. + * @param {string} uuid + * @returns {boolean} + */ + contains(uuid) { + return this.events.some((e) => e.uuid === uuid); + } + + /** + * Sorts the events in the event bar in descending order by time received. + */ + sort() { + [...this.eventBarUI.children] + .sort((a, b) => + Number(a.dataset.unixtimestamp) < Number(b.dataset.unixtimestamp) + ? 1 + : -1, + ) + .forEach((node) => this.eventBarUI.appendChild(node)); + } + + /** + * Checks if the specified event should appear in the event bar. + * @param {ExpiringEvent} event + * @returns {boolean} + * @private + */ + shouldEventBeDisplayed(event) { + if (this.contains(event.uuid)) { + return false; + } + + const currentTimestamp = Date.now(); + if (event.expirationTimestamp < currentTimestamp) { + return false; + } + + // subscriptions from a mass gift event don't appear in the event bar + // to avoid overcrowding it (one event showing how many gifts a person bought is enough) + if (event.fromMassGift) { + return false; + } + + return true; + } + + removeEvent(event) { + this.events = this.events.filter((e) => e.uuid !== event.uuid); + event.remove(); + } + + removeAllEvents() { + for (const e of this.events) { + e.remove(false); + } + + this.events = []; + } + + replaceEvents(events) { + this.removeAllEvents(); + + for (const e of events) { + this.add(e, false); + } + } + + get length() { + return this.eventBarUI.querySelectorAll(`.event-bar-event`).length; + } +} diff --git a/assets/chat/js/event-bar/EventBarEvent.js b/assets/chat/js/event-bar/EventBarEvent.js new file mode 100644 index 00000000..fdf14a84 --- /dev/null +++ b/assets/chat/js/event-bar/EventBarEvent.js @@ -0,0 +1,169 @@ +import { usernameColorFlair } from '../messages/ChatUserMessage'; +import { selectDonationTier } from '../messages/ChatDonationMessage'; +import { getTierStyles } from '../messages/subscriptions/ChatSubscriptionMessage'; +import { MessageBuilder, MessageTypes } from '../messages'; +import ChatUser from '../user'; +import EventEmitter from '../emitter'; + +export default class EventBarEvent extends EventEmitter { + /** + * @param {*} chat + * @param {string} type + * @param {import('./EventBar').ExpiringEvent} data + */ + constructor(chat, type, data) { + super(); + + this.type = type; + this.data = data; + + this.element = this.buildElement(chat); + } + + /** + * @param {*} chat + * @private + */ + buildElement(chat) { + /** @type HTMLDivElement */ + const eventTemplate = document + .querySelector('#event-bar-event-template') + ?.content.cloneNode(true).firstElementChild; + + eventTemplate.classList.add(this.type.toLowerCase()); + eventTemplate.dataset.uuid = this.data.uuid; + eventTemplate.dataset.unixtimestamp = this.data.timestamp; + + const user = new ChatUser(this.data.user); + const userTemplate = eventTemplate.querySelector('.user'); + const colorFlair = usernameColorFlair(chat.flairs, user); + if (colorFlair) { + userTemplate.classList.add(colorFlair.name); + } + userTemplate.textContent = user.displayName; + + const iconTemplate = eventTemplate.querySelector('.event-icon'); + iconTemplate.classList.add(this.type.toLowerCase()); + + switch (this.type) { + case MessageTypes.SUBSCRIPTION: + case MessageTypes.GIFTSUB: + case MessageTypes.MASSGIFT: { + const { rainbowColor, tierColor } = getTierStyles( + this.data.tier, + chat.flairs, + ); + + if (tierColor) eventTemplate.style.borderColor = tierColor; + if (rainbowColor) eventTemplate.classList.add('rainbow-border'); + break; + } + case MessageTypes.DONATION: { + const donationTier = selectDonationTier(this.data.amount); + eventTemplate.classList.add(donationTier[0]); + break; + } + default: + break; + } + + this.selectedElement = this.buildSelected().html(chat); + + return eventTemplate; + } + + startExpiry() { + this.expiryPercentage = this.calculateExpiryPercentage(); + + this.intervalID = setInterval(() => { + const percentageLeft = this.calculateExpiryPercentage(); + + if (percentageLeft <= 0) { + this.expire(); + return; + } + + this.expiryPercentage = percentageLeft; + }, 1000); + } + + /** + * Calculates percentage left until the event expires. + * @returns {number} + */ + calculateExpiryPercentage() { + const currentTimestamp = Date.now(); + const eventTimeLeft = this.data.expirationTimestamp - currentTimestamp; + const eventFullDuration = + this.data.expirationTimestamp - this.data.timestamp; + + return (eventTimeLeft * 100) / eventFullDuration; + } + + get expiryPercentage() { + return Number(this.element.dataset.percentageLeft); + } + + /** + * Sets the progress gradient of the event. + * @param {number} percentageLeft + */ + set expiryPercentage(percentageLeft) { + this.element.dataset.percentageLeft = percentageLeft; + this.element.querySelector('.event-contents').style.background = + `linear-gradient(90deg, #282828, #282828 ${percentageLeft}%, #151515 ${percentageLeft}%, #151515)`; + } + + buildSelected() { + switch (this.type) { + case MessageTypes.SUBSCRIPTION: { + return MessageBuilder.subscription(this.data); + } + case MessageTypes.GIFTSUB: { + return MessageBuilder.gift(this.data); + } + case MessageTypes.MASSGIFT: { + return MessageBuilder.massgift(this.data); + } + case MessageTypes.DONATION: { + return MessageBuilder.donation(this.data); + } + default: + return undefined; + } + } + + /** + * @private + */ + expire() { + this.stopUpdatingExpirationProgressBar(); + this.emit('eventExpired', this); + } + + /** + * @param {boolean} animate Animate the removal of the event + */ + remove(animate = true) { + this.stopUpdatingExpirationProgressBar(); + + if (animate) { + this.element.addEventListener('animationend', () => { + this.element.remove(); + }); + this.element.classList.add('removed'); + } else { + this.element.remove(); + } + } + + stopUpdatingExpirationProgressBar() { + if (this.intervalID) { + clearInterval(this.intervalID); + } + } + + get uuid() { + return this.data.uuid; + } +} diff --git a/assets/chat/js/focus.js b/assets/chat/js/focus.js index f00997c9..7ff52857 100644 --- a/assets/chat/js/focus.js +++ b/assets/chat/js/focus.js @@ -9,7 +9,9 @@ class ChatUserFocus { this.chat = chat; this.css = css; this.focused = []; - this.chat.output.on('click', (e) => this.toggleElement(e.target)); + this.chat.output.on('click', (e) => { + this.toggleElement(e.target); + }); } toggleElement(target) { diff --git a/assets/chat/js/menus/ChatEmoteMenu.js b/assets/chat/js/menus/ChatEmoteMenu.js index dba61a99..7f01df9d 100644 --- a/assets/chat/js/menus/ChatEmoteMenu.js +++ b/assets/chat/js/menus/ChatEmoteMenu.js @@ -6,7 +6,6 @@ export default class ChatEmoteMenu extends ChatMenu { super(ui, btn, chat); this.searchterm = ''; this.emoteMenuContent = this.ui.find('.all .content'); - this.favoriteEmoteMenuContent = this.ui.find('.favorite .content'); this.searchinput = this.ui.find( '#chat-emote-list-search .form-control:first', ); @@ -23,7 +22,6 @@ export default class ChatEmoteMenu extends ChatMenu { () => { this.searchterm = this.searchinput.val(); this.buildEmoteMenu(); - this.buildFavoriteEmoteMenu(); }, { atBegin: false }, ), @@ -34,30 +32,13 @@ export default class ChatEmoteMenu extends ChatMenu { super.show(); this.searchinput.focus(); this.buildEmoteMenu(); - this.buildFavoriteEmoteMenu(); } - buildFavoriteEmoteMenu() { + buildEmoteMenu() { const favoriteEmotes = [...this.chat.favoriteemotes].filter((e) => this.chat.emoteService.hasEmote(e), ); - if (favoriteEmotes.length === 0) { - this.favoriteEmoteMenuContent.html(`
-
Favorite Emotes
-

Right click an emote and favorite it!

-
`); - return; - } - const emotesStr = favoriteEmotes - .map((e) => this.buildEmoteItem(e, false)) - .join(''); - this.favoriteEmoteMenuContent.html(`
-
Favorite Emotes
-
${emotesStr}
-
`); - } - buildEmoteMenu() { this.emoteMenuContent.empty(); this.chat.emoteService.tiers.forEach((tier) => { @@ -68,7 +49,7 @@ export default class ChatEmoteMenu extends ChatMenu { const locked = tier > this.chat.user.subTier && !this.chat.user.isPrivileged(); this.emoteMenuContent.append( - this.buildEmoteMenuSection(title, emotes, locked), + this.buildEmoteMenuSection(title, emotes, favoriteEmotes, locked), ); }); @@ -80,9 +61,19 @@ export default class ChatEmoteMenu extends ChatMenu { } } - buildEmoteMenuSection(title, emotes, disabled = false) { - const emotesStr = emotes - .map((e) => this.buildEmoteItem(e, disabled)) + buildEmoteMenuSection(title, emotes, favoriteEmotes, disabled = false) { + let emotesStr = ''; + if (favoriteEmotes.length > 0) { + emotesStr += favoriteEmotes + .map((e) => this.buildEmoteItem(e, true, disabled)) + .join(''); + } + emotesStr += emotes + .map((e) => + !favoriteEmotes.includes(e) + ? this.buildEmoteItem(e, false, disabled) + : null, + ) .join(''); if (emotesStr !== '') { return `
@@ -97,16 +88,16 @@ export default class ChatEmoteMenu extends ChatMenu { return ''; } - buildEmoteItem(emote, disabled) { + buildEmoteItem(emote, favorite, disabled) { if (this.searchterm && this.searchterm.length > 0) { if (emote.toLowerCase().indexOf(this.searchterm.toLowerCase()) >= 0) { - return `
${emote}
`; } return ''; } - return `
${emote}
`; } diff --git a/assets/chat/js/menus/ChatEmoteTooltip.js b/assets/chat/js/menus/ChatEmoteTooltip.js index 31ffc766..2e0436a3 100644 --- a/assets/chat/js/menus/ChatEmoteTooltip.js +++ b/assets/chat/js/menus/ChatEmoteTooltip.js @@ -32,6 +32,7 @@ export default class ChatEmoteTooltip extends ChatMenuFloating { this.ui.favorite.on('click', () => { const result = this.chat.toggleFavoriteEmote(this.emote); + this.hide(); this.favorite = result; }); } diff --git a/assets/chat/js/menus/ChatEventActionMenu.js b/assets/chat/js/menus/ChatEventActionMenu.js new file mode 100644 index 00000000..7c1c6c14 --- /dev/null +++ b/assets/chat/js/menus/ChatEventActionMenu.js @@ -0,0 +1,24 @@ +import ChatMenuFloating from './ChatMenuFloating'; + +export default class ChatEventActionMenu extends ChatMenuFloating { + constructor(ui, btn, chat) { + super(ui, btn, chat); + + this.chat.ui.on('click', '.msg-event .event-button', (e) => { + this.openMenu(e); + return false; + }); + + this.ui.on('click', '#remove-event-button', this.removeEvent.bind(this)); + } + + openMenu(e) { + this.eventElement = e.currentTarget.closest('.msg-event'); + this.position(e); + this.show(); + } + + removeEvent() { + this.emit('removeEvent', this.eventElement.dataset.uuid); + } +} diff --git a/assets/chat/js/menus/index.js b/assets/chat/js/menus/index.js index 27fe072a..90ccead1 100644 --- a/assets/chat/js/menus/index.js +++ b/assets/chat/js/menus/index.js @@ -5,3 +5,4 @@ export { default as ChatEmoteMenu } from './ChatEmoteMenu'; export { default as ChatEmoteTooltip } from './ChatEmoteTooltip'; export { default as ChatWhisperUsers } from './ChatWhisperUsers'; export { default as ChatUserInfoMenu } from './ChatUserInfoMenu'; +export { default as ChatEventActionMenu } from './ChatEventActionMenu'; diff --git a/assets/chat/js/messages/ChatBroadcastMessage.js b/assets/chat/js/messages/ChatBroadcastMessage.js index 67a24d9e..a1c1ced8 100644 --- a/assets/chat/js/messages/ChatBroadcastMessage.js +++ b/assets/chat/js/messages/ChatBroadcastMessage.js @@ -61,4 +61,8 @@ export default class ChatBroadcastMessage extends ChatEventMessage { return this.wrap(eventTemplate.innerHTML, classes, attributes); } + + get hasActions() { + return false; + } } diff --git a/assets/chat/js/messages/ChatDonationMessage.js b/assets/chat/js/messages/ChatDonationMessage.js index f3de6e76..45148b0e 100644 --- a/assets/chat/js/messages/ChatDonationMessage.js +++ b/assets/chat/js/messages/ChatDonationMessage.js @@ -4,22 +4,23 @@ import MessageTypes from './MessageTypes'; const DONATION_TIERS = [0, 5, 10, 25, 50, 100]; +/** + * Toggles the correct classes for a specific donation amount. + * @param {number} amount + * @returns {array} + */ +export function selectDonationTier(amount) { + const tier = DONATION_TIERS.findIndex((value) => amount < value * 100); + return [`amount-${tier !== -1 ? DONATION_TIERS[tier - 1] : '100'}`]; +} + export default class ChatDonationMessage extends ChatEventMessage { - constructor(message, user, amount, timestamp) { - super(message, timestamp); + constructor(message, user, amount, timestamp, expirationTimestamp, uuid) { + super(message, timestamp, uuid); this.user = user; this.type = MessageTypes.DONATION; this.amount = amount; - } - - /** - * Toggles the correct classes for a specific donation amount. - * @param {number} amount - * @returns {array} - */ - selectDonationTier(amount) { - const tier = DONATION_TIERS.findIndex((value) => amount < value * 100); - return [`amount-${tier !== -1 ? DONATION_TIERS[tier - 1] : '100'}`]; + this.expirationTimestamp = expirationTimestamp; } html(chat = null) { @@ -44,7 +45,7 @@ export default class ChatDonationMessage extends ChatEventMessage { })}`, ); - const donationTier = this.selectDonationTier(this.amount); + const donationTier = selectDonationTier(this.amount); eventTemplate.classList.add(donationTier[0]); eventTemplate .querySelector('.event-icon') diff --git a/assets/chat/js/messages/ChatEventMessage.js b/assets/chat/js/messages/ChatEventMessage.js index 06834cad..7e666a9c 100644 --- a/assets/chat/js/messages/ChatEventMessage.js +++ b/assets/chat/js/messages/ChatEventMessage.js @@ -1,13 +1,14 @@ import ChatMessage from './ChatMessage'; export default class ChatEventMessage extends ChatMessage { - constructor(message, timestamp) { + constructor(message, timestamp, uuid) { super(message, timestamp); this.tag = null; this.title = ''; this.slashme = false; this.isown = false; this.mentioned = []; + this.uuid = uuid; } html(chat = null) { @@ -29,10 +30,21 @@ export default class ChatEventMessage extends ChatMessage { eventTemplate.querySelector('.event-bottom').remove(); } + if (!this.hasActions || !chat.user?.hasModPowers()) { + const eventButton = eventTemplate.querySelector('.event-button'); + eventButton.disabled = true; + } + + eventTemplate.dataset.uuid = this.uuid; + return eventTemplate; } updateTimeFormat() { // This avoids errors. Timestamps aren't rendered in event messages. } + + get hasActions() { + return true; + } } diff --git a/assets/chat/js/messages/MessageBuilder.js b/assets/chat/js/messages/MessageBuilder.js index 53739c20..5349d760 100644 --- a/assets/chat/js/messages/MessageBuilder.js +++ b/assets/chat/js/messages/MessageBuilder.js @@ -68,8 +68,10 @@ export default class MessageBuilder { new ChatUser(data.user), data.tier, data.tierLabel, + data.amount, data.streak, data.timestamp, + data.expirationTimestamp, data.uuid, ); } @@ -80,8 +82,11 @@ export default class MessageBuilder { new ChatUser(data.user), data.tier, data.tierLabel, + data.amount, new ChatUser(data.recipient), + data.fromMassGift, data.timestamp, + data.expirationTimestamp, data.uuid, ); } @@ -92,8 +97,10 @@ export default class MessageBuilder { new ChatUser(data.user), data.tier, data.tierLabel, + data.amount, data.quantity, data.timestamp, + data.expirationTimestamp, data.uuid, ); } @@ -104,6 +111,7 @@ export default class MessageBuilder { new ChatUser(data.user), data.amount, data.timestamp, + data.expirationTimestamp, data.uuid, ); } diff --git a/assets/chat/js/messages/PinnedMessage.js b/assets/chat/js/messages/PinnedMessage.js index cd83c2a1..707dcd31 100644 --- a/assets/chat/js/messages/PinnedMessage.js +++ b/assets/chat/js/messages/PinnedMessage.js @@ -45,15 +45,23 @@ export default class PinnedMessage extends ChatUserMessage { /** * Shows/hides the current message. * @param {boolean} state - * @returns {null} null */ - set visible(state) { + set displayed(state) { this.ui.classList.toggle('hidden', !state); document .getElementById('chat-pinned-show-btn') ?.classList.toggle('active', !state); } + /** + * Shows/hides the full pinned message frame. + * @param {boolean} state + */ + set hidden(state) { + const frame = document.getElementById('chat-pinned-message'); + frame.classList.toggle('active', !state); + } + /** * Unpins the current message. * @returns {null} null @@ -61,8 +69,8 @@ export default class PinnedMessage extends ChatUserMessage { unpin() { dismissPin(this.uuid); - const frame = document.getElementById('chat-pinned-frame'); - frame.classList.toggle('active', false); + this.hidden = true; + const frame = document.getElementById('chat-pinned-message'); frame.replaceChildren(); return null; @@ -77,7 +85,7 @@ export default class PinnedMessage extends ChatUserMessage { pin(chat = null, visibility = true) { this.ui.id = 'msg-pinned'; this.ui.classList.toggle('msg-pinned', true); - this.visible = visibility; + this.displayed = visibility; this.ui.querySelector('span.features').classList.toggle('hidden', true); chat.mainwindow.update(); @@ -108,7 +116,7 @@ export default class PinnedMessage extends ChatUserMessage { showPin.title = 'Show Pinned Message'; showPin.addEventListener('click', () => { - this.visible = true; + this.displayed = true; }); const closePin = document.createElement('a'); @@ -122,12 +130,12 @@ export default class PinnedMessage extends ChatUserMessage { closePin.addEventListener('click', () => { dismissPin(this.uuid); - this.visible = false; + this.displayed = false; }); this.ui.prepend(closePin); - const pinnedFrame = document.getElementById('chat-pinned-frame'); + const pinnedFrame = document.getElementById('chat-pinned-message'); pinnedFrame.classList.toggle('active', true); pinnedFrame.prepend(this.ui); pinnedFrame.prepend(showPin); diff --git a/assets/chat/js/messages/subscriptions/ChatGiftedSubscriptionMessage.js b/assets/chat/js/messages/subscriptions/ChatGiftedSubscriptionMessage.js index b0e39ea6..96a0166c 100644 --- a/assets/chat/js/messages/subscriptions/ChatGiftedSubscriptionMessage.js +++ b/assets/chat/js/messages/subscriptions/ChatGiftedSubscriptionMessage.js @@ -3,10 +3,22 @@ import ChatSubscriptionMessage from './ChatSubscriptionMessage'; import MessageTypes from '../MessageTypes'; export default class ChatGiftedSubscriptionMessage extends ChatSubscriptionMessage { - constructor(message, user, tier, tierLabel, giftee, timestamp) { - super(message, user, tier, tierLabel, timestamp); + constructor( + message, + user, + tier, + tierLabel, + amount, + giftee, + fromMassGift, + timestamp, + expiry, + uuid, + ) { + super(message, user, tier, tierLabel, amount, timestamp, expiry, uuid); this.type = MessageTypes.GIFTSUB; this.giftee = giftee; + this.fromMassGift = fromMassGift; } html(chat = null) { diff --git a/assets/chat/js/messages/subscriptions/ChatMassSubscriptionMessage.js b/assets/chat/js/messages/subscriptions/ChatMassSubscriptionMessage.js index a2e9ed6b..9bb82712 100644 --- a/assets/chat/js/messages/subscriptions/ChatMassSubscriptionMessage.js +++ b/assets/chat/js/messages/subscriptions/ChatMassSubscriptionMessage.js @@ -2,8 +2,18 @@ import ChatSubscriptionMessage from './ChatSubscriptionMessage'; import MessageTypes from '../MessageTypes'; export default class ChatMassSubscriptionMessage extends ChatSubscriptionMessage { - constructor(message, user, tier, tierLabel, quantity, timestamp) { - super(message, user, tier, tierLabel, timestamp); + constructor( + message, + user, + tier, + tierLabel, + amount, + quantity, + timestamp, + expiry, + uuid, + ) { + super(message, user, tier, tierLabel, amount, timestamp, expiry, uuid); this.type = MessageTypes.MASSGIFT; this.quantity = quantity; } diff --git a/assets/chat/js/messages/subscriptions/ChatRegularSubscriptionMessage.js b/assets/chat/js/messages/subscriptions/ChatRegularSubscriptionMessage.js index 797a4f98..ba29a5db 100644 --- a/assets/chat/js/messages/subscriptions/ChatRegularSubscriptionMessage.js +++ b/assets/chat/js/messages/subscriptions/ChatRegularSubscriptionMessage.js @@ -2,8 +2,18 @@ import ChatSubscriptionMessage from './ChatSubscriptionMessage'; import MessageTypes from '../MessageTypes'; export default class ChatRegularSubscriptionMessage extends ChatSubscriptionMessage { - constructor(message, user, tier, tierLabel, streak, timestamp) { - super(message, user, tier, tierLabel, timestamp); + constructor( + message, + user, + tier, + tierLabel, + amount, + streak, + timestamp, + expiry, + uuid, + ) { + super(message, user, tier, tierLabel, amount, timestamp, expiry, uuid); this.type = MessageTypes.SUBSCRIPTION; this.streak = streak; } diff --git a/assets/chat/js/messages/subscriptions/ChatSubscriptionMessage.js b/assets/chat/js/messages/subscriptions/ChatSubscriptionMessage.js index 7a076d3b..71bcf089 100644 --- a/assets/chat/js/messages/subscriptions/ChatSubscriptionMessage.js +++ b/assets/chat/js/messages/subscriptions/ChatSubscriptionMessage.js @@ -2,32 +2,46 @@ import { usernameColorFlair } from '../ChatUserMessage'; import ChatEventMessage from '../ChatEventMessage'; import features from '../../features'; +export function getTierStyles(tier, flairs) { + const tierFlair = features[`SUB_TIER_${tier}`]; + const tierInfo = flairs.find((el) => el.name === tierFlair); + const tierColor = tierInfo?.color; + + const tierClass = tierInfo?.rainbowColor ? `user ${tierFlair}` : ''; + + return { + rainbowColor: tierInfo?.rainbowColor, + tierClass, + tierColor: tierInfo?.rainbowColor ? '' : tierColor, + }; +} + export default class ChatSubscriptionMessage extends ChatEventMessage { - constructor(message, user, tier, tierLabel, timestamp) { - super(message, timestamp); + constructor( + message, + user, + tier, + tierLabel, + amount, + timestamp, + expirationTimestamp, + uuid, + ) { + super(message, timestamp, uuid); this.user = user; this.tier = tier; this.tierLabel = tierLabel; - } - - getTierStyles(chat = null) { - const tierFlair = features[`SUB_TIER_${this.tier}`]; - const tierInfo = chat.flairs.find((el) => el.name === tierFlair); - const tierColor = tierInfo?.color; - - const tierClass = tierInfo?.rainbowColor ? `user ${tierFlair}` : ''; - - return { - rainbowColor: tierInfo?.rainbowColor, - tierClass, - tierColor: tierInfo?.rainbowColor ? '' : tierColor, - }; + this.amount = amount; + this.expirationTimestamp = expirationTimestamp; } html(chat = null) { const eventTemplate = super.html(chat); - const { rainbowColor, tierClass, tierColor } = this.getTierStyles(chat); + const { rainbowColor, tierClass, tierColor } = getTierStyles( + this.tier, + chat.flairs, + ); if (tierColor) eventTemplate.style.borderColor = tierColor; if (rainbowColor) eventTemplate.classList.add('rainbow-border'); diff --git a/assets/chat/js/source.js b/assets/chat/js/source.js index 8f815e5e..e49cde90 100644 --- a/assets/chat/js/source.js +++ b/assets/chat/js/source.js @@ -97,6 +97,12 @@ class ChatSource extends EventEmitter { } parseAndDispatch(event) { + const { eventname, data } = this.parse(event); + this.emit('DISPATCH', { data, event: eventname }); // Event is used to hook into all dispatched events + this.emit(eventname, data); + } + + parse(event) { const eventname = event.data.split(' ', 1)[0].toUpperCase(); const payload = event.data.substring(eventname.length + 1); let data = null; @@ -105,8 +111,11 @@ class ChatSource extends EventEmitter { } catch (ignored) { data = payload; } - this.emit('DISPATCH', { data, event: eventname }); // Event is used to hook into all dispatched events - this.emit(eventname, data); + + return { + eventname, + data, + }; } send(eventname, data) { diff --git a/assets/views/embed.html b/assets/views/embed.html index 2d604df9..a1e854db 100644 --- a/assets/views/embed.html +++ b/assets/views/embed.html @@ -1,6 +1,8 @@
+
+
@@ -24,7 +26,8 @@
-
+ +
@@ -322,9 +325,6 @@
Emotes
-
-
-
+ +
+
+ +
+
diff --git a/assets/views/stream.html b/assets/views/stream.html index 0adad494..37e9b2b6 100644 --- a/assets/views/stream.html +++ b/assets/views/stream.html @@ -1,6 +1,8 @@
+
-
+
+
diff --git a/assets/views/templates.html b/assets/views/templates.html index 22d7d42d..fa5cae10 100644 --- a/assets/views/templates.html +++ b/assets/views/templates.html @@ -1,9 +1,11 @@