Compare commits

...

14 Commits

Author SHA1 Message Date
ChaosExAnima
a5899ae83f minor cleanup 2025-09-17 13:11:55 +02:00
ChaosExAnima
5c4b222dee adds donation banner 2025-09-17 13:10:38 +02:00
ChaosExAnima
4c9e6c9a3c renames CSS files 2025-09-16 17:30:01 +02:00
ChaosExAnima
255545385f swap to plain async thunk instead of data loading 2025-09-16 17:17:37 +02:00
ChaosExAnima
caec59c46d allow uncontrolled input and add a back button on checkout. 2025-09-16 17:17:37 +02:00
ChaosExAnima
a3cf9c669d style dropdown 2025-09-16 17:17:37 +02:00
ChaosExAnima
40894760d1 use new Redux state for donation modal 2025-09-16 17:17:37 +02:00
ChaosExAnima
e22184c20e create donation state 2025-09-16 17:17:36 +02:00
ChaosExAnima
aa99edaf6d set up success screen 2025-09-16 17:17:36 +02:00
ChaosExAnima
2f1aae289e format donate options via API 2025-09-16 17:17:36 +02:00
ChaosExAnima
d8522e4c5c set up API for donations 2025-09-16 17:17:36 +02:00
ChaosExAnima
1dcdcf404c move files to dedicated directory 2025-09-16 17:17:36 +02:00
ChaosExAnima
3c7e612d41 improves styles 2025-09-16 17:17:36 +02:00
ChaosExAnima
89e4fb2f0f starts donation modal 2025-09-16 17:17:36 +02:00
22 changed files with 848 additions and 7 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 KiB

View File

@@ -0,0 +1,68 @@
import { createAction } from '@reduxjs/toolkit';
import { apiGetDonateData } from '../api/donate';
import { createAppAsyncThunk, createAppThunk } from '../store/typed_functions';
import { focusCompose, resetCompose } from './compose';
import { closeModal, openModal } from './modal';
export const setDonateSeed = createAction<number>('donate/setSeed');
export const initializeDonate = createAppThunk(
(_arg, { dispatch, getState }) => {
if (!getState().donate.seed) {
let seed = Math.floor(Math.random() * 99) + 1;
try {
const storedSeed = localStorage.getItem('donate_seed');
if (storedSeed) {
seed = Number.parseInt(storedSeed, 10);
} else {
localStorage.setItem('donate_seed', seed.toString());
}
} catch {
// No local storage available, just set a seed for this session.
}
dispatch(setDonateSeed(seed));
}
void dispatch(fetchDonateData());
},
);
export const fetchDonateData = createAppAsyncThunk(
'donate/fetch',
(_args, { getState }) => {
const state = getState();
return apiGetDonateData({
locale: state.meta.get('locale', 'en') as string,
seed: state.donate.seed ?? 1, // If we somehow don't have the seed, just set it to 1.
});
},
);
export const showDonateModal = createAppThunk(
(_arg, { dispatch, getState }) => {
const state = getState();
const lastPoll = state.donate.nextPoll;
if (!lastPoll || Date.now() >= lastPoll) {
void dispatch(fetchDonateData());
}
dispatch(
openModal({
modalType: 'DONATE',
modalProps: {},
}),
);
},
);
export const composeDonateShare = createAppThunk(
(_arg, { dispatch, getState }) => {
const state = getState();
const shareText = state.donate.apiResponse?.donation_success_post;
if (shareText) {
dispatch(resetCompose());
dispatch(focusCompose(shareText));
}
dispatch(closeModal({ modalType: 'DONATE', ignoreFocus: false }));
},
);

View File

@@ -0,0 +1,30 @@
import type {
DonateServerRequest,
DonateServerResponse,
} from '../api_types/donate';
// TODO: Proxy this through the backend.
const API_URL = 'https://api.joinmastodon.org/v1/donations/campaigns/active';
export const apiGetDonateData = async ({
locale,
seed,
}: DonateServerRequest) => {
// Create the URL with query parameters.
const params = new URLSearchParams({
locale,
seed: seed.toString(),
platform: 'web',
source: 'menu',
});
const url = new URL(`${API_URL}?${params.toString()}`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Error fetching donation campaign: ${response.statusText}`);
}
if (response.status === 204) {
return null;
}
return response.json() as Promise<DonateServerResponse>;
};

View File

@@ -0,0 +1,23 @@
export const donationFrequencyTypes = [
'one_time',
'monthly',
'yearly',
] as const;
export type DonationFrequency = (typeof donationFrequencyTypes)[number];
export interface DonateServerRequest {
locale: string;
seed: number;
}
export interface DonateServerResponse {
id: string;
amounts: Record<DonationFrequency, Record<string, number[]>>;
donation_url: string;
banner_message: string;
banner_button_text: string;
donation_message: string;
donation_button_text: string;
donation_success_post: string;
default_currency: string;
}

View File

@@ -24,13 +24,13 @@ interface PropsWithText extends BaseProps {
children?: undefined;
}
type Props = PropsWithText | PropsChildren;
export type ButtonProps = PropsWithText | PropsChildren;
/**
* Primary UI component for user interaction that doesn't result in navigation.
*/
export const Button: React.FC<Props> = ({
export const Button: React.FC<ButtonProps> = ({
type = 'button',
onClick,
disabled,

View File

@@ -3,6 +3,8 @@ import { useCallback, useState, useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { changeSetting } from 'mastodon/actions/settings';
import { bannerSettings } from 'mastodon/settings';
@@ -16,6 +18,7 @@ const messages = defineMessages({
interface Props {
id: string;
className?: string;
}
export function useDismissableBannerState({ id }: Props) {
@@ -53,6 +56,7 @@ export function useDismissableBannerState({ id }: Props) {
export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
id,
children,
className,
}) => {
const intl = useIntl();
const { wasDismissed, dismiss } = useDismissableBannerState({
@@ -64,7 +68,7 @@ export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
}
return (
<div className='dismissable-banner'>
<div className={classNames('dismissable-banner', className)}>
<div className='dismissable-banner__action'>
<IconButton
icon='times'

View File

@@ -21,7 +21,7 @@ interface DropdownProps {
items: SelectItem[];
onChange: (value: string) => void;
current: string;
labelId: string;
labelId?: string;
descriptionId?: string;
emptyText?: MessageDescriptor;
classPrefix: string;
@@ -79,7 +79,7 @@ export const Dropdown: FC<
type='button'
{...buttonProps}
id={buttonId}
aria-labelledby={`${labelId} ${buttonId}`}
aria-labelledby={classNames(labelId, buttonId)}
aria-describedby={descriptionId}
aria-expanded={open}
aria-controls={listboxId}

View File

@@ -0,0 +1,43 @@
import { useCallback } from 'react';
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import donateBannerImage from '@/images/donation_banner.png';
import { showDonateModal } from '@/mastodon/actions/donate';
import { Button } from '@/mastodon/components/button';
import { DismissableBanner } from '@/mastodon/components/dismissable_banner';
import {
useAppDispatch,
useAppSelector,
} from '@/mastodon/store/typed_functions';
import './styles.scss';
export const DonateBanner: FC = () => {
const donationData = useAppSelector((state) => state.donate.apiResponse);
const dispatch = useAppDispatch();
const handleClick = useCallback(() => {
dispatch(showDonateModal());
}, [dispatch]);
if (!donationData) {
return null;
}
return (
<DismissableBanner
id={`donate-${donationData.id}`}
className='donate_banner'
>
<FormattedMessage
id='donate.banner.title'
defaultMessage='Support Mastodon'
tagName='h2'
/>
<p>{donationData.banner_message}</p>
<Button text={donationData.banner_button_text} onClick={handleClick} />
<img src={donateBannerImage} alt='' role='presentation' />
</DismissableBanner>
);
};

View File

@@ -0,0 +1,37 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { Button } from '@/mastodon/components/button';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
export const DonateCheckoutHint: FC<{
donateUrl?: string;
onBack: () => void;
}> = ({ donateUrl, onBack }) => {
if (!donateUrl) {
return <LoadingIndicator />;
}
return (
<>
<FormattedMessage
id='donate.checkout.instructions'
defaultMessage="Your checkout session is opened in another tab. If you don't see it, <link>click here</link>."
values={{
link: (chunks) => (
<a href={donateUrl} target='_blank' rel='noopener'>
{chunks}
</a>
),
}}
tagName='p'
/>
<Button secondary onClick={onBack}>
<FormattedMessage
id='donate.checkout.back'
defaultMessage='Edit donation'
/>
</Button>
</>
);
};

View File

@@ -0,0 +1,121 @@
import type { FC } from 'react';
import { forwardRef, useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import type { DonationFrequency } from '@/mastodon/api_types/donate';
import { IconButton } from '@/mastodon/components/icon_button';
import { useAppSelector } from '@/mastodon/store';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { DonateCheckoutHint } from './checkout';
import { DonateForm } from './form';
import { DonateSuccess } from './success';
import './styles.scss';
interface DonateModalProps {
onClose: () => void;
}
export interface DonateCheckoutArgs {
frequency: DonationFrequency;
amount: number;
currency: string;
}
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
// TODO: Use environment variable
const CHECKOUT_URL = 'http://localhost:3001/donate/checkout';
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- React throws a warning if not set.
const DonateModal: FC<DonateModalProps> = forwardRef(({ onClose }, ref) => {
const intl = useIntl();
const donationData = useAppSelector((state) => state.donate.apiResponse);
const [donateUrl, setDonateUrl] = useState<string | undefined>();
const handleCheckout = useCallback(
({ frequency, amount, currency }: DonateCheckoutArgs) => {
const params = new URLSearchParams({
frequency,
amount: amount.toString(),
currency,
source: window.location.origin,
});
setState('checkout');
const url = `${CHECKOUT_URL}?${params.toString()}`;
setDonateUrl(url);
try {
window.open(url);
} catch (err) {
console.warn('Error opening checkout window:', err);
}
},
[],
);
const handleCheckoutBack = useCallback(() => {
setState('start');
setDonateUrl(undefined);
}, []);
// Check response from opened page
const [state, setState] = useState<'start' | 'checkout' | 'success'>('start');
useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.data === 'payment_success' && state === 'checkout') {
setState('success');
}
};
window.addEventListener('message', handler);
return () => {
window.removeEventListener('message', handler);
};
}, [state]);
return (
<div className='modal-root__modal dialog-modal donate_modal'>
<div className='dialog-modal__content'>
<header className='row'>
<span className='dialog-modal__header__title title'>
{state === 'start' && donationData?.donation_message}
</span>
<IconButton
className='dialog-modal__header__close'
title={intl.formatMessage(messages.close)}
icon='times'
iconComponent={CloseIcon}
onClick={onClose}
/>
</header>
<div
className={classNames('dialog-modal__content__form', 'body', {
initial: state === 'start',
checkout: state === 'checkout',
success: state === 'success',
})}
>
{state === 'start' && <DonateForm onSubmit={handleCheckout} />}
{state === 'checkout' && (
<DonateCheckoutHint
donateUrl={donateUrl}
onBack={handleCheckoutBack}
/>
)}
{state === 'success' && <DonateSuccess onClose={onClose} />}
</div>
</div>
</div>
);
});
DonateModal.displayName = 'DonateModal';
// eslint-disable-next-line import/no-default-export -- modal_root expects a default export.
export default DonateModal;

View File

@@ -0,0 +1,182 @@
import type { FC, FocusEventHandler, SyntheticEvent } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import type {
DonateServerResponse,
DonationFrequency,
} from '@/mastodon/api_types/donate';
import type { ButtonProps } from '@/mastodon/components/button';
import { Button } from '@/mastodon/components/button';
import { Dropdown } from '@/mastodon/components/dropdown';
import type { SelectItem } from '@/mastodon/components/dropdown_selector';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { useAppSelector } from '@/mastodon/store';
import ExternalLinkIcon from '@/material-icons/400-24px/open_in_new.svg?react';
import type { DonateCheckoutArgs } from './donate_modal';
const messages = defineMessages({
one_time: { id: 'donate.frequency.one_time', defaultMessage: 'Just once' },
monthly: { id: 'donate.frequency.monthly', defaultMessage: 'Monthly' },
yearly: { id: 'donate.frequency.yearly', defaultMessage: 'Yearly' },
});
interface DonateFormProps {
onSubmit: (args: DonateCheckoutArgs) => void;
}
const DefaultAmount = 1000; // 10.00
export const DonateForm: FC<DonateFormProps> = (props) => {
const donateData = useAppSelector((state) => state.donate.apiResponse);
if (!donateData) {
return <LoadingIndicator />;
}
return <DonateFormInner {...props} data={donateData} />;
};
export const DonateFormInner: FC<
DonateFormProps & { data: DonateServerResponse }
> = ({ onSubmit, data: donateData }) => {
const intl = useIntl();
const [frequency, setFrequency] = useState<DonationFrequency>('one_time');
// Nested function to allow passing parameters in onClick.
const handleFrequencyToggle = useCallback((value: DonationFrequency) => {
return () => {
setFrequency(value);
};
}, []);
const [currency, setCurrency] = useState(donateData.default_currency);
const currencyOptions: SelectItem[] = useMemo(
() =>
Object.keys(donateData.amounts.one_time).map((code) => ({
value: code,
text: code,
})),
[donateData.amounts],
);
// Amounts handling
const [amount, setAmount] = useState(
() =>
donateData.amounts[frequency][donateData.default_currency]?.[0] ??
DefaultAmount,
);
const handleAmountChange = useCallback((event: SyntheticEvent) => {
// Coerce the value into a valid amount depending on the source of the event.
let newAmount = 1;
if (event.target instanceof HTMLButtonElement) {
newAmount = Number.parseInt(event.target.value);
} else if (event.target instanceof HTMLInputElement) {
newAmount = event.target.valueAsNumber * 100;
}
// If invalid, just use the default.
if (Number.isNaN(newAmount) || newAmount < 1) {
newAmount = DefaultAmount;
}
setAmount(newAmount);
}, []);
// The input field is uncontrolled to not interfere with user input, but set the value to the state on blue.
const handleAmountBlur: FocusEventHandler<HTMLInputElement> = useCallback(
(event) => {
event.target.value = (amount / 100).toFixed(2);
},
[amount],
);
const amountOptions: SelectItem[] = useMemo(() => {
const formatter = new Intl.NumberFormat('en', {
style: 'currency',
currency,
maximumFractionDigits: 0,
});
return Object.values(donateData.amounts[frequency][currency] ?? {}).map(
(value) => ({
value: value.toString(),
text: formatter.format(value / 100),
}),
);
}, [currency, donateData.amounts, frequency]);
const handleSubmit = useCallback(() => {
onSubmit({ frequency, amount, currency });
}, [amount, currency, frequency, onSubmit]);
return (
<>
<div className='row'>
{(Object.keys(donateData.amounts) as DonationFrequency[]).map(
(freq) => (
<ToggleButton
key={freq}
active={frequency === freq}
onClick={handleFrequencyToggle(freq)}
text={intl.formatMessage(messages[freq])}
/>
),
)}
</div>
<div className='row row--select'>
<Dropdown
items={currencyOptions}
current={currency}
classPrefix='donate_modal'
onChange={setCurrency}
/>
<input
type='number'
min='1'
step='0.01'
onChange={handleAmountChange}
onBlur={handleAmountBlur}
/>
</div>
<div className='row'>
{amountOptions.map((option) => (
<ToggleButton
key={option.value}
onClick={handleAmountChange}
active={amount === Number.parseInt(option.value)}
value={option.value}
text={option.text}
/>
))}
</div>
<Button className='submit' onClick={handleSubmit} block>
<FormattedMessage
id='donate.continue'
defaultMessage='Continue to payment'
/>
<ExternalLinkIcon />
</Button>
<p className='footer'>
<FormattedMessage
id='donate.redirect_notice'
defaultMessage='You will be redirected to joinmastodon.org for secure payment'
/>
</p>
</>
);
};
const ToggleButton: FC<ButtonProps & { active: boolean }> = ({
active,
...props
}) => {
return (
<Button
block
{...props}
className={classNames('toggle', props.className, { active })}
/>
);
};

View File

@@ -0,0 +1,220 @@
@use '../../../styles/mastodon/variables' as vars;
.donate_modal {
.dialog-modal__header {
gap: 1rem;
}
header {
padding: 24px 24px 0;
align-items: start;
}
form {
min-height: 200px;
position: relative;
}
.row {
display: flex;
gap: 1rem;
}
.row--select {
gap: 0;
width: 100%;
button {
background-color: var(--button-color);
border: none;
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
color: #fff;
display: flex;
align-items: center;
padding: 0.5rem;
&:hover,
&:focus {
background-color: var(--button-hover-color);
}
}
input {
flex-grow: 1;
background: none;
padding: 0.5rem;
color: var(--on-input-color);
border: 1px solid var(--button-color);
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
@media (prefers-color-scheme: dark) {
color-scheme: dark;
}
}
}
&__dropdown {
// TODO: Move these to CSS variables
--dropdown-text-color: #{vars.$primary-text-color};
--dropdown-active-color: #{vars.$ui-highlight-color};
box-shadow: var(--dropdown-shadow);
background: var(--dropdown-background-color);
backdrop-filter: #{vars.$backdrop-blur-filter};
border: 1px solid var(--dropdown-border-color);
padding: 4px;
border-radius: 4px;
overflow: hidden;
}
&__option {
font-size: 14px;
line-height: 20px;
letter-spacing: 0.25px;
padding: 8px 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
border-radius: 4px;
color: var(--dropdown-text-color);
&:hover,
&:active {
background: var(--dropdown-border-color);
}
&:focus,
&.active {
background: var(--dropdown-active-color);
color: var(--dropdown-text-color);
outline: 0;
}
}
.title {
flex-grow: 1;
}
.toggle {
border: 2px solid transparent;
&:not(.active) {
background-color: inherit;
border-color: var(--button-color);
color: var(--button-color);
}
&:hover,
&:focus {
border-color: var(--button-hover-color);
background-color: var(--button-hover-color);
color: white;
}
}
.submit {
padding: 0.6rem 1rem;
> svg {
height: 1em;
}
}
button > svg {
fill: currentColor;
}
.muted {
color: var(--input-placeholder-color);
}
.footer {
text-align: center;
color: var(--input-placeholder-color);
font-size: 0.9em;
}
.checkout {
a {
text-decoration: underline;
color: inherit;
}
}
.success {
.illustration {
width: 100%;
max-width: 350px;
margin: 0 auto 1rem;
}
h2 {
font-size: 1.4rem;
line-height: 1;
font-weight: 500;
}
}
}
.donate_banner {
background-color: #1b001f;
font-size: 15px;
line-height: 1.6;
border: none;
h2 {
font-weight: 600;
}
p {
color: rgb(255, 255, 255, 80%);
}
> button {
margin-top: 1rem;
}
img {
position: absolute;
top: -1rem;
width: 400px;
right: -200px;
@media screen and (width <= 600px) {
right: -250px;
}
}
@media screen and (width <= 350px) {
padding-right: 50px;
img {
right: -350px;
}
}
.dismissable-banner__action {
position: absolute;
right: 1rem;
top: 1rem;
z-index: 10;
padding: 0;
float: none;
> button {
background-color: #fff;
border-radius: 50%;
padding: 0.3rem;
color: hsla(247deg, 12%, 31%, 100%);
transition: background-color 0.2s;
&:hover,
&:focus {
background-color: #ddd;
}
}
}
}

View File

@@ -0,0 +1,65 @@
import { useCallback } from 'react';
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import donateIllustration from '@/images/donation_successful.png';
import { composeDonateShare } from '@/mastodon/actions/donate';
import { Button } from '@/mastodon/components/button';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
interface DonateSuccessProps {
onClose: () => void;
}
export const DonateSuccess: FC<DonateSuccessProps> = ({ onClose }) => {
const hasComposerContent = useAppSelector(
(state) => !!state.compose.get('text'),
);
const dispatch = useAppDispatch();
const handleShare = useCallback(() => {
dispatch(composeDonateShare());
}, [dispatch]);
return (
<>
<img
src={donateIllustration}
alt=''
role='presentation'
className='illustration'
/>
<FormattedMessage
id='donate.success.title'
defaultMessage='Thanks for your donation!'
tagName='h2'
/>
<p className='muted'>
<FormattedMessage
id='donate.success.subtitle'
defaultMessage='You should receive an email confirming your donation soon.'
/>
</p>
<Button block onClick={handleShare}>
<ShareIcon />
<FormattedMessage
id='donate.success.share'
defaultMessage='Spread the word'
/>
</Button>
<Button secondary block onClick={onClose}>
<FormattedMessage id='lightbox.close' defaultMessage='Close' />
</Button>
{hasComposerContent && (
<p className='footer'>
<FormattedMessage
id='donate.success.footer'
defaultMessage='Sharing will overwrite your current post draft.'
/>
</p>
)}
</>
);
};

View File

@@ -9,7 +9,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { fetchTrendingHashtags } from 'mastodon/actions/trends';
import { DismissableBanner } from 'mastodon/components/dismissable_banner';
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';

View File

@@ -27,6 +27,7 @@ import StatusListContainer from '../ui/containers/status_list_container';
import { ColumnSettings } from './components/column_settings';
import { CriticalUpdateBanner } from './components/critical_update_banner';
import { DonateBanner } from '../donate/banner';
const messages = defineMessages({
title: { id: 'column.home', defaultMessage: 'Home' },
@@ -127,7 +128,9 @@ class HomeTimeline extends PureComponent {
const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements, matchesBreakpoint } = this.props;
const pinned = !!columnId;
const { signedIn } = this.props.identity;
const banners = [];
const banners = [
<DonateBanner key="donate-banner" />
];
let announcementsButton;

View File

@@ -19,6 +19,7 @@ import {
ClosedRegistrationsModal,
IgnoreNotificationsModal,
AnnualReportModal,
DonateModal,
} from 'mastodon/features/ui/util/async-components';
import BundleContainer from '../containers/bundle_container';
@@ -80,6 +81,7 @@ export const MODAL_COMPONENTS = {
'IGNORE_NOTIFICATIONS': IgnoreNotificationsModal,
'ANNUAL_REPORT': AnnualReportModal,
'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }),
'DONATE': DonateModal,
};
export default class ModalRoot extends PureComponent {

View File

@@ -11,6 +11,7 @@ import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
import { initializeDonate } from '@/mastodon/actions/donate';
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
import { fetchNotifications } from 'mastodon/actions/notification_groups';
import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
@@ -394,6 +395,7 @@ class UI extends PureComponent {
this.props.dispatch(expandHomeTimeline());
this.props.dispatch(fetchNotifications());
this.props.dispatch(fetchServerTranslationLanguages());
this.props.dispatch(initializeDonate());
setTimeout(() => this.props.dispatch(fetchServer()), 3000);
}

View File

@@ -230,6 +230,8 @@ export function AnnualReportModal () {
return import('../components/annual_report_modal');
}
export const DonateModal = () => import('../../donate/donate_modal');
export function ListEdit () {
return import('../../lists/new');
}

View File

@@ -0,0 +1,36 @@
import { createReducer } from '@reduxjs/toolkit';
import { fetchDonateData, setDonateSeed } from '../actions/donate';
import type { DonateServerResponse } from '../api_types/donate';
interface DonateState {
apiResponse?: DonateServerResponse;
nextPoll?: number;
isFetching: boolean;
seed?: number;
}
const initialState: DonateState = {
isFetching: false,
};
export const donateReducer = createReducer(initialState, (builder) => {
builder
.addCase(setDonateSeed, (state, action) => {
state.seed = action.payload;
})
.addCase(fetchDonateData.pending, (state) => {
state.isFetching = true;
})
.addCase(fetchDonateData.rejected, (state) => {
state.isFetching = false;
})
.addCase(fetchDonateData.fulfilled, (state, action) => {
if (action.payload) {
state.apiResponse = action.payload;
}
// If we have data, poll in four hours, otherwise try again in one hour.
state.nextPoll = Date.now() + 1000 * 60 * 60 * (action.payload ? 4 : 1);
state.isFetching = false;
});
});

View File

@@ -12,6 +12,7 @@ import { composeReducer } from './compose';
import { contextsReducer } from './contexts';
import conversations from './conversations';
import custom_emojis from './custom_emojis';
import { donateReducer } from './donate';
import { dropdownMenuReducer } from './dropdown_menu';
import filters from './filters';
import height_cache from './height_cache';
@@ -43,6 +44,7 @@ import user_lists from './user_lists';
const reducers = {
announcements,
donate: donateReducer,
dropdownMenu: dropdownMenuReducer,
timelines,
meta,

View File

@@ -37,6 +37,8 @@
--input-placeholder-color: #{$dark-text-color};
--input-background-color: var(--surface-variant-background-color);
--on-input-color: #{$secondary-text-color};
--button-color: #{$ui-button-background-color};
--button-hover-color: #{$ui-button-focus-background-color};
}
body {