diff --git a/app/soapbox/features/crypto_donate/components/crypto_address.js b/app/soapbox/features/crypto_donate/components/crypto_address.js new file mode 100644 index 000000000..b2498101c --- /dev/null +++ b/app/soapbox/features/crypto_donate/components/crypto_address.js @@ -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 ( +
+
+
+ {title} +
+
{title || ticker.toUpperCase()}
+
+ + + + {explorerUrl && + + } +
+
+ {note &&
{note}
} +
+ +
+
+ ); + } + +} diff --git a/app/soapbox/features/crypto_donate/components/detailed_crypto_address.js b/app/soapbox/features/crypto_donate/components/detailed_crypto_address.js new file mode 100644 index 000000000..de5971d36 --- /dev/null +++ b/app/soapbox/features/crypto_donate/components/detailed_crypto_address.js @@ -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 ( +
+
+
+ {title} +
+
{title || ticker.toUpperCase()}
+
+ {explorerUrl && + + } +
+
+ {note &&
{note}
} +
+ +
+
+ +
+
+ ); + } + +} diff --git a/app/soapbox/features/crypto_donate/components/site_wallet.js b/app/soapbox/features/crypto_donate/components/site_wallet.js new file mode 100644 index 000000000..3e5d30bc5 --- /dev/null +++ b/app/soapbox/features/crypto_donate/components/site_wallet.js @@ -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 ( +
+ {coinList.map(coin => ( + + ))} +
+ ); + } + +} diff --git a/app/soapbox/features/crypto_donate/index.js b/app/soapbox/features/crypto_donate/index.js new file mode 100644 index 000000000..a06652ffe --- /dev/null +++ b/app/soapbox/features/crypto_donate/index.js @@ -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 ( + +
+ +
+
+ ); + } + +} diff --git a/app/soapbox/features/crypto_donate/utils/block_explorer.js b/app/soapbox/features/crypto_donate/utils/block_explorer.js new file mode 100644 index 000000000..0929250bf --- /dev/null +++ b/app/soapbox/features/crypto_donate/utils/block_explorer.js @@ -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); +}; diff --git a/app/soapbox/features/crypto_donate/utils/block_explorers.json b/app/soapbox/features/crypto_donate/utils/block_explorers.json new file mode 100644 index 000000000..624cd5026 --- /dev/null +++ b/app/soapbox/features/crypto_donate/utils/block_explorers.json @@ -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}" +} diff --git a/app/soapbox/features/crypto_donate/utils/coin_db.js b/app/soapbox/features/crypto_donate/utils/coin_db.js new file mode 100644 index 000000000..33c566cdb --- /dev/null +++ b/app/soapbox/features/crypto_donate/utils/coin_db.js @@ -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; diff --git a/app/soapbox/features/crypto_donate/utils/coin_icons.js b/app/soapbox/features/crypto_donate/utils/coin_icons.js new file mode 100644 index 000000000..2c0376bfa --- /dev/null +++ b/app/soapbox/features/crypto_donate/utils/coin_icons.js @@ -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; diff --git a/app/soapbox/features/crypto_donate/utils/manifest_map.js b/app/soapbox/features/crypto_donate/utils/manifest_map.js new file mode 100644 index 000000000..bab27695f --- /dev/null +++ b/app/soapbox/features/crypto_donate/utils/manifest_map.js @@ -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(); diff --git a/app/soapbox/features/forms/index.js b/app/soapbox/features/forms/index.js index d73e428dc..0ad1f361d 100644 --- a/app/soapbox/features/forms/index.js +++ b/app/soapbox/features/forms/index.js @@ -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 ( +
+ + +
+ ); + } + +} diff --git a/app/soapbox/features/ui/components/crypto_donate_modal.js b/app/soapbox/features/ui/components/crypto_donate_modal.js new file mode 100644 index 000000000..90ae87842 --- /dev/null +++ b/app/soapbox/features/ui/components/crypto_donate_modal.js @@ -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 ( +
+ +
+ ); + } + +} diff --git a/app/soapbox/features/ui/components/modal_root.js b/app/soapbox/features/ui/components/modal_root.js index b6cfa5737..b3f7f8681 100644 --- a/app/soapbox/features/ui/components/modal_root.js +++ b/app/soapbox/features/ui/components/modal_root.js @@ -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 { diff --git a/app/soapbox/features/ui/components/profile_info_panel.js b/app/soapbox/features/ui/components/profile_info_panel.js index 4af0d84c4..cb8a7ed76 100644 --- a/app/soapbox/features/ui/components/profile_info_panel.js +++ b/app/soapbox/features/ui/components/profile_info_panel.js @@ -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 { ))} - {fields.map((pair, i) => ( -
-
+ {fields.map((pair, i) => + isTicker(pair.get('name', '')) ? ( + + ) : ( +
+
-
- {pair.get('verified_at') && } -
-
- ))} +
+ {pair.get('verified_at') && } +
+
+ ), + )} )} diff --git a/app/soapbox/features/ui/index.js b/app/soapbox/features/ui/index.js index 20b2ecf58..9595bf2a5 100644 --- a/app/soapbox/features/ui/index.js +++ b/app/soapbox/features/ui/index.js @@ -92,6 +92,7 @@ import { AwaitingApproval, Reports, ModerationLog, + CryptoDonate, } from './util/async-components'; // Dummy import, to make sure that ends up in the application bundle. @@ -289,6 +290,8 @@ class SwitchingColumnsArea extends React.PureComponent { + + ); diff --git a/app/soapbox/features/ui/util/async-components.js b/app/soapbox/features/ui/util/async-components.js index cc5566937..dc06d1677 100644 --- a/app/soapbox/features/ui/util/async-components.js +++ b/app/soapbox/features/ui/util/async-components.js @@ -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'); +} diff --git a/app/styles/application.scss b/app/styles/application.scss index 366b7fcae..457a4faf4 100644 --- a/app/styles/application.scss +++ b/app/styles/application.scss @@ -81,6 +81,7 @@ @import 'components/server-info'; @import 'components/admin'; @import 'components/backups'; +@import 'components/crypto-donate'; // Holiday @import 'holiday/halloween'; diff --git a/app/styles/components/crypto-donate.scss b/app/styles/components/crypto-donate.scss new file mode 100644 index 000000000..10289e976 --- /dev/null +++ b/app/styles/components/crypto-donate.scss @@ -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; + } +} diff --git a/app/styles/forms.scss b/app/styles/forms.scss index ce782ac7d..4367a8efc 100644 --- a/app/styles/forms.scss +++ b/app/styles/forms.scss @@ -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; + } +} diff --git a/package.json b/package.json index a184ce62a..707c474be 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 91c283bcc..b4cce27af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"