Allow managing interaction policies

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2024-08-26 20:05:08 +02:00
parent 40141191c6
commit 4c990c8fff
15 changed files with 280 additions and 532 deletions

View file

@ -132,7 +132,7 @@
"multiselect-react-dropdown": "^2.0.25",
"object-to-formdata": "^4.5.1",
"path-browserify": "^1.0.1",
"pl-api": "^0.0.18",
"pl-api": "^0.0.20",
"postcss": "^8.4.29",
"process": "^0.11.10",
"punycode": "^2.1.1",

View file

@ -10,13 +10,9 @@ import {
authorizeFollowRequest,
blockAccount,
createAccount,
expandFollowers,
expandFollowing,
expandFollowRequests,
fetchAccount,
fetchAccountByUsername,
fetchFollowers,
fetchFollowing,
fetchFollowRequests,
fetchRelationships,
muteAccount,
@ -841,322 +837,6 @@ describe('removeFromFollowers()', () => {
});
});
describe('fetchFollowers()', () => {
const id = '1';
describe('when logged in', () => {
beforeEach(() => {
const state = rootState.set('me', '123');
store = mockStore(state);
});
describe('with a successful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${id}/followers`).reply(200, [], {
link: `<https://example.com/api/v1/accounts/${id}/followers?since_id=1>; rel='prev'`,
});
});
});
it('should dispatch the correct actions', async() => {
const expectedActions = [
{ type: 'FOLLOWERS_FETCH_REQUEST', id },
{ type: 'ACCOUNTS_IMPORT', accounts: [] },
{
type: 'FOLLOWERS_FETCH_SUCCESS',
id,
accounts: [],
next: null,
},
];
await store.dispatch(fetchFollowers(id));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('with an unsuccessful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${id}/followers`).networkError();
});
});
it('should dispatch the correct actions', async() => {
const expectedActions = [
{ type: 'FOLLOWERS_FETCH_REQUEST', id },
{ type: 'FOLLOWERS_FETCH_FAIL', id, error: new Error('Network Error') },
];
await store.dispatch(fetchFollowers(id));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});
});
describe('expandFollowers()', () => {
const id = '1';
describe('when logged out', () => {
beforeEach(() => {
const state = rootState.set('me', null);
store = mockStore(state);
});
it('should do nothing', async() => {
await store.dispatch(expandFollowers(id));
const actions = store.getActions();
expect(actions).toEqual([]);
});
});
describe('when logged in', () => {
beforeEach(() => {
const state = rootState
.set('user_lists', ReducerRecord({
followers: ImmutableMap({
[id]: ListRecord({
next: 'next_url',
}),
}),
}))
.set('me', '123');
store = mockStore(state);
});
describe('when the url is null', () => {
beforeEach(() => {
const state = rootState
.set('user_lists', ReducerRecord({
followers: ImmutableMap({
[id]: ListRecord({
next: null,
}),
}),
}))
.set('me', '123');
store = mockStore(state);
});
it('should do nothing', async() => {
await store.dispatch(expandFollowers(id));
const actions = store.getActions();
expect(actions).toEqual([]);
});
});
describe('with a successful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('next_url').reply(200, [], {
link: `<https://example.com/api/v1/accounts/${id}/followers?since_id=1>; rel='prev'`,
});
});
});
it('should dispatch the correct actions', async() => {
const expectedActions = [
{ type: 'FOLLOWERS_EXPAND_REQUEST', id },
{ type: 'ACCOUNTS_IMPORT', accounts: [] },
{
type: 'FOLLOWERS_EXPAND_SUCCESS',
id,
accounts: [],
next: null,
},
];
await store.dispatch(expandFollowers(id));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('with an unsuccessful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('next_url').networkError();
});
});
it('should dispatch the correct actions', async() => {
const expectedActions = [
{ type: 'FOLLOWERS_EXPAND_REQUEST', id },
{ type: 'FOLLOWERS_EXPAND_FAIL', id, error: new Error('Network Error') },
];
await store.dispatch(expandFollowers(id));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});
});
describe('fetchFollowing()', () => {
const id = '1';
describe('when logged in', () => {
beforeEach(() => {
const state = rootState.set('me', '123');
store = mockStore(state);
});
describe('with a successful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${id}/following`).reply(200, [], {
link: `<https://example.com/api/v1/accounts/${id}/following?since_id=1>; rel='prev'`,
});
});
});
it('should dispatch the correct actions', async() => {
const expectedActions = [
{ type: 'FOLLOWING_FETCH_REQUEST', id },
{ type: 'ACCOUNTS_IMPORT', accounts: [] },
{
type: 'FOLLOWING_FETCH_SUCCESS',
id,
accounts: [],
next: null,
},
];
await store.dispatch(fetchFollowing(id));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('with an unsuccessful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${id}/following`).networkError();
});
});
it('should dispatch the correct actions', async() => {
const expectedActions = [
{ type: 'FOLLOWING_FETCH_REQUEST', id },
{ type: 'FOLLOWING_FETCH_FAIL', id, error: new Error('Network Error') },
];
await store.dispatch(fetchFollowing(id));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});
});
describe('expandFollowing()', () => {
const id = '1';
describe('when logged out', () => {
beforeEach(() => {
const state = rootState.set('me', null);
store = mockStore(state);
});
it('should do nothing', async() => {
await store.dispatch(expandFollowing(id));
const actions = store.getActions();
expect(actions).toEqual([]);
});
});
describe('when logged in', () => {
beforeEach(() => {
const state = rootState
.set('user_lists', ReducerRecord({
following: ImmutableMap({
[id]: ListRecord({
next: 'next_url',
}),
}),
}))
.set('me', '123');
store = mockStore(state);
});
describe('when the url is null', () => {
beforeEach(() => {
const state = rootState
.set('user_lists', ReducerRecord({
following: ImmutableMap({
[id]: ListRecord({
next: null,
}),
}),
}))
.set('me', '123');
store = mockStore(state);
});
it('should do nothing', async() => {
await store.dispatch(expandFollowing(id));
const actions = store.getActions();
expect(actions).toEqual([]);
});
});
describe('with a successful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('next_url').reply(200, [], {
link: `<https://example.com/api/v1/accounts/${id}/following?since_id=1>; rel='prev'`,
});
});
});
it('should dispatch the correct actions', async() => {
const expectedActions = [
{ type: 'FOLLOWING_EXPAND_REQUEST', id },
{ type: 'ACCOUNTS_IMPORT', accounts: [] },
{
type: 'FOLLOWING_EXPAND_SUCCESS',
id,
accounts: [],
next: null,
},
];
await store.dispatch(expandFollowing(id));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('with an unsuccessful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('next_url').networkError();
});
});
it('should dispatch the correct actions', async() => {
const expectedActions = [
{ type: 'FOLLOWING_EXPAND_REQUEST', id },
{ type: 'FOLLOWING_EXPAND_FAIL', id, error: new Error('Network Error') },
];
await store.dispatch(expandFollowing(id));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});
});
describe('fetchRelationships()', () => {
const id = '1';

View file

@ -62,22 +62,6 @@ const ACCOUNT_LOOKUP_REQUEST = 'ACCOUNT_LOOKUP_REQUEST' as const;
const ACCOUNT_LOOKUP_SUCCESS = 'ACCOUNT_LOOKUP_SUCCESS' as const;
const ACCOUNT_LOOKUP_FAIL = 'ACCOUNT_LOOKUP_FAIL' as const;
const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST' as const;
const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS' as const;
const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL' as const;
const FOLLOWERS_EXPAND_REQUEST = 'FOLLOWERS_EXPAND_REQUEST' as const;
const FOLLOWERS_EXPAND_SUCCESS = 'FOLLOWERS_EXPAND_SUCCESS' as const;
const FOLLOWERS_EXPAND_FAIL = 'FOLLOWERS_EXPAND_FAIL' as const;
const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST' as const;
const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS' as const;
const FOLLOWING_FETCH_FAIL = 'FOLLOWING_FETCH_FAIL' as const;
const FOLLOWING_EXPAND_REQUEST = 'FOLLOWING_EXPAND_REQUEST' as const;
const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS' as const;
const FOLLOWING_EXPAND_FAIL = 'FOLLOWING_EXPAND_FAIL' as const;
const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST' as const;
const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS' as const;
const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL' as const;
@ -370,147 +354,6 @@ const removeFromFollowersFail = (accountId: string, error: unknown) => ({
error,
});
const fetchFollowers = (accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchFollowersRequest(accountId));
return getClient(getState()).accounts.getAccountFollowers(accountId)
.then(response => {
dispatch(importFetchedAccounts(response.items));
dispatch(fetchFollowersSuccess(accountId, response.items, response.next));
dispatch(fetchRelationships(response.items.map((item) => item.id)));
})
.catch(error => {
dispatch(fetchFollowersFail(accountId, error));
});
};
const fetchFollowersRequest = (accountId: string) => ({
type: FOLLOWERS_FETCH_REQUEST,
accountId,
});
const fetchFollowersSuccess = (accountId: string, accounts: Array<Account>, next: (() => Promise<PaginatedResponse<Account>>) | null) => ({
type: FOLLOWERS_FETCH_SUCCESS,
accountId,
accounts,
next,
});
const fetchFollowersFail = (accountId: string, error: unknown) => ({
type: FOLLOWERS_FETCH_FAIL,
accountId,
error,
});
const expandFollowers = (accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return null;
const next = getState().user_lists.followers.get(accountId)!.next;
if (next === null) return;
dispatch(expandFollowersRequest(accountId));
next().then(response => {
dispatch(importFetchedAccounts(response.items));
dispatch(expandFollowersSuccess(accountId, response.items, response.next));
dispatch(fetchRelationships(response.items.map((item) => item.id)));
})
.catch(error => {
dispatch(expandFollowersFail(accountId, error));
});
};
const expandFollowersRequest = (accountId: string) => ({
type: FOLLOWERS_EXPAND_REQUEST,
accountId,
});
const expandFollowersSuccess = (accountId: string, accounts: Array<Account>, next: (() => Promise<PaginatedResponse<Account>>) | null) => ({
type: FOLLOWERS_EXPAND_SUCCESS,
accountId,
accounts,
next,
});
const expandFollowersFail = (accountId: string, error: unknown) => ({
type: FOLLOWERS_EXPAND_FAIL,
accountId,
error,
});
const fetchFollowing = (accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchFollowingRequest(accountId));
return getClient(getState()).accounts.getAccountFollowing(accountId)
.then(response => {
dispatch(importFetchedAccounts(response.items));
dispatch(fetchFollowingSuccess(accountId, response.items, response.next));
dispatch(fetchRelationships(response.items.map((item) => item.id)));
})
.catch(error => {
dispatch(fetchFollowingFail(accountId, error));
});
};
const fetchFollowingRequest = (accountId: string) => ({
type: FOLLOWING_FETCH_REQUEST,
accountId,
});
const fetchFollowingSuccess = (accountId: string, accounts: Array<Account>, next: (() => Promise<PaginatedResponse<Account>>) | null) => ({
type: FOLLOWING_FETCH_SUCCESS,
accountId,
accounts,
next,
});
const fetchFollowingFail = (accountId: string, error: unknown) => ({
type: FOLLOWING_FETCH_FAIL,
accountId,
error,
});
const expandFollowing = (accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return null;
const next = getState().user_lists.following.get(accountId)!.next;
if (next === null) return;
dispatch(expandFollowingRequest(accountId));
next().then(response => {
dispatch(importFetchedAccounts(response.items));
dispatch(expandFollowingSuccess(accountId, response.items, response.next));
dispatch(fetchRelationships(response.items.map((item) => item.id)));
}).catch(error => {
dispatch(expandFollowingFail(accountId, error));
});
};
const expandFollowingRequest = (accountId: string) => ({
type: FOLLOWING_EXPAND_REQUEST,
accountId,
});
const expandFollowingSuccess = (accountId: string, accounts: Array<Account>, next: (() => Promise<PaginatedResponse<Account>>) | null) => ({
type: FOLLOWING_EXPAND_SUCCESS,
accountId,
accounts,
next,
});
const expandFollowingFail = (accountId: string, error: unknown) => ({
type: FOLLOWING_EXPAND_FAIL,
accountId,
error,
});
const fetchRelationships = (accountIds: string[]) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return null;
@ -874,18 +717,6 @@ export {
ACCOUNT_LOOKUP_REQUEST,
ACCOUNT_LOOKUP_SUCCESS,
ACCOUNT_LOOKUP_FAIL,
FOLLOWERS_FETCH_REQUEST,
FOLLOWERS_FETCH_SUCCESS,
FOLLOWERS_FETCH_FAIL,
FOLLOWERS_EXPAND_REQUEST,
FOLLOWERS_EXPAND_SUCCESS,
FOLLOWERS_EXPAND_FAIL,
FOLLOWING_FETCH_REQUEST,
FOLLOWING_FETCH_SUCCESS,
FOLLOWING_FETCH_FAIL,
FOLLOWING_EXPAND_REQUEST,
FOLLOWING_EXPAND_SUCCESS,
FOLLOWING_EXPAND_FAIL,
RELATIONSHIPS_FETCH_REQUEST,
RELATIONSHIPS_FETCH_SUCCESS,
RELATIONSHIPS_FETCH_FAIL,
@ -936,22 +767,6 @@ export {
removeFromFollowersRequest,
removeFromFollowersSuccess,
removeFromFollowersFail,
fetchFollowers,
fetchFollowersRequest,
fetchFollowersSuccess,
fetchFollowersFail,
expandFollowers,
expandFollowersRequest,
expandFollowersSuccess,
expandFollowersFail,
fetchFollowing,
fetchFollowingRequest,
fetchFollowingSuccess,
fetchFollowingFail,
expandFollowing,
expandFollowingRequest,
expandFollowingSuccess,
expandFollowingFail,
fetchRelationships,
fetchRelationshipsRequest,
fetchRelationshipsSuccess,

View file

@ -404,7 +404,7 @@ const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}) =>
scheduled_at: compose.schedule?.toISOString(),
language: compose.language || compose.suggested_language || undefined,
to: to.size ? to.toArray() : undefined,
federated: compose.federated,
local_only: !compose.federated,
};
if (compose.poll) {

View file

@ -192,12 +192,8 @@ const updateAuthAccount = (url: string, settings: any) => {
const key = `authAccount:${url}`;
return KVStore.getItem(key).then((oldAccount: any) => {
if (!oldAccount) return;
if (!oldAccount.__meta) oldAccount.__meta = { pleroma: { settings_store: {} } };
else if (!oldAccount.__meta.pleroma) oldAccount.__meta.pleroma = { settings_store: {} };
else if (!oldAccount.__meta.pleroma.settings_store) oldAccount.__meta.pleroma.settings_store = {};
oldAccount.__meta.pleroma.settings_store[FE_NAME] = settings;
// const settingsStore = oldAccount?.__meta || {};
// settingsStore[FE_NAME] = settings;
if (!oldAccount.settings_store) oldAccount.settings_store = {};
oldAccount.settings_store[FE_NAME] = settings;
KVStore.setItem(key, oldAccount);
}).catch(console.error);
};

View file

@ -31,6 +31,9 @@ export { useUpdateGroup } from './groups/useUpdateGroup';
// Instance
export { useTranslationLanguages } from './instance/useTranslationLanguages';
// Settings
export { useInteractionPolicies } from './settings/useInteractionPolicies';
// Statuses
export { useBookmarkFolders } from './statuses/useBookmarkFolders';
export { useBookmarkFolder } from './statuses/useBookmarkFolder';

View file

@ -0,0 +1,41 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { type InteractionPolicies, interactionPoliciesSchema } from 'pl-api';
import { useClient, useFeatures, useLoggedIn } from 'soapbox/hooks';
import { queryClient } from 'soapbox/queries/client';
const emptySchema = interactionPoliciesSchema.parse({});
const useInteractionPolicies = () => {
const client = useClient();
const { isLoggedIn } = useLoggedIn();
const features = useFeatures();
const { data, ...result } = useQuery({
queryKey: ['interactionPolicies'],
queryFn: client.settings.getInteractionPolicies,
placeholderData: emptySchema,
enabled: isLoggedIn && features.interactionRequests,
});
const {
mutate: updateInteractionPolicies,
isPending: isUpdating,
} = useMutation({
mutationFn: (policy: InteractionPolicies) =>
client.settings.updateInteractionPolicies(policy),
retry: false,
onSuccess: (policy) => {
queryClient.setQueryData(['interactionPolicies'], policy);
},
});
return {
interactionPolicies: data || emptySchema,
updateInteractionPolicies,
isUpdating,
...result,
};
};
export { useInteractionPolicies };

View file

@ -0,0 +1,47 @@
import clsx from 'clsx';
import React from 'react';
interface IInlineMultiselect<T extends string> {
items: Record<T, string>;
value?: T[];
onChange: ((values: T[]) => void);
disabled?: boolean;
}
/**
*
*/
const InlineMultiselect = <T extends string>({ items, value, onChange, disabled }: IInlineMultiselect<T>) => (
<div className='flex w-fit overflow-hidden rounded-md border border-gray-400 bg-white black:bg-black dark:border-gray-800 dark:bg-gray-900'>
{Object.entries(items).map(([key, label], i) => {
const checked = value?.includes(key as T);
return (
<label
className={clsx('px-3 py-2 text-white transition-colors hover:bg-primary-700 [&:has(:focus-visible)]:bg-primary-700', {
'cursor-pointer': !disabled,
'opacity-75': disabled,
'bg-gray-500': !checked,
'bg-primary-600': checked,
'border-l border-gray-400 dark:border-gray-800': i !== 0,
})}
key={key}
>
<input
name={key}
type='checkbox'
className='sr-only'
checked={checked}
onChange={({ target }) => onChange((target.checked ? [...(value || []), target.name] : value?.filter(key => key !== target.name) || []) as Array<T>)}
disabled={disabled}
/>
{label as string}
</label>
);
})}
</div>
);
export {
InlineMultiselect,
};

View file

@ -0,0 +1,175 @@
import React, { useEffect, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useInteractionPolicies } from 'soapbox/api/hooks';
import List, { ListItem } from 'soapbox/components/list';
import { Button, CardTitle, Column, Form, FormActions, Tabs } from 'soapbox/components/ui';
import { InlineMultiselect } from 'soapbox/components/ui/inline-multiselect/inline-multiselect';
import toast from 'soapbox/toast';
import Warning from '../compose/components/warning';
type Visibility = 'public' | 'unlisted' | 'private';
type Policy = 'can_favourite' | 'can_reblog' | 'can_reply';
type Rule = 'always' | 'with_approval';
type Scope = 'followers' | 'following' | 'mentioned' | 'public';
const policies: Array<Policy> = ['can_favourite', 'can_reply', 'can_reblog'];
const messages = defineMessages({
heading: { id: 'column.interaction_policies', defaultMessage: 'Interaction policies' },
public: { id: 'interaction_policies.tabs.public', defaultMessage: 'Public' },
unlisted: { id: 'interaction_policies.tabs.unlisted', defaultMessage: 'Unlisted' },
private: { id: 'interaction_policies.tabs.private', defaultMessage: 'Followers-only' },
submit: { id: 'interaction_policies.update', defaultMessage: 'Update' },
success: { id: 'interaction_policies.success', defaultMessage: 'Updated interaction policies' },
fail: { id: 'interaction_policies.fail', defaultMessage: 'Failed to update interaction policies' },
});
const scopeMessages = defineMessages({
followers: { id: 'interaction_policies.entry.followers', defaultMessage: 'Followers' },
following: { id: 'interaction_policies.entry.following', defaultMessage: 'People I follow' },
mentioned: { id: 'interaction_policies.entry.mentioned', defaultMessage: 'Mentioned' },
public: { id: 'interaction_policies.entry.public', defaultMessage: 'Everyone' },
});
const titleMessages = {
public: defineMessages({
can_favourite: { id: 'interaction_policies.title.public.can_favourite', defaultMessage: 'Who can like a public post?' },
can_reply: { id: 'interaction_policies.title.public.can_reply', defaultMessage: 'Who can reply to a public post?' },
can_reblog: { id: 'interaction_policies.title.public.can_reblog', defaultMessage: 'Who can repost a public post?' },
}),
unlisted: defineMessages({
can_favourite: { id: 'interaction_policies.title.unlisted.can_favourite', defaultMessage: 'Who can like an unlisted post?' },
can_reply: { id: 'interaction_policies.title.unlisted.can_reply', defaultMessage: 'Who can reply to an unlisted post?' },
can_reblog: { id: 'interaction_policies.title.unlisted.can_reblog', defaultMessage: 'Who can repost an unlisted post?' },
}),
private: defineMessages({
can_favourite: { id: 'interaction_policies.title.private.can_favourite', defaultMessage: 'Who can like a followers-only post?' },
can_reply: { id: 'interaction_policies.title.private.can_reply', defaultMessage: 'Who can reply to a followers-only post?' },
can_reblog: { id: 'interaction_policies.title.private.can_reblog', defaultMessage: 'Who can repost a followers-only post?' },
}),
};
const options: Record<Visibility, Record<Policy, Array<Scope>>> = {
public: {
can_favourite: ['followers', 'following', 'mentioned', 'public'],
can_reblog: ['followers', 'following', 'mentioned', 'public'],
can_reply: ['followers', 'following', 'public'],
},
unlisted: {
can_favourite: ['followers', 'following', 'mentioned', 'public'],
can_reblog: ['followers', 'following', 'mentioned', 'public'],
can_reply: ['followers', 'following', 'public'],
},
private: {
can_favourite: ['followers'],
can_reblog: [],
can_reply: ['followers'],
},
};
const InteractionPolicies = () => {
const { interactionPolicies: initial, updateInteractionPolicies, isUpdating } = useInteractionPolicies();
const intl = useIntl();
const [interactionPolicies, setInteractionPolicies] = useState(initial);
const [visibility, setVisibility] = useState<Visibility>('public');
useEffect(() => {
setInteractionPolicies(initial);
}, [initial]);
const getItems = (visibility: Visibility, policy: Policy) => Object.fromEntries(options[visibility][policy].map(scope => [scope, intl.formatMessage(scopeMessages[scope])])) as Record<Scope, string>;
const handleChange = (visibility: Visibility, policy: Policy, rule: Rule) => (value: Array<Scope>) => {
const newPolicies = { ...interactionPolicies };
newPolicies[visibility][policy][rule] = value;
newPolicies[visibility][policy][rule === 'always' ? 'with_approval' : 'always'] = newPolicies[visibility][policy][rule === 'always' ? 'with_approval' : 'always'].filter(rule => !value.includes(rule as any));
setInteractionPolicies(newPolicies);
};
const handleSubmit = () => {
updateInteractionPolicies(interactionPolicies, {
onSuccess: () => toast.success(messages.success),
onError: () => toast.success(messages.fail),
});
};
const renderPolicy = (visibility: 'public' | 'unlisted' | 'private') => (
<>
{policies.map((policy) => {
const items = getItems(visibility, policy);
if (!Object.keys(items).length) return null;
return (
<React.Fragment key={policy}>
<CardTitle
title={intl.formatMessage(titleMessages[visibility][policy])}
/>
{policy === 'can_reply' && (
<Warning message={<FormattedMessage id='interaction_policies.mentioned_warning' defaultMessage='Mentioned users can always reply.' />} />
)}
<List>
<ListItem label='Always'>
<InlineMultiselect<Scope>
items={items}
value={interactionPolicies[visibility][policy].always as Array<Scope>}
onChange={handleChange(visibility, policy, 'always')}
disabled={isUpdating}
/>
</ListItem>
<ListItem label='With approval'>
<InlineMultiselect
items={items}
value={interactionPolicies[visibility][policy].with_approval as Array<Scope>}
onChange={handleChange(visibility, policy, 'with_approval')}
disabled={isUpdating}
/>
</ListItem>
</List>
</React.Fragment>
);
})}
</>
);
return (
<Column label={intl.formatMessage(messages.heading)} backHref='/settings'>
<Form onSubmit={handleSubmit}>
<Tabs
items={[
{
text: intl.formatMessage(messages.public),
action: () => setVisibility('public'),
name: 'public',
},
{
text: intl.formatMessage(messages.unlisted),
action: () => setVisibility('unlisted'),
name: 'unlisted',
},
{
text: intl.formatMessage(messages.private),
action: () => setVisibility('private'),
name: 'private',
}]}
activeItem={visibility}
/>
{renderPolicy(visibility)}
<FormActions>
<Button type='submit' theme='primary' disabled={isUpdating}>
{intl.formatMessage(messages.submit)}
</Button>
</FormActions>
</Form>
</Column>
);
};
export { InteractionPolicies as default };

View file

@ -26,6 +26,7 @@ const messages = defineMessages({
exportData: { id: 'column.export_data', defaultMessage: 'Export data' },
filters: { id: 'navigation_bar.filters', defaultMessage: 'Filters' },
importData: { id: 'navigation_bar.import_data', defaultMessage: 'Import data' },
interactionPolicies: { id: 'column.interaction_policies', defaultMessage: 'Interaction policies' },
mfaDisabled: { id: 'mfa.disabled', defaultMessage: 'Disabled' },
mfaEnabled: { id: 'mfa.enabled', defaultMessage: 'Enabled' },
mutes: { id: 'settings.mutes', defaultMessage: 'Mutes' },
@ -82,6 +83,7 @@ const Settings = () => {
<ListItem label={intl.formatMessage(messages.blocks)} to='/blocks' />
{(features.filters || features.filtersV2) && <ListItem label={intl.formatMessage(messages.filters)} to='/filters' />}
{features.federating && <ListItem label={intl.formatMessage(messages.domainBlocks)} to='/domain_blocks' />}
{features.interactionRequests && <ListItem label={intl.formatMessage(messages.interactionPolicies)} to='/interaction_policies' />}
</List>
</CardBody>

View file

@ -129,6 +129,7 @@ import {
DraftStatuses,
Circle,
BubbleTimeline,
InteractionPolicies,
} from './util/async-components';
import GlobalHotkeys from './util/global-hotkeys';
import { WrappedRoute } from './util/react-router-helpers';
@ -297,6 +298,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
<WrappedRoute path='/settings/account' layout={DefaultLayout} component={DeleteAccount} content={children} />
<WrappedRoute path='/settings/mfa' layout={DefaultLayout} component={MfaForm} exact />
<WrappedRoute path='/settings/tokens' layout={DefaultLayout} component={AuthTokenList} content={children} />
{features.interactionRequests && <WrappedRoute path='/settings/interaction_policies' layout={DefaultLayout} component={InteractionPolicies} content={children} />}
<WrappedRoute path='/settings' layout={DefaultLayout} component={Settings} content={children} />
<WrappedRoute path='/soapbox/config' adminOnly layout={DefaultLayout} component={SoapboxConfig} content={children} />

View file

@ -122,4 +122,4 @@ export const Rules = lazy(() => import('soapbox/features/admin/rules'));
export const DraftStatuses = lazy(() => import('soapbox/features/draft-statuses'));
export const Circle = lazy(() => import('soapbox/features/circle'));
export const BubbleTimeline = lazy(() => import('soapbox/features/bubble-timeline'));
export const InteractionPolicies = lazy(() => import('soapbox/features/interaction-policies'));

View file

@ -27,7 +27,7 @@ const updateFrequentLanguages = (state: State, language: string) =>
const importSettings = (state: State, account: APIEntity) => {
account = fromJS(account);
const prefs = account.getIn(['__meta', 'pleroma', 'settings_store', FE_NAME], ImmutableMap());
const prefs = account.getIn(['settings_store', FE_NAME], ImmutableMap());
return state.merge(prefs) as State;
};
@ -41,7 +41,6 @@ const settings = (
): State => {
switch (action.type) {
case ME_FETCH_SUCCESS:
console.log('importing', action.me);
return importSettings(state, action.me);
case NOTIFICATIONS_FILTER_SET:
case SEARCH_FILTER_SET:

View file

@ -6,10 +6,6 @@ import {
import { AnyAction } from 'redux';
import {
FOLLOWERS_FETCH_SUCCESS,
FOLLOWERS_EXPAND_SUCCESS,
FOLLOWING_FETCH_SUCCESS,
FOLLOWING_EXPAND_SUCCESS,
FOLLOW_REQUESTS_FETCH_SUCCESS,
FOLLOW_REQUESTS_EXPAND_SUCCESS,
FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
@ -138,14 +134,6 @@ const normalizeFollowRequest = (state: State, notification: Notification) =>
const userLists = (state = ReducerRecord(), action: DirectoryAction | AnyAction) => {
switch (action.type) {
case FOLLOWERS_FETCH_SUCCESS:
return normalizeList(state, ['followers', action.accountId], action.accounts, action.next);
case FOLLOWERS_EXPAND_SUCCESS:
return appendToList(state, ['followers', action.accountId], action.accounts, action.next);
case FOLLOWING_FETCH_SUCCESS:
return normalizeList(state, ['following', action.accountId], action.accounts, action.next);
case FOLLOWING_EXPAND_SUCCESS:
return appendToList(state, ['following', action.accountId], action.accounts, action.next);
case REBLOGS_FETCH_SUCCESS:
return normalizeList(state, ['reblogged_by', action.statusId], action.accounts, action.next);
case REBLOGS_EXPAND_SUCCESS:

View file

@ -8385,10 +8385,10 @@ pkg-types@^1.0.3:
mlly "^1.2.0"
pathe "^1.1.0"
pl-api@^0.0.18:
version "0.0.18"
resolved "https://registry.yarnpkg.com/pl-api/-/pl-api-0.0.18.tgz#23542323cb9450e4c084a38e411a8dc5f3e806f5"
integrity sha512-JYNR3hKO8bHUOnMNERCXwYj6PMNToY9uqPC5lFbe0vQry77ioHzaqiGzA4F1cK1nSSYZhDR41psuBqisvIp9kg==
pl-api@^0.0.20:
version "0.0.20"
resolved "https://registry.yarnpkg.com/pl-api/-/pl-api-0.0.20.tgz#2839820d399d8018ca3c89b8529e2d4c239b77c1"
integrity sha512-FL5eCZnJDPuazGK9zMrIHsKmM9Mb1kvcaYMR6ecbGpkzmustjNL0f8gdH86rFYL1k33zyKLA3sd7OAXB4zFoMg==
dependencies:
blurhash "^2.0.5"
http-link-header "^1.1.3"