diff --git a/assets/chat/css/chat/_output.scss b/assets/chat/css/chat/_output.scss index 89998b70..7e2bcef2 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 { diff --git a/assets/chat/css/chat/event-bar/_event-bar-event.scss b/assets/chat/css/chat/event-bar/_event-bar-event.scss index a672258d..37bb53f2 100644 --- a/assets/chat/css/chat/event-bar/_event-bar-event.scss +++ b/assets/chat/css/chat/event-bar/_event-bar-event.scss @@ -5,7 +5,6 @@ position: relative; cursor: pointer; transition: transform 100ms; - animation: event-bar-appear 500ms linear; font-size: 1.1em; border-radius: 10px; @@ -103,6 +102,12 @@ 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; } diff --git a/assets/chat/css/chat/event-bar/_index.scss b/assets/chat/css/chat/event-bar/_index.scss index 4a6951a8..6a29d7f8 100644 --- a/assets/chat/css/chat/event-bar/_index.scss +++ b/assets/chat/css/chat/event-bar/_index.scss @@ -18,13 +18,12 @@ } } -#highlighted-message-wrapper { +#chat-event-selected { position: absolute; - width: 100%; z-index: 210; -} + inset: 0; + background-color: rgba(0, 0, 0, 0.5); -#chat-event-selected { .event-bar-selected-message { margin: a.$gutter-sm; @@ -32,6 +31,10 @@ opacity: 1; } } + + &.hidden { + display: none; + } } .onstreamchat { @@ -39,7 +42,8 @@ display: none; } - #highlighted-message-wrapper { + #chat-event-selected, + #chat-pinned-message { display: none; } } 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/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/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 fe71483b..37956368 100644 --- a/assets/chat/js/chat.js +++ b/assets/chat/js/chat.js @@ -31,6 +31,7 @@ import { ChatEmoteTooltip, ChatSettingsMenu, ChatUserInfoMenu, + ChatEventActionMenu, } from './menus'; import ChatEventBar from './event-bar/EventBar'; import ChatAutoComplete from './autocomplete'; @@ -378,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 @@ -1337,50 +1350,73 @@ class Chat { onSUBSCRIPTION(data) { MessageBuilder.subscription(data).into(this); - const eventBarEvent = new EventBarEvent( - this, - MessageTypes.SUBSCRIPTION, - data, - ); - this.eventBar.add(eventBarEvent); - if (this.eventBar.length === 1) { - this.mainwindow.update(); + + // 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); - const eventBarEvent = new EventBarEvent(this, MessageTypes.GIFTSUB, data); - this.eventBar.add(eventBarEvent); - if (this.eventBar.length === 1) { - this.mainwindow.update(); + + 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); - const eventBarEvent = new EventBarEvent(this, MessageTypes.MASSGIFT, data); - this.eventBar.add(eventBarEvent); - if (this.eventBar.length === 1) { - this.mainwindow.update(); + + 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); - const eventBarEvent = new EventBarEvent(this, MessageTypes.DONATION, data); - this.eventBar.add(eventBarEvent); - if (this.eventBar.length === 1) { - this.mainwindow.update(); + + 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) { - lines.forEach((line) => { - const { eventname, data } = this.source.parse({ data: line }); - const eventBarEvent = new EventBarEvent(this, eventname, data); - this.eventBar.add(eventBarEvent); + 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(); } @@ -1565,7 +1601,6 @@ class Chat { } onEVENTSELECTED() { - this.userfocus.toggleFocus('', false, true); // Hide full pinned message interface to make everything look nice if (this.pinnedMessage) this.pinnedMessage.hidden = true; } @@ -2601,6 +2636,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 index 94221350..3755c12a 100644 --- a/assets/chat/js/event-bar/EventBar.js +++ b/assets/chat/js/event-bar/EventBar.js @@ -5,6 +5,8 @@ import EventEmitter from '../emitter'; */ export default class ChatEventBar extends EventEmitter { + events = []; + constructor() { super(); /** @type HTMLDivElement */ @@ -20,20 +22,40 @@ export default class ChatEventBar extends EventEmitter { }); } }); + + 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) { + 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); @@ -49,6 +71,7 @@ export default class ChatEventBar extends EventEmitter { unselect() { if (this.eventSelectUI.hasChildNodes()) { this.eventSelectUI.replaceChildren(); + this.eventSelectUI.classList.add('hidden'); this.emit('eventUnselected'); } } @@ -63,6 +86,7 @@ export default class ChatEventBar extends EventEmitter { this.eventSelectUI.replaceChildren(); this.eventSelectUI.append(event); + this.eventSelectUI.classList.remove('hidden'); this.emit('eventSelected'); } @@ -73,9 +97,7 @@ export default class ChatEventBar extends EventEmitter { * @returns {boolean} */ contains(uuid) { - return !!this.eventBarUI.querySelector( - `.event-bar-event[data-uuid="${uuid}"]`, - ); + return this.events.some((e) => e.uuid === uuid); } /** @@ -116,6 +138,27 @@ export default class ChatEventBar extends EventEmitter { 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 index a8e84655..fdf14a84 100644 --- a/assets/chat/js/event-bar/EventBarEvent.js +++ b/assets/chat/js/event-bar/EventBarEvent.js @@ -3,14 +3,17 @@ 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 { +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; @@ -76,7 +79,7 @@ export default class EventBarEvent { const percentageLeft = this.calculateExpiryPercentage(); if (percentageLeft <= 0) { - this.remove(); + this.expire(); return; } @@ -133,11 +136,34 @@ export default class EventBarEvent { /** * @private */ - remove() { - this.element.addEventListener('animationend', () => { + 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(); - if (this.intervalID) clearInterval(this.intervalID); - }); - this.element.classList.add('removed'); + } + } + + 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 3e4d6bbd..7ff52857 100644 --- a/assets/chat/js/focus.js +++ b/assets/chat/js/focus.js @@ -11,7 +11,6 @@ class ChatUserFocus { this.focused = []; this.chat.output.on('click', (e) => { this.toggleElement(e.target); - this.chat.eventBar.unselect(); }); } 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/ChatEventMessage.js b/assets/chat/js/messages/ChatEventMessage.js index c41f9f31..7e666a9c 100644 --- a/assets/chat/js/messages/ChatEventMessage.js +++ b/assets/chat/js/messages/ChatEventMessage.js @@ -30,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/views/embed.html b/assets/views/embed.html index 2fe68d05..7725a32b 100644 --- a/assets/views/embed.html +++ b/assets/views/embed.html @@ -26,10 +26,8 @@
-
-
-
-
+ +
@@ -371,4 +369,12 @@

Want to chat?

+ +
+
+ +
+
diff --git a/assets/views/stream.html b/assets/views/stream.html index 9800e006..37e9b2b6 100644 --- a/assets/views/stream.html +++ b/assets/views/stream.html @@ -2,9 +2,7 @@
-
-
-
-
+
+
diff --git a/assets/views/templates.html b/assets/views/templates.html index 79d86e5c..fa5cae10 100644 --- a/assets/views/templates.html +++ b/assets/views/templates.html @@ -3,7 +3,9 @@
- +