2
0

Update Redux to handle quote posts (#35715)

This commit is contained in:
Echo
2025-08-08 10:44:05 +02:00
committed by GitHub
parent a485f97d21
commit 8ee4b3f906
8 changed files with 183 additions and 12 deletions

View File

@@ -228,6 +228,8 @@ export function submitCompose() {
visibility: getState().getIn(['compose', 'privacy']),
poll: getState().getIn(['compose', 'poll'], null),
language: getState().getIn(['compose', 'language']),
quoted_status_id: getState().getIn(['compose', 'quoted_status_id']),
quote_approval_policy: getState().getIn(['compose', 'quote_policy']),
},
headers: {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),

View File

@@ -1,9 +1,18 @@
import { createAction } from '@reduxjs/toolkit';
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import { apiUpdateMedia } from 'mastodon/api/compose';
import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
import type { MediaAttachment } from 'mastodon/models/media_attachment';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
import {
createDataLoadingThunk,
createAppThunk,
} from 'mastodon/store/typed_functions';
import type { ApiQuotePolicy } from '../api_types/quotes';
import type { Status } from '../models/status';
import { ensureComposeIsVisible } from './compose';
type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
unattached?: boolean;
@@ -68,3 +77,26 @@ export const changeUploadCompose = createDataLoadingThunk(
useLoadingBar: false,
},
);
export const quoteComposeByStatus = createAppThunk(
'compose/quoteComposeStatus',
(status: Status, { getState }) => {
ensureComposeIsVisible(getState);
return status;
},
);
export const quoteComposeById = createAppThunk(
(statusId: string, { dispatch, getState }) => {
const status = getState().statuses.get(statusId);
if (status) {
dispatch(quoteComposeByStatus(status));
}
},
);
export const quoteComposeCancel = createAction('compose/quoteComposeCancel');
export const setQuotePolicy = createAction<ApiQuotePolicy>(
'compose/setQuotePolicy',
);

View File

@@ -0,0 +1,23 @@
import type { ApiStatusJSON } from './statuses';
export type ApiQuoteState = 'accepted' | 'pending' | 'revoked' | 'unauthorized';
export type ApiQuotePolicy = 'public' | 'followers' | 'nobody';
interface ApiQuoteEmptyJSON {
state: Exclude<ApiQuoteState, 'accepted'>;
quoted_status: null;
}
interface ApiNestedQuoteJSON {
state: 'accepted';
quoted_status_id: string;
}
interface ApiQuoteAcceptedJSON {
state: 'accepted';
quoted_status: Omit<ApiStatusJSON, 'quote'> & {
quote: ApiNestedQuoteJSON | ApiQuoteEmptyJSON;
};
}
export type ApiQuoteJSON = ApiQuoteAcceptedJSON | ApiQuoteEmptyJSON;

View File

@@ -4,6 +4,7 @@ import type { ApiAccountJSON } from './accounts';
import type { ApiCustomEmojiJSON } from './custom_emoji';
import type { ApiMediaAttachmentJSON } from './media_attachments';
import type { ApiPollJSON } from './polls';
import type { ApiQuoteJSON } from './quotes';
// See app/modals/status.rb
export type StatusVisibility =
@@ -118,6 +119,7 @@ export interface ApiStatusJSON {
card?: ApiPreviewCardJSON;
poll?: ApiPollJSON;
quote?: ApiQuoteJSON;
}
export interface ApiContextJSON {

View File

@@ -1,6 +1,11 @@
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
import { changeUploadCompose } from 'mastodon/actions/compose_typed';
import {
changeUploadCompose,
quoteComposeByStatus,
quoteComposeCancel,
setQuotePolicy,
} from 'mastodon/actions/compose_typed';
import { timelineDelete } from 'mastodon/actions/timelines_typed';
import {
@@ -83,6 +88,11 @@ const initialState = ImmutableMap({
resetFileKey: Math.floor((Math.random() * 0x10000)),
idempotencyKey: null,
tagHistory: ImmutableList(),
// Quotes
quoted_status_id: null,
quote_policy: 'public',
default_quote_policy: 'public', // Set in hydration.
});
const initialPoll = ImmutableMap({
@@ -117,6 +127,8 @@ function clearAll(state) {
map.set('progress', 0);
map.set('poll', null);
map.set('idempotencyKey', uuid());
map.set('quoted_status_id', null);
map.set('quote_policy', state.get('default_quote_policy'));
});
}
@@ -317,6 +329,15 @@ export const composeReducer = (state = initialState, action) => {
return state.set('is_changing_upload', true);
} else if (changeUploadCompose.rejected.match(action)) {
return state.set('is_changing_upload', false);
} else if (quoteComposeByStatus.match(action)) {
const status = action.payload;
if (status.getIn(['quote_approval', 'current_user']) === 'automatic') {
return state.set('quoted_status_id', status.get('id'));
}
} else if (quoteComposeCancel.match(action)) {
return state.set('quoted_status_id', null);
} else if (setQuotePolicy.match(action)) {
return state.set('quote_policy', action.payload);
}
switch(action.type) {

View File

@@ -1,5 +1,12 @@
import type { GetThunkAPI } from '@reduxjs/toolkit';
import { createAsyncThunk, createSelector } from '@reduxjs/toolkit';
import type {
ActionCreatorWithPreparedPayload,
GetThunkAPI,
} from '@reduxjs/toolkit';
import {
createAsyncThunk as rtkCreateAsyncThunk,
createSelector,
createAction,
} from '@reduxjs/toolkit';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
@@ -18,7 +25,7 @@ interface AppMeta {
useLoadingBar?: boolean;
}
export const createAppAsyncThunk = createAsyncThunk.withTypes<{
export const createAppAsyncThunk = rtkCreateAsyncThunk.withTypes<{
state: RootState;
dispatch: AppDispatch;
rejectValue: AsyncThunkRejectValue;
@@ -43,9 +50,88 @@ interface AppThunkOptions<Arg> {
) => boolean;
}
const createBaseAsyncThunk = createAsyncThunk.withTypes<AppThunkConfig>();
// Type definitions for the sync thunks.
type AppThunk<Arg = void, Returned = void> = (
arg: Arg,
) => (dispatch: AppDispatch, getState: () => RootState) => Returned;
export function createThunk<Arg = void, Returned = void>(
type AppThunkCreator<Arg = void, Returned = void, ExtraArg = unknown> = (
arg: Arg,
api: AppThunkApi,
extra?: ExtraArg,
) => Returned;
type AppThunkActionCreator<
Arg = void,
Returned = void,
> = ActionCreatorWithPreparedPayload<
[Returned, Arg],
Returned,
string,
never,
{ arg: Arg }
>;
// Version that does not dispatch it's own action.
export function createAppThunk<Arg = void, Returned = void, ExtraArg = unknown>(
creator: AppThunkCreator<Arg, Returned, ExtraArg>,
extra?: ExtraArg,
): AppThunk<Arg, Returned>;
// Version that dispatches an named action with the result of the creator callback.
export function createAppThunk<Arg = void, Returned = void, ExtraArg = unknown>(
name: string,
creator: AppThunkCreator<Arg, Returned, ExtraArg>,
extra?: ExtraArg,
): AppThunk<Arg, Returned> & AppThunkActionCreator<Arg, Returned>;
/** Creates a thunk that dispatches an action. */
export function createAppThunk<Arg = void, Returned = void, ExtraArg = unknown>(
nameOrCreator: string | AppThunkCreator<Arg, Returned, ExtraArg>,
maybeCreatorOrExtra?: AppThunkCreator<Arg, Returned, ExtraArg> | ExtraArg,
maybeExtra?: ExtraArg,
) {
const isDispatcher = typeof nameOrCreator === 'string';
const name = isDispatcher ? nameOrCreator : undefined;
const creator = isDispatcher
? (maybeCreatorOrExtra as AppThunkCreator<Arg, Returned, ExtraArg>)
: nameOrCreator;
const extra = isDispatcher ? maybeExtra : (maybeCreatorOrExtra as ExtraArg);
let action: null | AppThunkActionCreator<Arg, Returned> = null;
// Creates a thunk that dispatches the action with the result of the creator.
const actionCreator: AppThunk<Arg, Returned> = (arg) => {
return (dispatch, getState) => {
const result = creator(arg, { dispatch, getState }, extra);
if (action) {
// Dispatches the action with the result.
const actionObj = action(result, arg);
dispatch(actionObj);
}
return result;
};
};
// No action name provided, return the thunk directly.
if (!name) {
return actionCreator;
}
// Create the action and assign the action creator to it in order
// to have things like `toString` and `match` available.
action = createAction(name, (payload: Returned, arg: Arg) => ({
payload,
meta: {
arg,
},
}));
return Object.assign({}, action, actionCreator);
}
const createBaseAsyncThunk = rtkCreateAsyncThunk.withTypes<AppThunkConfig>();
export function createAsyncThunk<Arg = void, Returned = void>(
name: string,
creator: (arg: Arg, api: AppThunkApi) => Returned | Promise<Returned>,
options: AppThunkOptions<Arg> = {},
@@ -104,7 +190,7 @@ export function createDataLoadingThunk<LoadDataResult, Args extends ArgsType>(
name: string,
loadData: (args: Args) => Promise<LoadDataResult>,
thunkOptions?: AppThunkOptions<Args>,
): ReturnType<typeof createThunk<Args, LoadDataResult>>;
): ReturnType<typeof createAsyncThunk<Args, LoadDataResult>>;
// Overload when the `onData` method returns discardLoadDataInPayload, then the payload is empty
export function createDataLoadingThunk<LoadDataResult, Args extends ArgsType>(
@@ -114,7 +200,7 @@ export function createDataLoadingThunk<LoadDataResult, Args extends ArgsType>(
| AppThunkOptions<Args>
| OnData<Args, LoadDataResult, DiscardLoadData>,
thunkOptions?: AppThunkOptions<Args>,
): ReturnType<typeof createThunk<Args, void>>;
): ReturnType<typeof createAsyncThunk<Args, void>>;
// Overload when the `onData` method returns nothing, then the mayload is the `onData` result
export function createDataLoadingThunk<LoadDataResult, Args extends ArgsType>(
@@ -124,7 +210,7 @@ export function createDataLoadingThunk<LoadDataResult, Args extends ArgsType>(
| AppThunkOptions<Args>
| OnData<Args, LoadDataResult, void>,
thunkOptions?: AppThunkOptions<Args>,
): ReturnType<typeof createThunk<Args, LoadDataResult>>;
): ReturnType<typeof createAsyncThunk<Args, LoadDataResult>>;
// Overload when there is an `onData` method returning something
export function createDataLoadingThunk<
@@ -138,7 +224,7 @@ export function createDataLoadingThunk<
| AppThunkOptions<Args>
| OnData<Args, LoadDataResult, Returned>,
thunkOptions?: AppThunkOptions<Args>,
): ReturnType<typeof createThunk<Args, Returned>>;
): ReturnType<typeof createAsyncThunk<Args, Returned>>;
/**
* This function creates a Redux Thunk that handles loading data asynchronously (usually from the API), dispatching `pending`, `fullfilled` and `rejected` actions.
@@ -189,7 +275,7 @@ export function createDataLoadingThunk<
thunkOptions = maybeThunkOptions;
}
return createThunk<Args, Returned>(
return createAsyncThunk<Args, Returned>(
name,
async (arg, { getState, dispatch }) => {
const data = await loadData(arg, {

View File

@@ -107,6 +107,10 @@ module User::HasSettings
settings['default_privacy'] || (account.locked? ? 'private' : 'public')
end
def setting_default_quote_policy
settings['default_quote_policy'] || 'public'
end
def allows_report_emails?
settings['notification_emails.report']
end

View File

@@ -54,6 +54,7 @@ class InitialStateSerializer < ActiveModel::Serializer
store[:default_privacy] = object.visibility || object_account_user.setting_default_privacy
store[:default_sensitive] = object_account_user.setting_default_sensitive
store[:default_language] = object_account_user.preferred_posting_language
store[:default_quote_policy] = object_account_user.setting_default_quote_policy
end
store[:text] = object.text if object.text