diff --git a/app/javascript/mastodon/actions/donate.ts b/app/javascript/mastodon/actions/donate.ts new file mode 100644 index 00000000000..6246292081c --- /dev/null +++ b/app/javascript/mastodon/actions/donate.ts @@ -0,0 +1,72 @@ +import { createAction } from '@reduxjs/toolkit'; + +import { apiGetDonateData } from '../api/donate'; +import { + createAppThunk, + createDataLoadingThunk, +} from '../store/typed_functions'; + +import { focusCompose, resetCompose } from './compose'; +import { closeModal, openModal } from './modal'; + +export const setDonateSeed = createAction('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 = createDataLoadingThunk( + 'donate/fetch', + (_args: unknown, { 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. + }); + }, + (data) => data, // This is needed for TypeScript to infer the correct overload. +); + +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 })); + }, +); diff --git a/app/javascript/mastodon/api/donate.ts b/app/javascript/mastodon/api/donate.ts new file mode 100644 index 00000000000..95c7381a70a --- /dev/null +++ b/app/javascript/mastodon/api/donate.ts @@ -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; +}; diff --git a/app/javascript/mastodon/api_types/donate.ts b/app/javascript/mastodon/api_types/donate.ts new file mode 100644 index 00000000000..45cf5bba79a --- /dev/null +++ b/app/javascript/mastodon/api_types/donate.ts @@ -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>; + donation_url: string; + banner_message: string; + banner_button_text: string; + donation_message: string; + donation_button_text: string; + donation_success_post: string; + default_currency: string; +} diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index 0583bf99c5e..3b1a3fe6d97 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -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); } diff --git a/app/javascript/mastodon/reducers/donate.ts b/app/javascript/mastodon/reducers/donate.ts new file mode 100644 index 00000000000..c9f1fc2ef41 --- /dev/null +++ b/app/javascript/mastodon/reducers/donate.ts @@ -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; + }); +}); diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index 19ecbbfff40..68cb4f23757 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -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,