From f9db1475b83165c07c91ca518395eba2c1435ec7 Mon Sep 17 00:00:00 2001 From: Ash Keel Date: Mon, 9 Jan 2023 16:31:27 +0100 Subject: [PATCH] feat: WIP recent events --- frontend/src/lib/eventSub.ts | 468 ++++++++++++++++++++++++++++ frontend/src/ui/pages/Dashboard.tsx | 116 +++++-- 2 files changed, 560 insertions(+), 24 deletions(-) create mode 100644 frontend/src/lib/eventSub.ts diff --git a/frontend/src/lib/eventSub.ts b/frontend/src/lib/eventSub.ts new file mode 100644 index 0000000..ac4c3bd --- /dev/null +++ b/frontend/src/lib/eventSub.ts @@ -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 + | TypedEventSubNotification + | TypedEventSubNotification< + EventSubNotificationType.CustomRewardAdded, + ChannelRewardEventData + > + | TypedEventSubNotification< + EventSubNotificationType.CustomRewardRemoved, + ChannelRewardEventData + > + | TypedEventSubNotification< + EventSubNotificationType.CustomRewardUpdated, + ChannelRewardEventData + > + | TypedEventSubNotification< + EventSubNotificationType.CustomRewardRedemptionAdded, + ChannelRedemptionEventData + > + | TypedEventSubNotification< + EventSubNotificationType.CustomRewardRedemptionUpdated, + ChannelRedemptionEventData + > + | TypedEventSubNotification< + EventSubNotificationType.Followed, + FollowEventData + > + | TypedEventSubNotification + | 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 + > + | TypedEventSubNotification< + EventSubNotificationType.PollProgress, + PollEventData + > + | TypedEventSubNotification< + EventSubNotificationType.PollEnded, + PollEndedEventData + > + | TypedEventSubNotification< + EventSubNotificationType.PredictionBegan, + PredictionEventData + > + | TypedEventSubNotification< + EventSubNotificationType.PredictionProgress, + PredictionEventData + > + | 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 = + | ({ [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 + 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 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 + extends PollBaseData { + started_at: string; + ends_at: string; +} + +export interface PollEndedEventData extends PollBaseData { + status: 'completed' | 'archived' | 'terminated'; + ended_at: string; +} + +type PredictionColor = 'blue' | 'pink'; +interface Outcome { + id: string; + title: string; + color: Color; +} +interface RunningOutcome extends Outcome { + 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] | [B, A]; + +interface PredictionBaseData extends StreamEventData { + id: string; + title: string; + started_at: string; + outcomes: Running extends true + ? UnorderedTuple, RunningOutcome<'pink'>> + : UnorderedTuple, Outcome<'pink'>>; +} + +export interface PredictionEventData + extends PredictionBaseData { + locks_at: string; +} + +export interface PredictionLockedEventData extends PredictionBaseData { + locked_at: string; +} + +export interface PredictionEndedEventData extends PredictionBaseData { + 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; +} diff --git a/frontend/src/ui/pages/Dashboard.tsx b/frontend/src/ui/pages/Dashboard.tsx index 8efcf91..0c2ce49 100644 --- a/frontend/src/ui/pages/Dashboard.tsx +++ b/frontend/src/ui/pages/Dashboard.tsx @@ -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, { }, }); +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 = {message.type}; + } + return {content}; +} + +function TwitchEventLog({ events }: { events: EventSubNotification[] }) { + // TODO Include a note specifying that it's not ALL events!! + return ( + <> + Latest events + + + {events + .filter((ev) => supportedMessages.includes(ev.subscription.type)) + .map((ev) => ( + + ))} + + + + ); +} + +function TwitchStreamStatus({ info }: { info: StreamInfo }) { + const { t } = useTranslation(); + return ( + + + + {t('pages.dashboard.live')} + + + {info.title} + + {info.game_name} -{' '} + {t('pages.dashboard.x-viewers', { + count: info.viewer_count, + })} + + + ); +} + function TwitchSection() { const { t } = useTranslation(); const twitchInfo = useLiveKey('twitch/stream-info'); // const twitchActivity = useLiveKey('twitch/chat-activity'); + const twitchEvents = useLiveKey( + 'twitch/eventsub-history', + ); + console.log(twitchEvents); return ( <> - {t('pages.dashboard.twitch-status')} + + {t('pages.dashboard.twitch-status')} + {twitchInfo && twitchInfo.length > 0 ? ( - - - - {t('pages.dashboard.live')} - - - {twitchInfo[0].title} - - {twitchInfo[0].game_name} -{' '} - {t('pages.dashboard.x-viewers', { - count: twitchInfo[0].viewer_count, - })} - - + ) : ( {t('pages.dashboard.not-live')} )} + {twitchEvents ? : null} ); }