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 || 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 || 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"