Implement Patron in FE

This commit is contained in:
2026-02-14 15:46:38 +00:00
parent ffa3f2a8b5
commit 37de635edf
17 changed files with 292 additions and 29 deletions

View File

@ -18,7 +18,9 @@ import { isTimezoneLabel } from '@/features/ui/components/profile-field';
import { UserPanel } from '@/features/ui/util/async-components';
import { useAppDispatch } from '@/hooks/use-app-dispatch';
import { useAppSelector } from '@/hooks/use-app-selector';
import { useFrontendConfig } from '@/hooks/use-frontend-config';
import { accountScrobbleQueryOptions } from '@/queries/accounts/account-scrobble';
import { usePatronUser } from '@/queries/patron/use-patron-user';
import { useAccountHoverCardActions, useAccountHoverCardStore } from '@/stores/account-hover-card';
import AccountLocalTime from './account-local-time';
@ -35,6 +37,7 @@ const messages = {
const getBadges = (
account?: Pick<Account, 'is_admin' | 'is_moderator'>,
isPatron?: boolean,
): JSX.Element[] => {
const badges = [];
@ -44,6 +47,10 @@ const getBadges = (
badges.push(<Badge key='moderator' slug='moderator' title={<FormattedMessage id='account_moderation_modal.roles.moderator' defaultMessage='Moderator' />} />);
}
if (isPatron) {
badges.push(<Badge key='patron' slug='patron' title={<FormattedMessage id='account.patron' defaultMessage='Patron' />} />);
}
return badges;
};
@ -61,9 +68,11 @@ const AccountHoverCard: React.FC<IAccountHoverCard> = ({ visible = true }) => {
const { updateAccountHoverCard, closeAccountHoverCard } = useAccountHoverCardActions();
const me = useAppSelector(state => state.me);
const frontendConfig = useFrontendConfig();
const { account } = useAccount(accountId || undefined, { withRelationship: true });
const { data: scrobble } = useQuery(accountScrobbleQueryOptions(account?.id));
const badges = getBadges(account);
const { data: patronUser } = usePatronUser(frontendConfig.patron.enabled ? account?.url : undefined);
const badges = getBadges(account, patronUser?.is_patron);
useEffect(() => {
if (accountId) dispatch(fetchRelationships([accountId]));
@ -146,6 +155,7 @@ const AccountHoverCard: React.FC<IAccountHoverCard> = ({ visible = true }) => {
accountId={account.id}
action={<ActionButton account={account} small />}
badges={badges}
isPatron={patronUser?.is_patron}
/>
{account.local ? (

View File

@ -4,6 +4,7 @@ import React, { useLayoutEffect, useRef, useState } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import HoverAccountWrapper from '@/components/hover-account-wrapper';
import PatronIndicator from '@/components/patron-indicator';
import Avatar from '@/components/ui/avatar';
import Emoji from '@/components/ui/emoji';
import HStack from '@/components/ui/hstack';
@ -16,6 +17,8 @@ import Emojify from '@/features/emoji/emojify';
import ActionButton from '@/features/ui/components/action-button';
import { useAcct } from '@/hooks/use-acct';
import { useAppSelector } from '@/hooks/use-app-selector';
import { useFrontendConfig } from '@/hooks/use-frontend-config';
import { usePatronUser } from '@/queries/patron/use-patron-user';
import { useSettings } from '@/stores/settings';
import Badge from './badge';
@ -156,6 +159,8 @@ const Account = ({
const me = useAppSelector((state) => state.me);
const username = useAcct(account);
const frontendConfig = useFrontendConfig();
const { data: patronUser } = usePatronUser(frontendConfig.patron.enabled ? account.url : undefined);
const { disableUserProvidedMedia } = useSettings();
const handleAction = () => {
@ -241,7 +246,9 @@ const Account = ({
<Avatar src={account.avatar} alt={account.avatar_description} username={account.username} />
) : (
<div className='rounded-lg'>
<PatronIndicator isPatron={patronUser?.is_patron}>
<Avatar src={account.avatar} size={avatarSize} alt={account.avatar_description} isCat={account.is_cat} username={account.username} />
</PatronIndicator>
{emoji && (
<Emoji
className='!absolute -right-1.5 bottom-0 size-5'
@ -310,7 +317,9 @@ const Account = ({
wrapper={(children) => <HoverAccountWrapper className='relative' accountId={account.id} element='span'>{children}</HoverAccountWrapper>}
>
<LinkEl className='rounded-lg' {...linkProps}>
<PatronIndicator isPatron={patronUser?.is_patron}>
<Avatar src={account.avatar} size={avatarSize} alt={account.avatar_description} isCat={account.is_cat} username={account.username} />
</PatronIndicator>
{emoji && (
<Emoji
className='!absolute -right-1.5 bottom-0 size-5'

View File

@ -13,9 +13,11 @@ import Icon from '@/components/ui/icon';
import Stack from '@/components/ui/stack';
import Text from '@/components/ui/text';
import ProfileStats from '@/features/ui/components/profile-stats';
import { FundingPanel } from '@/features/ui/util/async-components';
import { useAppDispatch } from '@/hooks/use-app-dispatch';
import { useAppSelector } from '@/hooks/use-app-selector';
import { useFeatures } from '@/hooks/use-features';
import { useFrontendConfig } from '@/hooks/use-frontend-config';
import { useInstance } from '@/hooks/use-instance';
import { useRegistrationStatus } from '@/hooks/use-registration-status';
import { useFollowRequestsCount } from '@/queries/accounts/use-follow-requests';
@ -92,6 +94,7 @@ const DropdownNavigation: React.FC = React.memo((): JSX.Element | null => {
const instance = useInstance();
const restrictUnauth = instance.pleroma.metadata.restrict_unauthenticated;
const frontendConfig = useFrontendConfig();
const containerRef = React.useRef<HTMLDivElement>(null);
@ -195,6 +198,13 @@ const DropdownNavigation: React.FC = React.memo((): JSX.Element | null => {
<Stack space={4}>
<Divider />
{frontendConfig.patron.enabled && (
<>
<FundingPanel compact />
<Divider />
</>
)}
<DropdownNavigationLink
to='/@{$username}'
params={{ username: account.acct }}

View File

@ -0,0 +1,17 @@
import React from 'react';
interface IPatronIndicator {
isPatron?: boolean;
children: React.ReactNode;
}
const PatronIndicator: React.FC<IPatronIndicator> = ({ isPatron, children }) => {
if (!isPatron) return <>{children}</>;
return (
<div className='rounded-lg bg-gradient-to-r from-blue-900 to-purple-500 p-[2px]' title='Patron'>
{children}
</div>
);
};
export { PatronIndicator as default };

View File

@ -16,9 +16,7 @@ const ProgressBar: React.FC<IProgressBar> = ({ progress, size = 'md' }) => {
const { reduceMotion } = useSettings();
const styles = useSpring({
from: { width: '0%' },
to: { width: `${progress}%` },
reset: true,
to: { width: `${Math.min(progress * 100, 100)}%` },
immediate: reduceMotion,
});
@ -30,9 +28,13 @@ const ProgressBar: React.FC<IProgressBar> = ({ progress, size = 'md' }) => {
})}
>
<animated.div
className='h-full bg-secondary-500'
className='relative h-full overflow-hidden bg-gradient-to-r from-blue-900 to-purple-500'
style={styles}
/>
>
{!reduceMotion && (
<div className='absolute inset-0 animate-shimmer bg-gradient-to-r from-transparent via-white/20 to-transparent' />
)}
</animated.div>
</div>
);
};

View File

@ -13,6 +13,7 @@ import AltIndicator from '@/components/alt-indicator';
import Badge from '@/components/badge';
import DropdownMenu, { Menu } from '@/components/dropdown-menu';
import Icon from '@/components/icon';
import PatronIndicator from '@/components/patron-indicator';
import StillImage from '@/components/still-image';
import Avatar from '@/components/ui/avatar';
import HStack from '@/components/ui/hstack';
@ -27,6 +28,7 @@ import SubscriptionButton from '@/features/ui/components/subscription-button';
import { useAppDispatch } from '@/hooks/use-app-dispatch';
import { useClient } from '@/hooks/use-client';
import { useFeatures } from '@/hooks/use-features';
import { useFrontendConfig } from '@/hooks/use-frontend-config';
import { useOwnAccount } from '@/hooks/use-own-account';
import {
useFollowAccountMutation,
@ -39,6 +41,7 @@ import {
} from '@/queries/accounts/use-relationship';
import { useChats } from '@/queries/chats';
import { queryClient } from '@/queries/client';
import { usePatronUser } from '@/queries/patron/use-patron-user';
import { blockDomainMutationOptions, unblockDomainMutationOptions } from '@/queries/settings/domain-blocks';
import { useModalsActions } from '@/stores/modals';
import { useSettings } from '@/stores/settings';
@ -141,7 +144,9 @@ const Header: React.FC<IHeader> = ({ account }) => {
const client = useClient();
const features = useFeatures();
const frontendConfig = useFrontendConfig();
const { account: ownAccount } = useOwnAccount();
const { data: patronUser } = usePatronUser(frontendConfig.patron.enabled ? account?.url : undefined);
const { mutate: followAccount } = useFollowAccountMutation(account?.id!);
const { mutate: unblockAccount } = useUnblockAccountMutation(account?.id!);
const { mutate: unmuteAccount } = useUnmuteAccountMutation(account?.id!);
@ -757,6 +762,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
<div className='px-4 sm:px-6'>
<HStack className='-mt-12' alignItems='bottom' space={5}>
<div className='relative flex'>
<PatronIndicator isPatron={patronUser?.is_patron}>
<a href={account.avatar} onClick={handleAvatarClick} target='_blank'>
<Avatar
src={account.avatar}
@ -768,6 +774,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
showAlt
/>
</a>
</PatronIndicator>
{account.verified && (
<div className='absolute -bottom-2 -right-2'>
<VerificationBadge className='!size-[24px] rounded-full !p-[2px] ring-2 ring-white black:ring-black dark:ring-primary-900' />

View File

@ -0,0 +1,97 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Button from '@/components/ui/button';
import HStack from '@/components/ui/hstack';
import Icon from '@/components/ui/icon';
import ProgressBar from '@/components/ui/progress-bar';
import Stack from '@/components/ui/stack';
import Text from '@/components/ui/text';
import Widget from '@/components/ui/widget';
import { usePatronInstance } from '@/queries/patron/use-patron-instance';
/** Formats integer to USD string. */
const moneyFormat = (amount: number): string => (
new Intl
.NumberFormat('en-US', {
style: 'currency',
currency: 'usd',
notation: 'compact',
})
.format(amount / 100)
);
interface IFundingPanel {
/** Renders a compact version without the Widget wrapper, for use in tight spaces like mobile nav. */
compact?: boolean;
}
const FundingPanel: React.FC<IFundingPanel> = ({ compact }) => {
const { data: patron } = usePatronInstance();
if (!patron || !patron.goals.length) return null;
const amount = patron.funding.amount;
const goal = patron.goals[0].amount;
const goalText = patron.goals[0].text;
const goalReached = amount >= goal;
let ratioText;
if (goalReached) {
ratioText = <><strong>{moneyFormat(goal)}</strong> per month <span>&mdash; reached!</span></>;
} else {
ratioText = <><strong>{moneyFormat(amount)} out of {moneyFormat(goal)}</strong> per month</>;
}
const handleDonateClick = () => {
window.open(patron.url, '_blank');
};
if (compact) {
return (
<button
type='button'
className='flex w-full flex-col gap-2 text-left'
onClick={handleDonateClick}
>
<Text size='sm' weight='medium'>
<FormattedMessage id='patron.title' defaultMessage='Funding Goal' />
</Text>
<ProgressBar progress={amount / goal} />
<HStack alignItems='center' justifyContent='between'>
<Text size='xs' theme='muted'>{ratioText}</Text>
<HStack alignItems='center' space={1} className='text-primary-600 dark:text-primary-400'>
<Text size='xs' weight='medium' theme='inherit'>
<FormattedMessage id='patron.donate' defaultMessage='Donate' />
</Text>
<Icon src={require('@phosphor-icons/core/regular/arrow-square-out.svg')} className='size-3.5' />
</HStack>
</HStack>
</button>
);
}
return (
<Widget
title={<FormattedMessage id='patron.title' defaultMessage='Funding Goal' />}
onActionClick={handleDonateClick}
>
<Stack space={4}>
<Stack space={2}>
<Text>{ratioText}</Text>
<ProgressBar progress={amount / goal} />
</Stack>
<Stack space={2}>
{goalText && <Text theme='muted'>{goalText}</Text>}
<Button block theme='primary' onClick={handleDonateClick}>
<FormattedMessage id='patron.donate' defaultMessage='Donate' />
</Button>
</Stack>
</Stack>
</Widget>
);
};
export { FundingPanel as default };

View File

@ -14,7 +14,9 @@ import Text from '@/components/ui/text';
import Emojify from '@/features/emoji/emojify';
import { useAcct } from '@/hooks/use-acct';
import { useAppSelector } from '@/hooks/use-app-selector';
import { useFrontendConfig } from '@/hooks/use-frontend-config';
import { accountScrobbleQueryOptions } from '@/queries/accounts/account-scrobble';
import { usePatronUser } from '@/queries/patron/use-patron-user';
import { capitalize } from '@/utils/strings';
import { ProfileField } from '../../util/async-components';
@ -43,8 +45,10 @@ const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) =>
const acct = useAcct(account);
const me = useAppSelector(state => state.me);
const ownAccount = account?.id === me;
const frontendConfig = useFrontendConfig();
const { data: scrobble } = useQuery(accountScrobbleQueryOptions(account?.id));
const { data: patronUser } = usePatronUser(frontendConfig.patron.enabled ? account?.url : undefined);
const getStaffBadge = (): React.ReactNode => {
if (account?.is_admin) {
@ -79,6 +83,10 @@ const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) =>
badges.push(staffBadge);
}
if (patronUser?.is_patron) {
badges.push(<Badge slug='patron' title={<FormattedMessage id='account.patron' defaultMessage='Patron' />} key='patron' />);
}
return [...badges, ...custom];
};

View File

@ -3,6 +3,7 @@ import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useAccount } from '@/api/hooks/accounts/use-account';
import PatronIndicator from '@/components/patron-indicator';
import StillImage from '@/components/still-image';
import Avatar from '@/components/ui/avatar';
import HStack from '@/components/ui/hstack';
@ -24,9 +25,10 @@ interface IUserPanel {
action?: JSX.Element;
badges?: JSX.Element[];
domain?: string;
isPatron?: boolean;
}
const UserPanel: React.FC<IUserPanel> = ({ accountId, action, badges, domain }) => {
const UserPanel: React.FC<IUserPanel> = ({ accountId, action, badges, domain, isPatron }) => {
const intl = useIntl();
const { demetricator, disableUserProvidedMedia } = useSettings();
const { account } = useAccount(accountId);
@ -57,6 +59,7 @@ const UserPanel: React.FC<IUserPanel> = ({ accountId, action, badges, domain })
title={acct}
className='-mt-12 block'
>
<PatronIndicator isPatron={isPatron}>
<Avatar
src={account.avatar}
alt={account.avatar_description}
@ -66,6 +69,7 @@ const UserPanel: React.FC<IUserPanel> = ({ accountId, action, badges, domain })
size={80}
className='size-20 bg-gray-50 ring-2 ring-white'
/>
</PatronIndicator>
</Link>
)}

View File

@ -110,6 +110,7 @@ export const AccountNotePanel = lazy(() => import('@/features/ui/components/pane
export const AnnouncementsPanel = lazy(() => import('@/components/announcements/announcements-panel'));
export const BirthdayPanel = lazy(() => import('@/features/ui/components/panels/birthday-panel'));
export const CryptoDonatePanel = lazy(() => import('@/features/crypto-donate/components/crypto-donate-panel'));
export const FundingPanel = lazy(() => import('@/features/patron/components/funding-panel'));
export const GroupMediaPanel = lazy(() => import('@/features/ui/components/panels/group-media-panel'));
export const InstanceInfoPanel = lazy(() => import('@/features/ui/components/panels/instance-info-panel'));
export const InstanceModerationPanel = lazy(() => import('@/features/ui/components/panels/instance-moderation-panel'));

View File

@ -13,6 +13,7 @@ import {
SignUpPanel,
PromoPanel,
CryptoDonatePanel,
FundingPanel,
BirthdayPanel,
AnnouncementsPanel,
ComposeForm,
@ -96,6 +97,9 @@ const HomeLayout = () => {
{(hasCrypto && cryptoLimit > 0 && me) && (
<CryptoDonatePanel limit={cryptoLimit} />
)}
{frontendConfig.patron.enabled && me && (
<FundingPanel />
)}
<PromoPanel />
{features.birthdays && (
<BirthdayPanel limit={10} />

View File

@ -50,6 +50,7 @@
"account.mute": "Mute @{name}",
"account.muted": "Muted",
"account.never_active": "Never",
"account.patron": "Patron",
"account.posts": "Posts",
"account.posts_with_replies": "Posts & replies",
"account.profile": "Profile",
@ -1378,6 +1379,8 @@
"notifications.queue_label": "Click to see {count} new {count, plural, one {notification} other {notifications}}",
"oauth_consumer.tooltip": "Sign in with {provider}",
"oauth_consumers.title": "Other ways to sign in",
"patron.donate": "Donate",
"patron.title": "Funding Goal",
"password_reset.confirmation": "Check your email for confirmation.",
"password_reset.fields.email_placeholder": "E-mail address",
"password_reset.fields.username_placeholder": "Email or username",

View File

@ -64,6 +64,9 @@ const frontendConfigSchema = coerceObject({
cryptoDonatePanel: coerceObject({
limit: v.fallback(v.number(), 1),
}),
patron: coerceObject({
enabled: v.fallback(v.boolean(), false),
}),
aboutPages: v.fallback(v.record(v.string(), coerceObject({
defaultLocale: v.fallback(v.string(), ''), // v.fallback(v.optional(v.string()), undefined),
locales: filteredArray(v.string()),

View File

@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import * as v from 'valibot';
import { getClient } from '@/api';
import { useFrontendConfig } from '@/hooks/use-frontend-config';
import { patronInstanceSchema } from '@/schemas/patron';
const usePatronInstance = () => {
const frontendConfig = useFrontendConfig();
return useQuery({
queryKey: ['patron', 'instance'],
queryFn: async () => {
const response = await getClient().request('/api/patron/v1/instance');
return v.parse(patronInstanceSchema, response.json);
},
enabled: frontendConfig.patron.enabled,
staleTime: 5 * 60 * 1000,
});
};
export { usePatronInstance };

View File

@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import * as v from 'valibot';
import { getClient } from '@/api';
import { useFrontendConfig } from '@/hooks/use-frontend-config';
import { patronUserSchema } from '@/schemas/patron';
const usePatronUser = (url: string | undefined) => {
const frontendConfig = useFrontendConfig();
return useQuery({
queryKey: ['patron', 'user', url],
queryFn: async () => {
const response = await getClient().request(`/api/patron/v1/accounts/${encodeURIComponent(url!)}`);
return v.parse(patronUserSchema, response.json);
},
enabled: frontendConfig.patron.enabled && !!url,
staleTime: 5 * 60 * 1000,
});
};
export { usePatronUser };

View File

@ -0,0 +1,39 @@
import * as v from 'valibot';
import { coerceObject } from '@/schemas/utils';
const patronUserSchema = coerceObject({
is_patron: v.fallback(v.boolean(), false),
url: v.fallback(v.string(), ''),
});
type PatronUser = v.InferOutput<typeof patronUserSchema>;
const patronFundingSchema = coerceObject({
amount: v.fallback(v.number(), 0),
patrons: v.fallback(v.number(), 0),
currency: v.fallback(v.string(), 'usd'),
interval: v.fallback(v.string(), 'monthly'),
});
const patronGoalSchema = coerceObject({
amount: v.fallback(v.number(), 0),
currency: v.fallback(v.string(), 'usd'),
interval: v.fallback(v.string(), 'monthly'),
text: v.fallback(v.string(), ''),
});
const patronInstanceSchema = coerceObject({
funding: patronFundingSchema,
goals: v.fallback(v.array(patronGoalSchema), []),
url: v.fallback(v.string(), ''),
});
type PatronInstance = v.InferOutput<typeof patronInstanceSchema>;
export {
patronUserSchema,
patronInstanceSchema,
type PatronUser,
type PatronInstance,
};

View File

@ -77,6 +77,7 @@ const config: Config = {
'enter': 'enter 200ms ease-out',
'leave': 'leave 150ms ease-in forwards',
'text-overflow': 'text-overflow 8s linear infinite',
'shimmer': 'shimmer 2.5s ease-in-out infinite',
},
keyframes: {
'sonar-scale-4': {
@ -108,6 +109,10 @@ const config: Config = {
'10%, 90%': { transform: 'translate(0, 0)', left: '0%' },
'40%, 60%': { transform: 'translate(-100%, 0)', left: '100%' },
},
'shimmer': {
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(100%)' },
},
},
},
},