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 292 additions and 89 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"
}

Binary file not shown.

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

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

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

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

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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