Merge branch 'next-patron' into 'next'
Next: restore Patron features, fix handful of small bugs See merge request soapbox-pub/soapbox-fe!1256
This commit is contained in:
commit
4363096e60
33 changed files with 385 additions and 261 deletions
17
app/soapbox/__fixtures__/patron-instance.json
Normal file
17
app/soapbox/__fixtures__/patron-instance.json
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"funding": {
|
||||
"amount": 3500,
|
||||
"patrons": 3,
|
||||
"currency": "usd",
|
||||
"interval": "monthly"
|
||||
},
|
||||
"goals": [
|
||||
{
|
||||
"amount": 20000,
|
||||
"currency": "usd",
|
||||
"interval": "monthly",
|
||||
"text": "I'll be able to afford an avocado."
|
||||
}
|
||||
],
|
||||
"url": "https://patron.gleasonator.com"
|
||||
}
|
4
app/soapbox/__fixtures__/patron-user.json
Normal file
4
app/soapbox/__fixtures__/patron-user.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"is_patron": true,
|
||||
"url": "https://gleasonator.com/users/dave"
|
||||
}
|
|
@ -274,6 +274,18 @@ export function unverifyUser(accountId) {
|
|||
};
|
||||
}
|
||||
|
||||
export function setDonor(accountId) {
|
||||
return (dispatch, getState) => {
|
||||
return dispatch(tagUsers([accountId], ['donor']));
|
||||
};
|
||||
}
|
||||
|
||||
export function removeDonor(accountId) {
|
||||
return (dispatch, getState) => {
|
||||
return dispatch(untagUsers([accountId], ['donor']));
|
||||
};
|
||||
}
|
||||
|
||||
export function addPermission(accountIds, permissionGroup) {
|
||||
return (dispatch, getState) => {
|
||||
const nicknames = nicknamesFromIds(getState, accountIds);
|
||||
|
|
|
@ -1,13 +1,26 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
const Badge = (props: any) => (
|
||||
<span data-testid='badge' className={'badge badge--' + props.slug}>{props.title}</span>
|
||||
interface IBadge {
|
||||
title: string,
|
||||
slug: 'patron' | 'donor' | 'admin' | 'moderator' | 'bot' | 'opaque',
|
||||
}
|
||||
|
||||
/** Badge to display on a user's profile. */
|
||||
const Badge: React.FC<IBadge> = ({ title, slug }) => (
|
||||
<span
|
||||
data-testid='badge'
|
||||
className={classNames('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium text-white', {
|
||||
'bg-fuchsia-700': slug === 'patron',
|
||||
'bg-yellow-500': slug === 'donor',
|
||||
'bg-black': slug === 'admin',
|
||||
'bg-cyan-600': slug === 'moderator',
|
||||
'bg-gray-100 text-gray-800': slug === 'bot',
|
||||
'bg-white bg-opacity-75 text-gray-900': slug === 'opaque',
|
||||
})}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
);
|
||||
|
||||
Badge.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
slug: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default Badge;
|
||||
|
|
|
@ -6,8 +6,8 @@ import { spring } from 'react-motion';
|
|||
import Overlay from 'react-overlays/lib/Overlay';
|
||||
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { IconButton } from 'soapbox/components/ui';
|
||||
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
||||
import Motion from 'soapbox/features/ui/util/optional_motion';
|
||||
|
||||
import type { Status } from 'soapbox/types/entities';
|
||||
|
@ -177,7 +177,7 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
|
|||
const { text, href, to, newTab, isLogout, icon, destructive } = option;
|
||||
|
||||
return (
|
||||
<li className={classNames('dropdown-menu__item', { destructive })} key={`${text}-${i}`}>
|
||||
<li className={classNames('dropdown-menu__item truncate', { destructive })} key={`${text}-${i}`}>
|
||||
<a
|
||||
href={href || to || '#'}
|
||||
role='button'
|
||||
|
@ -190,8 +190,8 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
|
|||
target={newTab ? '_blank' : undefined}
|
||||
data-method={isLogout ? 'delete' : undefined}
|
||||
>
|
||||
{icon && <Icon src={icon} />}
|
||||
{text}
|
||||
{icon && <SvgIcon src={icon} className='mr-3 h-5 w-5 flex-none' />}
|
||||
<span className='truncate'>{text}</span>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
|
|
|
@ -36,6 +36,10 @@ const getBadges = (account) => {
|
|||
badges.push(<Badge key='patron' slug='patron' title='Patron' />);
|
||||
}
|
||||
|
||||
if (account.donor) {
|
||||
badges.push(<Badge key='donor' slug='donor' title='Donor' />);
|
||||
}
|
||||
|
||||
return badges;
|
||||
};
|
||||
|
||||
|
|
|
@ -5,8 +5,8 @@ interface IProgressBar {
|
|||
}
|
||||
|
||||
const ProgressBar: React.FC<IProgressBar> = ({ progress }) => (
|
||||
<div className='progress-bar'>
|
||||
<div className='progress-bar__progress' style={{ width: `${Math.floor(progress*100)}%` }} />
|
||||
<div className='h-2 w-full rounded-md bg-gray-300 dark:bg-slate-700 overflow-hidden'>
|
||||
<div className='h-full bg-primary-500' style={{ width: `${Math.floor(progress*100)}%` }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
|
@ -31,7 +31,7 @@ const Main: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, classN
|
|||
|
||||
const Aside: React.FC = ({ children }) => (
|
||||
<aside className='hidden xl:block xl:col-span-3'>
|
||||
<StickyBox offsetTop={80} className='space-y-6 pb-4' >
|
||||
<StickyBox offsetTop={80} className='space-y-6 pb-12' >
|
||||
{children}
|
||||
</StickyBox>
|
||||
</aside>
|
||||
|
|
|
@ -54,6 +54,8 @@ const messages = defineMessages({
|
|||
deleteUser: { id: 'admin.users.actions.delete_user', defaultMessage: 'Delete @{name}' },
|
||||
verifyUser: { id: 'admin.users.actions.verify_user', defaultMessage: 'Verify @{name}' },
|
||||
unverifyUser: { id: 'admin.users.actions.unverify_user', defaultMessage: 'Unverify @{name}' },
|
||||
setDonor: { id: 'admin.users.actions.set_donor', defaultMessage: 'Set @{name} as a donor' },
|
||||
removeDonor: { id: 'admin.users.actions.remove_donor', defaultMessage: 'Remove @{name} as a donor' },
|
||||
promoteToAdmin: { id: 'admin.users.actions.promote_to_admin', defaultMessage: 'Promote @{name} to an admin' },
|
||||
promoteToModerator: { id: 'admin.users.actions.promote_to_moderator', defaultMessage: 'Promote @{name} to a moderator' },
|
||||
demoteToModerator: { id: 'admin.users.actions.demote_to_moderator', defaultMessage: 'Demote @{name} to a moderator' },
|
||||
|
@ -386,20 +388,34 @@ class Header extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
if (account.get('verified')) {
|
||||
if (account.verified) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.unverifyUser, { name: account.get('username') }),
|
||||
text: intl.formatMessage(messages.unverifyUser, { name: account.username }),
|
||||
action: this.props.onUnverifyUser,
|
||||
icon: require('@tabler/icons/icons/check.svg'),
|
||||
});
|
||||
} else {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.verifyUser, { name: account.get('username') }),
|
||||
text: intl.formatMessage(messages.verifyUser, { name: account.username }),
|
||||
action: this.props.onVerifyUser,
|
||||
icon: require('@tabler/icons/icons/check.svg'),
|
||||
});
|
||||
}
|
||||
|
||||
if (account.donor) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.removeDonor, { name: account.username }),
|
||||
action: this.props.onRemoveDonor,
|
||||
icon: require('@tabler/icons/icons/coin.svg'),
|
||||
});
|
||||
} else {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.setDonor, { name: account.username }),
|
||||
action: this.props.onSetDonor,
|
||||
icon: require('@tabler/icons/icons/coin.svg'),
|
||||
});
|
||||
}
|
||||
|
||||
if (features.suggestionsV2 && meAccount.admin) {
|
||||
if (account.getIn(['pleroma', 'is_suggested'])) {
|
||||
menu.push({
|
||||
|
@ -614,7 +630,7 @@ class Header extends ImmutablePureComponent {
|
|||
return (
|
||||
<Comp key={idx} {...itemProps} className='group'>
|
||||
<div className='flex items-center'>
|
||||
<SvgIcon src={menuItem.icon} className='mr-3 h-5 w-5 text-gray-400 group-hover:text-gray-500' />
|
||||
<SvgIcon src={menuItem.icon} className='mr-3 h-5 w-5 text-gray-400 flex-none group-hover:text-gray-500' />
|
||||
|
||||
<div className='truncate'>{menuItem.text}</div>
|
||||
</div>
|
||||
|
|
|
@ -110,6 +110,14 @@ class Header extends ImmutablePureComponent {
|
|||
this.props.onUnverifyUser(this.props.account);
|
||||
}
|
||||
|
||||
handleSetDonor = () => {
|
||||
this.props.onSetDonor(this.props.account);
|
||||
}
|
||||
|
||||
handleRemoveDonor = () => {
|
||||
this.props.onRemoveDonor(this.props.account);
|
||||
}
|
||||
|
||||
handlePromoteToAdmin = () => {
|
||||
this.props.onPromoteToAdmin(this.props.account);
|
||||
}
|
||||
|
@ -163,6 +171,8 @@ class Header extends ImmutablePureComponent {
|
|||
onDeleteUser={this.handleDeleteUser}
|
||||
onVerifyUser={this.handleVerifyUser}
|
||||
onUnverifyUser={this.handleUnverifyUser}
|
||||
onSetDonor={this.handleSetDonor}
|
||||
onRemoveDonor={this.handleRemoveDonor}
|
||||
onPromoteToAdmin={this.handlePromoteToAdmin}
|
||||
onPromoteToModerator={this.handlePromoteToModerator}
|
||||
onDemoteToUser={this.handleDemoteToUser}
|
||||
|
|
|
@ -18,6 +18,8 @@ import {
|
|||
import {
|
||||
verifyUser,
|
||||
unverifyUser,
|
||||
setDonor,
|
||||
removeDonor,
|
||||
promoteToAdmin,
|
||||
promoteToModerator,
|
||||
demoteToUser,
|
||||
|
@ -47,6 +49,8 @@ const messages = defineMessages({
|
|||
blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
|
||||
userVerified: { id: 'admin.users.user_verified_message', defaultMessage: '@{acct} was verified' },
|
||||
userUnverified: { id: 'admin.users.user_unverified_message', defaultMessage: '@{acct} was unverified' },
|
||||
setDonor: { id: 'admin.users.set_donor_message', defaultMessage: '@{acct} was set as a donor' },
|
||||
removeDonor: { id: 'admin.users.remove_donor_message', defaultMessage: '@{acct} was removed as a donor' },
|
||||
promotedToAdmin: { id: 'admin.users.actions.promote_to_admin_message', defaultMessage: '@{acct} was promoted to an admin' },
|
||||
promotedToModerator: { id: 'admin.users.actions.promote_to_moderator_message', defaultMessage: '@{acct} was promoted to a moderator' },
|
||||
demotedToModerator: { id: 'admin.users.actions.demote_to_moderator_message', defaultMessage: '@{acct} was demoted to a moderator' },
|
||||
|
@ -206,6 +210,23 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||
.catch(() => {});
|
||||
},
|
||||
|
||||
onSetDonor(account) {
|
||||
const message = intl.formatMessage(messages.setDonor, { acct: account.get('acct') });
|
||||
|
||||
dispatch(setDonor(account.get('id')))
|
||||
.then(() => dispatch(snackbar.success(message)))
|
||||
.catch(() => {});
|
||||
},
|
||||
|
||||
onRemoveDonor(account) {
|
||||
const message = intl.formatMessage(messages.removeDonor, { acct: account.get('acct') });
|
||||
|
||||
dispatch(removeDonor(account.get('id')))
|
||||
.then(() => dispatch(snackbar.success(message)))
|
||||
.catch(() => {});
|
||||
},
|
||||
|
||||
|
||||
onPromoteToAdmin(account) {
|
||||
const message = intl.formatMessage(messages.promotedToAdmin, { acct: account.get('acct') });
|
||||
|
||||
|
|
|
@ -42,12 +42,12 @@ const CryptoAddress: React.FC<ICryptoAddress> = (props): JSX.Element => {
|
|||
|
||||
<HStack alignItems='center' className='ml-auto'>
|
||||
<a className='text-gray-500 ml-1' href='#' onClick={handleModalClick}>
|
||||
<Icon src={require('@tabler/icons/icons/qrcode.svg')} />
|
||||
<Icon src={require('@tabler/icons/icons/qrcode.svg')} size={20} />
|
||||
</a>
|
||||
|
||||
{explorerUrl && (
|
||||
<a className='text-gray-500 ml-1' href={explorerUrl} target='_blank'>
|
||||
<Icon src={require('@tabler/icons/icons/external-link.svg')} />
|
||||
<Icon src={require('@tabler/icons/icons/external-link.svg')} size={20} />
|
||||
</a>
|
||||
)}
|
||||
</HStack>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
import { Column, Stack } from 'soapbox/components/ui';
|
||||
import Accordion from 'soapbox/features/ui/components/accordion';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
|
@ -18,23 +18,21 @@ const CryptoDonate: React.FC = (): JSX.Element => {
|
|||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} withHeader>
|
||||
<div className='crypto-donate'>
|
||||
<div className='explanation-box'>
|
||||
<Accordion
|
||||
headline={<FormattedMessage id='crypto_donate.explanation_box.title' defaultMessage='Sending cryptocurrency donations' />}
|
||||
expanded={explanationBoxExpanded}
|
||||
onToggle={toggleExplanationBox}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='crypto_donate.explanation_box.message'
|
||||
defaultMessage='{siteTitle} accepts cryptocurrency donations. You may send a donation to any of the addresses below. Thank you for your support!'
|
||||
values={{ siteTitle }}
|
||||
/>
|
||||
</Accordion>
|
||||
<Stack space={5}>
|
||||
<Accordion
|
||||
headline={<FormattedMessage id='crypto_donate.explanation_box.title' defaultMessage='Sending cryptocurrency donations' />}
|
||||
expanded={explanationBoxExpanded}
|
||||
onToggle={toggleExplanationBox}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='crypto_donate.explanation_box.message'
|
||||
defaultMessage='{siteTitle} accepts cryptocurrency donations. You may send a donation to any of the addresses below. Thank you for your support!'
|
||||
values={{ siteTitle }}
|
||||
/>
|
||||
</Accordion>
|
||||
|
||||
</div>
|
||||
<SiteWallet />
|
||||
</div>
|
||||
</Stack>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -2,6 +2,7 @@ import classNames from 'classnames';
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Text } from 'soapbox/components/ui';
|
||||
import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
|
||||
|
||||
import type { Menu } from 'soapbox/components/dropdown_menu';
|
||||
|
@ -40,10 +41,10 @@ const Accordion: React.FC<IAccordion> = ({ headline, children, menu, expanded =
|
|||
onClick={handleToggle}
|
||||
title={intl.formatMessage(expanded ? messages.collapse : messages.expand)}
|
||||
>
|
||||
{headline}
|
||||
<Text weight='bold'>{headline}</Text>
|
||||
</button>
|
||||
<div className='accordion__content'>
|
||||
{children}
|
||||
<Text>{children}</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,78 +0,0 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
import React from 'react';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { injectIntl } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { fetchPatronInstance } from 'soapbox/actions/patron';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
|
||||
import ProgressBar from '../../../components/progress_bar';
|
||||
|
||||
const moneyFormat = amount => (
|
||||
new Intl
|
||||
.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'usd',
|
||||
notation: 'compact',
|
||||
})
|
||||
.format(amount/100)
|
||||
);
|
||||
|
||||
class FundingPanel extends ImmutablePureComponent {
|
||||
|
||||
componentDidMount() {
|
||||
this.props.dispatch(fetchPatronInstance());
|
||||
}
|
||||
|
||||
render() {
|
||||
const { patron } = this.props;
|
||||
if (patron.isEmpty()) return null;
|
||||
|
||||
const amount = patron.getIn(['funding', 'amount']);
|
||||
const goal = patron.getIn(['goals', '0', 'amount']);
|
||||
const goal_text = patron.getIn(['goals', '0', 'text']);
|
||||
const goal_reached = amount >= goal;
|
||||
let ratio_text;
|
||||
|
||||
if (goal_reached) {
|
||||
ratio_text = <><strong>{moneyFormat(goal)}</strong> per month<span className='funding-panel__reached'>— reached!</span></>;
|
||||
} else {
|
||||
ratio_text = <><strong>{moneyFormat(amount)} out of {moneyFormat(goal)}</strong> per month</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='wtf-panel funding-panel'>
|
||||
<div className='wtf-panel-header'>
|
||||
<Icon src={require('@tabler/icons/icons/chart-line.svg')} className='wtf-panel-header__icon' />
|
||||
<span className='wtf-panel-header__label'>
|
||||
<span>Funding Goal</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className='wtf-panel__content'>
|
||||
<div className='funding-panel__ratio'>
|
||||
{ratio_text}
|
||||
</div>
|
||||
<ProgressBar progress={amount/goal} />
|
||||
<div className='funding-panel__description'>
|
||||
{goal_text}
|
||||
</div>
|
||||
<a className='button' href={patron.get('url')}>Donate</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
patron: state.getIn(['patron', 'instance'], ImmutableMap()),
|
||||
};
|
||||
};
|
||||
|
||||
export default injectIntl(
|
||||
connect(mapStateToProps, null, null, {
|
||||
forwardRef: true,
|
||||
},
|
||||
)(FundingPanel));
|
77
app/soapbox/features/ui/components/funding_panel.tsx
Normal file
77
app/soapbox/features/ui/components/funding_panel.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { fetchPatronInstance } from 'soapbox/actions/patron';
|
||||
import { Widget, Button, Text } from 'soapbox/components/ui';
|
||||
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
import ProgressBar from '../../../components/progress_bar';
|
||||
|
||||
/** Open link in a new tab. */
|
||||
// https://stackoverflow.com/a/28374344/8811886
|
||||
const openInNewTab = (href: string): void => {
|
||||
Object.assign(document.createElement('a'), {
|
||||
target: '_blank',
|
||||
href: href,
|
||||
}).click();
|
||||
};
|
||||
|
||||
/** Formats integer to USD string. */
|
||||
const moneyFormat = (amount: number): string => (
|
||||
new Intl
|
||||
.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'usd',
|
||||
notation: 'compact',
|
||||
})
|
||||
.format(amount/100)
|
||||
);
|
||||
|
||||
const FundingPanel: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const patron = useAppSelector(state => state.patron.instance);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchPatronInstance());
|
||||
}, []);
|
||||
|
||||
if (patron.funding.isEmpty() || patron.goals.isEmpty()) return null;
|
||||
|
||||
const amount = patron.getIn(['funding', 'amount']) as number;
|
||||
const goal = patron.getIn(['goals', '0', 'amount']) as number;
|
||||
const goalText = patron.getIn(['goals', '0', 'text']) as string;
|
||||
const goalReached = amount >= goal;
|
||||
let ratioText;
|
||||
|
||||
if (goalReached) {
|
||||
ratioText = <><strong>{moneyFormat(goal)}</strong> per month<span className='funding-panel__reached'>— reached!</span></>;
|
||||
} else {
|
||||
ratioText = <><strong>{moneyFormat(amount)} out of {moneyFormat(goal)}</strong> per month</>;
|
||||
}
|
||||
|
||||
const handleDonateClick = () => {
|
||||
openInNewTab(patron.url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Widget
|
||||
title={<FormattedMessage id='patron.title' defaultMessage='Funding Goal' />}
|
||||
onActionClick={handleDonateClick}
|
||||
>
|
||||
<div className='funding-panel__ratio'>
|
||||
<Text>{ratioText}</Text>
|
||||
</div>
|
||||
<ProgressBar progress={amount/goal} />
|
||||
<div className='funding-panel__description'>
|
||||
<Text>{goalText}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Button theme='ghost' onClick={handleDonateClick}>
|
||||
<FormattedMessage id='patron.donate' defaultMessage='Donate' />
|
||||
</Button>
|
||||
</div>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
export default FundingPanel;
|
|
@ -75,7 +75,7 @@ const LinkFooter: React.FC = (): JSX.Element => {
|
|||
defaultMessage='{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).'
|
||||
values={{
|
||||
code_name: sourceCode.displayName,
|
||||
code_link: <a href={sourceCode.url} rel='noopener' target='_blank'>{sourceCode.repository}</a>,
|
||||
code_link: <Text theme='subtle'><a className='underline' href={sourceCode.url} rel='noopener' target='_blank'>{sourceCode.repository}</a></Text>,
|
||||
code_version: sourceCode.version,
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -72,6 +72,10 @@ class ProfileInfoPanel extends ImmutablePureComponent {
|
|||
badges.push(<Badge slug='patron' title='Patron' key='patron' />);
|
||||
}
|
||||
|
||||
if (account.donor) {
|
||||
badges.push(<Badge slug='donor' title='Donor' key='donor' />);
|
||||
}
|
||||
|
||||
return badges;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,53 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import Video from 'soapbox/features/video';
|
||||
|
||||
export default @withRouter
|
||||
class VideoModal extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
media: ImmutablePropTypes.map.isRequired,
|
||||
status: ImmutablePropTypes.record,
|
||||
account: ImmutablePropTypes.record,
|
||||
time: PropTypes.number,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
history: PropTypes.object,
|
||||
};
|
||||
|
||||
handleStatusClick = e => {
|
||||
const { status, account } = this.props;
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.props.history.push(`/@${account.get('acct')}/posts/${status.get('id')}`);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { media, status, account, time, onClose } = this.props;
|
||||
|
||||
const link = status && account && <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>;
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal video-modal'>
|
||||
<div>
|
||||
<Video
|
||||
preview={media.get('preview_url')}
|
||||
blurhash={media.get('blurhash')}
|
||||
src={media.get('url')}
|
||||
startTime={time}
|
||||
onCloseVideo={onClose}
|
||||
link={link}
|
||||
detailed
|
||||
alt={media.get('description')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
49
app/soapbox/features/ui/components/video_modal.tsx
Normal file
49
app/soapbox/features/ui/components/video_modal.tsx
Normal file
|
@ -0,0 +1,49 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import Video from 'soapbox/features/video';
|
||||
|
||||
import type { Status, Account, Attachment } from 'soapbox/types/entities';
|
||||
|
||||
interface IVideoModal {
|
||||
media: Attachment,
|
||||
status: Status,
|
||||
account: Account,
|
||||
time: number,
|
||||
onClose: () => void,
|
||||
}
|
||||
|
||||
const VideoModal: React.FC<IVideoModal> = ({ status, account, media, time, onClose }) => {
|
||||
const history = useHistory();
|
||||
|
||||
const handleStatusClick: React.MouseEventHandler = e => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
history.push(`/@${account.acct}/posts/${status.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const link = status && account && (
|
||||
<a href={status.url} onClick={handleStatusClick}>
|
||||
<FormattedMessage id='lightbox.view_context' defaultMessage='View context' />
|
||||
</a>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='block w-full max-w-xl mx-auto overflow-hidden text-left align-middle transition-all transform shadow-xl rounded-2xl pointer-events-auto'>
|
||||
<Video
|
||||
preview={media.preview_url}
|
||||
blurhash={media.blurhash}
|
||||
src={media.url}
|
||||
startTime={time}
|
||||
onCloseVideo={onClose}
|
||||
link={link}
|
||||
detailed
|
||||
alt={media.description}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoModal;
|
|
@ -614,8 +614,8 @@ class Video extends React.PureComponent {
|
|||
<div className='video-player__buttons right'>
|
||||
{(sensitive && !onCloseVideo) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon src={require('@tabler/icons/icons/eye-off.svg')} /></button>}
|
||||
{(!fullscreen && onOpenVideo) && <button type='button' title={intl.formatMessage(messages.expand)} aria-label={intl.formatMessage(messages.expand)} className='player-button' onClick={this.handleOpenVideo}><Icon src={require('@tabler/icons/icons/maximize.svg')} /></button>}
|
||||
{onCloseVideo && <button type='button' title={intl.formatMessage(messages.close)} aria-label={intl.formatMessage(messages.close)} className='player-button' onClick={this.handleCloseVideo}><Icon src={require('@tabler/icons/icons/minimize.svg')} /></button>}
|
||||
<button type='button' title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} className='player-button' onClick={this.toggleFullscreen}><Icon src={fullscreen ? require('@tabler/icons/icons/minimize.svg') : require('@tabler/icons/icons/arrows-maximize.svg')} /></button>
|
||||
{/* onCloseVideo && <button type='button' title={intl.formatMessage(messages.close)} aria-label={intl.formatMessage(messages.close)} className='player-button' onClick={this.handleCloseVideo}><Icon src={require('@tabler/icons/icons/x.svg')} /></button> */}
|
||||
<button type='button' title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} className='player-button' onClick={this.toggleFullscreen}><Icon src={fullscreen ? require('@tabler/icons/icons/arrows-minimize.svg') : require('@tabler/icons/icons/arrows-maximize.svg')} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -16,6 +16,7 @@ import { normalizeEmoji } from 'soapbox/normalizers/emoji';
|
|||
import { unescapeHTML } from 'soapbox/utils/html';
|
||||
import { mergeDefined, makeEmojiMap } from 'soapbox/utils/normalizers';
|
||||
|
||||
import type { PatronAccount } from 'soapbox/reducers/patron';
|
||||
import type { Emoji, Field, EmbeddedEntity } from 'soapbox/types/entities';
|
||||
|
||||
// https://docs.joinmastodon.org/entities/account/
|
||||
|
@ -54,10 +55,11 @@ export const AccountRecord = ImmutableRecord({
|
|||
admin: false,
|
||||
display_name_html: '',
|
||||
domain: '',
|
||||
donor: false,
|
||||
moderator: false,
|
||||
note_emojified: '',
|
||||
note_plain: '',
|
||||
patron: ImmutableMap<string, any>(),
|
||||
patron: null as PatronAccount | null,
|
||||
relationship: ImmutableList<ImmutableMap<string, any>>(),
|
||||
should_refetch: false,
|
||||
staff: false,
|
||||
|
@ -91,7 +93,7 @@ const normalizePleromaLegacyFields = (account: ImmutableMap<string, any>) => {
|
|||
});
|
||||
};
|
||||
|
||||
// Add avatar, if missing
|
||||
/** Add avatar, if missing */
|
||||
const normalizeAvatar = (account: ImmutableMap<string, any>) => {
|
||||
const avatar = account.get('avatar');
|
||||
const avatarStatic = account.get('avatar_static');
|
||||
|
@ -103,7 +105,7 @@ const normalizeAvatar = (account: ImmutableMap<string, any>) => {
|
|||
});
|
||||
};
|
||||
|
||||
// Add header, if missing
|
||||
/** Add header, if missing */
|
||||
const normalizeHeader = (account: ImmutableMap<string, any>) => {
|
||||
const header = account.get('header');
|
||||
const headerStatic = account.get('header_static');
|
||||
|
@ -115,18 +117,18 @@ const normalizeHeader = (account: ImmutableMap<string, any>) => {
|
|||
});
|
||||
};
|
||||
|
||||
// Normalize custom fields
|
||||
/** Normalize custom fields */
|
||||
const normalizeFields = (account: ImmutableMap<string, any>) => {
|
||||
return account.update('fields', ImmutableList(), fields => fields.map(FieldRecord));
|
||||
};
|
||||
|
||||
// Normalize emojis
|
||||
/** Normalize emojis */
|
||||
const normalizeEmojis = (entity: ImmutableMap<string, any>) => {
|
||||
const emojis = entity.get('emojis', ImmutableList()).map(normalizeEmoji);
|
||||
return entity.set('emojis', emojis);
|
||||
};
|
||||
|
||||
// Normalize Pleroma/Fedibird birthday
|
||||
/** Normalize Pleroma/Fedibird birthday */
|
||||
const normalizeBirthday = (account: ImmutableMap<string, any>) => {
|
||||
const birthday = [
|
||||
account.getIn(['pleroma', 'birthday']),
|
||||
|
@ -136,13 +138,13 @@ const normalizeBirthday = (account: ImmutableMap<string, any>) => {
|
|||
return account.set('birthday', birthday);
|
||||
};
|
||||
|
||||
// Get Pleroma tags
|
||||
/** Get Pleroma tags */
|
||||
const getTags = (account: ImmutableMap<string, any>): ImmutableList<any> => {
|
||||
const tags = account.getIn(['pleroma', 'tags']);
|
||||
return ImmutableList(ImmutableList.isList(tags) ? tags : []);
|
||||
};
|
||||
|
||||
// Normalize Truth Social/Pleroma verified
|
||||
/** Normalize Truth Social/Pleroma verified */
|
||||
const normalizeVerified = (account: ImmutableMap<string, any>) => {
|
||||
return account.update('verified', verified => {
|
||||
return [
|
||||
|
@ -152,7 +154,12 @@ const normalizeVerified = (account: ImmutableMap<string, any>) => {
|
|||
});
|
||||
};
|
||||
|
||||
// Normalize Fedibird/Truth Social/Pleroma location
|
||||
/** Get donor status from tags. */
|
||||
const normalizeDonor = (account: ImmutableMap<string, any>) => {
|
||||
return account.set('donor', getTags(account).includes('donor'));
|
||||
};
|
||||
|
||||
/** Normalize Fedibird/Truth Social/Pleroma location */
|
||||
const normalizeLocation = (account: ImmutableMap<string, any>) => {
|
||||
return account.update('location', location => {
|
||||
return [
|
||||
|
@ -163,20 +170,20 @@ const normalizeLocation = (account: ImmutableMap<string, any>) => {
|
|||
});
|
||||
};
|
||||
|
||||
// Set username from acct, if applicable
|
||||
/** Set username from acct, if applicable */
|
||||
const fixUsername = (account: ImmutableMap<string, any>) => {
|
||||
const acct = account.get('acct') || '';
|
||||
const username = account.get('username') || '';
|
||||
return account.set('username', username || acct.split('@')[0]);
|
||||
};
|
||||
|
||||
// Set display name from username, if applicable
|
||||
/** Set display name from username, if applicable */
|
||||
const fixDisplayName = (account: ImmutableMap<string, any>) => {
|
||||
const displayName = account.get('display_name') || '';
|
||||
return account.set('display_name', displayName.trim().length === 0 ? account.get('username') : displayName);
|
||||
};
|
||||
|
||||
// Emojification, etc
|
||||
/** Emojification, etc */
|
||||
const addInternalFields = (account: ImmutableMap<string, any>) => {
|
||||
const emojiMap = makeEmojiMap(account.get('emojis'));
|
||||
|
||||
|
@ -257,6 +264,7 @@ export const normalizeAccount = (account: Record<string, any>) => {
|
|||
normalizeHeader(account);
|
||||
normalizeFields(account);
|
||||
normalizeVerified(account);
|
||||
normalizeDonor(account);
|
||||
normalizeBirthday(account);
|
||||
normalizeLocation(account);
|
||||
normalizeFqn(account);
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
TrendsPanel,
|
||||
SignUpPanel,
|
||||
PromoPanel,
|
||||
FundingPanel,
|
||||
CryptoDonatePanel,
|
||||
BirthdayPanel,
|
||||
} from 'soapbox/features/ui/util/async-components';
|
||||
|
@ -33,7 +34,7 @@ const mapStateToProps = state => {
|
|||
return {
|
||||
me,
|
||||
account: state.getIn(['accounts', me]),
|
||||
showFundingPanel: hasPatron,
|
||||
hasPatron,
|
||||
hasCrypto,
|
||||
cryptoLimit,
|
||||
features,
|
||||
|
@ -49,7 +50,7 @@ class HomePage extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { me, children, account, features, hasCrypto, cryptoLimit } = this.props;
|
||||
const { me, children, account, features, hasPatron, hasCrypto, cryptoLimit } = this.props;
|
||||
|
||||
const acct = account ? account.get('acct') : '';
|
||||
|
||||
|
@ -90,6 +91,11 @@ class HomePage extends ImmutablePureComponent {
|
|||
{Component => <Component limit={3} />}
|
||||
</BundleContainer>
|
||||
)}
|
||||
{hasPatron && (
|
||||
<BundleContainer fetchComponent={FundingPanel}>
|
||||
{Component => <Component />}
|
||||
</BundleContainer>
|
||||
)}
|
||||
{hasCrypto && cryptoLimit > 0 && (
|
||||
<BundleContainer fetchComponent={CryptoDonatePanel}>
|
||||
{Component => <Component limit={cryptoLimit} />}
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import { Map as ImmutableMap, fromJS } from 'immutable';
|
||||
import { Record as ImmutableRecord } from 'immutable';
|
||||
|
||||
import { PATRON_ACCOUNT_FETCH_SUCCESS } from '../../actions/patron';
|
||||
import reducer from '../patron';
|
||||
|
||||
describe('patron reducer', () => {
|
||||
it('should return the initial state', () => {
|
||||
expect(reducer(undefined, {})).toEqual(ImmutableMap());
|
||||
const result = reducer(undefined, {});
|
||||
expect(ImmutableRecord.isRecord(result)).toBe(true);
|
||||
expect(result.instance.url).toBe('');
|
||||
});
|
||||
|
||||
describe('PATRON_ACCOUNT_FETCH_SUCCESS', () => {
|
||||
|
@ -17,14 +19,15 @@ describe('patron reducer', () => {
|
|||
is_patron: true,
|
||||
},
|
||||
};
|
||||
const state = ImmutableMap();
|
||||
expect(reducer(state, action)).toEqual(fromJS({
|
||||
accounts: {
|
||||
'https://gleasonator.com/users/alex': {
|
||||
is_patron: true,
|
||||
},
|
||||
|
||||
const result = reducer(undefined, action);
|
||||
|
||||
expect(result.accounts.toJS()).toEqual({
|
||||
'https://gleasonator.com/users/alex': {
|
||||
is_patron: true,
|
||||
url: 'https://gleasonator.com/users/alex',
|
||||
},
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -91,6 +91,17 @@ const addTags = (
|
|||
state.updateIn([id, 'pleroma', 'tags'], ImmutableList(), v =>
|
||||
ImmutableOrderedSet(fromJS(v)).union(tags).toList(),
|
||||
);
|
||||
|
||||
tags.forEach(tag => {
|
||||
switch(tag) {
|
||||
case 'verified':
|
||||
state.setIn([id, 'verified'], true);
|
||||
break;
|
||||
case 'donor':
|
||||
state.setIn([id, 'donor'], true);
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
@ -105,6 +116,17 @@ const removeTags = (
|
|||
state.updateIn([id, 'pleroma', 'tags'], ImmutableList(), v =>
|
||||
ImmutableOrderedSet(fromJS(v)).subtract(tags).toList(),
|
||||
);
|
||||
|
||||
tags.forEach(tag => {
|
||||
switch(tag) {
|
||||
case 'verified':
|
||||
state.setIn([id, 'verified'], false);
|
||||
break;
|
||||
case 'donor':
|
||||
state.setIn([id, 'donor'], false);
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
import { Map as ImmutableMap, fromJS } from 'immutable';
|
||||
|
||||
import {
|
||||
PATRON_INSTANCE_FETCH_SUCCESS,
|
||||
PATRON_ACCOUNT_FETCH_SUCCESS,
|
||||
} from '../actions/patron';
|
||||
|
||||
const initialState = ImmutableMap();
|
||||
|
||||
const normalizePatronAccount = (state, account) => {
|
||||
const normalized = fromJS(account).deleteAll(['url']);
|
||||
return state.setIn(['accounts', account.url], normalized);
|
||||
};
|
||||
|
||||
export default function patron(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case PATRON_INSTANCE_FETCH_SUCCESS:
|
||||
return state.set('instance', fromJS(action.instance));
|
||||
case PATRON_ACCOUNT_FETCH_SUCCESS:
|
||||
return normalizePatronAccount(state, action.account);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
50
app/soapbox/reducers/patron.ts
Normal file
50
app/soapbox/reducers/patron.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import {
|
||||
Map as ImmutableMap,
|
||||
List as ImmutableList,
|
||||
Record as ImmutableRecord,
|
||||
fromJS,
|
||||
} from 'immutable';
|
||||
|
||||
import {
|
||||
PATRON_INSTANCE_FETCH_SUCCESS,
|
||||
PATRON_ACCOUNT_FETCH_SUCCESS,
|
||||
} from '../actions/patron';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
|
||||
const PatronAccountRecord = ImmutableRecord({
|
||||
is_patron: false,
|
||||
url: '',
|
||||
});
|
||||
|
||||
const PatronInstanceRecord = ImmutableRecord({
|
||||
funding: ImmutableMap(),
|
||||
goals: ImmutableList(),
|
||||
url: '',
|
||||
});
|
||||
|
||||
const ReducerRecord = ImmutableRecord({
|
||||
instance: PatronInstanceRecord() as PatronInstance,
|
||||
accounts: ImmutableMap<string, PatronAccount>(),
|
||||
});
|
||||
|
||||
type State = ReturnType<typeof ReducerRecord>;
|
||||
|
||||
export type PatronAccount = ReturnType<typeof PatronAccountRecord>;
|
||||
export type PatronInstance = ReturnType<typeof PatronInstanceRecord>;
|
||||
|
||||
const normalizePatronAccount = (state: State, account: Record<string, any>) => {
|
||||
const normalized = PatronAccountRecord(account);
|
||||
return state.setIn(['accounts', normalized.url], normalized);
|
||||
};
|
||||
|
||||
export default function patron(state = ReducerRecord(), action: AnyAction) {
|
||||
switch(action.type) {
|
||||
case PATRON_INSTANCE_FETCH_SUCCESS:
|
||||
return state.set('instance', PatronInstanceRecord(ImmutableMap(fromJS(action.instance))));
|
||||
case PATRON_ACCOUNT_FETCH_SUCCESS:
|
||||
return normalizePatronAccount(state, action.account);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -26,7 +26,7 @@ const getAccountMeta = (state: RootState, id: string) => state.accounts_
|
|||
const getAccountAdminData = (state: RootState, id: string) => state.admin.users.get(id);
|
||||
const getAccountPatron = (state: RootState, id: string) => {
|
||||
const url = state.accounts.get(id)?.url;
|
||||
return state.patron.getIn(['accounts', url]);
|
||||
return url ? state.patron.accounts.get(url) : null;
|
||||
};
|
||||
|
||||
export const makeGetAccount = () => {
|
||||
|
@ -47,7 +47,7 @@ export const makeGetAccount = () => {
|
|||
map.set('pleroma', meta.get('pleroma', ImmutableMap()).merge(base.get('pleroma', ImmutableMap()))); // Lol, thanks Pleroma
|
||||
map.set('relationship', relationship);
|
||||
map.set('moved', moved || null);
|
||||
map.set('patron', patron);
|
||||
map.set('patron', patron || null);
|
||||
map.setIn(['pleroma', 'admin'], admin);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -63,7 +63,6 @@
|
|||
@import 'components/getting-started';
|
||||
@import 'components/promo-panel';
|
||||
@import 'components/still-image';
|
||||
@import 'components/badge';
|
||||
@import 'components/theme-toggle';
|
||||
@import 'components/trends';
|
||||
@import 'components/wtf-panel';
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
}
|
||||
|
||||
.accordion {
|
||||
@apply text-black dark:text-white bg-gray-100 dark:bg-slate-800;
|
||||
@apply text-black dark:text-white bg-gray-100 dark:bg-slate-900;
|
||||
padding: 15px 20px;
|
||||
font-size: 14px;
|
||||
border-radius: 8px;
|
||||
|
@ -50,6 +50,7 @@
|
|||
margin-bottom: 10px !important;
|
||||
|
||||
&::after {
|
||||
@apply text-black dark:text-white;
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
.badge {
|
||||
@apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-primary-600 text-white;
|
||||
|
||||
&--patron {
|
||||
@apply bg-primary-600 text-white;
|
||||
}
|
||||
|
||||
&--admin {
|
||||
@apply bg-black;
|
||||
}
|
||||
|
||||
&--moderator {
|
||||
@apply bg-cyan-600 text-white;
|
||||
}
|
||||
|
||||
&--bot {
|
||||
@apply bg-gray-100 text-gray-800;
|
||||
}
|
||||
|
||||
&--opaque {
|
||||
@apply bg-white bg-opacity-75 text-gray-900;
|
||||
}
|
||||
}
|
|
@ -159,7 +159,7 @@
|
|||
}
|
||||
|
||||
.media-modal__button--active {
|
||||
background-color: var(--highlight-text-color);
|
||||
@apply bg-accent-500;
|
||||
}
|
||||
|
||||
.media-modal__close {
|
||||
|
|
|
@ -198,16 +198,3 @@ body.admin {
|
|||
padding: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
background: var(--background-color);
|
||||
overflow: hidden;
|
||||
|
||||
&__progress {
|
||||
height: 100%;
|
||||
background: var(--brand-color);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue