mirror of
https://git.sr.ht/~ashkeel/strimertul
synced 2024-09-18 01:50:50 +00:00
feat: WIP recent events
This commit is contained in:
parent
5f4a909017
commit
f9db1475b8
2 changed files with 560 additions and 24 deletions
468
frontend/src/lib/eventSub.ts
Normal file
468
frontend/src/lib/eventSub.ts
Normal file
|
@ -0,0 +1,468 @@
|
|||
export enum EventSubNotificationType {
|
||||
ChannelUpdated = 'channel.update',
|
||||
UserUpdated = 'user.update',
|
||||
Cheered = 'channel.cheer',
|
||||
Raided = 'channel.raid',
|
||||
CustomRewardAdded = 'channel.channel_points_custom_reward.add',
|
||||
CustomRewardRemoved = 'channel.channel_points_custom_reward.remove',
|
||||
CustomRewardUpdated = 'channel.channel_points_custom_reward.update',
|
||||
CustomRewardRedemptionAdded = 'channel.channel_points_custom_reward_redemption.add',
|
||||
CustomRewardRedemptionUpdated = 'channel.channel_points_custom_reward_redemption.update',
|
||||
Followed = 'channel.follow',
|
||||
GoalBegan = 'channel.goal.begin',
|
||||
GoalEnded = 'channel.goal.end',
|
||||
GoalProgress = 'channel.goal.progress',
|
||||
HypeTrainBegan = 'channel.hype_train.begin',
|
||||
HypeTrainEnded = 'channel.hype_train.end',
|
||||
HypeTrainProgress = 'channel.hype_train.progress',
|
||||
ModeratorAdded = 'channel.moderator.add',
|
||||
ModeratorRemoved = 'channel.moderator.remove',
|
||||
PollBegan = 'channel.poll.begin',
|
||||
PollEnded = 'channel.poll.end',
|
||||
PollProgress = 'channel.poll.progress',
|
||||
PredictionBegan = 'channel.prediction.begin',
|
||||
PredictionEnded = 'channel.prediction.end',
|
||||
PredictionLocked = 'channel.prediction.lock',
|
||||
PredictionProgress = 'channel.prediction.progress',
|
||||
StreamWentOffline = 'stream.offline',
|
||||
StreamWentOnline = 'stream.online',
|
||||
Subscription = 'channel.subscribe',
|
||||
SubscriptionEnded = 'channel.subscription.end',
|
||||
SubscriptionGifted = 'channel.subscription.gift',
|
||||
SubscriptionWithMessage = 'channel.subscription.message',
|
||||
ViewerBanned = 'channel.ban',
|
||||
ViewerUnbanned = 'channel.unban',
|
||||
}
|
||||
|
||||
export interface EventSubSubscription {
|
||||
id: string;
|
||||
type: EventSubNotificationType;
|
||||
version: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
export interface EventSubNotification {
|
||||
subscription: EventSubSubscription;
|
||||
event: unknown;
|
||||
}
|
||||
|
||||
export const unwrapEvent = (message: EventSubNotification) =>
|
||||
({
|
||||
type: message.subscription.type,
|
||||
subscription: message.subscription,
|
||||
event: message.event,
|
||||
} as EventSubMessage);
|
||||
|
||||
interface TypedEventSubNotification<
|
||||
T extends EventSubNotificationType,
|
||||
Payload,
|
||||
> {
|
||||
type: T;
|
||||
subscription: EventSubSubscription;
|
||||
event: Payload;
|
||||
}
|
||||
|
||||
export type EventSubMessage =
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.ChannelUpdated,
|
||||
ChannelUpdatedEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.UserUpdated,
|
||||
UserUpdatedEventData
|
||||
>
|
||||
| TypedEventSubNotification<EventSubNotificationType.Cheered, CheerEventData>
|
||||
| TypedEventSubNotification<EventSubNotificationType.Raided, RaidEventData>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.CustomRewardAdded,
|
||||
ChannelRewardEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.CustomRewardRemoved,
|
||||
ChannelRewardEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.CustomRewardUpdated,
|
||||
ChannelRewardEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.CustomRewardRedemptionAdded,
|
||||
ChannelRedemptionEventData<false>
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.CustomRewardRedemptionUpdated,
|
||||
ChannelRedemptionEventData<true>
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.Followed,
|
||||
FollowEventData
|
||||
>
|
||||
| TypedEventSubNotification<EventSubNotificationType.GoalBegan, GoalEventData>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.GoalEnded,
|
||||
GoalEndedEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.GoalProgress,
|
||||
GoalEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.HypeTrainBegan,
|
||||
HypeTrainEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.HypeTrainProgress,
|
||||
HypeTrainEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.HypeTrainEnded,
|
||||
HypeTrainEndedEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.ModeratorAdded,
|
||||
ModeratorEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.ModeratorRemoved,
|
||||
ModeratorEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.PollBegan,
|
||||
PollEventData<false>
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.PollProgress,
|
||||
PollEventData<true>
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.PollEnded,
|
||||
PollEndedEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.PredictionBegan,
|
||||
PredictionEventData<false>
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.PredictionProgress,
|
||||
PredictionEventData<true>
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.PredictionLocked,
|
||||
PredictionLockedEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.PredictionEnded,
|
||||
PredictionEndedEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.StreamWentOffline,
|
||||
StreamEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.StreamWentOnline,
|
||||
StreamWentOnlineEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.Subscription,
|
||||
SubscriptionEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.SubscriptionEnded,
|
||||
SubscriptionEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.SubscriptionGifted,
|
||||
SubscriptionGiftedEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.SubscriptionWithMessage,
|
||||
SubscriptionMessageEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.ViewerBanned,
|
||||
UserBannedEventData
|
||||
>
|
||||
| TypedEventSubNotification<
|
||||
EventSubNotificationType.ViewerUnbanned,
|
||||
UserUnbannedEventData
|
||||
>;
|
||||
|
||||
export interface StreamEventData {
|
||||
broadcaster_user_id: string;
|
||||
broadcaster_user_login: string;
|
||||
broadcaster_user_name: string;
|
||||
}
|
||||
|
||||
export interface StreamWentOnlineEventData extends StreamEventData {
|
||||
id: string;
|
||||
type: 'live' | 'playlist' | 'watch_party' | 'premiere' | 'rerun';
|
||||
started_at: string;
|
||||
}
|
||||
|
||||
type Optional<field extends string, Extra> =
|
||||
| ({ [key in field]: true } & Extra)
|
||||
| { [key in field]: false };
|
||||
|
||||
type UserBannedEventData = StreamEventData & {
|
||||
user_id: string;
|
||||
user_login: string;
|
||||
user_name: string;
|
||||
moderator_user_id: string;
|
||||
moderator_user_login: string;
|
||||
moderator_user_name: string;
|
||||
reason: string;
|
||||
banned_at: string;
|
||||
} & Optional<'is_permanent', { ends_at: string }>;
|
||||
|
||||
export interface UserUnbannedEventData {
|
||||
user_id: string;
|
||||
user_login: string;
|
||||
user_name: string;
|
||||
moderator_user_id: string;
|
||||
moderator_user_login: string;
|
||||
moderator_user_name: string;
|
||||
}
|
||||
|
||||
export interface ChannelUpdatedEventData extends StreamEventData {
|
||||
title: string;
|
||||
language: string;
|
||||
category_id: string;
|
||||
category_name: string;
|
||||
is_mature: boolean;
|
||||
}
|
||||
|
||||
export interface FollowEventData extends StreamEventData {
|
||||
user_id: string;
|
||||
user_login: string;
|
||||
user_name: string;
|
||||
followed_at: string;
|
||||
}
|
||||
|
||||
export interface UserUpdatedEventData {
|
||||
user_id: string;
|
||||
user_login: string;
|
||||
user_name: string;
|
||||
email?: string;
|
||||
email_verified: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CheerEventData extends StreamEventData {
|
||||
is_anonymous: boolean;
|
||||
user_id: string | null;
|
||||
user_login: string | null;
|
||||
user_name: string | null;
|
||||
message: string;
|
||||
bits: number;
|
||||
}
|
||||
|
||||
export interface RaidEventData {
|
||||
from_broadcaster_user_id: string;
|
||||
from_broadcaster_user_login: string;
|
||||
from_broadcaster_user_name: string;
|
||||
to_broadcaster_user_id: string;
|
||||
to_broadcaster_user_login: string;
|
||||
to_broadcaster_user_name: string;
|
||||
viewers: number;
|
||||
}
|
||||
|
||||
export interface ChannelRewardEventData extends StreamEventData {
|
||||
id: string;
|
||||
is_enabled: boolean;
|
||||
is_paused: boolean;
|
||||
is_in_stock: boolean;
|
||||
title: string;
|
||||
cost: number;
|
||||
prompt: string;
|
||||
is_user_input_required: boolean;
|
||||
should_redemptions_skip_request_queue: boolean;
|
||||
cooldown_expires_at: string | null;
|
||||
redemptions_redeemed_current_stream: number | null;
|
||||
max_per_stream: Optional<'is_enabled', { value: number }>;
|
||||
max_per_user_per_stream: Optional<'is_enabled', { value: number }>;
|
||||
global_cooldown: Optional<'is_enabled', { seconds: number }>;
|
||||
background_color: string;
|
||||
image: {
|
||||
url_1x: string;
|
||||
url_2x: string;
|
||||
url_4x: string;
|
||||
} | null;
|
||||
default_image: {
|
||||
url_1x: string;
|
||||
url_2x: string;
|
||||
url_4x: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChannelRedemptionEventData<Updated extends boolean>
|
||||
extends StreamEventData {
|
||||
id: string;
|
||||
user_id: string;
|
||||
user_login: string;
|
||||
user_name: string;
|
||||
user_input: string;
|
||||
status: Updated extends true
|
||||
? 'fulfilled' | 'canceled'
|
||||
: 'unfulfilled' | 'unknown' | 'fulfilled' | 'canceled';
|
||||
reward: ChannelRewardEventData;
|
||||
redeemed_at: string;
|
||||
}
|
||||
|
||||
export interface GoalEventData extends StreamEventData {
|
||||
id: string;
|
||||
type: 'follower' | 'subscription';
|
||||
description: string;
|
||||
current_amount: number;
|
||||
target_amount: number;
|
||||
started_at: Date;
|
||||
}
|
||||
|
||||
export interface GoalEndedEventData extends GoalEventData {
|
||||
is_achieved: boolean;
|
||||
ended_at: Date;
|
||||
}
|
||||
|
||||
export interface HypeTrainContribution {
|
||||
user_id: string;
|
||||
user_login: string;
|
||||
user_name: string;
|
||||
type: 'bits' | 'subscription' | 'other';
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface HypeTrainBaseData extends StreamEventData {
|
||||
id: string;
|
||||
level: number;
|
||||
total: number;
|
||||
top_contributions:
|
||||
| [HypeTrainContribution]
|
||||
| [HypeTrainContribution, HypeTrainContribution]
|
||||
| null;
|
||||
started_at: string;
|
||||
}
|
||||
|
||||
export interface HypeTrainEventData extends HypeTrainBaseData {
|
||||
progress: number;
|
||||
goal: number;
|
||||
last_contribution: HypeTrainContribution;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export interface HypeTrainEndedEventData extends HypeTrainBaseData {
|
||||
ended_at: string;
|
||||
cooldown_ends_at: string;
|
||||
}
|
||||
|
||||
export interface ModeratorEventData extends StreamEventData {
|
||||
user_id: string;
|
||||
user_login: string;
|
||||
user_name: string;
|
||||
}
|
||||
|
||||
interface PollBaseData<Running extends boolean> extends StreamEventData {
|
||||
id: string;
|
||||
title: string;
|
||||
choices: Running extends true
|
||||
? {
|
||||
id: string;
|
||||
title: string;
|
||||
bits_votes: number;
|
||||
channel_points_votes: number;
|
||||
votes: number;
|
||||
}
|
||||
: {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
bits_voting: Optional<'is_enabled', { amount_per_vote: number }>;
|
||||
channel_points_voting: Optional<'is_enabled', { amount_per_vote: number }>;
|
||||
started_at: string;
|
||||
}
|
||||
|
||||
export interface PollEventData<Running extends boolean>
|
||||
extends PollBaseData<Running> {
|
||||
started_at: string;
|
||||
ends_at: string;
|
||||
}
|
||||
|
||||
export interface PollEndedEventData extends PollBaseData<true> {
|
||||
status: 'completed' | 'archived' | 'terminated';
|
||||
ended_at: string;
|
||||
}
|
||||
|
||||
type PredictionColor = 'blue' | 'pink';
|
||||
interface Outcome<Color extends PredictionColor> {
|
||||
id: string;
|
||||
title: string;
|
||||
color: Color;
|
||||
}
|
||||
interface RunningOutcome<Color extends PredictionColor> extends Outcome<Color> {
|
||||
users: number;
|
||||
channel_points: number;
|
||||
top_predictors: {
|
||||
user_name: string;
|
||||
user_login: string;
|
||||
user_id: string;
|
||||
channel_points_won: number | null;
|
||||
channel_points_used: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
type UnorderedTuple<A, B> = [A, B] | [B, A];
|
||||
|
||||
interface PredictionBaseData<Running extends boolean> extends StreamEventData {
|
||||
id: string;
|
||||
title: string;
|
||||
started_at: string;
|
||||
outcomes: Running extends true
|
||||
? UnorderedTuple<RunningOutcome<'blue'>, RunningOutcome<'pink'>>
|
||||
: UnorderedTuple<Outcome<'blue'>, Outcome<'pink'>>;
|
||||
}
|
||||
|
||||
export interface PredictionEventData<Running extends boolean>
|
||||
extends PredictionBaseData<Running> {
|
||||
locks_at: string;
|
||||
}
|
||||
|
||||
export interface PredictionLockedEventData extends PredictionBaseData<true> {
|
||||
locked_at: string;
|
||||
}
|
||||
|
||||
export interface PredictionEndedEventData extends PredictionBaseData<true> {
|
||||
winning_outcome_id: string | null;
|
||||
status: 'resolved' | 'canceled';
|
||||
ended_at: string;
|
||||
}
|
||||
|
||||
interface SubscriptionBaseData extends StreamEventData {
|
||||
user_id: string;
|
||||
user_login: string;
|
||||
user_name: string;
|
||||
tier: '1000' | '2000' | '3000';
|
||||
}
|
||||
|
||||
export interface SubscriptionEventData extends SubscriptionBaseData {
|
||||
is_gift: boolean;
|
||||
}
|
||||
|
||||
export interface SubscriptionGiftedEventData extends SubscriptionBaseData {
|
||||
total: number;
|
||||
cumulative_total: number | null;
|
||||
is_anonymous: boolean;
|
||||
}
|
||||
|
||||
export interface SubscriptionMessageEventData extends SubscriptionBaseData {
|
||||
message: {
|
||||
text: string;
|
||||
emotes: {
|
||||
begin: number;
|
||||
end: number;
|
||||
id: string;
|
||||
}[]; // Oh god not this again
|
||||
};
|
||||
cumulative_months: number;
|
||||
streak_months: number | null;
|
||||
duration_months: number;
|
||||
}
|
|
@ -2,9 +2,15 @@ import { CircleIcon } from '@radix-ui/react-icons';
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLiveKey } from '~/lib/react';
|
||||
import {
|
||||
EventSubNotification,
|
||||
EventSubNotificationType,
|
||||
unwrapEvent,
|
||||
} from '~/lib/eventSub';
|
||||
import { PageContainer, SectionHeader, styled, TextBlock } from '../theme';
|
||||
import WIPNotice from '../components/utils/WIPNotice';
|
||||
import BrowserLink from '../components/BrowserLink';
|
||||
import Scrollbar from '../components/utils/Scrollbar';
|
||||
|
||||
interface StreamInfo {
|
||||
id: string;
|
||||
|
@ -65,41 +71,103 @@ const Darken = styled(BrowserLink, {
|
|||
},
|
||||
});
|
||||
|
||||
function TwitchSection() {
|
||||
const { t } = useTranslation();
|
||||
const twitchInfo = useLiveKey<StreamInfo[]>('twitch/stream-info');
|
||||
// const twitchActivity = useLiveKey<StreamInfo[]>('twitch/chat-activity');
|
||||
const EventListContainer = styled('div', {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '5px',
|
||||
});
|
||||
|
||||
const TwitchEventContainer = styled('div', {
|
||||
background: '$gray3',
|
||||
padding: '8px',
|
||||
borderRadius: '5px',
|
||||
});
|
||||
|
||||
const supportedMessages: EventSubNotificationType[] = [
|
||||
EventSubNotificationType.Followed,
|
||||
];
|
||||
|
||||
function TwitchEvent({ data }: { data: EventSubNotification }) {
|
||||
let content: JSX.Element | string;
|
||||
const message = unwrapEvent(data);
|
||||
switch (message.type) {
|
||||
case EventSubNotificationType.Followed: {
|
||||
content = `${message.event.user_name} followed you!`;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
content = <small>{message.type}</small>;
|
||||
}
|
||||
return <TwitchEventContainer>{content}</TwitchEventContainer>;
|
||||
}
|
||||
|
||||
function TwitchEventLog({ events }: { events: EventSubNotification[] }) {
|
||||
// TODO Include a note specifying that it's not ALL events!!
|
||||
return (
|
||||
<>
|
||||
<SectionHeader>{t('pages.dashboard.twitch-status')}</SectionHeader>
|
||||
{twitchInfo && twitchInfo.length > 0 ? (
|
||||
<SectionHeader>Latest events</SectionHeader>
|
||||
<Scrollbar vertical={true} viewport={{ maxHeight: '200px' }}>
|
||||
<EventListContainer>
|
||||
{events
|
||||
.filter((ev) => supportedMessages.includes(ev.subscription.type))
|
||||
.map((ev) => (
|
||||
<TwitchEvent
|
||||
key={`${ev.subscription.id}-${ev.subscription.created_at}`}
|
||||
data={ev}
|
||||
/>
|
||||
))}
|
||||
</EventListContainer>
|
||||
</Scrollbar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TwitchStreamStatus({ info }: { info: StreamInfo }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StreamBlock>
|
||||
<LiveIndicator
|
||||
css={{
|
||||
backgroundImage: `url(${twitchInfo[0].thumbnail_url
|
||||
backgroundImage: `url(${info.thumbnail_url
|
||||
.replace('{width}', '160')
|
||||
.replace('{height}', '90')})`,
|
||||
}}
|
||||
>
|
||||
<Darken
|
||||
target="_blank"
|
||||
href={`https://twitch.tv/${twitchInfo[0].user_login}`}
|
||||
>
|
||||
<Darken target="_blank" href={`https://twitch.tv/${info.user_login}`}>
|
||||
<CircleIcon /> {t('pages.dashboard.live')}
|
||||
</Darken>
|
||||
</LiveIndicator>
|
||||
<StreamTitle>{twitchInfo[0].title}</StreamTitle>
|
||||
<StreamTitle>{info.title}</StreamTitle>
|
||||
<StreamInfo>
|
||||
{twitchInfo[0].game_name} -{' '}
|
||||
{info.game_name} -{' '}
|
||||
{t('pages.dashboard.x-viewers', {
|
||||
count: twitchInfo[0].viewer_count,
|
||||
count: info.viewer_count,
|
||||
})}
|
||||
</StreamInfo>
|
||||
</StreamBlock>
|
||||
);
|
||||
}
|
||||
|
||||
function TwitchSection() {
|
||||
const { t } = useTranslation();
|
||||
const twitchInfo = useLiveKey<StreamInfo[]>('twitch/stream-info');
|
||||
// const twitchActivity = useLiveKey<StreamInfo[]>('twitch/chat-activity');
|
||||
const twitchEvents = useLiveKey<EventSubNotification[]>(
|
||||
'twitch/eventsub-history',
|
||||
);
|
||||
console.log(twitchEvents);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader spacing="none">
|
||||
{t('pages.dashboard.twitch-status')}
|
||||
</SectionHeader>
|
||||
{twitchInfo && twitchInfo.length > 0 ? (
|
||||
<TwitchStreamStatus info={twitchInfo[0]} />
|
||||
) : (
|
||||
<TextBlock>{t('pages.dashboard.not-live')}</TextBlock>
|
||||
)}
|
||||
{twitchEvents ? <TwitchEventLog events={twitchEvents} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue