mirror of https://git.sr.ht/~ashkeel/strimertul
607 lines
16 KiB
TypeScript
607 lines
16 KiB
TypeScript
import {
|
|
CircleIcon,
|
|
ExclamationTriangleIcon,
|
|
InfoCircledIcon,
|
|
UpdateIcon,
|
|
} from '@radix-ui/react-icons';
|
|
import { Trans, useTranslation } from 'react-i18next';
|
|
import {
|
|
EventSubNotification,
|
|
EventSubNotificationType,
|
|
unwrapEvent,
|
|
} from '~/lib/eventSub';
|
|
import { useLiveKey, useModule } from '~/lib/react';
|
|
import { useAppDispatch, useAppSelector } from '~/store';
|
|
import { modules } from '~/store/api/reducer';
|
|
import * as HoverCard from '@radix-ui/react-hover-card';
|
|
import { useEffect, useState } from 'react';
|
|
import { main } from '@wailsapp/go/models';
|
|
import { GetProblems, GetTwitchAuthURL } from '@wailsapp/go/main/App';
|
|
import { BrowserOpenURL } from '@wailsapp/runtime/runtime';
|
|
import {
|
|
PageContainer,
|
|
SectionHeader,
|
|
styled,
|
|
TextBlock,
|
|
theme,
|
|
TooltipContent,
|
|
} from '../theme';
|
|
import BrowserLink from '../components/BrowserLink';
|
|
import Scrollbar from '../components/utils/Scrollbar';
|
|
import RevealLink from '../components/utils/RevealLink';
|
|
|
|
interface StreamInfo {
|
|
id: string;
|
|
user_name: string;
|
|
user_login: string;
|
|
game_name: string;
|
|
title: string;
|
|
viewer_count: number;
|
|
started_at: string;
|
|
language: string;
|
|
thumbnail_url: string;
|
|
}
|
|
|
|
const StreamBlock = styled('div', {
|
|
display: 'grid',
|
|
gap: '1rem',
|
|
gridTemplateColumns: '160px 1fr',
|
|
});
|
|
const StreamTitle = styled('h3', {
|
|
gridRow: 1,
|
|
gridColumn: 2,
|
|
fontWeight: 400,
|
|
margin: 0,
|
|
marginTop: '0.5rem',
|
|
});
|
|
const StreamInfo = styled('div', {
|
|
gridRow: 2,
|
|
gridColumn: 2,
|
|
fontWeight: 'bold',
|
|
margin: 0,
|
|
marginBottom: '0.5rem',
|
|
});
|
|
const LiveIndicator = styled('div', {
|
|
gridRow: '1/3',
|
|
gridColumn: 1,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
fontWeight: 'bold',
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center',
|
|
});
|
|
const Darken = styled(BrowserLink, {
|
|
flex: 1,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
background: 'rgba(0,0,0,0.5)',
|
|
width: '100%',
|
|
height: '100%',
|
|
gap: '0.5rem',
|
|
color: '$red11 !important',
|
|
textDecoration: 'none !important',
|
|
transition: 'all 0.2s ease-in-out',
|
|
'&:hover': {
|
|
opacity: 0.5,
|
|
},
|
|
});
|
|
|
|
const EventListContainer = styled('div', {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '5px',
|
|
});
|
|
|
|
const TwitchEventContainer = styled('div', {
|
|
background: '$gray3',
|
|
padding: '8px',
|
|
borderRadius: '5px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
});
|
|
|
|
const TwitchEventContent = styled('div', {
|
|
flex: 1,
|
|
});
|
|
const TwitchEventActions = styled('div', {
|
|
display: 'flex',
|
|
margin: '0 10px',
|
|
'& a': {
|
|
color: '$gray10',
|
|
'&:hover': {
|
|
color: '$gray12',
|
|
cursor: 'pointer',
|
|
},
|
|
},
|
|
});
|
|
const TwitchEventTime = styled('time', {
|
|
color: '$gray10',
|
|
fontSize: '13px',
|
|
});
|
|
|
|
const UsefulLinksMenu = styled('ul', {
|
|
margin: '0',
|
|
listStyleType: 'square',
|
|
li: {
|
|
padding: '3px',
|
|
},
|
|
});
|
|
|
|
const supportedMessages: EventSubNotificationType[] = [
|
|
EventSubNotificationType.Followed,
|
|
EventSubNotificationType.CustomRewardRedemptionAdded,
|
|
EventSubNotificationType.StreamWentOnline,
|
|
EventSubNotificationType.StreamWentOffline,
|
|
EventSubNotificationType.ChannelUpdated,
|
|
EventSubNotificationType.Raided,
|
|
EventSubNotificationType.Cheered,
|
|
EventSubNotificationType.Subscription,
|
|
EventSubNotificationType.SubscriptionWithMessage,
|
|
EventSubNotificationType.SubscriptionGifted,
|
|
];
|
|
|
|
const eventSubKeyFunction = (ev: EventSubNotification) =>
|
|
`${ev.subscription.type}-${ev.subscription.created_at}-${JSON.stringify(
|
|
ev.event,
|
|
)}`;
|
|
|
|
function TwitchEvent({ data }: { data: EventSubNotification }) {
|
|
const { t } = useTranslation();
|
|
const client = useAppSelector((state) => state.api.client);
|
|
|
|
const replay = () => {
|
|
void client.putJSON(
|
|
`twitch/ev/eventsub-event/${data.subscription.type}`,
|
|
data,
|
|
);
|
|
};
|
|
|
|
let content: JSX.Element | string;
|
|
const message = unwrapEvent(data);
|
|
let date = data.date
|
|
? new Date(data.date)
|
|
: new Date(data.subscription.created_at);
|
|
switch (message.type) {
|
|
case EventSubNotificationType.Followed: {
|
|
content = (
|
|
<>
|
|
<Trans
|
|
t={t}
|
|
i18nKey={'pages.dashboard.twitch-events.events.follow'}
|
|
values={{ name: message.event.user_name }}
|
|
components={{
|
|
n: <b />,
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
date = new Date(message.event.followed_at);
|
|
break;
|
|
}
|
|
case EventSubNotificationType.CustomRewardRedemptionAdded: {
|
|
content = (
|
|
<>
|
|
<Trans
|
|
t={t}
|
|
i18nKey={'pages.dashboard.twitch-events.events.redemption'}
|
|
values={{
|
|
name: message.event.user_name,
|
|
reward: message.event.reward.title,
|
|
}}
|
|
components={{
|
|
n: <b />,
|
|
r: <b />,
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
date = new Date(message.event.redeemed_at);
|
|
break;
|
|
}
|
|
case EventSubNotificationType.StreamWentOnline: {
|
|
content = (
|
|
<>
|
|
<Trans
|
|
t={t}
|
|
i18nKey={'pages.dashboard.twitch-events.events.stream-start'}
|
|
/>
|
|
</>
|
|
);
|
|
date = new Date(message.event.started_at);
|
|
break;
|
|
}
|
|
case EventSubNotificationType.StreamWentOffline: {
|
|
content = (
|
|
<>
|
|
<Trans
|
|
t={t}
|
|
i18nKey={'pages.dashboard.twitch-events.events.stream-stop'}
|
|
/>
|
|
</>
|
|
);
|
|
break;
|
|
}
|
|
case EventSubNotificationType.ChannelUpdated: {
|
|
content = (
|
|
<>
|
|
<Trans
|
|
t={t}
|
|
i18nKey={'pages.dashboard.twitch-events.events.channel-updated'}
|
|
/>
|
|
</>
|
|
);
|
|
break;
|
|
}
|
|
case EventSubNotificationType.Raided: {
|
|
content = (
|
|
<>
|
|
<Trans
|
|
t={t}
|
|
i18nKey={'pages.dashboard.twitch-events.events.raided'}
|
|
values={{
|
|
name: message.event.from_broadcaster_user_name,
|
|
viewers: message.event.viewers,
|
|
}}
|
|
components={{
|
|
n: <b />,
|
|
v: <b />,
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
break;
|
|
}
|
|
case EventSubNotificationType.Cheered: {
|
|
content = (
|
|
<>
|
|
<Trans
|
|
t={t}
|
|
i18nKey={'pages.dashboard.twitch-events.events.cheered'}
|
|
values={{
|
|
name: message.event.is_anonymous
|
|
? t('pages.dashboard.twitch-events.anonymous')
|
|
: message.event.user_name,
|
|
bits: message.event.bits,
|
|
}}
|
|
components={{
|
|
n: <b />,
|
|
b: <b />,
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
break;
|
|
}
|
|
case EventSubNotificationType.Subscription:
|
|
content = (
|
|
<>
|
|
<Trans
|
|
t={t}
|
|
i18nKey={'pages.dashboard.twitch-events.events.subscribed'}
|
|
values={{
|
|
name: message.event.user_name,
|
|
tier: message.event.tier.substring(0, 1),
|
|
}}
|
|
components={{
|
|
n: <b />,
|
|
t: <></>,
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
break;
|
|
case EventSubNotificationType.SubscriptionWithMessage:
|
|
content = (
|
|
<>
|
|
<Trans
|
|
t={t}
|
|
i18nKey={'pages.dashboard.twitch-events.events.subscribed-multi'}
|
|
values={{
|
|
name: message.event.user_name,
|
|
months: message.event.cumulative_months,
|
|
tier: message.event.tier.substring(0, 1),
|
|
}}
|
|
components={{
|
|
n: <b />,
|
|
m: <></>,
|
|
t: <></>,
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
break;
|
|
case EventSubNotificationType.SubscriptionGifted:
|
|
content = (
|
|
<>
|
|
<Trans
|
|
t={t}
|
|
i18nKey={'pages.dashboard.twitch-events.events.subscrition-gift'}
|
|
values={{
|
|
count: message.event.total,
|
|
name: message.event.is_anonymous
|
|
? t('pages.dashboard.twitch-events.anonymous')
|
|
: message.event.user_name,
|
|
tier: message.event.tier.substring(0, 1),
|
|
}}
|
|
components={{
|
|
n: <b />,
|
|
c: <></>,
|
|
t: <></>,
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
break;
|
|
default:
|
|
content = <small>{message.type}</small>;
|
|
}
|
|
|
|
return (
|
|
<TwitchEventContainer>
|
|
<TwitchEventContent>{content}</TwitchEventContent>
|
|
<TwitchEventTime
|
|
title={date?.toLocaleString()}
|
|
dateTime={message.subscription.created_at}
|
|
>
|
|
{date?.toLocaleTimeString()}
|
|
</TwitchEventTime>
|
|
<TwitchEventActions>
|
|
<a
|
|
aria-label={t('pages.dashboard.twitch-events.replay')}
|
|
title={t('pages.dashboard.twitch-events.replay')}
|
|
onClick={() => {
|
|
replay();
|
|
}}
|
|
>
|
|
<UpdateIcon />
|
|
</a>
|
|
</TwitchEventActions>
|
|
</TwitchEventContainer>
|
|
);
|
|
}
|
|
|
|
function TwitchEventLog({ events }: { events: EventSubNotification[] }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<>
|
|
<HoverCard.Root>
|
|
<HoverCard.Trigger asChild>
|
|
<SectionHeader>
|
|
{t('pages.dashboard.twitch-events.header')}
|
|
<a style={{ marginLeft: '10px' }}>
|
|
<InfoCircledIcon />
|
|
</a>
|
|
</SectionHeader>
|
|
</HoverCard.Trigger>
|
|
<HoverCard.Portal>
|
|
<TooltipContent>
|
|
{t('pages.dashboard.twitch-events.warning')}
|
|
</TooltipContent>
|
|
</HoverCard.Portal>
|
|
</HoverCard.Root>
|
|
<Scrollbar vertical={true} viewport={{ maxHeight: '250px' }}>
|
|
<EventListContainer>
|
|
{events
|
|
.filter((ev) => supportedMessages.includes(ev.subscription.type))
|
|
.sort((a, b) =>
|
|
a.date && b.date
|
|
? Date.parse(b.date) - Date.parse(a.date)
|
|
: Date.parse(b.subscription.created_at) -
|
|
Date.parse(a.subscription.created_at),
|
|
)
|
|
.map((ev) => (
|
|
<TwitchEvent key={eventSubKeyFunction(ev)} data={ev} />
|
|
))}
|
|
</EventListContainer>
|
|
</Scrollbar>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function TwitchStreamStatus({ info }: { info: StreamInfo }) {
|
|
const { t } = useTranslation();
|
|
const [uiConfig, setUiConfig] = useModule(modules.uiConfig);
|
|
const dispatch = useAppDispatch();
|
|
return (
|
|
<StreamBlock>
|
|
<LiveIndicator
|
|
css={{
|
|
backgroundImage: `url(${info.thumbnail_url
|
|
.replace('{width}', '160')
|
|
.replace('{height}', '90')})`,
|
|
}}
|
|
>
|
|
<Darken target="_blank" href={`https://twitch.tv/${info.user_login}`}>
|
|
<CircleIcon /> {t('pages.dashboard.live')}
|
|
</Darken>
|
|
</LiveIndicator>
|
|
<StreamTitle>{info.title}</StreamTitle>
|
|
<StreamInfo>
|
|
{info.game_name} -{' '}
|
|
{t('pages.dashboard.x-viewers', {
|
|
num: uiConfig.hideViewers ? '...' : `${info.viewer_count}`,
|
|
})}{' '}
|
|
<RevealLink
|
|
value={!uiConfig.hideViewers}
|
|
setter={(newVal) => {
|
|
void dispatch(setUiConfig({ ...uiConfig, hideViewers: !newVal }));
|
|
}}
|
|
/>
|
|
</StreamInfo>
|
|
</StreamBlock>
|
|
);
|
|
}
|
|
|
|
function TwitchSection() {
|
|
const { t } = useTranslation();
|
|
const twitchInfo = useLiveKey<StreamInfo[]>('twitch/stream-info');
|
|
const kv = useAppSelector((state) => state.api.client);
|
|
const [twitchEvents, setTwitchEvents] = useState<EventSubNotification[]>([]);
|
|
|
|
const keyfn = (ev: EventSubNotification) => JSON.stringify(ev);
|
|
|
|
const addTwitchEvents = (events: EventSubNotification[]) => {
|
|
setTwitchEvents((currentEvents) => {
|
|
const allEvents = currentEvents.concat(events);
|
|
const eventKeys = allEvents.map(keyfn);
|
|
|
|
// Clean up duplicates before setting to state
|
|
const updatedEvents = allEvents.filter(
|
|
(ev, pos) => eventKeys.indexOf(keyfn(ev)) === pos,
|
|
);
|
|
|
|
return updatedEvents;
|
|
});
|
|
};
|
|
|
|
const loadRecentEvents = async () => {
|
|
const keymap = await kv.getKeysByPrefix('twitch/eventsub-history/');
|
|
const events = Object.values(keymap)
|
|
.map((value) => JSON.parse(value) as EventSubNotification[])
|
|
.flat();
|
|
|
|
addTwitchEvents(events);
|
|
};
|
|
|
|
useEffect(() => {
|
|
void loadRecentEvents();
|
|
|
|
const onKeyChange = (value: string) => {
|
|
const event = JSON.parse(value) as EventSubNotification;
|
|
if (!supportedMessages.includes(event.subscription.type)) {
|
|
return;
|
|
}
|
|
void addTwitchEvents([event]);
|
|
};
|
|
|
|
void kv.subscribePrefix('twitch/ev/eventsub-event/', onKeyChange);
|
|
|
|
return () => {
|
|
void kv.unsubscribePrefix('twitch/ev/eventsub-event/', onKeyChange);
|
|
};
|
|
}, []);
|
|
|
|
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}
|
|
</>
|
|
);
|
|
}
|
|
|
|
const ProblemBlock = styled('div', {
|
|
border: '2px solid $gray6',
|
|
padding: '0.5rem 1rem',
|
|
borderRadius: theme.borderRadius.toolbar,
|
|
variants: {
|
|
severity: {
|
|
warn: {
|
|
borderColor: '$yellow6',
|
|
backgroundColor: '$yellow3',
|
|
color: '$yellow12',
|
|
svg: {
|
|
color: '$yellow11',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
display: 'flex',
|
|
gap: '1rem',
|
|
alignItems: 'center',
|
|
lineHeight: '1.4',
|
|
svg: {
|
|
marginTop: '0.25rem',
|
|
},
|
|
a: {
|
|
cursor: 'pointer',
|
|
},
|
|
});
|
|
|
|
function ProblemList() {
|
|
const [problems, setProblems] = useState<main.Problem[]>([]);
|
|
const { t } = useTranslation();
|
|
const kv = useAppSelector((state) => state.api.client);
|
|
|
|
useEffect(() => {
|
|
void GetProblems().then(setProblems);
|
|
}, []);
|
|
|
|
const reauthenticate = async () => {
|
|
// Wait for re-auth so we can clear the banner
|
|
const onKeyChange = () => {
|
|
void GetProblems().then(setProblems);
|
|
void kv.unsubscribeKey('twitch/auth-keys', onKeyChange);
|
|
};
|
|
void kv.subscribeKey('twitch/auth-keys', onKeyChange);
|
|
|
|
const url = await GetTwitchAuthURL('stream');
|
|
BrowserOpenURL(url);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{problems.map((p) => {
|
|
switch (p.id) {
|
|
case 'twitch:eventsub_scope':
|
|
return (
|
|
<ProblemBlock severity="warn">
|
|
<ExclamationTriangleIcon
|
|
style={{ width: 'auto', minWidth: '40px', height: '40px' }}
|
|
/>
|
|
<header>
|
|
<Trans
|
|
t={t}
|
|
i18nKey={'pages.dashboard.problems.eventsub-scope'}
|
|
components={{
|
|
a: (
|
|
<a
|
|
onClick={() => {
|
|
void reauthenticate();
|
|
}}
|
|
></a>
|
|
),
|
|
}}
|
|
/>
|
|
</header>
|
|
</ProblemBlock>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function Dashboard(): React.ReactElement {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<PageContainer>
|
|
<ProblemList />
|
|
<TwitchSection />
|
|
<SectionHeader>{t('pages.dashboard.quick-links')}</SectionHeader>
|
|
<UsefulLinksMenu>
|
|
<li>
|
|
<BrowserLink href="https://strimertul.stream/guide/">
|
|
{t('pages.dashboard.link-user-guide')}
|
|
</BrowserLink>
|
|
</li>
|
|
<li>
|
|
<BrowserLink href="https://strimertul.stream/api/v31/">
|
|
{t('pages.dashboard.link-api')}
|
|
</BrowserLink>
|
|
</li>
|
|
</UsefulLinksMenu>
|
|
</PageContainer>
|
|
);
|
|
}
|