Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .defaults.env
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,9 @@ DEFAULT_SCENE_SID="JGLt8DP"
# Uncomment to load the app config from the reticulum server in development.
# Useful when testing the admin panel.
# LOAD_APP_CONFIG=true

# microtransactions API
# API that takes {id, name, premium}, creates a Stripe session, and returns { url } to prebuilt checkout page
STRIPE_CHECKOUT_SESSION_URL=
# API that takes {receiptId} (stripe session id) and returns { entryId } of purchased avatar
STRIPE_VERIFY_RECEIPT_URL=
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
// Format on save for Prettier
"editor.formatOnSave": true,
"editor.formatOnSave": false,
// Disable html formatting for now
"html.format.enable": false,
// Disable the default javascript formatter
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,32 @@
# Avatar Payments Fork

This Hubs fork enables setting certain avatars as "premium" and requiring users
to purchase them before use using a popup window checkout flow.

Required configuration

These can be added to the `.env` file prior to build/deploy or they can be set
in the Hubs Cloud Admin by adding meta tags to the Extra Room Header HTML advanced setting


.env:

```
STRIPE_CHECKOUT_SESSION_URL=
STRIPE_VERIFY_RECEIPT_URL=
PAYMENTS_API_KEY=
```

header HTML

```html
<meta name="env:stripe_checout_session_url" content="https://api.url">
<meta name="env:sripe_verify_receipt_url" content="https://api.url">
<meta name="env:payments_api_key" content="https://api.url">
```

The two API URLS may be the same url, requests are differntiate with `step` value 'checkout' or 'receipt'

# [Mozilla Hubs](https://hubs.mozilla.com/)

[![License: MPL 2.0](https://img.shields.io/badge/License-MPL%202.0-brightgreen.svg)](https://opensource.org/licenses/MPL-2.0) [![Build Status](https://travis-ci.org/mozilla/hubs.svg?branch=master)](https://travis-ci.org/mozilla/hubs) [![Discord](https://img.shields.io/discord/498741086295031808)](https://discord.gg/CzAbuGu)
Expand Down
14 changes: 14 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ import { AuthContextProvider } from "./react-components/auth/AuthContext";
import "./react-components/styles/global.scss";
import { ThemeProvider } from "./react-components/styles/theme";

const params = new URLSearchParams(window.location.search);
if (params.has("stripe_session_id") && window.opener) {
// pass receipt ID back to main window for verification
window.opener.postMessage({
type: "stripeCheckoutPopup",
receiptId: params.get("stripe_session_id")
});
} else if (params.has("stripe_session_error") && window.opener) {
window.opener.postMessage({
type: "stripeCheckoutPopup",
error: params.get("stripe_session_error")
});
}

registerTelemetry("/home", "Hubs Home Page");

const store = new Store();
Expand Down
197 changes: 192 additions & 5 deletions src/react-components/media-browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ const emptyMessages = defineMessages({
}
});

const PREMIUM_LEVELS = 3;
// TODO: Migrate to use MediaGrid and media specific components like RoomTile
class MediaBrowserContainer extends Component {
static propTypes = {
Expand All @@ -143,7 +144,15 @@ class MediaBrowserContainer extends Component {
store: PropTypes.object.isRequired
};

state = { query: "", facets: [], showNav: true, selectNextResult: false, clearStashedQueryOnClose: false };
state = {
query: "",
facets: [],
showNav: true,
selectNextResult: false,
clearStashedQueryOnClose: false,
premiumsFetched: 0,
premiums: {}
};

constructor(props) {
super(props);
Expand All @@ -152,7 +161,46 @@ class MediaBrowserContainer extends Component {
this.props.mediaSearchStore.addEventListener("sourcechanged", this.sourceChanged);
}

componentDidMount() {}
componentDidMount() {
/**
* reticulum does not include tags in search results,
* but it does let you query by tag, so we prefetch a list of
* which avatars are premium to compare with avatars shown in the browser
*/
this.premiumFetchStarted = Date.now();
const getPremiumPage = next => {
const premiumLevel = this.state.premiumsFetched + 1;
const search = new URLSearchParams({
source: "avatar_listings",
filter: `premium_${premiumLevel}`
});
if (next) {
search.set("cursor", next);
}
fetchReticulumAuthenticated(`/api/v1/media/search?${search.toString()}`)
.then(results => {
if (results.entries.length) {
const premiums = { ...this.state.premiums };
results.entries.forEach(entry => {
premiums[entry.id] = premiumLevel;
});
this.setState({ premiums });
}
if (results.meta && results.meta.next_cursor) {
getPremiumPage(results.meta.next_cursor);
} else if (this.state.premiumsFetched < PREMIUM_LEVELS) {
this.setState({ premiumsFetched: premiumLevel }, () => getPremiumPage());
}
})
.catch(err => {
console.warn(`Error looking up premium avatars: ${err.message}`);
if (this.state.premiumsFetched < PREMIUM_LEVELS) {
this.setState({ premiumsFetched: premiumLevel }, () => getPremiumPage());
}
});
};
getPremiumPage();
}

componentWillUnmount() {
this.props.mediaSearchStore.removeEventListener("statechanged", this.storeUpdated);
Expand Down Expand Up @@ -185,7 +233,11 @@ class MediaBrowserContainer extends Component {
const searchParams = new URLSearchParams(props.history.location.search);
const result = props.mediaSearchStore.result;

const newState = { result, query: this.state.query || searchParams.get("q") || "" };
const newState = {
...this.state,
result,
query: this.state.query || searchParams.get("q") || ""
};
const urlSource = this.getUrlSource(searchParams);
newState.showNav = !!(searchParams.get("media_nav") !== "false");
newState.selectAction = searchParams.get("selectAction") || "spawn";
Expand Down Expand Up @@ -223,9 +275,115 @@ class MediaBrowserContainer extends Component {
this.setState({ query });
};

handleEntryClicked = (evt, entry) => {
handleEntryClicked = async (evt, entry) => {
evt.preventDefault();

if (this.state.premiums[entry.id]) {
const width = 730;
const height = 785;
const left = (window.innerWidth - width) / 2 + window.screenLeft;
const top = (window.innerHeight - height) / 2 + window.screenTop;
const features = `toolbar=no, menubar=no, width=${width}, height=${height}, top=${top}, left=${left}`;
const popup = window.open("about:blank", "stripeCheckoutPopup", features);

// check if already purchased
const purchasedAvatars = this.props.store.state.credentials.purchasedAvatars || {};
if (purchasedAvatars[entry.id] && (await this.verifyReceipt(purchasedAvatars[entry.id], entry.id))) {
if (popup) {
popup.close();
}
return this.selectEntry(entry);
}
let checkoutSessionURL;
try {
const checkoutSessionResult = await window
.fetch(configs.STRIPE_CHECKOUT_SESSION_URL, {
method: "POST",
body: JSON.stringify({
step: "checkout",
id: entry.id,
name: entry.name,
premium: this.state.premiums[entry.id]
}),
headers: {
"Content-Type": "application/json",
"X-Api-Key": configs.PAYMENTS_API_KEY
}
})
.then(res => res.json());
if (!checkoutSessionResult.url) {
throw new Error("No checkout redirect url returned");
}
checkoutSessionURL = checkoutSessionResult.url;
} catch (err) {
if (popup) {
popup.close();
}
console.error(`Error setuping up checkout session ${err.message}`);
window.alert("Unable to process transaction. Please try again");
return;
}
if (window.mixpanel) {
window.mixpanel.track("AvatarPurchaseStart", {
id: entry.id,
name: entry.name,
premium: this.state.premiums[entry.id]
});
}
// checkoutSessionURL
if (!popup) {
alert("Could not open checkout window. Please check if popup was blocked and allow it");
return;
} else {
popup.location = checkoutSessionURL;
// disable disconnect timeouts while popup is open
this.props.scene.addState("payment-authorizing");
const closedCheckInterval = window.setInterval(() => {
if (popup.closed) {
window.clearInterval(closedCheckInterval);
this.props.scene.removeState("payment-authorizing");
}
}, 100);
}
const receiptId = await new Promise(resolve => {
const handler = ({ data }) => {
if (data && data.type !== "stripeCheckoutPopup") {
return;
}
window.removeEventListener("message", handler);
// have to close the popup in this thread because, in chrome, having the popup close itself crashes the browser
if (popup) {
popup.close();
}
if (data.error) {
console.log(`Payment aborted`);
return resolve(false);
}
resolve(data.receiptId);
};
window.addEventListener("message", handler);
});
if (!receiptId) {
// payment was cancelled before completion
console.log("Avatar payment not completed");
return;
}
purchasedAvatars[entry.id] = receiptId;
this.props.store.update({ credentials: { purchasedAvatars } });
if (!(await this.verifyReceipt(receiptId, entry.id))) {
window.alert("Unable to process transaction. Please try again");
return;
}
if (window.mixpanel) {
window.mixpanel.track("AvatarPurchaseComplete", {
id: entry.id,
name: entry.name,
premium: this.state.premiums[entry.id]
});
}
return this.selectEntry(entry);
}

if (!entry.lucky_query) {
this.selectEntry(entry);
} else {
Expand All @@ -238,6 +396,31 @@ class MediaBrowserContainer extends Component {
}
};

verifyReceipt = async (receiptId, entryId) => {
try {
const receiptVerifyResult = await window
.fetch(configs.STRIPE_VERIFY_RECEIPT_URL, {
method: "POST",
body: JSON.stringify({
step: "receipt",
receiptId
}),
headers: {
"Content-Type": "application/json",
"X-Api-Key": configs.PAYMENTS_API_KEY
}
})
.then(res => res.json());
if (receiptVerifyResult.avatarId !== entryId) {
throw new Error("Invalid receipt");
}
return entryId;
} catch (err) {
console.error(`Receipt verification failed: ${err.message}`);
return false;
}
};

onShowSimilar = (id, name) => {
this.handleFacetClicked({ params: { similar_to: id, similar_name: name } });
};
Expand Down Expand Up @@ -426,7 +609,10 @@ class MediaBrowserContainer extends Component {
</>
);
}

if (this.state.premiumsFetched < PREMIUM_LEVELS && Date.now() - this.premiumFetchStarted < 5000) {
// don't render until premium avatars identified so you can't get one free by being fast
return false;
}
return (
<MediaBrowser
browserRef={r => (this.browserDiv = r)}
Expand Down Expand Up @@ -552,6 +738,7 @@ class MediaBrowserContainer extends Component {
onEdit={onEdit}
onShowSimilar={onShowSimilar}
onCopy={onCopy}
premium={this.state.premiums[entry.id]}
/>
);
})}
Expand Down
4 changes: 4 additions & 0 deletions src/react-components/profile-entry-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ export default class ProfileEntryPanel extends Component {
this.scene.addEventListener("action_avatar_saved", this.refetchAvatar);

this.refetchAvatar();
// show avatar picker immediately in entry flow
if (this.props.containerType !== "sidebar") {
this.props.mediaSearchStore.sourceNavigateWithNoNav("avatars", "use");
}
}

componentDidUpdate(_prevProps, prevState) {
Expand Down
18 changes: 16 additions & 2 deletions src/react-components/room/MediaTiles.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const PUBLISHER_FOR_ENTRY_TYPE = {
twitch_stream: "Twitch"
};

const prices = [0, 1, 3, 5];

function useThumbnailSize(isImage, isAvatar, imageAspect) {
return useMemo(
() => {
Expand Down Expand Up @@ -128,7 +130,17 @@ CreateTile.propTypes = {
type: PropTypes.string
};

export function MediaTile({ entry, processThumbnailUrl, onClick, onEdit, onShowSimilar, onCopy, onInfo, ...rest }) {
export function MediaTile({
entry,
processThumbnailUrl,
onClick,
onEdit,
onShowSimilar,
onCopy,
onInfo,
premium,
...rest
}) {
const intl = useIntl();
const creator = entry.attributions && entry.attributions.creator;
const publisherName =
Expand Down Expand Up @@ -202,6 +214,7 @@ export function MediaTile({ entry, processThumbnailUrl, onClick, onEdit, onShowS
<PeopleIcon /> <span>{entry.member_count}</span>
</div>
)}
<div className={styles.tilePrice}>{premium && <div className={styles.tileAction}>${prices[premium]}</div>}</div>
<div className={styles.tileActions}>
{entry.type === "avatar" && (
<TileAction
Expand Down Expand Up @@ -280,5 +293,6 @@ MediaTile.propTypes = {
onEdit: PropTypes.func,
onShowSimilar: PropTypes.func,
onCopy: PropTypes.func,
onInfo: PropTypes.func
onInfo: PropTypes.func,
premium: PropTypes.number
};
Loading