Twitch integration (#815)
* twitch account linking; profile page twitch panel; twitch landing page * fix import * twitch logo * save twitch credentials cloud function * use user id instead of bot id, add manifold api endpoint * properly add function to index * Added support for new redirect Twitch auth. * Added clean error handling in case of Twitch link fail. * remove simulator * Removed legacy non-redirect Twitch auth code. Added "add bot to channel" button in user profile and relevant data to user type. * Removed unnecessary imports. * Fixed line endings. * Allow users to modify private user twitchInfo firestore object * Local dev on savetwitchcredentials function Co-authored-by: Phil <phil.bladen@gmail.com> Co-authored-by: Marshall Polaris <marshall@pol.rs>
This commit is contained in:
		
							parent
							
								
									7144e57c93
								
							
						
					
					
						commit
						a2d61a1daa
					
				|  | @ -68,6 +68,11 @@ export type PrivateUser = { | |||
|   /** @deprecated - use notificationSubscriptionTypes */ | ||||
|   notificationPreferences?: notification_subscribe_types | ||||
|   notificationSubscriptionTypes: notification_subscription_types | ||||
|   twitchInfo?: { | ||||
|     twitchName: string | ||||
|     controlToken: string | ||||
|     botEnabled?: boolean | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export type notification_destination_types = 'email' | 'browser' | ||||
|  |  | |||
|  | @ -77,7 +77,7 @@ service cloud.firestore { | |||
|       allow read: if userId == request.auth.uid || isAdmin(); | ||||
|       allow update: if (userId == request.auth.uid || isAdmin()) | ||||
|                        && request.resource.data.diff(resource.data).affectedKeys() | ||||
|                        .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails','notificationSubscriptionTypes' ]); | ||||
|                          .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails', 'notificationSubscriptionTypes', 'twitchInfo']); | ||||
|     } | ||||
| 
 | ||||
|     match /private-users/{userId}/views/{viewId} { | ||||
|  | @ -161,7 +161,7 @@ service cloud.firestore { | |||
|                        && request.resource.data.diff(resource.data).affectedKeys() | ||||
|                                                                     .hasOnly(['isSeen', 'viewTime']); | ||||
|     } | ||||
|      | ||||
| 
 | ||||
|     match /{somePath=**}/groupMembers/{memberId} { | ||||
|       allow read; | ||||
|     } | ||||
|  |  | |||
|  | @ -71,6 +71,7 @@ import { stripewebhook, createcheckoutsession } from './stripe' | |||
| import { getcurrentuser } from './get-current-user' | ||||
| import { acceptchallenge } from './accept-challenge' | ||||
| import { createpost } from './create-post' | ||||
| import { savetwitchcredentials } from './save-twitch-credentials' | ||||
| 
 | ||||
| const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { | ||||
|   return onRequest(opts, handler as any) | ||||
|  | @ -96,6 +97,7 @@ const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) | |||
| const getCurrentUserFunction = toCloudFunction(getcurrentuser) | ||||
| const acceptChallenge = toCloudFunction(acceptchallenge) | ||||
| const createPostFunction = toCloudFunction(createpost) | ||||
| const saveTwitchCredentials = toCloudFunction(savetwitchcredentials) | ||||
| 
 | ||||
| export { | ||||
|   healthFunction as health, | ||||
|  | @ -119,4 +121,5 @@ export { | |||
|   getCurrentUserFunction as getcurrentuser, | ||||
|   acceptChallenge as acceptchallenge, | ||||
|   createPostFunction as createpost, | ||||
|   saveTwitchCredentials as savetwitchcredentials | ||||
| } | ||||
|  |  | |||
							
								
								
									
										22
									
								
								functions/src/save-twitch-credentials.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								functions/src/save-twitch-credentials.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,22 @@ | |||
| import * as admin from 'firebase-admin' | ||||
| import { z } from 'zod' | ||||
| 
 | ||||
| import { newEndpoint, validate } from './api' | ||||
| 
 | ||||
| const bodySchema = z.object({ | ||||
|   twitchInfo: z.object({ | ||||
|     twitchName: z.string(), | ||||
|     controlToken: z.string(), | ||||
|   }), | ||||
| }) | ||||
| 
 | ||||
| 
 | ||||
| export const savetwitchcredentials = newEndpoint({}, async (req, auth) => { | ||||
|   const { twitchInfo } = validate(bodySchema, req.body) | ||||
|   const userId = auth.uid | ||||
| 
 | ||||
|   await firestore.doc(`private-users/${userId}`).update({ twitchInfo }) | ||||
|   return { success: true } | ||||
| }) | ||||
| 
 | ||||
| const firestore = admin.firestore() | ||||
|  | @ -27,6 +27,7 @@ import { unsubscribe } from './unsubscribe' | |||
| import { stripewebhook, createcheckoutsession } from './stripe' | ||||
| import { getcurrentuser } from './get-current-user' | ||||
| import { createpost } from './create-post' | ||||
| import { savetwitchcredentials } from './save-twitch-credentials' | ||||
| 
 | ||||
| type Middleware = (req: Request, res: Response, next: NextFunction) => void | ||||
| const app = express() | ||||
|  | @ -65,6 +66,7 @@ addJsonEndpointRoute('/resolvemarket', resolvemarket) | |||
| addJsonEndpointRoute('/unsubscribe', unsubscribe) | ||||
| addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) | ||||
| addJsonEndpointRoute('/getcurrentuser', getcurrentuser) | ||||
| addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials) | ||||
| addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) | ||||
| addEndpointRoute('/createpost', createpost) | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										133
									
								
								web/components/profile/twitch-panel.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								web/components/profile/twitch-panel.tsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,133 @@ | |||
| import clsx from 'clsx' | ||||
| import { MouseEventHandler, ReactNode, useState } from 'react' | ||||
| import toast from 'react-hot-toast' | ||||
| 
 | ||||
| import { LinkIcon } from '@heroicons/react/solid' | ||||
| import { usePrivateUser, useUser } from 'web/hooks/use-user' | ||||
| import { updatePrivateUser } from 'web/lib/firebase/users' | ||||
| import { track } from 'web/lib/service/analytics' | ||||
| import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account' | ||||
| import { copyToClipboard } from 'web/lib/util/copy' | ||||
| import { Button, ColorType } from './../button' | ||||
| import { Row } from './../layout/row' | ||||
| import { LoadingIndicator } from './../loading-indicator' | ||||
| 
 | ||||
| function BouncyButton(props: { | ||||
|   children: ReactNode | ||||
|   onClick?: MouseEventHandler<any> | ||||
|   color?: ColorType | ||||
| }) { | ||||
|   const { children, onClick, color } = props | ||||
|   return ( | ||||
|     <Button | ||||
|       color={color} | ||||
|       size="lg" | ||||
|       onClick={onClick} | ||||
|       className="btn h-[inherit] flex-shrink-[inherit] border-none font-normal normal-case" | ||||
|     > | ||||
|       {children} | ||||
|     </Button> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function TwitchPanel() { | ||||
|   const user = useUser() | ||||
|   const privateUser = usePrivateUser() | ||||
| 
 | ||||
|   const twitchInfo = privateUser?.twitchInfo | ||||
|   const twitchName = privateUser?.twitchInfo?.twitchName | ||||
|   const twitchToken = privateUser?.twitchInfo?.controlToken | ||||
|   const twitchBotConnected = privateUser?.twitchInfo?.botEnabled | ||||
| 
 | ||||
|   const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" /> | ||||
| 
 | ||||
|   const copyOverlayLink = async () => { | ||||
|     copyToClipboard(`http://localhost:1000/overlay?t=${twitchToken}`) | ||||
|     toast.success('Overlay link copied!', { | ||||
|       icon: linkIcon, | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   const copyDockLink = async () => { | ||||
|     copyToClipboard(`http://localhost:1000/dock?t=${twitchToken}`) | ||||
|     toast.success('Dock link copied!', { | ||||
|       icon: linkIcon, | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   const updateBotConnected = (connected: boolean) => async () => { | ||||
|     if (user && twitchInfo) { | ||||
|       twitchInfo.botEnabled = connected | ||||
|       await updatePrivateUser(user.id, { twitchInfo }) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   const [twitchLoading, setTwitchLoading] = useState(false) | ||||
| 
 | ||||
|   const createLink = async () => { | ||||
|     if (!user || !privateUser) return | ||||
|     setTwitchLoading(true) | ||||
| 
 | ||||
|     const promise = linkTwitchAccountRedirect(user, privateUser) | ||||
|     track('link twitch from profile') | ||||
|     await promise | ||||
| 
 | ||||
|     setTwitchLoading(false) | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <div> | ||||
|         <label className="label">Twitch</label> | ||||
| 
 | ||||
|         {!twitchName ? ( | ||||
|           <Row> | ||||
|             <Button | ||||
|               color="indigo" | ||||
|               onClick={createLink} | ||||
|               disabled={twitchLoading} | ||||
|             > | ||||
|               Link your Twitch account | ||||
|             </Button> | ||||
|             {twitchLoading && <LoadingIndicator className="ml-4" />} | ||||
|           </Row> | ||||
|         ) : ( | ||||
|           <Row> | ||||
|             <span className="mr-4 text-gray-500">Linked Twitch account</span>{' '} | ||||
|             {twitchName} | ||||
|           </Row> | ||||
|         )} | ||||
|       </div> | ||||
| 
 | ||||
|       {twitchToken && ( | ||||
|         <div> | ||||
|           <div className="flex w-full"> | ||||
|             <div | ||||
|               className={clsx( | ||||
|                 'flex grow gap-4', | ||||
|                 twitchToken ? '' : 'tooltip tooltip-top' | ||||
|               )} | ||||
|               data-tip="You must link your Twitch account first" | ||||
|             > | ||||
|               <BouncyButton color="blue" onClick={copyOverlayLink}> | ||||
|                 Copy overlay link | ||||
|               </BouncyButton> | ||||
|               <BouncyButton color="indigo" onClick={copyDockLink}> | ||||
|                 Copy dock link | ||||
|               </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> | ||||
|       )} | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										9
									
								
								web/lib/api/api-key.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								web/lib/api/api-key.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,9 @@ | |||
| import { updatePrivateUser } from '../firebase/users' | ||||
| 
 | ||||
| export const generateNewApiKey = async (userId: string) => { | ||||
|   const newApiKey = crypto.randomUUID() | ||||
| 
 | ||||
|   return await updatePrivateUser(userId, { apiKey: newApiKey }) | ||||
|     .then(() => newApiKey) | ||||
|     .catch(() => undefined) | ||||
| } | ||||
							
								
								
									
										41
									
								
								web/lib/twitch/link-twitch-account.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								web/lib/twitch/link-twitch-account.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,41 @@ | |||
| import { PrivateUser, User } from 'common/user' | ||||
| 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
 | ||||
| 
 | ||||
| export async function initLinkTwitchAccount( | ||||
|   manifoldUserID: string, | ||||
|   manifoldUserAPIKey: string | ||||
| ): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> { | ||||
|   const response = await fetch(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, { | ||||
|     method: 'POST', | ||||
|     headers: { | ||||
|       'Content-Type': 'application/json', | ||||
|     }, | ||||
|     body: JSON.stringify({ | ||||
|       manifoldID: manifoldUserID, | ||||
|       apiKey: manifoldUserAPIKey, | ||||
|       redirectURL: window.location.href, | ||||
|     }), | ||||
|   }) | ||||
|   const responseData = await response.json() | ||||
|   if (!response.ok) { | ||||
|     throw new Error(responseData.message) | ||||
|   } | ||||
|   const responseFetch = fetch( | ||||
|     `${TWITCH_BOT_PUBLIC_URL}/api/linkResult?userID=${manifoldUserID}` | ||||
|   ) | ||||
|   return [responseData.twitchAuthURL, responseFetch.then((r) => r.json())] | ||||
| } | ||||
| 
 | ||||
| export async function linkTwitchAccountRedirect( | ||||
|   user: User, | ||||
|   privateUser: PrivateUser | ||||
| ) { | ||||
|   const apiKey = privateUser.apiKey ?? (await generateNewApiKey(user.id)) | ||||
|   if (!apiKey) throw new Error("Couldn't retrieve or create Manifold api key") | ||||
| 
 | ||||
|   const [twitchAuthURL] = await initLinkTwitchAccount(user.id, apiKey) | ||||
| 
 | ||||
|   window.location.href = twitchAuthURL | ||||
| } | ||||
							
								
								
									
										23
									
								
								web/pages/api/v0/twitch/save.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								web/pages/api/v0/twitch/save.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,23 @@ | |||
| import { NextApiRequest, NextApiResponse } from 'next' | ||||
| import { | ||||
|   CORS_ORIGIN_MANIFOLD, | ||||
|   CORS_ORIGIN_LOCALHOST, | ||||
| } from 'common/envs/constants' | ||||
| import { applyCorsHeaders } from 'web/lib/api/cors' | ||||
| import { fetchBackend, forwardResponse } from 'web/lib/api/proxy' | ||||
| 
 | ||||
| export const config = { api: { bodyParser: true } } | ||||
| 
 | ||||
| export default async function route(req: NextApiRequest, res: NextApiResponse) { | ||||
|   await applyCorsHeaders(req, res, { | ||||
|     origin: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST], | ||||
|     methods: 'POST', | ||||
|   }) | ||||
|   try { | ||||
|     const backendRes = await fetchBackend(req, 'savetwitchcredentials') | ||||
|     await forwardResponse(res, backendRes) | ||||
|   } catch (err) { | ||||
|     console.error('Error talking to cloud function: ', err) | ||||
|     res.status(500).json({ message: 'Error communicating with backend.' }) | ||||
|   } | ||||
| } | ||||
|  | @ -12,15 +12,13 @@ import { uploadImage } from 'web/lib/firebase/storage' | |||
| import { Col } from 'web/components/layout/col' | ||||
| import { Row } from 'web/components/layout/row' | ||||
| import { User, PrivateUser } from 'common/user' | ||||
| import { | ||||
|   getUserAndPrivateUser, | ||||
|   updateUser, | ||||
|   updatePrivateUser, | ||||
| } from 'web/lib/firebase/users' | ||||
| import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users' | ||||
| import { defaultBannerUrl } from 'web/components/user-page' | ||||
| import { SiteLink } from 'web/components/site-link' | ||||
| import Textarea from 'react-expanding-textarea' | ||||
| import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' | ||||
| import { generateNewApiKey } from 'web/lib/api/api-key' | ||||
| import { TwitchPanel } from 'web/components/profile/twitch-panel' | ||||
| 
 | ||||
| export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { | ||||
|   return { props: { auth: await getUserAndPrivateUser(creds.uid) } } | ||||
|  | @ -96,11 +94,8 @@ export default function ProfilePage(props: { | |||
|   } | ||||
| 
 | ||||
|   const updateApiKey = async (e: React.MouseEvent) => { | ||||
|     const newApiKey = crypto.randomUUID() | ||||
|     setApiKey(newApiKey) | ||||
|     await updatePrivateUser(user.id, { apiKey: newApiKey }).catch(() => { | ||||
|       setApiKey(privateUser.apiKey || '') | ||||
|     }) | ||||
|     const newApiKey = await generateNewApiKey(user.id) | ||||
|     setApiKey(newApiKey ?? '') | ||||
|     e.preventDefault() | ||||
|   } | ||||
| 
 | ||||
|  | @ -242,6 +237,8 @@ export default function ProfilePage(props: { | |||
|               </button> | ||||
|             </div> | ||||
|           </div> | ||||
| 
 | ||||
|           <TwitchPanel /> | ||||
|         </Col> | ||||
|       </Col> | ||||
|     </Page> | ||||
|  |  | |||
							
								
								
									
										120
									
								
								web/pages/twitch.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								web/pages/twitch.tsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,120 @@ | |||
| import { useState } from 'react' | ||||
| 
 | ||||
| import { Page } from 'web/components/page' | ||||
| import { Col } from 'web/components/layout/col' | ||||
| import { ManifoldLogo } from 'web/components/nav/manifold-logo' | ||||
| import { useSaveReferral } from 'web/hooks/use-save-referral' | ||||
| import { SEO } from 'web/components/SEO' | ||||
| import { Spacer } from 'web/components/layout/spacer' | ||||
| import { firebaseLogin, getUserAndPrivateUser } from 'web/lib/firebase/users' | ||||
| 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 { usePrivateUser, useUser } from 'web/hooks/use-user' | ||||
| import { LoadingIndicator } from 'web/components/loading-indicator' | ||||
| import toast from 'react-hot-toast' | ||||
| 
 | ||||
| export default function TwitchLandingPage() { | ||||
|   useSaveReferral() | ||||
|   useTracking('view twitch landing page') | ||||
| 
 | ||||
|   const user = useUser() | ||||
|   const privateUser = usePrivateUser() | ||||
|   const twitchUser = privateUser?.twitchInfo?.twitchName | ||||
| 
 | ||||
|   const callback = | ||||
|     user && privateUser | ||||
|       ? () => linkTwitchAccountRedirect(user, privateUser) | ||||
|       : async () => { | ||||
|           const result = await firebaseLogin() | ||||
| 
 | ||||
|           const userId = result.user.uid | ||||
|           const { user, privateUser } = await getUserAndPrivateUser(userId) | ||||
|           if (!user || !privateUser) return | ||||
| 
 | ||||
|           await linkTwitchAccountRedirect(user, privateUser) | ||||
|         } | ||||
| 
 | ||||
|   const [isLoading, setLoading] = useState(false) | ||||
| 
 | ||||
|   const getStarted = async () => { | ||||
|     try { | ||||
|       setLoading(true) | ||||
| 
 | ||||
|       const promise = callback() | ||||
|       track('twitch page button click') | ||||
|       await promise | ||||
|     } catch (e) { | ||||
|       console.error(e) | ||||
|       toast.error('Failed to sign up. Please try again later.') | ||||
|       setLoading(false) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <Page> | ||||
|       <SEO | ||||
|         title="Manifold Markets on Twitch" | ||||
|         description="Get more out of Twitch with play-money betting markets." | ||||
|       /> | ||||
|       <div className="px-4 pt-2 md:mt-0 lg:hidden"> | ||||
|         <ManifoldLogo /> | ||||
|       </div> | ||||
|       <Col className="items-center"> | ||||
|         <Col className="max-w-3xl"> | ||||
|           <Col className="mb-6 rounded-xl sm:m-12 sm:mt-0"> | ||||
|             <Row className="self-center"> | ||||
|               <img height={200} width={200} src="/twitch-logo.png" /> | ||||
|               <img height={200} width={200} src="/flappy-logo.gif" /> | ||||
|             </Row> | ||||
|             <div className="m-4 max-w-[550px] self-center"> | ||||
|               <h1 className="text-3xl sm:text-6xl xl:text-6xl"> | ||||
|                 <div className="font-semibold sm:mb-2"> | ||||
|                   <span className="bg-gradient-to-r from-indigo-500 to-blue-500 bg-clip-text font-bold text-transparent"> | ||||
|                     Bet | ||||
|                   </span>{' '} | ||||
|                   on your favorite streams | ||||
|                 </div> | ||||
|               </h1> | ||||
|               <Spacer h={6} /> | ||||
|               <div className="mb-4 px-2 "> | ||||
|                 Get more out of Twitch with play-money betting markets.{' '} | ||||
|                 {!twitchUser && | ||||
|                   'Click the button below to link your Twitch account.'} | ||||
|                 <br /> | ||||
|               </div> | ||||
|             </div> | ||||
| 
 | ||||
|             <Spacer h={6} /> | ||||
| 
 | ||||
|             {twitchUser ? ( | ||||
|               <div className="mt-3 self-center rounded-lg bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-4 "> | ||||
|                 <div className="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6"> | ||||
|                   <div className="truncate text-sm font-medium text-gray-500"> | ||||
|                     Twitch account linked | ||||
|                   </div> | ||||
|                   <div className="mt-1 text-2xl font-semibold text-gray-900"> | ||||
|                     {twitchUser} | ||||
|                   </div> | ||||
|                 </div> | ||||
|               </div> | ||||
|             ) : isLoading ? ( | ||||
|               <LoadingIndicator spinnerClassName="!w-16 !h-16" /> | ||||
|             ) : ( | ||||
|               <Button | ||||
|                 size="2xl" | ||||
|                 color="gradient" | ||||
|                 className="self-center" | ||||
|                 onClick={getStarted} | ||||
|               > | ||||
|                 Get started | ||||
|               </Button> | ||||
|             )} | ||||
|           </Col> | ||||
|         </Col> | ||||
|       </Col> | ||||
|     </Page> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								web/public/twitch-logo.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								web/public/twitch-logo.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 22 KiB | 
		Loading…
	
		Reference in New Issue
	
	Block a user