Skip to content

Commit 136c09c

Browse files
committed
feat: add patreon login support
feat: patreon error handling feat: add patreon logout button
1 parent 1c50b91 commit 136c09c

25 files changed

+1059
-122
lines changed

.gitignore

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Local env vars
2-
.env
2+
*.env
3+
!.example.env
34

45
# Logs
56
logs

packages/metastream-app/.example.env

+7
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,10 @@ GA_CLIENT_ID=
55
# METASTREAM_TURN_SERVER='turn:localhost:3478?transport=tcp'
66
METASTREAM_TURN_USERNAME=username
77
METASTREAM_TURN_CREDENTIAL=password
8+
9+
# Patreon
10+
PATREON_CLIENT_ID=
11+
PATREON_REDIRECT_URI=
12+
13+
# Firebase
14+
FIREBASE_CONFIG=

packages/metastream-app/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
"classnames": "^2.2.5",
7979
"deep-diff": "^1.0.2",
8080
"exponential-backoff": "^2.1.1",
81+
"firebase": "^7.13.1",
8182
"history": "^4.7.2",
8283
"html-parse-stringify2": "^2.0.1",
8384
"i18next": "^12.0.0",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { StorageKey } from 'constants/storage'
2+
import { EventEmitter } from 'events'
3+
4+
type Firebase = typeof import('firebase/app')
5+
6+
export const enum MetastreamUserTier {
7+
None = 0,
8+
Starter = 1,
9+
Supporter = 2
10+
}
11+
12+
interface UserDocument {
13+
tier: MetastreamUserTier
14+
}
15+
16+
let instance: AccountService
17+
18+
export class AccountService extends EventEmitter {
19+
static get() {
20+
return instance || (instance = new AccountService())
21+
}
22+
23+
private _firebase?: Firebase
24+
private get firebase() {
25+
if (!this._firebase) {
26+
throw new Error('Firebase is not initialized')
27+
}
28+
return this._firebase
29+
}
30+
31+
private _userData?: UserDocument
32+
33+
get tier() {
34+
return (this._userData && this._userData.tier) || MetastreamUserTier.None
35+
}
36+
37+
private async initFirebase() {
38+
if (!process.env.FIREBASE_CONFIG) {
39+
throw new Error('Firebase not configured')
40+
}
41+
42+
const firebase = await import(/* webpackChunkName: "firebase-app" */ 'firebase/app')
43+
await Promise.all([
44+
import(/* webpackChunkName: "firebase-auth" */ 'firebase/auth'),
45+
import(/* webpackChunkName: "firebase-firestore" */ 'firebase/firestore')
46+
])
47+
48+
if (!this._firebase) {
49+
const firebaseConfig = JSON.parse(process.env.FIREBASE_CONFIG)
50+
firebase.initializeApp(firebaseConfig)
51+
this._firebase = firebase
52+
}
53+
54+
if (process.env.NODE_ENV === 'development') {
55+
;(window as any).app.firebase = firebase
56+
}
57+
58+
return firebase
59+
}
60+
61+
private async fetchUserDocument() {
62+
const userRecord = this.firebase.auth().currentUser!
63+
const userDocRef = await this.firebase
64+
.firestore()
65+
.collection('users')
66+
.doc(userRecord.uid)
67+
.get()
68+
const userData = userDocRef.data() as UserDocument
69+
this._userData = userData
70+
this.emit('change')
71+
return userData
72+
}
73+
74+
async checkLogin() {
75+
const didLogin = Boolean(localStorage.getItem(StorageKey.Login))
76+
if (didLogin) {
77+
const firebase = await this.initFirebase() // init
78+
const user = await new Promise(resolve => {
79+
const unsubscribe = firebase.auth().onAuthStateChanged(user => {
80+
unsubscribe()
81+
resolve(user)
82+
})
83+
})
84+
85+
if (process.env.NODE_ENV === 'development') {
86+
console.debug('Firebase user', user)
87+
}
88+
89+
if (user) {
90+
await this.fetchUserDocument()
91+
}
92+
}
93+
}
94+
95+
async promptLogin() {
96+
const customToken = await new Promise<string>((resolve, reject) => {
97+
const params: { [key: string]: string | undefined } = {
98+
response_type: 'code',
99+
client_id: process.env.PATREON_CLIENT_ID,
100+
redirect_uri: process.env.PATREON_REDIRECT_URI,
101+
scope: ['identity', 'identity[email]'].join(' ')
102+
}
103+
104+
const url = new URL('https://www.patreon.com/oauth2/authorize')
105+
for (const key of Object.keys(params)) {
106+
url.searchParams.set(key, params[key] + '')
107+
}
108+
109+
const win = window.open(url.href, 'MetastreamAuth')
110+
if (!win) {
111+
reject()
112+
return
113+
}
114+
115+
window.addEventListener('message', function onAuthMessage(event: MessageEvent) {
116+
if (event.origin !== location.origin) return
117+
118+
if (win.closed) {
119+
window.removeEventListener('message', onAuthMessage)
120+
reject()
121+
return
122+
}
123+
124+
const action = typeof event.data === 'object' && event.data
125+
if (action && action.type === 'auth-token') {
126+
win.close()
127+
window.removeEventListener('message', onAuthMessage)
128+
129+
if (action.error) {
130+
reject(new Error(action.error))
131+
} else {
132+
resolve(action.token)
133+
}
134+
}
135+
})
136+
})
137+
138+
await this.initFirebase()
139+
const userCred = await this.firebase.auth().signInWithCustomToken(customToken)
140+
141+
if (process.env.NODE_ENV === 'development') {
142+
console.log(userCred)
143+
}
144+
145+
// Remember that the user logged in so we can restore on refresh
146+
try {
147+
localStorage.setItem(StorageKey.Login, '1')
148+
} catch {}
149+
150+
await this.fetchUserDocument()
151+
}
152+
153+
async logout() {
154+
if (!this._firebase) return
155+
await this.firebase.auth().signOut()
156+
157+
try {
158+
localStorage.removeItem(StorageKey.Login)
159+
} catch {}
160+
161+
this._userData = undefined
162+
this.emit('change')
163+
}
164+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { useState, useEffect } from 'react'
2+
import { AccountService } from './account'
3+
4+
export function usePatronTier() {
5+
const [tier, setTier] = useState(AccountService.get().tier)
6+
7+
useEffect(() => {
8+
function handleAccountChange() {
9+
setTier(AccountService.get().tier)
10+
}
11+
12+
AccountService.get().on('change', handleAccountChange)
13+
return () => {
14+
AccountService.get().off('change', handleAccountChange)
15+
}
16+
})
17+
18+
return tier
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<title>Authenticating with Metastream</title>
6+
</head>
7+
<body>
8+
<script>
9+
;(function() {
10+
const { opener } = window
11+
if (!opener) return
12+
13+
const params = new URLSearchParams(location.search)
14+
const error = params.get('e')
15+
const token = params.get('t')
16+
17+
const message = { type: 'auth-token', error, token }
18+
opener.postMessage(message, location.origin)
19+
})()
20+
</script>
21+
</body>
22+
</html>
Loading
Loading

packages/metastream-app/src/components/Icon.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ interface IProps {
2121

2222
// too lazy to move into css
2323
const DEFAULT_STYLE: React.CSSProperties = {
24-
display: 'inline-block'
24+
display: 'inline-block',
25+
flexShrink: 0
2526
}
2627

2728
const SIZE_SCALE = {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
.container {
2+
composes: center-vertical from '~styles/layout.css';
3+
align-items: center;
4+
justify-content: space-evenly;
5+
}
6+
7+
.then {
8+
margin: 0 1rem;
9+
}
10+
11+
.buttons {
12+
composes: center-vertical from '~styles/layout.css';
13+
}
14+
15+
@media only screen and (max-width: 1150px) {
16+
.container {
17+
flex-direction: column;
18+
}
19+
}
20+
21+
.error {
22+
margin-bottom: 0.5rem;
23+
padding: 0.5rem 1rem;
24+
background: rgba(255, 0, 0, 0.3);
25+
text-align: center;
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import React, { SFC, useState } from 'react'
2+
import cx from 'classnames'
3+
import { LoginButton, SignupButton } from './buttons'
4+
import { MetastreamUserTier, AccountService } from 'account/account'
5+
import { t } from 'locale'
6+
import styles from './DonateBar.css'
7+
import { MediumText } from 'components/common/typography'
8+
import { usePatronTier } from 'account/hooks'
9+
10+
interface Props {
11+
className?: string
12+
}
13+
14+
export const DonateBar: SFC<Props> = ({ className }) => {
15+
const [error, setError] = useState<string | null>(null)
16+
17+
const tier = usePatronTier()
18+
if (tier !== MetastreamUserTier.None) return null
19+
20+
return (
21+
<div className={className}>
22+
{error && <div className={styles.error}>{error}</div>}
23+
<div className={styles.container}>
24+
<MediumText>{t('supportMetastream')}&nbsp;🎉</MediumText>
25+
<div className={styles.buttons}>
26+
<SignupButton />
27+
<span className={styles.then}>THEN</span>
28+
<LoginButton
29+
onClick={async () => {
30+
if (error) setError(null)
31+
try {
32+
await AccountService.get().promptLogin()
33+
} catch (e) {
34+
console.log(e)
35+
;(window as any).E = e
36+
setError(e.message)
37+
}
38+
}}
39+
/>
40+
</div>
41+
</div>
42+
</div>
43+
)
44+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import React, { MouseEventHandler } from 'react'
2+
import { HighlightButton } from 'components/common/button'
3+
import { t } from 'locale'
4+
import { assetUrl } from 'utils/appUrl'
5+
import { openInBrowser } from 'utils/url'
6+
7+
interface Props {
8+
onClick?: MouseEventHandler
9+
}
10+
11+
export const LoginButton = (props: Props) => (
12+
<HighlightButton size="medium" icon="log-in" {...props}>
13+
{t('patreonLogin')}
14+
</HighlightButton>
15+
)
16+
17+
export const LogoutButton = (props: Props) => (
18+
<HighlightButton size="medium" icon="log-out" {...props}>
19+
{t('patreonLogout')}
20+
</HighlightButton>
21+
)
22+
23+
export const SignupButton = () => (
24+
<button
25+
onClick={() => openInBrowser('https://www.patreon.com/metastream')}
26+
style={{ fontSize: 0 }}
27+
aria-label={t('patreonPledge')}
28+
>
29+
<img src={assetUrl('images/become_a_patron_button.png')} width="170px" height="40px" alt="" />
30+
</button>
31+
)

packages/metastream-app/src/components/browser/Controls.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export class WebControls extends Component<IProps, IState> {
111111
highlight={this.state.canRequest}
112112
title={
113113
<MediumText>
114-
When you&rsquo;re ready to share, press the{' '}
114+
When you&rsquo;re ready to share a link, press the{' '}
115115
<HighlightText>Add To Session</HighlightText> button.
116116
</MediumText>
117117
}

packages/metastream-app/src/components/browser/WebBrowser.css

+4
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,7 @@
2626
border-radius: 0;
2727
}
2828
}
29+
30+
.donateBar {
31+
margin: 0 1.5rem 1.5rem 1.5rem;
32+
}

packages/metastream-app/src/components/browser/WebBrowser.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { assetUrl } from 'utils/appUrl'
99
import { IReactReduxProps } from 'types/redux-thunk'
1010
import { Webview } from 'components/Webview'
1111
import { sendMediaRequest } from 'lobby/actions/media-request'
12+
import { DonateBar } from 'components/account/DonateBar'
1213

1314
const NONCE = shortid()
1415
const DEFAULT_URL = `${assetUrl('homescreen.html')}?nonce=${NONCE}`
@@ -98,6 +99,7 @@ export class _WebBrowser extends Component<PrivateProps> {
9899
onRequestUrl={url => this.requestUrl(url, 'browser')}
99100
/>
100101
{this.renderContent()}
102+
<DonateBar className={styles.donateBar} />
101103
</div>
102104
)
103105
}

packages/metastream-app/src/components/common/button.css

+1-1
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
}
5959

6060
.medium {
61-
padding: 6px 12px;
61+
padding: 0 12px;
6262
height: 40px;
6363
font-size: 18px;
6464
}

0 commit comments

Comments
 (0)