Merge branch 'crypto-donate' into 'develop'

Crypto donations

See merge request soapbox-pub/soapbox-fe!521
This commit is contained in:
Alex Gleason 2021-06-10 01:57:29 +00:00
commit a3ee5789bc
20 changed files with 408 additions and 8 deletions

View file

@ -0,0 +1,55 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Icon from 'soapbox/components/icon';
import CoinDB from '../utils/coin_db';
import { getCoinIcon } from '../utils/coin_icons';
import { openModal } from 'soapbox/actions/modal';
import { CopyableInput } from 'soapbox/features/forms';
import { getExplorerUrl } from '../utils/block_explorer';
export default @connect()
class CryptoAddress extends ImmutablePureComponent {
static propTypes = {
address: PropTypes.string.isRequired,
ticker: PropTypes.string.isRequired,
note: PropTypes.string,
}
handleModalClick = e => {
this.props.dispatch(openModal('CRYPTO_DONATE', this.props));
e.preventDefault();
}
render() {
const { address, ticker, note } = this.props;
const title = CoinDB.getIn([ticker, 'name']);
const explorerUrl = getExplorerUrl(ticker, address);
return (
<div className='crypto-address'>
<div className='crypto-address__head'>
<div className='crypto-address__icon'>
<img src={getCoinIcon(ticker)} alt={title} />
</div>
<div className='crypto-address__title'>{title || ticker.toUpperCase()}</div>
<div className='crypto-address__actions'>
<a href='' onClick={this.handleModalClick}>
<Icon id='qrcode' />
</a>
{explorerUrl && <a href={explorerUrl} target='_blank'>
<Icon id='external-link' />
</a>}
</div>
</div>
{note && <div className='crypto-address__note'>{note}</div>}
<div className='crypto-address__address simple_form'>
<CopyableInput value={address} />
</div>
</div>
);
}
}

View file

@ -0,0 +1,50 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Icon from 'soapbox/components/icon';
import QRCode from 'qrcode.react';
import CoinDB from '../utils/coin_db';
import { getCoinIcon } from '../utils/coin_icons';
import { CopyableInput } from 'soapbox/features/forms';
import { getExplorerUrl } from '../utils/block_explorer';
export default @connect()
class DetailedCryptoAddress extends ImmutablePureComponent {
static propTypes = {
address: PropTypes.string.isRequired,
ticker: PropTypes.string.isRequired,
note: PropTypes.string,
}
render() {
const { address, ticker, note } = this.props;
const title = CoinDB.getIn([ticker, 'name']);
const explorerUrl = getExplorerUrl(ticker, address);
return (
<div className='crypto-address'>
<div className='crypto-address__head'>
<div className='crypto-address__icon'>
<img src={getCoinIcon(ticker)} alt={title} />
</div>
<div className='crypto-address__title'>{title || ticker.toUpperCase()}</div>
<div className='crypto-address__actions'>
{explorerUrl && <a href={explorerUrl} target='_blank'>
<Icon id='external-link' />
</a>}
</div>
</div>
{note && <div className='crypto-address__note'>{note}</div>}
<div className='crypto-address__qrcode'>
<QRCode value={address} />
</div>
<div className='crypto-address__address simple_form'>
<CopyableInput value={address} />
</div>
</div>
);
}
}

View file

@ -0,0 +1,38 @@
import React from 'react';
import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import CryptoAddress from './crypto_address';
const mapStateToProps = state => {
// Address example:
// {"ticker": "btc", "address": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n", "note": "This is our main address"}
return {
coinList: state.getIn(['soapbox', 'crypto_addresses']),
};
};
export default @connect(mapStateToProps)
class CoinList extends ImmutablePureComponent {
static propTypes = {
coinList: ImmutablePropTypes.list,
}
render() {
const { coinList } = this.props;
if (!coinList) return null;
return (
<div className='site-wallet'>
{coinList.map(coin => (
<CryptoAddress
key={coin.get('ticker')}
{...coin.toJS()}
/>
))}
</div>
);
}
}

View file

@ -0,0 +1,32 @@
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Column from '../ui/components/column';
import SiteWallet from './components/site_wallet';
const messages = defineMessages({
heading: { id: 'column.crypto_donate', defaultMessage: 'Donate Cryptocurrency' },
});
export default
@injectIntl
class CryptoDonate extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
};
render() {
const { intl } = this.props;
return (
<Column icon='bitcoin' heading={intl.formatMessage(messages.heading)} backBtnSlim>
<div className='crypto-donate'>
<SiteWallet />
</div>
</Column>
);
}
}

View file

@ -0,0 +1,7 @@
import blockExplorers from './block_explorers.json';
export const getExplorerUrl = (ticker, address) => {
const template = blockExplorers[ticker];
if (!template) return false;
return template.replace('{address}', address);
};

View file

@ -0,0 +1,8 @@
{
"bch": "https://explorer.bitcoin.com/bch/address/{address}",
"btc": "https://explorer.bitcoin.com/btc/address/{address}",
"doge": "https://dogechain.info/address/{address}",
"eth": "https://etherscan.io/address/{address}",
"ubq": "https://ubiqscan.io/address/{address}",
"xmr": "https://monerohash.com/explorer/search?value={address}"
}

View file

@ -0,0 +1,6 @@
import { fromJS } from 'immutable';
import manifestMap from './manifest_map';
// All this does is converts the result from manifest_map.js into an ImmutableMap
const coinDB = fromJS(manifestMap);
export default coinDB;

View file

@ -0,0 +1,20 @@
// Does some trickery to import all the icons into the project
// See: https://stackoverflow.com/questions/42118296/dynamically-import-images-from-a-directory-using-webpack
const icons = {};
function importAll(r) {
const pathRegex = /\.\/(.*)\.svg/i;
r.keys().forEach((key) => {
const ticker = pathRegex.exec(key)[1];
return icons[ticker] = r(key).default;
});
}
importAll(require.context('cryptocurrency-icons/svg/color/', true, /\.svg$/));
export default icons;
// For getting the icon
export const getCoinIcon = ticker => icons[ticker] || icons.generic || null;

View file

@ -0,0 +1,12 @@
// @preval
// Converts cryptocurrency-icon's manifest file from a list to a map.
// See: https://github.com/spothq/cryptocurrency-icons/blob/master/manifest.json
const manifest = require('cryptocurrency-icons/manifest.json');
const { Map: ImmutableMap, fromJS } = require('immutable');
const manifestMap = fromJS(manifest).reduce((acc, entry) => {
return acc.set(entry.get('symbol').toLowerCase(), entry);
}, ImmutableMap());
module.exports = manifestMap.toJS();

View file

@ -1,5 +1,6 @@
import React, { useState } from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { v4 as uuidv4 } from 'uuid';
@ -264,3 +265,38 @@ export const FileChooserLogo = props => (
FileChooserLogo.defaultProps = {
accept: ['image/svg', 'image/png'],
};
export class CopyableInput extends ImmutablePureComponent {
static propTypes = {
value: PropTypes.string,
}
setInputRef = c => {
this.input = c;
}
handleCopyClick = e => {
if (!this.input) return;
this.input.select();
this.input.setSelectionRange(0, 99999);
document.execCommand('copy');
}
render() {
const { value } = this.props;
return (
<div className='copyable-input'>
<input ref={this.setInputRef} type='text' value={value} readOnly />
<button onClick={this.handleCopyClick}>
<FormattedMessage id='forms.copy' defaultMessage='Copy' />
</button>
</div>
);
}
}

View file

@ -0,0 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
import DetailedCryptoAddress from 'soapbox/features/crypto_donate/components/detailed_crypto_address';
export default class CryptoDonateModal extends React.PureComponent {
static propTypes = {
address: PropTypes.string.isRequired,
ticker: PropTypes.string.isRequired,
note: PropTypes.string,
};
render() {
return (
<div className='modal-root__modal crypto-donate-modal'>
<DetailedCryptoAddress {...this.props} />
</div>
);
}
}

View file

@ -13,6 +13,7 @@ import FocalPointModal from './focal_point_modal';
import HotkeysModal from './hotkeys_modal';
import ComposeModal from './compose_modal';
import UnauthorizedModal from './unauthorized_modal';
import CryptoDonateModal from './crypto_donate_modal';
import {
MuteModal,
@ -37,6 +38,7 @@ const MODAL_COMPONENTS = {
'HOTKEYS': () => Promise.resolve({ default: HotkeysModal }),
'COMPOSE': () => Promise.resolve({ default: ComposeModal }),
'UNAUTHORIZED': () => Promise.resolve({ default: UnauthorizedModal }),
'CRYPTO_DONATE': () => Promise.resolve({ default: CryptoDonateModal }),
};
export default class ModalRoot extends React.PureComponent {

View file

@ -13,6 +13,12 @@ import { List as ImmutableList } from 'immutable';
import { getAcct, isAdmin, isModerator } from 'soapbox/utils/accounts';
import { displayFqn } from 'soapbox/utils/state';
import classNames from 'classnames';
import CryptoAddress from 'soapbox/features/crypto_donate/components/crypto_address';
const TICKER_REGEX = /\$([a-zA-Z]*)/i;
const getTicker = value => (value.match(TICKER_REGEX) || [])[1];
const isTicker = value => Boolean(getTicker(value));
const messages = defineMessages({
linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' },
@ -123,15 +129,19 @@ class ProfileInfoPanel extends ImmutablePureComponent {
</dl>
))}
{fields.map((pair, i) => (
<dl className='profile-info-panel-content__fields__item' key={i}>
<dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} />
{fields.map((pair, i) =>
isTicker(pair.get('name', '')) ? (
<CryptoAddress ticker={getTicker(pair.get('name')).toLowerCase()} address={pair.get('value_plain')} />
) : (
<dl className='profile-info-panel-content__fields__item' key={i}>
<dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} />
<dd className={pair.get('verified_at') && 'verified'} title={pair.get('value_plain')}>
{pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><Icon id='check' className='verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} />
</dd>
</dl>
))}
<dd className={pair.get('verified_at') && 'verified'} title={pair.get('value_plain')}>
{pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><Icon id='check' className='verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} />
</dd>
</dl>
),
)}
</div>
)}
</div>

View file

@ -92,6 +92,7 @@ import {
AwaitingApproval,
Reports,
ModerationLog,
CryptoDonate,
} from './util/async-components';
// Dummy import, to make sure that <Status /> ends up in the application bundle.
@ -289,6 +290,8 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/admin/log' page={AdminPage} component={ModerationLog} content={children} exact />
<WrappedRoute path='/info' layout={LAYOUT.EMPTY} component={ServerInfo} content={children} />
<WrappedRoute path='/donate/crypto' layout={LAYOUT.DEFAULT} component={CryptoDonate} content={children} />
<WrappedRoute layout={LAYOUT.EMPTY} component={GenericNotFound} content={children} />
</Switch>
);

View file

@ -229,3 +229,7 @@ export function Reports() {
export function ModerationLog() {
return import(/* webpackChunkName: "features/admin/moderation_log" */'../../admin/moderation_log');
}
export function CryptoDonate() {
return import(/* webpackChunkName: "features/crypto_donate" */'../../crypto_donate');
}

View file

@ -81,6 +81,7 @@
@import 'components/server-info';
@import 'components/admin';
@import 'components/backups';
@import 'components/crypto-donate';
// Holiday
@import 'holiday/halloween';

View file

@ -0,0 +1,69 @@
.crypto-address {
padding: 20px;
display: flex;
flex-direction: column;
&__head {
display: flex;
align-items: center;
margin-bottom: 6px;
}
&__title {
font-weight: bold;
}
&__icon {
display: flex;
align-items: flex-start;
justify-content: center;
width: 24px;
margin-right: 10px;
img {
width: 100%;
}
}
&__actions {
margin-left: auto;
a {
color: var(--primary-text-color--faint);
margin-left: 10px;
}
}
&__note {
margin-bottom: 10px;
}
&__qrcode {
margin-bottom: 12px;
padding: 10px;
display: flex;
align-items: center;
justify-content: center;
}
&__address {
margin-top: auto;
}
}
.site-wallet {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
.crypto-donate-modal {
background: var(--foreground-color);
border-radius: 8px;
padding-bottom: 13px;
}
.profile-info-panel-content__fields {
.crypto-address {
padding: 10px 0;
}
}

View file

@ -788,3 +788,23 @@ code {
}
}
}
.copyable-input {
display: flex;
align-items: center;
justify-content: center;
input {
flex: 1;
font-size: 14px !important;
border-radius: 4px 0 0 4px !important;
}
button {
width: auto;
font-size: 14px;
margin: 0;
padding-bottom: 9px;
border-radius: 0 4px 4px 0;
}
}

View file

@ -59,6 +59,7 @@
"classnames": "^2.2.5",
"compression-webpack-plugin": "^6.0.2",
"copy-webpack-plugin": "6.4.0",
"cryptocurrency-icons": "^0.17.2",
"css-loader": "^4.3.0",
"cssnano": "^4.1.10",
"detect-passive-events": "^2.0.0",

View file

@ -3866,6 +3866,11 @@ crypto-browserify@^3.11.0:
randombytes "^2.0.0"
randomfill "^1.0.3"
cryptocurrency-icons@^0.17.2:
version "0.17.2"
resolved "https://registry.yarnpkg.com/cryptocurrency-icons/-/cryptocurrency-icons-0.17.2.tgz#25811b450d8698e7985bc91005d89555f13e6686"
integrity sha512-301lellubLNhxkySIBNNG3VD05rWfMR+CFgo9LoLfuNybG2OLy0mpWduxv65WZkJpLl9hhpaVAxCV5SYbG5o9A==
css-color-names@0.0.4, css-color-names@^0.0.4:
version "0.0.4"
resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0"