strimertul/frontend/src/ui/pages/Dashboard.tsx

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>
);
}