diff --git a/app/javascript/mastodon/actions/server.js b/app/javascript/mastodon/actions/server.js index 32ee093afa..47b6e7a176 100644 --- a/app/javascript/mastodon/actions/server.js +++ b/app/javascript/mastodon/actions/server.js @@ -1,3 +1,5 @@ +import { checkAnnualReport } from '@/mastodon/reducers/slices/annual_report'; + import api from '../api'; import { importFetchedAccount } from './importer'; @@ -29,6 +31,9 @@ export const fetchServer = () => (dispatch, getState) => { .get('/api/v2/instance').then(({ data }) => { if (data.contact.account) dispatch(importFetchedAccount(data.contact.account)); dispatch(fetchServerSuccess(data)); + if (data.wrapstodon) { + void dispatch(checkAnnualReport()); + } }).catch(err => dispatch(fetchServerFail(err))); }; diff --git a/app/javascript/mastodon/api/annual_report.ts b/app/javascript/mastodon/api/annual_report.ts new file mode 100644 index 0000000000..dc080035d4 --- /dev/null +++ b/app/javascript/mastodon/api/annual_report.ts @@ -0,0 +1,38 @@ +import api, { apiRequestGet, getAsyncRefreshHeader } from '../api'; +import type { ApiAccountJSON } from '../api_types/accounts'; +import type { ApiStatusJSON } from '../api_types/statuses'; +import type { AnnualReport } from '../models/annual_report'; + +export type ApiAnnualReportState = + | 'available' + | 'generating' + | 'eligible' + | 'ineligible'; + +export const apiGetAnnualReportState = async (year: number) => { + const response = await api().get<{ state: ApiAnnualReportState }>( + `/api/v1/annual_reports/${year}/state`, + ); + + return { + state: response.data.state, + refresh: getAsyncRefreshHeader(response), + }; +}; + +export const apiRequestGenerateAnnualReport = async (year: number) => { + const response = await api().post(`/api/v1/annual_reports/${year}/generate`); + + return { + refresh: getAsyncRefreshHeader(response), + }; +}; + +export interface ApiAnnualReportResponse { + annual_reports: AnnualReport[]; + accounts: ApiAccountJSON[]; + statuses: ApiStatusJSON[]; +} + +export const apiGetAnnualReport = (year: number) => + apiRequestGet(`v1/annual_reports/${year}`); diff --git a/app/javascript/mastodon/components/status_list.jsx b/app/javascript/mastodon/components/status_list.jsx index cb2a7464cb..78e6fbcf5f 100644 --- a/app/javascript/mastodon/components/status_list.jsx +++ b/app/javascript/mastodon/components/status_list.jsx @@ -8,6 +8,8 @@ import { debounce } from 'lodash'; import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines'; import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator'; import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions'; +import { AnnualReportTimeline } from 'mastodon/features/annual_report/timeline'; +import { TIMELINE_WRAPSTODON } from '@/mastodon/reducers/slices/annual_report'; import { StatusQuoteManager } from '../components/status_quoted'; @@ -63,10 +65,12 @@ export default class StatusList extends ImmutablePureComponent { switch(statusId) { case TIMELINE_SUGGESTIONS: return ( - + ); + case TIMELINE_WRAPSTODON: + return ( + + ) case TIMELINE_GAP: return ( { + const { state } = useAppSelector((state) => state.annualReport); + const year = useAppSelector(selectWrapstodonYear); + + const dispatch = useAppDispatch(); + const handleBuildRequest = useCallback(() => { + void dispatch(generateReport()); + }, [dispatch]); + + const handleOpen = useCallback(() => { + dispatch( + // TODO: Implement opening the annual report view when components are ready. + showAlert({ + message: 'Not yet implemented.', + }), + ); + }, [dispatch]); + + if (!year || !state || state === 'ineligible') { + return null; + } + + return ( + + ); +}; diff --git a/app/javascript/mastodon/features/ui/containers/status_list_container.js b/app/javascript/mastodon/features/ui/containers/status_list_container.js index d581ad5fe4..66e3b91c7b 100644 --- a/app/javascript/mastodon/features/ui/containers/status_list_container.js +++ b/app/javascript/mastodon/features/ui/containers/status_list_container.js @@ -4,9 +4,10 @@ import { connect } from 'react-redux'; import { debounce } from 'lodash'; -import { scrollTopTimeline, loadPending } from '../../../actions/timelines'; -import StatusList from '../../../components/status_list'; -import { me } from '../../../initial_state'; +import { scrollTopTimeline, loadPending, TIMELINE_SUGGESTIONS } from '@/mastodon/actions/timelines'; +import StatusList from '@/mastodon/components/status_list'; +import { me } from '@/mastodon/initial_state'; +import { TIMELINE_WRAPSTODON } from '@/mastodon/reducers/slices/annual_report'; const makeGetStatusIds = (pending = false) => createSelector([ (state, { type }) => state.getIn(['settings', type], ImmutableMap()), @@ -14,7 +15,7 @@ const makeGetStatusIds = (pending = false) => createSelector([ (state) => state.get('statuses'), ], (columnSettings, statusIds, statuses) => { return statusIds.filter(id => { - if (id === null || id === 'inline-follow-suggestions') return true; + if (id === null || id === TIMELINE_SUGGESTIONS || id === TIMELINE_WRAPSTODON) return true; const statusForId = statuses.get(id); diff --git a/app/javascript/mastodon/models/annual_report.ts b/app/javascript/mastodon/models/annual_report.ts index c0a101e6c8..a2c0c51786 100644 --- a/app/javascript/mastodon/models/annual_report.ts +++ b/app/javascript/mastodon/models/annual_report.ts @@ -37,8 +37,30 @@ interface AnnualReportV1 { archetype: Archetype; } -export interface AnnualReport { - year: number; - schema_version: number; - data: AnnualReportV1; +interface AnnualReportV2 { + archetype: Archetype; + time_series: TimeSeriesMonth[]; + top_hashtags: NameAndCount[]; + top_statuses: TopStatuses; + most_used_apps: NameAndCount[]; + type_distribution: { + total: number; + reblogs: number; + replies: number; + standalone: number; + }; } + +export type AnnualReport = { + year: number; +} & ( + | { + schema_version: 1; + data: AnnualReportV1; + } + | { + schema_version: 2; + data: AnnualReportV2; + share_url: string | null; + } +); diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index 19ecbbfff4..7343f5e164 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -33,6 +33,7 @@ import { relationshipsReducer } from './relationships'; import { searchReducer } from './search'; import server from './server'; import settings from './settings'; +import { sliceReducers } from './slices'; import status_lists from './status_lists'; import statuses from './statuses'; import { suggestionsReducer } from './suggestions'; @@ -80,6 +81,7 @@ const reducers = { notificationPolicy: notificationPolicyReducer, notificationRequests: notificationRequestsReducer, navigation: navigationReducer, + ...sliceReducers, }; // We want the root state to be an ImmutableRecord, which is an object with a defined list of keys, diff --git a/app/javascript/mastodon/reducers/slices/annual_report.ts b/app/javascript/mastodon/reducers/slices/annual_report.ts new file mode 100644 index 0000000000..db1c064e71 --- /dev/null +++ b/app/javascript/mastodon/reducers/slices/annual_report.ts @@ -0,0 +1,118 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { insertIntoTimeline } from '@/mastodon/actions/timelines'; +import type { ApiAnnualReportState } from '@/mastodon/api/annual_report'; +import { + apiGetAnnualReport, + apiGetAnnualReportState, + apiRequestGenerateAnnualReport, +} from '@/mastodon/api/annual_report'; +import type { AnnualReport } from '@/mastodon/models/annual_report'; + +import { + createAppSelector, + createAppThunk, + createDataLoadingThunk, +} from '../../store/typed_functions'; + +export const TIMELINE_WRAPSTODON = 'inline-wrapstodon'; + +interface AnnualReportState { + state?: ApiAnnualReportState; + report?: AnnualReport; +} + +const annualReportSlice = createSlice({ + name: 'annualReport', + initialState: {} as AnnualReportState, + reducers: {}, + extraReducers(builder) { + builder + .addCase(fetchReportState.fulfilled, (state, action) => { + state.state = action.payload; + }) + .addCase(generateReport.pending, (state) => { + state.state = 'generating'; + }) + .addCase(getReport.fulfilled, (state, action) => { + if (action.payload) { + state.report = action.payload; + } + }); + }, +}); + +export const annualReport = annualReportSlice.reducer; + +export const selectWrapstodonYear = createAppSelector( + [(state) => state.server.getIn(['server', 'wrapstodon'])], + (year: unknown) => (typeof year === 'number' && year > 2000 ? year : null), +); + +// This kicks everything off, and is called after fetching the server info. +export const checkAnnualReport = createAppThunk( + `${annualReportSlice.name}/checkAnnualReport`, + async (_arg: unknown, { dispatch, getState }) => { + const year = selectWrapstodonYear(getState()); + if (!year) { + return; + } + const state = await dispatch(fetchReportState()); + if ( + state.meta.requestStatus === 'fulfilled' && + state.payload !== 'ineligible' + ) { + dispatch(insertIntoTimeline('home', TIMELINE_WRAPSTODON, 1)); + } + }, +); + +const fetchReportState = createDataLoadingThunk( + `${annualReportSlice.name}/fetchReportState`, + async (_arg: unknown, { getState }) => { + const year = selectWrapstodonYear(getState()); + if (!year) { + throw new Error('Year is not set'); + } + return apiGetAnnualReportState(year); + }, + ({ state, refresh }, { dispatch }) => { + if (state === 'generating' && refresh) { + window.setTimeout(() => { + void dispatch(fetchReportState()); + }, 1_000 * refresh.retry); + } else if (state === 'available') { + void dispatch(getReport()); + } + + return state; + }, + { useLoadingBar: false }, +); + +// Triggers the generation of the annual report. +export const generateReport = createDataLoadingThunk( + `${annualReportSlice.name}/generateReport`, + async (_arg: unknown, { getState }) => { + const year = selectWrapstodonYear(getState()); + if (!year) { + throw new Error('Year is not set'); + } + return apiRequestGenerateAnnualReport(year); + }, + (_arg: unknown, { dispatch }) => { + void dispatch(fetchReportState()); + }, +); + +export const getReport = createDataLoadingThunk( + `${annualReportSlice.name}/getReport`, + async (_arg: unknown, { getState }) => { + const year = selectWrapstodonYear(getState()); + if (!year) { + throw new Error('Year is not set'); + } + return apiGetAnnualReport(year); + }, + (data) => data.annual_reports[0], +); diff --git a/app/javascript/mastodon/reducers/slices/index.ts b/app/javascript/mastodon/reducers/slices/index.ts new file mode 100644 index 0000000000..dfea395127 --- /dev/null +++ b/app/javascript/mastodon/reducers/slices/index.ts @@ -0,0 +1,5 @@ +import { annualReport } from './annual_report'; + +export const sliceReducers = { + annualReport, +}; diff --git a/app/javascript/mastodon/store/typed_functions.ts b/app/javascript/mastodon/store/typed_functions.ts index 5ceb05909f..79bca08a52 100644 --- a/app/javascript/mastodon/store/typed_functions.ts +++ b/app/javascript/mastodon/store/typed_functions.ts @@ -205,7 +205,7 @@ export function createDataLoadingThunk( thunkOptions?: AppThunkOptions, ): ReturnType>; -// Overload when the `onData` method returns nothing, then the mayload is the `onData` result +// Overload when the `onData` method returns nothing, then the payload is the `onData` result export function createDataLoadingThunk( name: string, loadData: LoadData,