mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-09-07 20:39:07 +00:00
Compare commits
2 Commits
d1d0f99043
...
014df155ae
Author | SHA1 | Date | |
---|---|---|---|
014df155ae | |||
c4be936c63 |
@ -11,7 +11,7 @@
|
||||
/**
|
||||
* SocketMessage received for every event and passed to the new `(eventObj) => { ... }` handlers
|
||||
* @typedef {Object} SocketMessage
|
||||
* @prop {Number} [event_id] - UID of the event used to re-trigger an event
|
||||
* @prop {String} [event_id] - UID of the event used to re-trigger an event
|
||||
* @prop {Boolean} [is_live] - Whether the event was sent through a replay (false) or occurred live (true)
|
||||
* @prop {String} [reason] - Reason of this message (one of `bulk-replay`, `live-event`, `single-replay`)
|
||||
* @prop {String} [time] - RFC3339 timestamp of the event
|
||||
|
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')
|
@ -42,7 +42,7 @@ type (
|
||||
|
||||
// socketMessage represents the message overlay sockets will receive
|
||||
socketMessage struct {
|
||||
EventID uint64 `json:"event_id"`
|
||||
EventID uint64 `json:"event_id,string"`
|
||||
IsLive bool `json:"is_live"`
|
||||
Reason sendReason `json:"reason"`
|
||||
Time time.Time `json:"time"`
|
||||
|
Loading…
Reference in New Issue
Block a user