Twitch prerelease (#882)

* Bot linking button functional.

* Implemented initial prototype of new Twitch signup page.

* Removed old Twitch signup page.

* Moved new Twitch page to correct URL.

* Twitch account linking functional.

* Fixed charity link.

* Changed to point to live bot server.

* Slightly improve spacing and alignment on Twitch page

* Tidy up, handle some errors when talking to bot

* Seriously do the thing where Twitch link is hidden by default

Co-authored-by: Marshall Polaris <marshall@pol.rs>
This commit is contained in:
Phil 2022-09-16 08:22:13 +01:00 committed by GitHub
parent 1321b95eb1
commit 833ec518b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 347 additions and 58 deletions

View File

@ -6,38 +6,101 @@ import { LinkIcon } from '@heroicons/react/solid'
import { usePrivateUser, useUser } from 'web/hooks/use-user' import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { updatePrivateUser } from 'web/lib/firebase/users' import { updatePrivateUser } from 'web/lib/firebase/users'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account' import {
linkTwitchAccountRedirect,
updateBotEnabledForUser,
} from 'web/lib/twitch/link-twitch-account'
import { copyToClipboard } from 'web/lib/util/copy' import { copyToClipboard } from 'web/lib/util/copy'
import { Button, ColorType } from './../button' import { Button, ColorType } from './../button'
import { Row } from './../layout/row' import { Row } from './../layout/row'
import { LoadingIndicator } from './../loading-indicator' import { LoadingIndicator } from './../loading-indicator'
import { PrivateUser } from 'common/user'
function BouncyButton(props: { function BouncyButton(props: {
children: ReactNode children: ReactNode
onClick?: MouseEventHandler<any> onClick?: MouseEventHandler<any>
color?: ColorType color?: ColorType
className?: string
}) { }) {
const { children, onClick, color } = props const { children, onClick, color, className } = props
return ( return (
<Button <Button
color={color} color={color}
size="lg" size="lg"
onClick={onClick} onClick={onClick}
className="btn h-[inherit] flex-shrink-[inherit] border-none font-normal normal-case" className={clsx(
'btn h-[inherit] flex-shrink-[inherit] border-none font-normal normal-case',
className
)}
> >
{children} {children}
</Button> </Button>
) )
} }
function BotConnectButton(props: {
privateUser: PrivateUser | null | undefined
}) {
const { privateUser } = props
const [loading, setLoading] = useState(false)
const updateBotConnected = (connected: boolean) => async () => {
if (!privateUser) return
const twitchInfo = privateUser.twitchInfo
if (!twitchInfo) return
const error = connected
? 'Failed to add bot to your channel'
: 'Failed to remove bot from your channel'
const success = connected
? 'Added bot to your channel'
: 'Removed bot from your channel'
setLoading(true)
toast.promise(
updateBotEnabledForUser(privateUser, connected).then(() =>
updatePrivateUser(privateUser.id, {
twitchInfo: { ...twitchInfo, botEnabled: connected },
})
),
{ loading: 'Updating bot settings...', error, success }
)
try {
} finally {
setLoading(false)
}
}
return (
<>
{privateUser?.twitchInfo?.botEnabled ? (
<BouncyButton
color="red"
onClick={updateBotConnected(false)}
className={clsx(loading && 'btn-disabled')}
>
Remove bot from your channel
</BouncyButton>
) : (
<BouncyButton
color="green"
onClick={updateBotConnected(true)}
className={clsx(loading && 'btn-disabled')}
>
Add bot to your channel
</BouncyButton>
)}
</>
)
}
export function TwitchPanel() { export function TwitchPanel() {
const user = useUser() const user = useUser()
const privateUser = usePrivateUser() const privateUser = usePrivateUser()
const twitchInfo = privateUser?.twitchInfo const twitchInfo = privateUser?.twitchInfo
const twitchName = privateUser?.twitchInfo?.twitchName const twitchName = twitchInfo?.twitchName
const twitchToken = privateUser?.twitchInfo?.controlToken const twitchToken = twitchInfo?.controlToken
const twitchBotConnected = privateUser?.twitchInfo?.botEnabled
const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" /> const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />
@ -55,13 +118,6 @@ export function TwitchPanel() {
}) })
} }
const updateBotConnected = (connected: boolean) => async () => {
if (user && twitchInfo) {
twitchInfo.botEnabled = connected
await updatePrivateUser(user.id, { twitchInfo })
}
}
const [twitchLoading, setTwitchLoading] = useState(false) const [twitchLoading, setTwitchLoading] = useState(false)
const createLink = async () => { const createLink = async () => {
@ -115,17 +171,12 @@ export function TwitchPanel() {
<BouncyButton color="indigo" onClick={copyDockLink}> <BouncyButton color="indigo" onClick={copyDockLink}>
Copy dock link Copy dock link
</BouncyButton> </BouncyButton>
{twitchBotConnected ? (
<BouncyButton color="red" onClick={updateBotConnected(false)}>
Remove bot from your channel
</BouncyButton>
) : (
<BouncyButton color="green" onClick={updateBotConnected(true)}>
Add bot to your channel
</BouncyButton>
)}
</div> </div>
</div> </div>
<div className="mt-4" />
<div className="flex w-full">
<BotConnectButton privateUser={privateUser} />
</div>
</div> </div>
)} )}
</> </>

View File

@ -3,29 +3,33 @@ import { generateNewApiKey } from '../api/api-key'
const TWITCH_BOT_PUBLIC_URL = 'https://king-prawn-app-5btyw.ondigitalocean.app' // TODO: Add this to env config appropriately const TWITCH_BOT_PUBLIC_URL = 'https://king-prawn-app-5btyw.ondigitalocean.app' // TODO: Add this to env config appropriately
async function postToBot(url: string, body: unknown) {
const result = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const json = await result.json()
if (!result.ok) {
throw new Error(json.message)
} else {
return json
}
}
export async function initLinkTwitchAccount( export async function initLinkTwitchAccount(
manifoldUserID: string, manifoldUserID: string,
manifoldUserAPIKey: string manifoldUserAPIKey: string
): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> { ): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> {
const response = await fetch(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, { const response = await postToBot(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
manifoldID: manifoldUserID, manifoldID: manifoldUserID,
apiKey: manifoldUserAPIKey, apiKey: manifoldUserAPIKey,
redirectURL: window.location.href, redirectURL: window.location.href,
}),
}) })
const responseData = await response.json()
if (!response.ok) {
throw new Error(responseData.message)
}
const responseFetch = fetch( const responseFetch = fetch(
`${TWITCH_BOT_PUBLIC_URL}/api/linkResult?userID=${manifoldUserID}` `${TWITCH_BOT_PUBLIC_URL}/api/linkResult?userID=${manifoldUserID}`
) )
return [responseData.twitchAuthURL, responseFetch.then((r) => r.json())] return [response.twitchAuthURL, responseFetch.then((r) => r.json())]
} }
export async function linkTwitchAccountRedirect( export async function linkTwitchAccountRedirect(
@ -39,3 +43,22 @@ export async function linkTwitchAccountRedirect(
window.location.href = twitchAuthURL window.location.href = twitchAuthURL
} }
export async function updateBotEnabledForUser(
privateUser: PrivateUser,
botEnabled: boolean
) {
if (botEnabled) {
return postToBot(`${TWITCH_BOT_PUBLIC_URL}/registerchanneltwitch`, {
apiKey: privateUser.apiKey,
}).then((r) => {
if (!r.success) throw new Error(r.message)
})
} else {
return postToBot(`${TWITCH_BOT_PUBLIC_URL}/unregisterchanneltwitch`, {
apiKey: privateUser.apiKey,
}).then((r) => {
if (!r.success) throw new Error(r.message)
})
}
}

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { RefreshIcon } from '@heroicons/react/outline' import { RefreshIcon } from '@heroicons/react/outline'
import { useRouter } from 'next/router'
import { AddFundsButton } from 'web/components/add-funds-button' import { AddFundsButton } from 'web/components/add-funds-button'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { SEO } from 'web/components/SEO' import { SEO } from 'web/components/SEO'
@ -64,6 +64,7 @@ function EditUserField(props: {
export default function ProfilePage(props: { export default function ProfilePage(props: {
auth: { user: User; privateUser: PrivateUser } auth: { user: User; privateUser: PrivateUser }
}) { }) {
const router = useRouter()
const { user, privateUser } = props.auth const { user, privateUser } = props.auth
const [avatarUrl, setAvatarUrl] = useState(user.avatarUrl || '') const [avatarUrl, setAvatarUrl] = useState(user.avatarUrl || '')
const [avatarLoading, setAvatarLoading] = useState(false) const [avatarLoading, setAvatarLoading] = useState(false)
@ -237,8 +238,7 @@ export default function ProfilePage(props: {
</button> </button>
</div> </div>
</div> </div>
{router.query.twitch && <TwitchPanel />}
<TwitchPanel />
</Col> </Col>
</Col> </Col>
</Page> </Page>

View File

@ -1,29 +1,34 @@
import { PrivateUser, User } from 'common/user'
import Link from 'next/link'
import { useState } from 'react' import { useState } from 'react'
import { Page } from 'web/components/page' import toast from 'react-hot-toast'
import { Button } from 'web/components/button'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { ManifoldLogo } from 'web/components/nav/manifold-logo' import { Row } from 'web/components/layout/row'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { SEO } from 'web/components/SEO'
import { Spacer } from 'web/components/layout/spacer' import { Spacer } from 'web/components/layout/spacer'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { ManifoldLogo } from 'web/components/nav/manifold-logo'
import { Page } from 'web/components/page'
import { SEO } from 'web/components/SEO'
import { Title } from 'web/components/title'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { useTracking } from 'web/hooks/use-tracking'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { firebaseLogin, getUserAndPrivateUser } from 'web/lib/firebase/users' import { firebaseLogin, getUserAndPrivateUser } from 'web/lib/firebase/users'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { Row } from 'web/components/layout/row'
import { Button } from 'web/components/button'
import { useTracking } from 'web/hooks/use-tracking'
import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account' import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { LoadingIndicator } from 'web/components/loading-indicator'
import toast from 'react-hot-toast'
export default function TwitchLandingPage() { function TwitchPlaysManifoldMarkets(props: {
useSaveReferral() user?: User | null
useTracking('view twitch landing page') privateUser?: PrivateUser | null
}) {
const { user, privateUser } = props
const user = useUser()
const privateUser = usePrivateUser()
const twitchUser = privateUser?.twitchInfo?.twitchName const twitchUser = privateUser?.twitchInfo?.twitchName
const [isLoading, setLoading] = useState(false)
const callback = const callback =
user && privateUser user && privateUser
? () => linkTwitchAccountRedirect(user, privateUser) ? () => linkTwitchAccountRedirect(user, privateUser)
@ -37,8 +42,6 @@ export default function TwitchLandingPage() {
await linkTwitchAccountRedirect(user, privateUser) await linkTwitchAccountRedirect(user, privateUser)
} }
const [isLoading, setLoading] = useState(false)
const getStarted = async () => { const getStarted = async () => {
try { try {
setLoading(true) setLoading(true)
@ -53,6 +56,191 @@ export default function TwitchLandingPage() {
} }
} }
return (
<div>
<Row className="mb-4">
<img
src="/twitch-glitch.svg"
className="mb-[0.4rem] mr-4 inline h-10 w-10"
></img>
<Title
text={'Twitch plays Manifold Markets'}
className={'!-my-0 md:block'}
/>
</Row>
<Col className="gap-4">
<div>
Similar to Twitch channel point predictions, Manifold Markets allows
you to create and feature on stream any question you like with users
predicting to earn play money.
</div>
<div>
The key difference is that Manifold's questions function more like a
stock market and viewers can buy and sell shares over the course of
the event and not just at the start. The market will eventually
resolve to yes or no at which point the winning shareholders will
receive their profit.
</div>
Start playing now by logging in with Google and typing commands in chat!
{twitchUser ? (
<Button size="xl" color="green" className="btn-disabled self-center">
Account connected: {twitchUser}
</Button>
) : isLoading ? (
<LoadingIndicator spinnerClassName="!w-11 !h-11" />
) : (
<Button
size="xl"
color="gradient"
className="my-4 self-center !px-16"
onClick={getStarted}
>
Start playing
</Button>
)}
<div>
Instead of Twitch channel points we use our play money, mana (m$). All
viewers start with M$1000 and more can be earned for free and then{' '}
<Link href="/charity" className="underline">
donated to a charity
</Link>{' '}
of their choice at no cost!
</div>
</Col>
</div>
)
}
function Subtitle(props: { text: string }) {
const { text } = props
return <div className="text-2xl">{text}</div>
}
function Command(props: { command: string; desc: string }) {
const { command, desc } = props
return (
<div>
<p className="inline font-bold">{'!' + command}</p>
{' - '}
<p className="inline">{desc}</p>
</div>
)
}
function TwitchChatCommands() {
return (
<div>
<Title text="Twitch Chat Commands" className="md:block" />
<Col className="gap-4">
<Subtitle text="For Chat" />
<Command command="bet yes#" desc="Bets a # of Mana on yes." />
<Command command="bet no#" desc="Bets a # of Mana on no." />
<Command
command="sell"
desc="Sells all shares you own. Using this command causes you to
cash out early before the market resolves. This could be profitable
(if the probability has moved towards the direction you bet) or cause
a loss, although at least you keep some Mana. For maximum profit (but
also risk) it is better to not sell and wait for a favourable
resolution."
/>
<Command command="balance" desc="Shows how much Mana you own." />
<Command command="allin yes" desc="Bets your entire balance on yes." />
<Command command="allin no" desc="Bets your entire balance on no." />
<Subtitle text="For Mods/Streamer" />
<Command
command="create <question>"
desc="Creates and features the question. Be careful... this will override any question that is currently featured."
/>
<Command command="resolve yes" desc="Resolves the market as 'Yes'." />
<Command command="resolve no" desc="Resolves the market as 'No'." />
<Command
command="resolve n/a"
desc="Resolves the market as 'N/A' and refunds everyone their Mana."
/>
</Col>
</div>
)
}
function BotSetupStep(props: {
stepNum: number
buttonName?: string
text: string
}) {
const { stepNum, buttonName, text } = props
return (
<Col className="flex-1">
{buttonName && (
<>
<Button color="green">{buttonName}</Button>
<Spacer h={4} />
</>
)}
<div>
<p className="inline font-bold">Step {stepNum}. </p>
{text}
</div>
</Col>
)
}
function SetUpBot(props: { privateUser?: PrivateUser | null }) {
const { privateUser } = props
const twitchLinked = privateUser?.twitchInfo?.twitchName
return (
<>
<Title
text={'Set up the bot for your own stream'}
className={'!mb-4 md:block'}
/>
<Col className="gap-4">
<img
src="https://raw.githubusercontent.com/PhilBladen/ManifoldTwitchIntegration/master/docs/OBS.png"
className="!-my-2"
></img>
To add the bot to your stream make sure you have logged in then follow
the steps below.
{!twitchLinked && (
<Button
size="xl"
color="gradient"
className="my-4 self-center !px-16"
// onClick={getStarted}
>
Start playing
</Button>
)}
<div className="flex flex-col gap-6 sm:flex-row">
<BotSetupStep
stepNum={1}
buttonName={twitchLinked && 'Add bot to channel'}
text="Use the button above to add the bot to your channel. Then mod it by typing in your Twitch chat: /mod ManifoldBot (or whatever you named the bot) If the bot is modded it will not work properly on the backend."
/>
<BotSetupStep
stepNum={2}
buttonName={twitchLinked && 'Overlay link'}
text="Create a new browser source in your streaming software such as OBS. Paste in the above link and resize it to your liking. We recommend setting the size to 400x400."
/>
<BotSetupStep
stepNum={3}
buttonName={twitchLinked && 'Control dock link'}
text="The bot can be controlled entirely through chat. But we made an easy to use control panel. Share the link with your mods or embed it into your OBS as a custom dock."
/>
</div>
</Col>
</>
)
}
export default function TwitchLandingPage() {
useSaveReferral()
useTracking('view twitch landing page')
const user = useUser()
const privateUser = usePrivateUser()
return ( return (
<Page> <Page>
<SEO <SEO
@ -62,7 +250,7 @@ export default function TwitchLandingPage() {
<div className="px-4 pt-2 md:mt-0 lg:hidden"> <div className="px-4 pt-2 md:mt-0 lg:hidden">
<ManifoldLogo /> <ManifoldLogo />
</div> </div>
<Col className="items-center"> {/* <Col className="items-center">
<Col className="max-w-3xl"> <Col className="max-w-3xl">
<Col className="mb-6 rounded-xl sm:m-12 sm:mt-0"> <Col className="mb-6 rounded-xl sm:m-12 sm:mt-0">
<Row className="self-center"> <Row className="self-center">
@ -114,6 +302,12 @@ export default function TwitchLandingPage() {
)} )}
</Col> </Col>
</Col> </Col>
</Col> */}
<Col className="max-w-3xl gap-8 rounded bg-white p-10 text-gray-600 shadow-md sm:mx-auto">
<TwitchPlaysManifoldMarkets user={user} privateUser={privateUser} />
<TwitchChatCommands />
<SetUpBot privateUser={privateUser} />
</Col> </Col>
</Page> </Page>
) )

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.6, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 2400 2800" style="enable-background:new 0 0 2400 2800;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#9146FF;}
</style>
<title>Asset 2</title>
<g>
<polygon class="st0" points="2200,1300 1800,1700 1400,1700 1050,2050 1050,1700 600,1700 600,200 2200,200 "/>
<g>
<g id="Layer_1-2">
<path class="st1" d="M500,0L0,500v1800h600v500l500-500h400l900-900V0H500z M2200,1300l-400,400h-400l-350,350v-350H600V200h1600
V1300z"/>
<rect x="1700" y="550" class="st1" width="200" height="600"/>
<rect x="1150" y="550" class="st1" width="200" height="600"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 890 B