mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-12-30 00:21:16 +00:00
[overlays] Add eventfeed as default-overlay
Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
parent
b38ecc9d0b
commit
c4be936c63
3 changed files with 760 additions and 0 deletions
19
internal/apimodules/overlays/default/eventfeed.custom.js
Normal file
19
internal/apimodules/overlays/default/eventfeed.custom.js
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
/**
|
||||||
|
* Allows to add filters for custom events created through the customHandler
|
||||||
|
*
|
||||||
|
* @returns {Object} Custom filter definitions as `filterKey: {name: "Name", visible: true}`
|
||||||
|
*/
|
||||||
|
const customFilters = () => ({})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles custom events and creates feed items from them
|
||||||
|
*
|
||||||
|
* @param {*} param0 Event-Object as returned by the websocket
|
||||||
|
* @returns {Object} Event to add to the event list of the feed
|
||||||
|
*/
|
||||||
|
const customHandler = eventObj => {
|
||||||
|
console.log('custom event unhandled:', eventObj)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export { customFilters, customHandler }
|
156
internal/apimodules/overlays/default/eventfeed.html
Normal file
156
internal/apimodules/overlays/default/eventfeed.html
Normal file
|
@ -0,0 +1,156 @@
|
||||||
|
<html data-bs-theme="dark">
|
||||||
|
<head>
|
||||||
|
<title>Event-Feed</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/combine/npm/bootstrap@5.3/dist/css/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.5/css/all.min.css">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
[v-cloak] { display: none; }
|
||||||
|
.border-event {
|
||||||
|
border-left-width: 5px !important;
|
||||||
|
border-left-style: solid !important;
|
||||||
|
border-left-color: #9147ff;
|
||||||
|
}
|
||||||
|
.border-event.event-bits { border-left-color: #5cffbe !important; }
|
||||||
|
.border-event.event-channelpoint { border-left-color: #ffd37a !important; }
|
||||||
|
.border-event.event-follow { border-left-color: #ff38db !important; }
|
||||||
|
.border-event.event-raid { border-left-color: #ebeb00 !important; }
|
||||||
|
.border-event.event-streamOffline { border-left-color: rgb(var(--bs-danger-rgb)) !important; }
|
||||||
|
.border-event.event-subs { border-left-color: #1f69ff !important; }
|
||||||
|
.m50 {
|
||||||
|
max-height: 40vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.premono {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app" v-cloak>
|
||||||
|
<div class="container-fluid py-3">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
|
||||||
|
<!-- Stream-Summary -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
|
<span
|
||||||
|
v-for="item in sortedStats"
|
||||||
|
class="me-2 d-inline-flex align-items-center"
|
||||||
|
:key="item.key"
|
||||||
|
>
|
||||||
|
<i :class="`fa-fw ${item.icon}`"></i>
|
||||||
|
<span class="badge rounded-pill text-bg-primary ms-1">
|
||||||
|
{{ item.value }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event-List -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
Recent events
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary dropdown-toggle"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
<i class="fas fa-filter fa-fw me-1"></i>
|
||||||
|
Filters ({{ filterCount }})
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li
|
||||||
|
v-for="(filter, filterKey) in filters"
|
||||||
|
:key="filterKey"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
:class="{'dropdown-item': true, 'active': filter.visible}" href="#"
|
||||||
|
@click.prevent="toggleFilterVisibility(filterKey)"
|
||||||
|
>
|
||||||
|
{{ filter.name }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
@click="markRead"
|
||||||
|
>
|
||||||
|
<i class="fas fa-eye fa-fw me-1"></i>
|
||||||
|
Mark read
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
|
||||||
|
<!-- Active Hypetrain pin -->
|
||||||
|
<div class="list-group-item" v-if="hypetrain.active">
|
||||||
|
<div class="d-flex w-100 align-items-center">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i :class="`fas fa-train fa-fw me-2`"></i>
|
||||||
|
Hypetrain in progress towards Level {{ hypetrain.level }}…
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress my-3">
|
||||||
|
<div class="progress-bar progress-bar-striped"
|
||||||
|
:style="`width: ${(hypetrain.progress * 100).toFixed(2)}%`"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event-Item -->
|
||||||
|
<div
|
||||||
|
:class="eventClass(event)"
|
||||||
|
v-for="event in recentEvents"
|
||||||
|
:key="event.time.getTime()"
|
||||||
|
>
|
||||||
|
<div class="d-flex w-100 align-items-center">
|
||||||
|
<h5 class="mb-0 me-auto"><i :class="`${event.icon} fa-fw me-2`"></i> {{ event.title }}</h5>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm me-1"
|
||||||
|
v-if="event.hasReplay"
|
||||||
|
@click="repeatEvent(event.eventId)"
|
||||||
|
title="Re-Play Event"
|
||||||
|
>
|
||||||
|
<i class="fas fa-share fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
<small :title="timeDisplay(event.time)">
|
||||||
|
{{ timeSince(event.time) }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex my-1 w-100 justify-content-between align-items-start premono" v-if="event.text">
|
||||||
|
{{ event.text }}
|
||||||
|
</div>
|
||||||
|
<p class="mb-1" v-if="resolveSubtext(event.subtext)">
|
||||||
|
<small>
|
||||||
|
<span class="premono">{{ resolveSubtext(event.subtext) }}</span>
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="eventfeed.js" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
585
internal/apimodules/overlays/default/eventfeed.js
Normal file
585
internal/apimodules/overlays/default/eventfeed.js
Normal file
|
@ -0,0 +1,585 @@
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Event
|
||||||
|
* @property {number} eventId ID of the event as returned by the server
|
||||||
|
* @property {Object|undefined} extraData Any additional data specific to this event type
|
||||||
|
* @property {string} filterKey Event-Type key
|
||||||
|
* @property {string|undefined} originId ID from the Twitch server for de-duplication
|
||||||
|
* @property {string|function|undefined} subtext Additional text, usually user-message
|
||||||
|
* @property {string|undefined} text Descriptive text of the event
|
||||||
|
* @property {Date} time The moment the event occurred
|
||||||
|
* @property {string} title The title of the event
|
||||||
|
* @property {boolean} hasReplay Whether the replay button should be shown
|
||||||
|
* @property {boolean} isMeta Whether not to display event in frontend
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { customFilters, customHandler } from './eventfeed.custom.js'
|
||||||
|
import { createApp } from 'https://cdn.jsdelivr.net/npm/vue@3.4/dist/vue.esm-browser.prod.js'
|
||||||
|
import dayjs from 'https://cdn.jsdelivr.net/npm/dayjs@1.11/+esm'
|
||||||
|
import dayjsLocalizedFormat from 'https://cdn.jsdelivr.net/npm/dayjs@1.11/plugin/localizedFormat.js/+esm'
|
||||||
|
import dayjsRelativeTime from 'https://cdn.jsdelivr.net/npm/dayjs@1.11/plugin/relativeTime.js/+esm'
|
||||||
|
import EventClient from './eventclient.mjs'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'io.luzifer.eventfeed'
|
||||||
|
|
||||||
|
const defaultFilters = {
|
||||||
|
adbreak: { name: 'Adbreaks', visible: true },
|
||||||
|
ban: { name: 'Bans / Timeouts', visible: true },
|
||||||
|
bits: { name: 'Bits', visible: true },
|
||||||
|
channelpoint: { name: 'Channel-Points', visible: true },
|
||||||
|
donation: { name: 'Donations', visible: true },
|
||||||
|
follow: { name: 'Follows', visible: true },
|
||||||
|
hypetrain: { name: 'Hypetrains', visible: true },
|
||||||
|
pollEnd: { name: 'Poll-Summary', visible: true },
|
||||||
|
raid: { name: 'Raids', visible: true },
|
||||||
|
shoutout: { name: 'Shoutouts', visible: true },
|
||||||
|
streamOffline: { name: 'Stream-Offline', visible: true },
|
||||||
|
streamUpdate: { name: 'Stream-Update', visible: true },
|
||||||
|
subs: { name: 'Subs', visible: true },
|
||||||
|
watchStreak: { name: 'Watchstreaks', visible: true },
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAnonSubgifter = 'ananonymousgifter'
|
||||||
|
const userAnonCheerer = 'ananonymouscheerer'
|
||||||
|
|
||||||
|
const app = createApp({
|
||||||
|
computed: {
|
||||||
|
filterCount() {
|
||||||
|
const filters = Object.values(this.filters)
|
||||||
|
return `${filters.filter(f => f.visible).length} / ${filters.length}`
|
||||||
|
},
|
||||||
|
|
||||||
|
filters() {
|
||||||
|
return Object.fromEntries(Object.entries({
|
||||||
|
...defaultFilters,
|
||||||
|
...customFilters(),
|
||||||
|
...this.storedData.filters || {},
|
||||||
|
})
|
||||||
|
.filter(e => Object.keys(defaultFilters).includes(e[0]) || Object.keys(customFilters()).includes(e[0]))
|
||||||
|
.sort((a, b) => a[1].name.localeCompare(b[1].name)))
|
||||||
|
},
|
||||||
|
|
||||||
|
hypetrain() {
|
||||||
|
const evts = [...this.events]
|
||||||
|
.filter(evt => evt.filterKey === 'hypetrain')
|
||||||
|
.sort((b, a) => a.time.getTime() - b.time.getTime())
|
||||||
|
|
||||||
|
if (evts.length < 1) {
|
||||||
|
return {
|
||||||
|
active: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return evts[0].extraData
|
||||||
|
},
|
||||||
|
|
||||||
|
recentEvents() {
|
||||||
|
return [...this.events]
|
||||||
|
.filter(evt => !evt.isMeta)
|
||||||
|
.filter(evt => this.filters[evt.filterKey]?.visible !== false)
|
||||||
|
.filter(evt => !this.knownMultiGiftIDs.includes(evt.originId))
|
||||||
|
.sort((b, a) => a.time.getTime() - b.time.getTime())
|
||||||
|
},
|
||||||
|
|
||||||
|
sortedStats() {
|
||||||
|
const evts = [...this.events]
|
||||||
|
.filter(evt => evt.time.getTime() > this.streamOfflineTime.getTime())
|
||||||
|
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
icon: 'fas fa-gem',
|
||||||
|
key: 'bits',
|
||||||
|
value: evts
|
||||||
|
.filter(evt => evt.filterKey === 'bits')
|
||||||
|
.reduce((sum, evt) => sum + evt.extraData.bits, 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-circle-dollar-to-slot',
|
||||||
|
key: 'donation',
|
||||||
|
value: evts
|
||||||
|
.filter(evt => evt.filterKey === 'donation')
|
||||||
|
.reduce((sum, evt) => sum + evt.extraData.amount, 0)
|
||||||
|
.toFixed(2),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-heart',
|
||||||
|
key: 'follow',
|
||||||
|
value: evts
|
||||||
|
.filter(evt => evt.filterKey === 'follow')
|
||||||
|
.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-parachute-box',
|
||||||
|
key: 'raid',
|
||||||
|
value: evts
|
||||||
|
.filter(evt => evt.filterKey === 'raid')
|
||||||
|
.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-star',
|
||||||
|
key: 'sub',
|
||||||
|
value: evts
|
||||||
|
.filter(evt => evt.filterKey === 'subs')
|
||||||
|
.filter(evt => !this.knownMultiGiftIDs.includes(evt.originId))
|
||||||
|
.reduce((sum, evt) => sum + evt.extraData.count, 0),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
window.setInterval(() => {
|
||||||
|
this.now = new Date()
|
||||||
|
}, 60000)
|
||||||
|
|
||||||
|
this.eventClient = new EventClient({
|
||||||
|
handlers: {
|
||||||
|
adbreak_begin: ({ event_id, fields, time }) => this.handleAdBreak(event_id, fields, time),
|
||||||
|
ban: ({ event_id, fields, time }) => this.handleBan(event_id, fields, time),
|
||||||
|
bits: ({ event_id, fields, time }) => this.handleBits(event_id, fields, time),
|
||||||
|
category_update: ({ event_id, fields, time }) => this.handleCategoryUpdate(event_id, fields, time),
|
||||||
|
channelpoint_redeem: ({ event_id, fields, time }) => this.handleChannelPoints(event_id, fields, time),
|
||||||
|
custom: eventobj => this.handleCustom(eventobj),
|
||||||
|
follow: ({ event_id, fields, time }) => this.handleFollow(event_id, fields, time),
|
||||||
|
hypetrain_begin: ({ event_id, fields, time }) => this.handleHypetrain(event_id, fields, time, 'start'),
|
||||||
|
hypetrain_end: ({ event_id, fields, time }) => this.handleHypetrain(event_id, fields, time, 'end'),
|
||||||
|
hypetrain_progress: ({ event_id, fields, time }) => this.handleHypetrain(event_id, fields, time, 'progress'),
|
||||||
|
kofi_donation: ({ event_id, fields, time }) => this.handleKoFiDonation(event_id, fields, time),
|
||||||
|
poll_end: ({ event_id, fields, time }) => this.handlePollEnd(event_id, fields, time),
|
||||||
|
raid: ({ event_id, fields, time }) => this.handleRaid(event_id, fields, time),
|
||||||
|
resub: ({ event_id, fields, reason, time, type }) => this.handleSub(type, event_id, fields, time, reason),
|
||||||
|
shoutout_created: ({ event_id, fields, time }) => this.handleShoutoutCreated(event_id, fields, time),
|
||||||
|
shoutout_received: ({ event_id, fields, time }) => this.handleShoutoutReceived(event_id, fields, time),
|
||||||
|
stream_offline: ({ event_id, time }) => this.handleStreamOffline(event_id, time),
|
||||||
|
sub: ({ event_id, fields, time, type }) => this.handleSub(type, event_id, fields, time),
|
||||||
|
subgift: ({ event_id, fields, time, type }) => this.handleSubgift(type, event_id, fields, time),
|
||||||
|
submysterygift: ({ event_id, fields, time, type }) => this.handleSubgift(type, event_id, fields, time),
|
||||||
|
timeout: ({ event_id, fields, time }) => this.handleTimeout(event_id, fields, time),
|
||||||
|
title_update: ({ event_id, fields, time }) => this.handleTitleUpdate(event_id, fields, time),
|
||||||
|
watch_streak: ({ event_id, fields, time }) => this.handleWatchStreak(event_id, fields, time),
|
||||||
|
},
|
||||||
|
|
||||||
|
maxReplayAge: 168,
|
||||||
|
replay: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.storageLoad()
|
||||||
|
window.addEventListener('storage', ev => {
|
||||||
|
if (ev.key !== this.storageKey()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Our key has been changed, reload stored data
|
||||||
|
this.storageLoad()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
eventClient: null,
|
||||||
|
events: [],
|
||||||
|
now: new Date(),
|
||||||
|
storedData: {},
|
||||||
|
|
||||||
|
// Workaround for Twitch not sending hypetrain progress in end-event
|
||||||
|
// eslint-disable-next-line sort-keys
|
||||||
|
hypetrainProgress: 0,
|
||||||
|
knownMultiGiftIDs: [],
|
||||||
|
streamOfflineTime: new Date(0),
|
||||||
|
subgiftRecipients: {},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* @param {Event} event
|
||||||
|
*/
|
||||||
|
addEvent(event) {
|
||||||
|
if (!event.eventId || !event.filterKey || !event.time || !event.title) {
|
||||||
|
throw new Error(`Event missing fields: ${event}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.events = [
|
||||||
|
...this.events.filter(evt => evt.eventId !== event.eventId),
|
||||||
|
event,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
eventClass(event) {
|
||||||
|
const classes = ['border-event', 'list-group-item']
|
||||||
|
|
||||||
|
if (this.storedData.readDate && this.storedData.readDate > event.time.getTime()) {
|
||||||
|
classes.push('disabled')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.filterKey) {
|
||||||
|
classes.push(`event-${event.filterKey}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return classes.join(' ')
|
||||||
|
},
|
||||||
|
|
||||||
|
handleAdBreak(eventId, data, time) {
|
||||||
|
this.addEvent({
|
||||||
|
eventId,
|
||||||
|
filterKey: 'adbreak',
|
||||||
|
icon: 'fas fa-rectangle-ad text-warning',
|
||||||
|
text: `${data.duration}s ad-break is now running`,
|
||||||
|
time: time ? new Date(time) : null,
|
||||||
|
title: 'Ad-Break started',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleBan(eventId, data, time) {
|
||||||
|
this.addEvent({
|
||||||
|
eventId,
|
||||||
|
filterKey: 'ban',
|
||||||
|
icon: 'fas fa-ban',
|
||||||
|
time: new Date(time),
|
||||||
|
title: `${data.target_name} has been banned`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleBits(eventId, data, time) {
|
||||||
|
const from = data.user === userAnonCheerer ? 'Someone' : data.user
|
||||||
|
|
||||||
|
this.addEvent({
|
||||||
|
eventId,
|
||||||
|
extraData: { bits: data.bits },
|
||||||
|
filterKey: 'bits',
|
||||||
|
hasReplay: true,
|
||||||
|
icon: 'fas fa-gem',
|
||||||
|
subtext: data.message,
|
||||||
|
text: `${from} just spent ${data.bits} Bits`,
|
||||||
|
time: time ? new Date(time) : null,
|
||||||
|
title: 'Bits donated',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleCategoryUpdate(eventId, data, time) {
|
||||||
|
this.addEvent({
|
||||||
|
eventId,
|
||||||
|
filterKey: 'streamUpdate',
|
||||||
|
icon: 'fas fa-gamepad',
|
||||||
|
text: data.category,
|
||||||
|
time: new Date(time),
|
||||||
|
title: 'Category updated',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleChannelPoints(eventId, data, time) {
|
||||||
|
this.addEvent({
|
||||||
|
eventId,
|
||||||
|
filterKey: 'channelpoint',
|
||||||
|
hasReplay: true,
|
||||||
|
icon: 'fas fa-diamond',
|
||||||
|
subtext: data.user_input,
|
||||||
|
text: `${data.user} redeemed "${data.reward_title}"`,
|
||||||
|
time: new Date(time),
|
||||||
|
title: 'Reward Redeemed',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleCustom(eventObj) {
|
||||||
|
const evt = customHandler(eventObj)
|
||||||
|
if (evt !== null) {
|
||||||
|
this.addEvent(evt)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleFollow(eventId, data, time) {
|
||||||
|
this.addEvent({
|
||||||
|
eventId,
|
||||||
|
filterKey: 'follow',
|
||||||
|
hasReplay: true,
|
||||||
|
icon: 'fas fa-user',
|
||||||
|
text: `${data.user} just followed`,
|
||||||
|
time: new Date(time),
|
||||||
|
title: 'New Follower',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleHypetrain(eventId, data, time, phase) {
|
||||||
|
const evt = {
|
||||||
|
eventId,
|
||||||
|
extraData: {
|
||||||
|
active: phase !== 'end',
|
||||||
|
level: data.level,
|
||||||
|
progress: data.levelProgress || this.hypetrainProgress,
|
||||||
|
},
|
||||||
|
|
||||||
|
filterKey: 'hypetrain',
|
||||||
|
icon: 'fas fa-train',
|
||||||
|
time: new Date(time),
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hypetrainProgress = evt.extraData.progress
|
||||||
|
|
||||||
|
switch (phase) {
|
||||||
|
case 'start':
|
||||||
|
this.addEvent({
|
||||||
|
...evt,
|
||||||
|
text: `A hypetrain started on ${(data.levelProgress * 100).toFixed(0)}% towards level ${data.level}`,
|
||||||
|
title: 'Hypetrain started',
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'progress':
|
||||||
|
this.addEvent({
|
||||||
|
...evt,
|
||||||
|
isMeta: true,
|
||||||
|
title: 'Hypetrain progressed',
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'end':
|
||||||
|
this.addEvent({
|
||||||
|
...evt,
|
||||||
|
text: `A hypetrain ended on ${(this.hypetrainProgress * 100).toFixed(0)}% towards level ${data.level}`,
|
||||||
|
title: 'Hypetrain ended',
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleKoFiDonation(eventId, data, time) {
|
||||||
|
let text
|
||||||
|
if (data.isSubscription && data.isFirstSubPayment) {
|
||||||
|
text = `${data.from} just started a monthly subscription of ${Number(data.amount).toFixed(2)}€`
|
||||||
|
} else if (data.isSubscription && !data.isFirstSubPayment) {
|
||||||
|
text = `${data.from} continued their monthly subscription of ${Number(data.amount).toFixed(2)}€`
|
||||||
|
} else {
|
||||||
|
text = `${data.from} just donated ${Number(data.amount).toFixed(2)}€`
|
||||||
|
}
|
||||||
|
|
||||||
|
this.addEvent({
|
||||||
|
eventId,
|
||||||
|
extraData: { amount: Number(data.amount) },
|
||||||
|
filterKey: 'donation',
|
||||||
|
icon: 'fas fa-circle-dollar-to-slot',
|
||||||
|
subtext: data.message ? data.message : undefined,
|
||||||
|
text,
|
||||||
|
time: new Date(time),
|
||||||
|
title: 'Ko-fi Donation received',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handlePollEnd(eventId, data, time) {
|
||||||
|
if (data.poll.status === 'archived') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.addEvent({
|
||||||
|
eventId,
|
||||||
|
filterKey: 'pollEnd',
|
||||||
|
icon: 'fas fa-square-poll-vertical',
|
||||||
|
subtext: data.poll.choices.map(choice => `${choice.title} (${choice.votes})`).join(' | '),
|
||||||
|
text: data.poll.title,
|
||||||
|
time: new Date(time),
|
||||||
|
title: `Poll Ended (${data.poll.status})`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleRaid(eventId, data, time) {
|
||||||
|
this.addEvent({
|
||||||
|
eventId,
|
||||||
|
filterKey: 'raid',
|
||||||
|
hasReplay: true,
|
||||||
|
icon: 'fas fa-parachute-box',
|
||||||
|
soundUrl: '/public/fanfare.webm',
|
||||||
|
text: `${data.from} just raided with ${data.viewercount} raiders`,
|
||||||
|
time: new Date(time),
|
||||||
|
title: 'Incoming raid',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleShoutoutCreated(eventId, data, time) {
|
||||||
|
this.addEvent({
|
||||||
|
eventId,
|
||||||
|
filterKey: 'shoutout',
|
||||||
|
icon: 'fas fa-bullhorn',
|
||||||
|
text: `We gave a shoutout for ${data.to} to ${data.viewers} viewers`,
|
||||||
|
time: new Date(time),
|
||||||
|
title: 'Shoutout created',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleShoutoutReceived(eventId, data, time) {
|
||||||
|
this.addEvent({
|
||||||
|
eventId,
|
||||||
|
filterKey: 'shoutout',
|
||||||
|
icon: 'fas fa-bullhorn',
|
||||||
|
text: `${data.from} just gave us a shoutout to ${data.viewers} viewers`,
|
||||||
|
time: new Date(time),
|
||||||
|
title: 'Shoutout received',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleStreamOffline(eventId, time) {
|
||||||
|
this.addEvent({
|
||||||
|
eventId,
|
||||||
|
filterKey: 'streamOffline',
|
||||||
|
icon: 'fas fa-clapperboard text-danger',
|
||||||
|
time: new Date(time),
|
||||||
|
title: 'Stream Offline',
|
||||||
|
})
|
||||||
|
|
||||||
|
this.streamOfflineTime = new Date(time)
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSub(evt, eventId, data, time) {
|
||||||
|
const text = evt === 'resub' ? `resubscribed for the ${data.subscribed_months}. time` : 'subscribed'
|
||||||
|
const tier = data.plan === 'Prime' ? 'P' : `T${Number(data.plan) / 1000}`
|
||||||
|
const title = evt === 'resub' ? `Resub (${tier})` : `New Sub (${tier})`
|
||||||
|
this.addEvent({
|
||||||
|
eventId,
|
||||||
|
extraData: { count: 1 },
|
||||||
|
filterKey: 'subs',
|
||||||
|
hasReplay: true,
|
||||||
|
icon: 'fas fa-star',
|
||||||
|
subtext: data.message,
|
||||||
|
text: `${data.user} just ${text} (${tier})`,
|
||||||
|
time: new Date(time),
|
||||||
|
title,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSubgift(evt, eventId, data, time) {
|
||||||
|
const from = data.user === userAnonSubgifter ? 'ANON' : data.from
|
||||||
|
|
||||||
|
const tier = data.plan === 'Prime' ? 'Prime' : `Tier ${Number(data.plan) / 1000}`
|
||||||
|
|
||||||
|
if (evt === 'submysterygift') {
|
||||||
|
this.addEvent({
|
||||||
|
eventId,
|
||||||
|
extraData: { count: data.number },
|
||||||
|
filterKey: 'subs',
|
||||||
|
hasReplay: true,
|
||||||
|
icon: 'fas fa-gift',
|
||||||
|
subtext: () => this.subgiftRecipients[data.origin_id] ? `To: ${this.subgiftRecipients[data.origin_id].join(', ')}` : undefined,
|
||||||
|
text: `${from} just gifted ${data.number} subs`,
|
||||||
|
time: time ? new Date(time) : null,
|
||||||
|
title: `Subs gifted (${tier})`,
|
||||||
|
variant: 'warning',
|
||||||
|
})
|
||||||
|
|
||||||
|
this.knownMultiGiftIDs.push(data.origin_id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.origin_id) {
|
||||||
|
this.subgiftRecipients[data.origin_id] = [
|
||||||
|
...this.subgiftRecipients[data.origin_id] || [],
|
||||||
|
data.to,
|
||||||
|
].sort((a, b) => a.localeCompare(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.addEvent({
|
||||||
|
eventId,
|
||||||
|
extraData: { count: 1 },
|
||||||
|
filterKey: 'subs',
|
||||||
|
hasReplay: true,
|
||||||
|
icon: 'fas fa-gift',
|
||||||
|
originId: data.origin_id,
|
||||||
|
text: `${from} just gifted ${data.to} a sub`,
|
||||||
|
time: time ? new Date(time) : null,
|
||||||
|
title: `Sub gifted (${tier})`,
|
||||||
|
variant: 'warning',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleTimeout(eventId, data, time) {
|
||||||
|
this.addEvent({
|
||||||
|
eventId,
|
||||||
|
filterKey: 'ban',
|
||||||
|
icon: 'fas fa-ban',
|
||||||
|
time: new Date(time),
|
||||||
|
title: `${data.target_name} has been timed out for ${data.seconds}s`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleTitleUpdate(eventId, data, time) {
|
||||||
|
this.addEvent({
|
||||||
|
eventId,
|
||||||
|
filterKey: 'streamUpdate',
|
||||||
|
icon: 'fas fa-heading',
|
||||||
|
text: data.title,
|
||||||
|
time: new Date(time),
|
||||||
|
title: 'Title updated',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleWatchStreak(eventId, data, time) {
|
||||||
|
this.addEvent({
|
||||||
|
eventId,
|
||||||
|
filterKey: 'watchStreak',
|
||||||
|
icon: 'fas fa-circle-info',
|
||||||
|
subtext: data.message,
|
||||||
|
text: `${data.user} watched ${data.streak} consecutive streams`,
|
||||||
|
time: new Date(time),
|
||||||
|
title: 'Watch-Streak shared',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
markRead() {
|
||||||
|
this.storedData.readDate = new Date().getTime()
|
||||||
|
this.storageSave()
|
||||||
|
},
|
||||||
|
|
||||||
|
repeatEvent(eventId) {
|
||||||
|
return this.eventClient.replayEvent(eventId)
|
||||||
|
},
|
||||||
|
|
||||||
|
resolveSubtext(subtext) {
|
||||||
|
if (typeof subtext === 'function') {
|
||||||
|
return subtext()
|
||||||
|
}
|
||||||
|
|
||||||
|
return subtext
|
||||||
|
},
|
||||||
|
|
||||||
|
storageKey() {
|
||||||
|
const channel = this.eventClient.paramOptionFallback('channel').replace(/^#*/, '')
|
||||||
|
return [STORAGE_KEY, channel].join('.')
|
||||||
|
},
|
||||||
|
|
||||||
|
storageLoad() {
|
||||||
|
this.storedData = {
|
||||||
|
// Default values
|
||||||
|
filters: {},
|
||||||
|
readDate: 0,
|
||||||
|
|
||||||
|
// Stored data
|
||||||
|
...JSON.parse(window.localStorage.getItem(this.storageKey()) || '{}'),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
storageSave() {
|
||||||
|
window.localStorage.setItem(this.storageKey(), JSON.stringify(this.storedData))
|
||||||
|
},
|
||||||
|
|
||||||
|
timeDisplay(time) {
|
||||||
|
return dayjs(time).format('llll')
|
||||||
|
},
|
||||||
|
|
||||||
|
timeSince(time) {
|
||||||
|
return dayjs(time).from(this.now)
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleFilterVisibility(filter) {
|
||||||
|
if (!this.storedData.filters[filter]) {
|
||||||
|
this.storedData.filters[filter] = this.filters[filter]
|
||||||
|
}
|
||||||
|
|
||||||
|
this.storedData.filters[filter].visible = !this.storedData.filters[filter].visible
|
||||||
|
this.storageSave()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
name: 'EventFeed',
|
||||||
|
})
|
||||||
|
|
||||||
|
dayjs.extend(dayjsLocalizedFormat)
|
||||||
|
dayjs.extend(dayjsRelativeTime)
|
||||||
|
|
||||||
|
app.mount('#app')
|
Loading…
Reference in a new issue