mirror of https://git.sr.ht/~ashkeel/strimertul
552 lines
16 KiB
TypeScript
552 lines
16 KiB
TypeScript
import { CheckIcon, ExternalLinkIcon } from '@radix-ui/react-icons';
|
|
import { GetTwitchAuthURL, GetTwitchLoggedUser } from '@wailsapp/go/main/App';
|
|
import { helix } from '@wailsapp/go/models';
|
|
import { BrowserOpenURL } from '@wailsapp/runtime/runtime';
|
|
import React, { useEffect, useState } from 'react';
|
|
import { Trans, useTranslation } from 'react-i18next';
|
|
import eventsubTests from '~/data/eventsub-tests';
|
|
import { useModule, useStatus } from '~/lib/react';
|
|
import { useAppDispatch, useAppSelector } from '~/store';
|
|
import apiReducer, { modules } from '~/store/api/reducer';
|
|
import { checkTwitchKeys } from '~/lib/twitch';
|
|
import BrowserLink from '../components/BrowserLink';
|
|
import DefinitionTable from '../components/DefinitionTable';
|
|
import RevealLink from '../components/utils/RevealLink';
|
|
import SaveButton from '../components/forms/SaveButton';
|
|
import {
|
|
Button,
|
|
ButtonGroup,
|
|
Checkbox,
|
|
CheckboxIndicator,
|
|
Field,
|
|
FieldNote,
|
|
FlexRow,
|
|
InputBox,
|
|
Label,
|
|
PageContainer,
|
|
PageHeader,
|
|
PageTitle,
|
|
PasswordInputBox,
|
|
SectionHeader,
|
|
styled,
|
|
TabButton,
|
|
TabContainer,
|
|
TabContent,
|
|
TabList,
|
|
TextBlock,
|
|
} from '../theme';
|
|
import AlertContent from '../components/AlertContent';
|
|
import { Alert } from '../theme/alert';
|
|
|
|
const StepList = styled('ul', {
|
|
lineHeight: '1.5',
|
|
listStyleType: 'none',
|
|
listStylePosition: 'outside',
|
|
});
|
|
const Step = styled('li', {
|
|
marginBottom: '0.5rem',
|
|
paddingLeft: '1rem',
|
|
'&::marker': {
|
|
color: '$teal11',
|
|
content: '▧',
|
|
display: 'inline-block',
|
|
marginLeft: '-0.5rem',
|
|
},
|
|
});
|
|
|
|
function TwitchBotSettings() {
|
|
const [botConfig, setBotConfig, loadStatus] = useModule(
|
|
modules.twitchBotConfig,
|
|
);
|
|
const [twitchConfig, setTwitchConfig] = useModule(modules.twitchConfig);
|
|
const status = useStatus(loadStatus.save);
|
|
const dispatch = useAppDispatch();
|
|
const { t } = useTranslation();
|
|
const [revealBotToken, setRevealBotToken] = useState(false);
|
|
const active = twitchConfig?.enable_bot ?? false;
|
|
const disabled = !active || status?.type === 'pending';
|
|
|
|
return (
|
|
<form
|
|
onSubmit={(ev) => {
|
|
void dispatch(setTwitchConfig(twitchConfig));
|
|
void dispatch(setBotConfig(botConfig));
|
|
ev.preventDefault();
|
|
}}
|
|
>
|
|
<TextBlock>{t('pages.twitch-settings.bot-settings-copy')}</TextBlock>
|
|
<Field>
|
|
<FlexRow spacing={1}>
|
|
<Checkbox
|
|
checked={active}
|
|
onCheckedChange={(ev) =>
|
|
dispatch(
|
|
apiReducer.actions.twitchConfigChanged({
|
|
...twitchConfig,
|
|
enable_bot: !!ev,
|
|
}),
|
|
)
|
|
}
|
|
id="enable-bot"
|
|
>
|
|
<CheckboxIndicator>{active && <CheckIcon />}</CheckboxIndicator>
|
|
</Checkbox>
|
|
|
|
<Label htmlFor="enable-bot">
|
|
{t('pages.twitch-settings.enable-bot')}
|
|
</Label>
|
|
</FlexRow>
|
|
</Field>
|
|
<Field size="fullWidth">
|
|
<Label htmlFor="bot-channel">
|
|
{t('pages.twitch-settings.bot-channel')}
|
|
</Label>
|
|
<InputBox
|
|
type="text"
|
|
id="bot-channel"
|
|
required={active}
|
|
disabled={disabled}
|
|
value={botConfig?.channel ?? ''}
|
|
onChange={(ev) =>
|
|
dispatch(
|
|
apiReducer.actions.twitchBotConfigChanged({
|
|
...botConfig,
|
|
channel: ev.target.value,
|
|
}),
|
|
)
|
|
}
|
|
/>
|
|
</Field>
|
|
<SectionHeader>
|
|
{t('pages.twitch-settings.bot-info-header')}
|
|
</SectionHeader>
|
|
<Field size="fullWidth">
|
|
<Label htmlFor="bot-username">
|
|
{t('pages.twitch-settings.bot-username')}
|
|
</Label>
|
|
<InputBox
|
|
type="text"
|
|
id="bot-username"
|
|
required={active}
|
|
disabled={disabled}
|
|
value={botConfig?.username ?? ''}
|
|
onChange={(ev) =>
|
|
dispatch(
|
|
apiReducer.actions.twitchBotConfigChanged({
|
|
...botConfig,
|
|
username: ev.target.value,
|
|
}),
|
|
)
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field size="fullWidth">
|
|
<Label htmlFor="bot-oauth">
|
|
{t('pages.twitch-settings.bot-oauth')}
|
|
<RevealLink value={revealBotToken} setter={setRevealBotToken} />
|
|
</Label>
|
|
<PasswordInputBox
|
|
reveal={revealBotToken}
|
|
id="bot-oauth"
|
|
required={active}
|
|
disabled={disabled}
|
|
value={botConfig?.oauth ?? ''}
|
|
onChange={(ev) =>
|
|
dispatch(
|
|
apiReducer.actions.twitchBotConfigChanged({
|
|
...botConfig,
|
|
oauth: ev.target.value,
|
|
}),
|
|
)
|
|
}
|
|
/>
|
|
<FieldNote>
|
|
<Trans i18nKey="pages.twitch-settings.bot-oauth-note">
|
|
<BrowserLink href="https://twitchapps.com/tmi/">
|
|
https://twitchapps.com/tmi/
|
|
</BrowserLink>
|
|
</Trans>
|
|
</FieldNote>
|
|
</Field>
|
|
<SectionHeader>
|
|
{t('pages.twitch-settings.bot-chat-header')}
|
|
</SectionHeader>
|
|
<Field size="fullWidth">
|
|
<Label htmlFor="bot-chat-history">
|
|
{t('pages.twitch-settings.bot-chat-history')}
|
|
</Label>
|
|
<InputBox
|
|
type="number"
|
|
id="bot-chat-history"
|
|
required={active}
|
|
disabled={disabled}
|
|
defaultValue={botConfig?.chat_history}
|
|
onChange={(ev) =>
|
|
dispatch(
|
|
apiReducer.actions.twitchBotConfigChanged({
|
|
...botConfig,
|
|
chat_history: parseInt(ev.target.value, 10),
|
|
}),
|
|
)
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field size="fullWidth">
|
|
<Label htmlFor="bot-chat-history">
|
|
{t('pages.twitch-settings.bot-chat-cooldown-tip')}
|
|
</Label>
|
|
<InputBox
|
|
type="number"
|
|
id="bot-chat-history"
|
|
required={active}
|
|
disabled={disabled}
|
|
defaultValue={botConfig ? botConfig.command_cooldown ?? 2 : undefined}
|
|
onChange={(ev) =>
|
|
dispatch(
|
|
apiReducer.actions.twitchBotConfigChanged({
|
|
...botConfig,
|
|
command_cooldown: parseInt(ev.target.value, 10),
|
|
}),
|
|
)
|
|
}
|
|
/>
|
|
</Field>
|
|
<SaveButton status={status} />
|
|
</form>
|
|
);
|
|
}
|
|
|
|
type TestResult = { open: boolean; error?: Error };
|
|
|
|
function TwitchAPISettings() {
|
|
const { t } = useTranslation();
|
|
const [httpConfig] = useModule(modules.httpConfig);
|
|
const [twitchConfig, setTwitchConfig, loadStatus] = useModule(
|
|
modules.twitchConfig,
|
|
);
|
|
const status = useStatus(loadStatus.save);
|
|
const dispatch = useAppDispatch();
|
|
const [revealClientSecret, setRevealClientSecret] = useState(false);
|
|
const [testing, setTesting] = useState(false);
|
|
const [testResult, setTestResult] = useState<TestResult>({
|
|
open: false,
|
|
});
|
|
|
|
const checkCredentials = async () => {
|
|
setTesting(true);
|
|
if (twitchConfig) {
|
|
try {
|
|
await checkTwitchKeys(
|
|
twitchConfig.api_client_id,
|
|
twitchConfig.api_client_secret,
|
|
);
|
|
setTestResult({ open: true });
|
|
} catch (e: unknown) {
|
|
setTestResult({ open: true, error: e as Error });
|
|
}
|
|
}
|
|
setTesting(false);
|
|
};
|
|
|
|
return (
|
|
<form
|
|
onSubmit={(ev) => {
|
|
void dispatch(setTwitchConfig(twitchConfig));
|
|
ev.preventDefault();
|
|
}}
|
|
>
|
|
<SectionHeader spacing={'none'}>
|
|
{t('pages.twitch-settings.api-subheader')}
|
|
</SectionHeader>
|
|
<TextBlock>{t('pages.twitch-settings.apiguide-1')}</TextBlock>
|
|
<StepList>
|
|
<Step>
|
|
<Trans i18nKey="pages.twitch-settings.apiguide-2">
|
|
{' '}
|
|
<BrowserLink href="https://dev.twitch.tv/console/apps/create">
|
|
https://dev.twitch.tv/console/apps/create
|
|
</BrowserLink>
|
|
</Trans>
|
|
</Step>
|
|
<Step>
|
|
{t('pages.twitch-settings.apiguide-3')}
|
|
|
|
<DefinitionTable
|
|
entries={{
|
|
[t('pages.twitch-settings.app-oauth-redirect-url')]: `http://${
|
|
httpConfig?.bind.indexOf(':') > 0
|
|
? httpConfig.bind
|
|
: `localhost${httpConfig?.bind ?? ':4337'}`
|
|
}/twitch/callback`,
|
|
[t('pages.twitch-settings.app-category')]: 'Broadcasting Suite',
|
|
}}
|
|
/>
|
|
</Step>
|
|
<Step>
|
|
<Trans i18nKey="pages.twitch-settings.apiguide-4">
|
|
{'str1 '}
|
|
<b>str2</b>
|
|
</Trans>
|
|
</Step>
|
|
</StepList>
|
|
<Field size="fullWidth" css={{ marginTop: '2rem' }}>
|
|
<Label htmlFor="clientid">
|
|
{t('pages.twitch-settings.app-client-id')}
|
|
</Label>
|
|
<InputBox
|
|
type="text"
|
|
id="clientid"
|
|
placeholder={t('pages.twitch-settings.app-client-id')}
|
|
required={true}
|
|
value={twitchConfig?.api_client_id ?? ''}
|
|
onChange={(ev) =>
|
|
dispatch(
|
|
apiReducer.actions.twitchConfigChanged({
|
|
...twitchConfig,
|
|
api_client_id: ev.target.value,
|
|
}),
|
|
)
|
|
}
|
|
/>
|
|
</Field>
|
|
|
|
<Field size="fullWidth">
|
|
<Label htmlFor="clientsecret">
|
|
{t('pages.twitch-settings.app-client-secret')}
|
|
<RevealLink
|
|
value={revealClientSecret}
|
|
setter={setRevealClientSecret}
|
|
/>
|
|
</Label>
|
|
<PasswordInputBox
|
|
reveal={revealClientSecret}
|
|
id="clientsecret"
|
|
placeholder={t('pages.twitch-settings.app-client-secret')}
|
|
required={true}
|
|
value={twitchConfig?.api_client_secret ?? ''}
|
|
onChange={(ev) =>
|
|
dispatch(
|
|
apiReducer.actions.twitchConfigChanged({
|
|
...twitchConfig,
|
|
api_client_secret: ev.target.value,
|
|
}),
|
|
)
|
|
}
|
|
/>
|
|
</Field>
|
|
<ButtonGroup>
|
|
<SaveButton status={status} />
|
|
<Button
|
|
type="button"
|
|
onClick={() => {
|
|
void checkCredentials();
|
|
}}
|
|
disabled={testing}
|
|
>
|
|
{t('pages.twitch-settings.test-button')}
|
|
</Button>
|
|
</ButtonGroup>
|
|
<Alert
|
|
defaultOpen={false}
|
|
open={testResult.open}
|
|
onOpenChange={(val: boolean) => {
|
|
setTestResult({ ...testResult, open: val });
|
|
}}
|
|
>
|
|
<AlertContent
|
|
variation={testResult.error ? 'danger' : 'default'}
|
|
description={
|
|
testResult.error
|
|
? t('pages.twitch-settings.test-failed', {
|
|
error: testResult.error.message,
|
|
})
|
|
: t('pages.twitch-settings.test-succeeded')
|
|
}
|
|
actionText={t('form-actions.ok')}
|
|
onAction={() => {
|
|
setTestResult({ ...testResult, open: false });
|
|
}}
|
|
/>
|
|
</Alert>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
interface SyncError {
|
|
ok: false;
|
|
error: string;
|
|
}
|
|
|
|
const TwitchUser = styled('div', {
|
|
display: 'flex',
|
|
gap: '0.8rem',
|
|
alignItems: 'center',
|
|
fontSize: '14pt',
|
|
fontWeight: '300',
|
|
});
|
|
const TwitchPic = styled('img', {
|
|
width: '48px',
|
|
borderRadius: '50%',
|
|
});
|
|
const TwitchName = styled('p', { fontWeight: 'bold' });
|
|
|
|
function TwitchEventSubSettings() {
|
|
const { t } = useTranslation();
|
|
const [userStatus, setUserStatus] = useState<helix.User | SyncError>(null);
|
|
const kv = useAppSelector((state) => state.api.client);
|
|
|
|
const getUserInfo = async () => {
|
|
try {
|
|
const res = await GetTwitchLoggedUser();
|
|
setUserStatus(res);
|
|
} catch (e) {
|
|
console.error(e);
|
|
setUserStatus({ ok: false, error: (e as Error).message });
|
|
}
|
|
};
|
|
|
|
const startAuthFlow = async () => {
|
|
const url = await GetTwitchAuthURL();
|
|
BrowserOpenURL(url);
|
|
};
|
|
|
|
const sendFakeEvent = async (event: keyof typeof eventsubTests) => {
|
|
const data = eventsubTests[event];
|
|
await kv.putJSON('twitch/ev/eventsub-event', {
|
|
...data,
|
|
subscription: {
|
|
...data.subscription,
|
|
created_at: new Date().toISOString(),
|
|
},
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
// Get user info
|
|
void getUserInfo();
|
|
|
|
const onKeyChange = () => {
|
|
void getUserInfo();
|
|
};
|
|
void kv.subscribeKey('twitch/auth-keys', onKeyChange);
|
|
return () => {
|
|
void kv.unsubscribeKey('twitch/auth-keys', onKeyChange);
|
|
};
|
|
}, []);
|
|
|
|
let userBlock = <i>{t('pages.twitch-settings.events.loading-data')}</i>;
|
|
if (userStatus !== null) {
|
|
if ('id' in userStatus) {
|
|
userBlock = (
|
|
<>
|
|
<TwitchUser>
|
|
<TextBlock>
|
|
{t('pages.twitch-settings.events.authenticated-as')}
|
|
</TextBlock>
|
|
<TwitchPic
|
|
src={userStatus.profile_image_url}
|
|
alt={t('pages.twitch-settings.events.profile-picture')}
|
|
/>
|
|
<TwitchName>{userStatus.display_name}</TwitchName>
|
|
</TwitchUser>
|
|
</>
|
|
);
|
|
} else {
|
|
userBlock = <span>{t('pages.twitch-settings.events.err-no-user')}</span>;
|
|
}
|
|
}
|
|
return (
|
|
<>
|
|
<TextBlock>{t('pages.twitch-settings.events.auth-message')}</TextBlock>
|
|
<Button
|
|
variation="primary"
|
|
onClick={() => {
|
|
void startAuthFlow();
|
|
}}
|
|
>
|
|
<ExternalLinkIcon /> {t('pages.twitch-settings.events.auth-button')}
|
|
</Button>
|
|
<SectionHeader>
|
|
{t('pages.twitch-settings.events.current-status')}
|
|
</SectionHeader>
|
|
{userBlock}
|
|
<SectionHeader>
|
|
{t('pages.twitch-settings.events.sim-events')}
|
|
</SectionHeader>
|
|
<ButtonGroup>
|
|
{Object.keys(eventsubTests).map((ev: keyof typeof eventsubTests) => (
|
|
<Button
|
|
key={ev}
|
|
onClick={() => {
|
|
void sendFakeEvent(ev);
|
|
}}
|
|
>
|
|
{t(`pages.twitch-settings.events.sim.${ev}`, { defaultValue: ev })}
|
|
</Button>
|
|
))}
|
|
</ButtonGroup>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function TwitchSettingsPage(): React.ReactElement {
|
|
const { t } = useTranslation();
|
|
const [twitchConfig, setTwitchConfig] = useModule(modules.twitchConfig);
|
|
const dispatch = useAppDispatch();
|
|
|
|
const active = twitchConfig?.enabled ?? false;
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader>
|
|
<PageTitle>{t('pages.twitch-settings.title')}</PageTitle>
|
|
<TextBlock>{t('pages.twitch-settings.subtitle')}</TextBlock>
|
|
<Field css={{ paddingTop: '1rem' }}>
|
|
<FlexRow spacing={1}>
|
|
<Checkbox
|
|
checked={active}
|
|
onCheckedChange={(ev) => {
|
|
void dispatch(
|
|
setTwitchConfig({
|
|
...twitchConfig,
|
|
enabled: !!ev,
|
|
}),
|
|
);
|
|
}}
|
|
id="enable"
|
|
>
|
|
<CheckboxIndicator>{active && <CheckIcon />}</CheckboxIndicator>
|
|
</Checkbox>
|
|
|
|
<Label htmlFor="enable">{t('pages.twitch-settings.enable')}</Label>
|
|
</FlexRow>
|
|
</Field>
|
|
</PageHeader>
|
|
<div style={{ display: active ? '' : 'none' }}>
|
|
<TabContainer defaultValue="api-config">
|
|
<TabList>
|
|
<TabButton value="api-config">
|
|
{t('pages.twitch-settings.api-configuration')}
|
|
</TabButton>
|
|
<TabButton value="eventsub">
|
|
{t('pages.twitch-settings.eventsub')}
|
|
</TabButton>
|
|
<TabButton value="bot-settings">
|
|
{t('pages.twitch-settings.bot-settings')}
|
|
</TabButton>
|
|
</TabList>
|
|
<TabContent value="api-config">
|
|
<TwitchAPISettings />
|
|
</TabContent>
|
|
<TabContent value="eventsub">
|
|
<TwitchEventSubSettings />
|
|
</TabContent>
|
|
<TabContent value="bot-settings">
|
|
<TwitchBotSettings />
|
|
</TabContent>
|
|
</TabContainer>
|
|
</div>
|
|
</PageContainer>
|
|
);
|
|
}
|