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:
Alex Gleason 2022-04-25 00:19:54 +00:00
commit 4363096e60
33 changed files with 385 additions and 261 deletions

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

View file

@ -0,0 +1,4 @@
{
"is_patron": true,
"url": "https://gleasonator.com/users/dave"
}

View file

@ -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);

View file

@ -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;

View file

@ -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>
);

View file

@ -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;
};

View file

@ -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>
);

View file

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

View file

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

View file

@ -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}

View file

@ -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') });

View file

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

View file

@ -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>
);
};

View file

@ -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>
);

View file

@ -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'>&mdash; 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));

View 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'>&mdash; 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;

View file

@ -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,
}}
/>

View file

@ -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;
}

View file

@ -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>
);
}
}

View 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;

View file

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

View file

@ -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);

View file

@ -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} />}

View file

@ -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',
},
}));
});
});
});
});

View file

@ -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;
}
});
});
});
};

View file

@ -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;
}
}

View 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;
}
}

View file

@ -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);
});
});

View file

@ -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';

View file

@ -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: '';
}
}

View file

@ -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;
}
}

View file

@ -159,7 +159,7 @@
}
.media-modal__button--active {
background-color: var(--highlight-text-color);
@apply bg-accent-500;
}
.media-modal__close {

View file

@ -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);
}
}