Display Wrapstodon inline widget (#37106)

This commit is contained in:
Echo
2025-12-03 14:58:38 +01:00
committed by GitHub
parent 234990cc37
commit e5e3a64a9b
11 changed files with 252 additions and 13 deletions

View File

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

View File

@@ -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<ApiAnnualReportResponse>(`v1/annual_reports/${year}`);

View File

@@ -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 (
<InlineFollowSuggestions
key='inline-follow-suggestions'
/>
<InlineFollowSuggestions key={TIMELINE_SUGGESTIONS} />
);
case TIMELINE_WRAPSTODON:
return (
<AnnualReportTimeline key={TIMELINE_WRAPSTODON} />
)
case TIMELINE_GAP:
return (
<LoadGap

View File

@@ -59,7 +59,7 @@ export const AnnualReport: React.FC<{
const report = response?.annual_reports[0];
if (!report) {
if (!report || report.schema_version !== 1) {
return null;
}

View File

@@ -0,0 +1,44 @@
import { useCallback } from 'react';
import type { FC } from 'react';
import { showAlert } from '@/mastodon/actions/alerts';
import {
generateReport,
selectWrapstodonYear,
} from '@/mastodon/reducers/slices/annual_report';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { AnnualReportAnnouncement } from './announcement';
export const AnnualReportTimeline: FC = () => {
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 (
<AnnualReportAnnouncement
year={year.toString()}
hasData={state === 'available'}
isLoading={state === 'generating'}
onRequestBuild={handleBuildRequest}
onOpen={handleOpen}
/>
);
};

View File

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

View File

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

View File

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

View File

@@ -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],
);

View File

@@ -0,0 +1,5 @@
import { annualReport } from './annual_report';
export const sliceReducers = {
annualReport,
};

View File

@@ -205,7 +205,7 @@ export function createDataLoadingThunk<LoadDataResult, Args extends ArgsType>(
thunkOptions?: AppThunkOptions<Args>,
): ReturnType<typeof createAsyncThunk<Args, void>>;
// 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<LoadDataResult, Args extends ArgsType>(
name: string,
loadData: LoadData<Args, LoadDataResult>,