Merge remote-tracking branch 'origin/develop' into group-hashtags

group-hashtags
Alex Gleason 2023-03-27 13:26:18 -05:00
commit fef3905935
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 7211D1F99744FBB7
53 zmienionych plików z 1089 dodań i 380 usunięć

Wyświetl plik

@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Posts: truncate Nostr pubkeys in reply mentions.
- Posts: upgraded emoji picker component.
- UI: unified design of "approve" and "reject" buttons in follow requests and waitlist.
### Fixed
- Posts: fixed emojis being cut off in reactions modal.

Wyświetl plik

@ -789,9 +789,11 @@ const submitGroupEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, get
const note = getState().group_editor.note;
const avatar = getState().group_editor.avatar;
const header = getState().group_editor.header;
const visibility = getState().group_editor.locked ? 'members_only' : 'everyone'; // Truth Social
const params: Record<string, any> = {
display_name: displayName,
group_visibility: visibility,
note,
};

Wyświetl plik

@ -4,7 +4,7 @@ import { openModal } from './modals';
import type { AxiosError } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { Account, ChatMessage, Status } from 'soapbox/types/entities';
import type { Account, ChatMessage, Group, Status } from 'soapbox/types/entities';
const REPORT_INIT = 'REPORT_INIT';
const REPORT_CANCEL = 'REPORT_CANCEL';
@ -20,19 +20,29 @@ const REPORT_BLOCK_CHANGE = 'REPORT_BLOCK_CHANGE';
const REPORT_RULE_CHANGE = 'REPORT_RULE_CHANGE';
enum ReportableEntities {
ACCOUNT = 'ACCOUNT',
CHAT_MESSAGE = 'CHAT_MESSAGE',
GROUP = 'GROUP',
STATUS = 'STATUS'
}
type ReportedEntity = {
status?: Status
chatMessage?: ChatMessage
group?: Group
}
const initReport = (account: Account, entities?: ReportedEntity) => (dispatch: AppDispatch) => {
const { status, chatMessage } = entities || {};
const initReport = (entityType: ReportableEntities, account: Account, entities?: ReportedEntity) => (dispatch: AppDispatch) => {
const { status, chatMessage, group } = entities || {};
dispatch({
type: REPORT_INIT,
entityType,
account,
status,
chatMessage,
group,
});
return dispatch(openModal('REPORT'));
@ -56,7 +66,8 @@ const submitReport = () =>
return api(getState).post('/api/v1/reports', {
account_id: reports.getIn(['new', 'account_id']),
status_ids: reports.getIn(['new', 'status_ids']),
message_ids: [reports.getIn(['new', 'chat_message', 'id'])],
message_ids: [reports.getIn(['new', 'chat_message', 'id'])].filter(Boolean),
group_id: reports.getIn(['new', 'group', 'id']),
rule_ids: reports.getIn(['new', 'rule_ids']),
comment: reports.getIn(['new', 'comment']),
forward: reports.getIn(['new', 'forward']),
@ -97,6 +108,7 @@ const changeReportRule = (ruleId: string) => ({
});
export {
ReportableEntities,
REPORT_INIT,
REPORT_CANCEL,
REPORT_SUBMIT_REQUEST,

Wyświetl plik

@ -0,0 +1,72 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { HStack, IconButton, Text } from 'soapbox/components/ui';
interface IAuthorizeRejectButtons {
onAuthorize(): Promise<unknown> | unknown
onReject(): Promise<unknown> | unknown
}
/** Buttons to approve or reject a pending item, usually an account. */
const AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ onAuthorize, onReject }) => {
const [state, setState] = useState<'authorized' | 'rejected' | 'pending'>('pending');
async function handleAuthorize() {
try {
await onAuthorize();
setState('authorized');
} catch (e) {
console.error(e);
}
}
async function handleReject() {
try {
await onReject();
setState('rejected');
} catch (e) {
console.error(e);
}
}
switch (state) {
case 'pending':
return (
<HStack space={3} alignItems='center'>
<IconButton
src={require('@tabler/icons/x.svg')}
onClick={handleReject}
theme='seamless'
className='h-10 w-10 items-center justify-center border-2 border-danger-600/10 hover:border-danger-600'
iconClassName='h-6 w-6 text-danger-600'
/>
<IconButton
src={require('@tabler/icons/check.svg')}
onClick={handleAuthorize}
theme='seamless'
className='h-10 w-10 items-center justify-center border-2 border-primary-500/10 hover:border-primary-500'
iconClassName='h-6 w-6 text-primary-500'
/>
</HStack>
);
case 'authorized':
return (
<div className='rounded-full bg-gray-100 px-4 py-2 dark:bg-gray-800'>
<Text theme='muted' size='sm'>
<FormattedMessage id='authorize.success' defaultMessage='Approved' />
</Text>
</div>
);
case 'rejected':
return (
<div className='rounded-full bg-gray-100 px-4 py-2 dark:bg-gray-800'>
<Text theme='muted' size='sm'>
<FormattedMessage id='reject.success' defaultMessage='Rejected' />
</Text>
</div>
);
}
};
export { AuthorizeRejectButtons };

Wyświetl plik

@ -0,0 +1,54 @@
import clsx from 'clsx';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { HStack, Icon, Text } from 'soapbox/components/ui';
interface IPendingItemsRow {
/** Path to navigate the user when clicked. */
to: string
/** Number of pending items. */
count: number
/** Size of the icon. */
size?: 'md' | 'lg'
}
const PendingItemsRow: React.FC<IPendingItemsRow> = ({ to, count, size = 'md' }) => {
return (
<Link to={to} className='group' data-testid='pending-items-row'>
<HStack alignItems='center' justifyContent='between'>
<HStack alignItems='center' space={2}>
<div className={clsx('rounded-full bg-primary-200 text-primary-500 dark:bg-primary-800 dark:text-primary-200', {
'p-3': size === 'lg',
'p-2.5': size === 'md',
})}
>
<Icon
src={require('@tabler/icons/exclamation-circle.svg')}
className={clsx({
'h-5 w-5': size === 'md',
'h-7 w-7': size === 'lg',
})}
/>
</div>
<Text weight='bold' size='md'>
<FormattedMessage
id='groups.pending.count'
defaultMessage='{number, plural, one {# pending request} other {# pending requests}}'
values={{ number: count }}
/>
</Text>
</HStack>
<Icon
src={require('@tabler/icons/chevron-right.svg')}
className='h-5 w-5 text-gray-600 transition-colors group-hover:text-gray-700 dark:text-gray-600 dark:group-hover:text-gray-500'
/>
</HStack>
</Link>
);
};
export { PendingItemsRow };

Wyświetl plik

@ -12,7 +12,7 @@ import { toggleBookmark, toggleFavourite, togglePin, toggleReblog } from 'soapbo
import { openModal } from 'soapbox/actions/modals';
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
import { initMuteModal } from 'soapbox/actions/mutes';
import { initReport } from 'soapbox/actions/reports';
import { initReport, ReportableEntities } from 'soapbox/actions/reports';
import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses';
import DropdownMenu from 'soapbox/components/dropdown-menu';
import StatusActionButton from 'soapbox/components/status-action-button';
@ -254,7 +254,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
secondary: intl.formatMessage(messages.blockAndReport),
onSecondary: () => {
dispatch(blockAccount(account.id));
dispatch(initReport(account, { status }));
dispatch(initReport(ReportableEntities.STATUS, account, { status }));
},
}));
};
@ -271,7 +271,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
};
const handleReport: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(initReport(status.account as Account, { status }));
dispatch(initReport(ReportableEntities.STATUS, status.account as Account, { status }));
};
const handleConversationMuteClick: React.EventHandler<React.MouseEvent> = (e) => {

Wyświetl plik

@ -64,7 +64,7 @@ const CardHeader: React.FC<ICardHeader> = ({ className, children, backHref, onBa
const backAttributes = backHref ? { to: backHref } : { onClick: onBackClick };
return (
<Comp {...backAttributes} className='text-gray-900 focus:ring-2 focus:ring-primary-500 dark:text-gray-100' aria-label={intl.formatMessage(messages.back)}>
<Comp {...backAttributes} className='rounded-full text-gray-900 focus:ring-2 focus:ring-primary-500 dark:text-gray-100' aria-label={intl.formatMessage(messages.back)}>
<SvgIcon src={require('@tabler/icons/arrow-left.svg')} className='h-6 w-6 rtl:rotate-180' />
<span className='sr-only' data-testid='back-button'>{intl.formatMessage(messages.back)}</span>
</Comp>

Wyświetl plik

@ -14,7 +14,7 @@ interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** Don't render a background behind the icon. */
transparent?: boolean
/** Predefined styles to display for the button. */
theme?: 'seamless' | 'outlined'
theme?: 'seamless' | 'outlined' | 'secondary'
/** Override the data-testid */
'data-testid'?: string
}
@ -30,6 +30,7 @@ const IconButton = React.forwardRef((props: IIconButton, ref: React.ForwardedRef
className={clsx('flex items-center space-x-2 rounded-full p-1 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:ring-offset-0', {
'bg-white dark:bg-transparent': !transparent,
'border border-solid bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500': theme === 'outlined',
'border-transparent bg-primary-100 dark:bg-primary-800 hover:bg-primary-50 dark:hover:bg-primary-700 focus:bg-primary-100 dark:focus:bg-primary-800 text-primary-500 dark:text-primary-200': theme === 'secondary',
'opacity-50': filteredProps.disabled,
}, className)}
{...filteredProps}

Wyświetl plik

@ -156,7 +156,7 @@ const Tabs = ({ items, activeItem }: ITabs) => {
>
<div className='relative'>
{count ? (
<span className='absolute -top-2 left-full ml-1'>
<span className='absolute left-full ml-2'>
<Counter count={count} />
</span>
) : null}

Wyświetl plik

@ -1,4 +1,12 @@
import { deleteEntities, entitiesFetchFail, entitiesFetchRequest, importEntities } from '../actions';
import {
deleteEntities,
dismissEntities,
entitiesFetchFail,
entitiesFetchRequest,
entitiesFetchSuccess,
importEntities,
incrementEntities,
} from '../actions';
import reducer, { State } from '../reducer';
import { createListState } from '../utils';
@ -36,7 +44,8 @@ test('import entities into a list', () => {
const cache = result.TestEntity as EntityCache<TestEntity>;
expect(cache.store['2']!.msg).toBe('benis');
expect(cache.lists.thingies?.ids.size).toBe(3);
expect(cache.lists.thingies!.ids.size).toBe(3);
expect(cache.lists.thingies!.state.totalCount).toBe(3);
// Now try adding an additional item.
const entities2: TestEntity[] = [
@ -48,7 +57,8 @@ test('import entities into a list', () => {
const cache2 = result2.TestEntity as EntityCache<TestEntity>;
expect(cache2.store['4']!.msg).toBe('hehe');
expect(cache2.lists.thingies?.ids.size).toBe(4);
expect(cache2.lists.thingies!.ids.size).toBe(4);
expect(cache2.lists.thingies!.state.totalCount).toBe(4);
// Finally, update an item.
const entities3: TestEntity[] = [
@ -60,7 +70,8 @@ test('import entities into a list', () => {
const cache3 = result3.TestEntity as EntityCache<TestEntity>;
expect(cache3.store['2']!.msg).toBe('yolofam');
expect(cache3.lists.thingies?.ids.size).toBe(4); // unchanged
expect(cache3.lists.thingies!.ids.size).toBe(4); // unchanged
expect(cache3.lists.thingies!.state.totalCount).toBe(4);
});
test('fetching updates the list state', () => {
@ -79,6 +90,44 @@ test('failure adds the error to the state', () => {
expect(result.TestEntity!.lists.thingies!.state.error).toBe(error);
});
test('import entities with override', () => {
const state: State = {
TestEntity: {
store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } },
lists: {
thingies: {
ids: new Set(['1', '2', '3']),
state: { ...createListState(), totalCount: 3 },
},
},
},
};
const entities: TestEntity[] = [
{ id: '4', msg: 'yolo' },
{ id: '5', msg: 'benis' },
];
const now = new Date();
const action = entitiesFetchSuccess(entities, 'TestEntity', 'thingies', {
next: undefined,
prev: undefined,
totalCount: 2,
error: null,
fetched: true,
fetching: false,
lastFetchedAt: now,
invalid: false,
}, true);
const result = reducer(state, action);
const cache = result.TestEntity as EntityCache<TestEntity>;
expect([...cache.lists.thingies!.ids]).toEqual(['4', '5']);
expect(cache.lists.thingies!.state.lastFetchedAt).toBe(now); // Also check that newState worked
});
test('deleting items', () => {
const state: State = {
TestEntity: {
@ -86,7 +135,7 @@ test('deleting items', () => {
lists: {
'': {
ids: new Set(['1', '2', '3']),
state: createListState(),
state: { ...createListState(), totalCount: 3 },
},
},
},
@ -97,4 +146,64 @@ test('deleting items', () => {
expect(result.TestEntity!.store).toMatchObject({ '2': { id: '2' } });
expect([...result.TestEntity!.lists['']!.ids]).toEqual(['2']);
expect(result.TestEntity!.lists['']!.state.totalCount).toBe(1);
});
test('dismiss items', () => {
const state: State = {
TestEntity: {
store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } },
lists: {
yolo: {
ids: new Set(['1', '2', '3']),
state: { ...createListState(), totalCount: 3 },
},
},
},
};
const action = dismissEntities(['3', '1'], 'TestEntity', 'yolo');
const result = reducer(state, action);
expect(result.TestEntity!.store).toMatchObject(state.TestEntity!.store);
expect([...result.TestEntity!.lists.yolo!.ids]).toEqual(['2']);
expect(result.TestEntity!.lists.yolo!.state.totalCount).toBe(1);
});
test('increment items', () => {
const state: State = {
TestEntity: {
store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } },
lists: {
thingies: {
ids: new Set(['1', '2', '3']),
state: { ...createListState(), totalCount: 3 },
},
},
},
};
const action = incrementEntities('TestEntity', 'thingies', 1);
const result = reducer(state, action);
expect(result.TestEntity!.lists.thingies!.state.totalCount).toBe(4);
});
test('decrement items', () => {
const state: State = {
TestEntity: {
store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } },
lists: {
thingies: {
ids: new Set(['1', '2', '3']),
state: { ...createListState(), totalCount: 3 },
},
},
},
};
const action = incrementEntities('TestEntity', 'thingies', -1);
const result = reducer(state, action);
expect(result.TestEntity!.lists.thingies!.state.totalCount).toBe(2);
});

Wyświetl plik

@ -2,9 +2,12 @@ import type { Entity, EntityListState } from './types';
const ENTITIES_IMPORT = 'ENTITIES_IMPORT' as const;
const ENTITIES_DELETE = 'ENTITIES_DELETE' as const;
const ENTITIES_DISMISS = 'ENTITIES_DISMISS' as const;
const ENTITIES_INCREMENT = 'ENTITIES_INCREMENT' as const;
const ENTITIES_FETCH_REQUEST = 'ENTITIES_FETCH_REQUEST' as const;
const ENTITIES_FETCH_SUCCESS = 'ENTITIES_FETCH_SUCCESS' as const;
const ENTITIES_FETCH_FAIL = 'ENTITIES_FETCH_FAIL' as const;
const ENTITIES_INVALIDATE_LIST = 'ENTITIES_INVALIDATE_LIST' as const;
/** Action to import entities into the cache. */
function importEntities(entities: Entity[], entityType: string, listKey?: string) {
@ -29,6 +32,24 @@ function deleteEntities(ids: Iterable<string>, entityType: string, opts: DeleteE
};
}
function dismissEntities(ids: Iterable<string>, entityType: string, listKey: string) {
return {
type: ENTITIES_DISMISS,
ids,
entityType,
listKey,
};
}
function incrementEntities(entityType: string, listKey: string, diff: number) {
return {
type: ENTITIES_INCREMENT,
entityType,
listKey,
diff,
};
}
function entitiesFetchRequest(entityType: string, listKey?: string) {
return {
type: ENTITIES_FETCH_REQUEST,
@ -37,13 +58,20 @@ function entitiesFetchRequest(entityType: string, listKey?: string) {
};
}
function entitiesFetchSuccess(entities: Entity[], entityType: string, listKey?: string, newState?: EntityListState) {
function entitiesFetchSuccess(
entities: Entity[],
entityType: string,
listKey?: string,
newState?: EntityListState,
overwrite = false,
) {
return {
type: ENTITIES_FETCH_SUCCESS,
entityType,
entities,
listKey,
newState,
overwrite,
};
}
@ -56,25 +84,42 @@ function entitiesFetchFail(entityType: string, listKey: string | undefined, erro
};
}
function invalidateEntityList(entityType: string, listKey: string) {
return {
type: ENTITIES_INVALIDATE_LIST,
entityType,
listKey,
};
}
/** Any action pertaining to entities. */
type EntityAction =
ReturnType<typeof importEntities>
| ReturnType<typeof deleteEntities>
| ReturnType<typeof dismissEntities>
| ReturnType<typeof incrementEntities>
| ReturnType<typeof entitiesFetchRequest>
| ReturnType<typeof entitiesFetchSuccess>
| ReturnType<typeof entitiesFetchFail>;
| ReturnType<typeof entitiesFetchFail>
| ReturnType<typeof invalidateEntityList>;
export {
ENTITIES_IMPORT,
ENTITIES_DELETE,
ENTITIES_DISMISS,
ENTITIES_INCREMENT,
ENTITIES_FETCH_REQUEST,
ENTITIES_FETCH_SUCCESS,
ENTITIES_FETCH_FAIL,
ENTITIES_INVALIDATE_LIST,
importEntities,
deleteEntities,
dismissEntities,
incrementEntities,
entitiesFetchRequest,
entitiesFetchSuccess,
entitiesFetchFail,
invalidateEntityList,
EntityAction,
};

Wyświetl plik

@ -1,4 +1,5 @@
export enum Entities {
ACCOUNTS = 'Accounts',
GROUPS = 'Groups',
GROUP_RELATIONSHIPS = 'GroupRelationships',
GROUP_MEMBERSHIPS = 'GroupMemberships',

Wyświetl plik

@ -1,3 +1,7 @@
export { useEntities } from './useEntities';
export { useEntity } from './useEntity';
export { useEntityActions } from './useEntityActions';
export { useEntityActions } from './useEntityActions';
export { useCreateEntity } from './useCreateEntity';
export { useDeleteEntity } from './useDeleteEntity';
export { useDismissEntity } from './useDismissEntity';
export { useIncrementEntity } from './useIncrementEntity';

Wyświetl plik

@ -1,6 +1,47 @@
import type { Entity } from '../types';
import type { AxiosResponse } from 'axios';
import type z from 'zod';
type EntitySchema<TEntity extends Entity = Entity> = z.ZodType<TEntity, z.ZodTypeDef, any>;
export type { EntitySchema };
/**
* Tells us where to find/store the entity in the cache.
* This value is accepted in hooks, but needs to be parsed into an `EntitiesPath`
* before being passed to the store.
*/
type ExpandedEntitiesPath = [
/** Name of the entity type for use in the global cache, eg `'Notification'`. */
entityType: string,
/**
* Name of a particular index of this entity type.
* Multiple params get combined into one string with a `:` separator.
*/
...listKeys: string[],
]
/** Used to look up an entity in a list. */
type EntitiesPath = [entityType: string, listKey: string]
/** Used to look up a single entity by its ID. */
type EntityPath = [entityType: string, entityId: string]
/** Callback functions for entity actions. */
interface EntityCallbacks<Value, Error = unknown> {
onSuccess?(value: Value): void
onError?(error: Error): void
}
/**
* Passed into hooks to make requests.
* Must return an Axios response.
*/
type EntityFn<T> = (value: T) => Promise<AxiosResponse>
export type {
EntitySchema,
ExpandedEntitiesPath,
EntitiesPath,
EntityPath,
EntityCallbacks,
EntityFn,
};

Wyświetl plik

@ -0,0 +1,51 @@
import { z } from 'zod';
import { useAppDispatch, useLoading } from 'soapbox/hooks';
import { importEntities } from '../actions';
import { parseEntitiesPath } from './utils';
import type { Entity } from '../types';
import type { EntityCallbacks, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types';
interface UseCreateEntityOpts<TEntity extends Entity = Entity> {
schema?: EntitySchema<TEntity>
}
function useCreateEntity<TEntity extends Entity = Entity, Data = unknown>(
expandedPath: ExpandedEntitiesPath,
entityFn: EntityFn<Data>,
opts: UseCreateEntityOpts<TEntity> = {},
) {
const dispatch = useAppDispatch();
const [isLoading, setPromise] = useLoading();
const { entityType, listKey } = parseEntitiesPath(expandedPath);
async function createEntity(data: Data, callbacks: EntityCallbacks<TEntity> = {}): Promise<void> {
try {
const result = await setPromise(entityFn(data));
const schema = opts.schema || z.custom<TEntity>();
const entity = schema.parse(result.data);
// TODO: optimistic updating
dispatch(importEntities([entity], entityType, listKey));
if (callbacks.onSuccess) {
callbacks.onSuccess(entity);
}
} catch (error) {
if (callbacks.onError) {
callbacks.onError(error);
}
}
}
return {
createEntity,
isLoading,
};
}
export { useCreateEntity };

Wyświetl plik

@ -0,0 +1,54 @@
import { useAppDispatch, useGetState, useLoading } from 'soapbox/hooks';
import { deleteEntities, importEntities } from '../actions';
import type { EntityCallbacks, EntityFn } from './types';
/**
* Optimistically deletes an entity from the store.
* This hook should be used to globally delete an entity from all lists.
* To remove an entity from a single list, see `useDismissEntity`.
*/
function useDeleteEntity(
entityType: string,
entityFn: EntityFn<string>,
) {
const dispatch = useAppDispatch();
const getState = useGetState();
const [isLoading, setPromise] = useLoading();
async function deleteEntity(entityId: string, callbacks: EntityCallbacks<string> = {}): Promise<void> {
// Get the entity before deleting, so we can reverse the action if the API request fails.
const entity = getState().entities[entityType]?.store[entityId];
// Optimistically delete the entity from the _store_ but keep the lists in tact.
dispatch(deleteEntities([entityId], entityType, { preserveLists: true }));
try {
await setPromise(entityFn(entityId));
// Success - finish deleting entity from the state.
dispatch(deleteEntities([entityId], entityType));
if (callbacks.onSuccess) {
callbacks.onSuccess(entityId);
}
} catch (e) {
if (entity) {
// If the API failed, reimport the entity.
dispatch(importEntities([entity], entityType));
}
if (callbacks.onError) {
callbacks.onError(e);
}
}
}
return {
deleteEntity,
isLoading,
};
}
export { useDeleteEntity };

Wyświetl plik

@ -0,0 +1,32 @@
import { useAppDispatch, useLoading } from 'soapbox/hooks';
import { dismissEntities } from '../actions';
import { parseEntitiesPath } from './utils';
import type { EntityFn, ExpandedEntitiesPath } from './types';
/**
* Removes an entity from a specific list.
* To remove an entity globally from all lists, see `useDeleteEntity`.
*/
function useDismissEntity(expandedPath: ExpandedEntitiesPath, entityFn: EntityFn<string>) {
const dispatch = useAppDispatch();
const [isLoading, setPromise] = useLoading();
const { entityType, listKey } = parseEntitiesPath(expandedPath);
// TODO: optimistic dismissing
async function dismissEntity(entityId: string) {
const result = await setPromise(entityFn(entityId));
dispatch(dismissEntities([entityId], entityType, listKey));
return result;
}
return {
dismissEntity,
isLoading,
};
}
export { useDismissEntity };

Wyświetl plik

@ -4,25 +4,16 @@ import z from 'zod';
import { getNextLink, getPrevLink } from 'soapbox/api';
import { useApi, useAppDispatch, useAppSelector, useGetState } from 'soapbox/hooks';
import { filteredArray } from 'soapbox/schemas/utils';
import { realNumberSchema } from 'soapbox/utils/numbers';
import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess } from '../actions';
import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess, invalidateEntityList } from '../actions';
import { parseEntitiesPath } from './utils';
import type { Entity, EntityListState } from '../types';
import type { EntitySchema } from './types';
import type { EntitiesPath, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types';
import type { RootState } from 'soapbox/store';
/** Tells us where to find/store the entity in the cache. */
type EntityPath = [
/** Name of the entity type for use in the global cache, eg `'Notification'`. */
entityType: string,
/**
* Name of a particular index of this entity type.
* Multiple params get combined into one string with a `:` separator.
* You can use empty-string (`''`) if you don't need separate lists.
*/
...listKeys: string[],
]
/** Additional options for the hook. */
interface UseEntitiesOpts<TEntity extends Entity> {
/** A zod schema to parse the API entities. */
@ -39,9 +30,9 @@ interface UseEntitiesOpts<TEntity extends Entity> {
/** A hook for fetching and displaying API entities. */
function useEntities<TEntity extends Entity>(
/** Tells us where to find/store the entity in the cache. */
path: EntityPath,
expandedPath: ExpandedEntitiesPath,
/** API route to GET, eg `'/api/v1/notifications'`. If undefined, nothing will be fetched. */
endpoint: string | undefined,
entityFn: EntityFn<void>,
/** Additional options for the hook. */
opts: UseEntitiesOpts<TEntity> = {},
) {
@ -49,9 +40,7 @@ function useEntities<TEntity extends Entity>(
const dispatch = useAppDispatch();
const getState = useGetState();
const [entityType, ...listKeys] = path;
const listKey = listKeys.join(':');
const { entityType, listKey, path } = parseEntitiesPath(expandedPath);
const entities = useAppSelector(state => selectEntities<TEntity>(state, path));
const isEnabled = opts.enabled ?? true;
@ -59,59 +48,71 @@ function useEntities<TEntity extends Entity>(
const lastFetchedAt = useListState(path, 'lastFetchedAt');
const isFetched = useListState(path, 'fetched');
const isError = !!useListState(path, 'error');
const totalCount = useListState(path, 'totalCount');
const isInvalid = useListState(path, 'invalid');
const next = useListState(path, 'next');
const prev = useListState(path, 'prev');
const fetchPage = async(url: string): Promise<void> => {
const fetchPage = async(req: EntityFn<void>, overwrite = false): Promise<void> => {
// Get `isFetching` state from the store again to prevent race conditions.
const isFetching = selectListState(getState(), path, 'fetching');
if (isFetching) return;
dispatch(entitiesFetchRequest(entityType, listKey));
try {
const response = await api.get(url);
const response = await req();
const schema = opts.schema || z.custom<TEntity>();
const entities = filteredArray(schema).parse(response.data);
const parsedCount = realNumberSchema.safeParse(response.headers['x-total-count']);
dispatch(entitiesFetchSuccess(entities, entityType, listKey, {
next: getNextLink(response),
prev: getPrevLink(response),
totalCount: parsedCount.success ? parsedCount.data : undefined,
fetching: false,
fetched: true,
error: null,
lastFetchedAt: new Date(),
}));
invalid: false,
}, overwrite));
} catch (error) {
dispatch(entitiesFetchFail(entityType, listKey, error));
}
};
const fetchEntities = async(): Promise<void> => {
if (endpoint) {
await fetchPage(endpoint);
}
await fetchPage(entityFn, true);
};
const fetchNextPage = async(): Promise<void> => {
if (next) {
await fetchPage(next);
await fetchPage(() => api.get(next));
}
};
const fetchPreviousPage = async(): Promise<void> => {
if (prev) {
await fetchPage(prev);
await fetchPage(() => api.get(prev));
}
};
const invalidate = () => {
dispatch(invalidateEntityList(entityType, listKey));
};
const staleTime = opts.staleTime ?? 60000;
useEffect(() => {
if (isEnabled && !isFetching && (!lastFetchedAt || lastFetchedAt.getTime() + staleTime <= Date.now())) {
if (!isEnabled) return;
if (isFetching) return;
const isUnset = !lastFetchedAt;
const isStale = lastFetchedAt ? Date.now() >= lastFetchedAt.getTime() + staleTime : false;
if (isInvalid || isUnset || isStale) {
fetchEntities();
}
}, [endpoint, isEnabled]);
}, [isEnabled]);
return {
entities,
@ -120,18 +121,22 @@ function useEntities<TEntity extends Entity>(
fetchPreviousPage,
hasNextPage: !!next,
hasPreviousPage: !!prev,
totalCount,
isError,
isFetched,
isFetching,
isLoading: isFetching && entities.length === 0,
invalidate,
/** The `X-Total-Count` from the API if available, or the length of items in the store. */
count: typeof totalCount === 'number' ? totalCount : entities.length,
};
}
/** Get cache at path from Redux. */
const selectCache = (state: RootState, path: EntityPath) => state.entities[path[0]];
const selectCache = (state: RootState, path: EntitiesPath) => state.entities[path[0]];
/** Get list at path from Redux. */
const selectList = (state: RootState, path: EntityPath) => {
const selectList = (state: RootState, path: EntitiesPath) => {
const [, ...listKeys] = path;
const listKey = listKeys.join(':');
@ -139,18 +144,18 @@ const selectList = (state: RootState, path: EntityPath) => {
};
/** Select a particular item from a list state. */
function selectListState<K extends keyof EntityListState>(state: RootState, path: EntityPath, key: K) {
function selectListState<K extends keyof EntityListState>(state: RootState, path: EntitiesPath, key: K) {
const listState = selectList(state, path)?.state;
return listState ? listState[key] : undefined;
}
/** Hook to get a particular item from a list state. */
function useListState<K extends keyof EntityListState>(path: EntityPath, key: K) {
function useListState<K extends keyof EntityListState>(path: EntitiesPath, key: K) {
return useAppSelector(state => selectListState(state, path, key));
}
/** Get list of entities from Redux. */
function selectEntities<TEntity extends Entity>(state: RootState, path: EntityPath): readonly TEntity[] {
function selectEntities<TEntity extends Entity>(state: RootState, path: EntitiesPath): readonly TEntity[] {
const cache = selectCache(state, path);
const list = selectList(state, path);

Wyświetl plik

@ -1,14 +1,12 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import z from 'zod';
import { useApi, useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector, useLoading } from 'soapbox/hooks';
import { importEntities } from '../actions';
import type { Entity } from '../types';
import type { EntitySchema } from './types';
type EntityPath = [entityType: string, entityId: string]
import type { EntitySchema, EntityPath, EntityFn } from './types';
/** Additional options for the hook. */
interface UseEntityOpts<TEntity extends Entity> {
@ -20,10 +18,10 @@ interface UseEntityOpts<TEntity extends Entity> {
function useEntity<TEntity extends Entity>(
path: EntityPath,
endpoint: string,
entityFn: EntityFn<void>,
opts: UseEntityOpts<TEntity> = {},
) {
const api = useApi();
const [isFetching, setPromise] = useLoading();
const dispatch = useAppDispatch();
const [entityType, entityId] = path;
@ -33,18 +31,16 @@ function useEntity<TEntity extends Entity>(
const entity = useAppSelector(state => state.entities[entityType]?.store[entityId] as TEntity | undefined);
const [isFetching, setIsFetching] = useState(false);
const isLoading = isFetching && !entity;
const fetchEntity = () => {
setIsFetching(true);
api.get(endpoint).then(({ data }) => {
const entity = schema.parse(data);
const fetchEntity = async () => {
try {
const response = await setPromise(entityFn());
const entity = schema.parse(response.data);
dispatch(importEntities([entity], entityType));
setIsFetching(false);
}).catch(() => {
setIsFetching(false);
});
} catch (e) {
// do nothing
}
};
useEffect(() => {

Wyświetl plik

@ -1,110 +1,39 @@
import { useState } from 'react';
import { z } from 'zod';
import { useApi } from 'soapbox/hooks';
import { useApi, useAppDispatch, useGetState } from 'soapbox/hooks';
import { deleteEntities, importEntities } from '../actions';
import { useCreateEntity } from './useCreateEntity';
import { useDeleteEntity } from './useDeleteEntity';
import { parseEntitiesPath } from './utils';
import type { Entity } from '../types';
import type { EntitySchema } from './types';
import type { AxiosResponse } from 'axios';
type EntityPath = [entityType: string, listKey?: string]
import type { EntitySchema, ExpandedEntitiesPath } from './types';
interface UseEntityActionsOpts<TEntity extends Entity = Entity> {
schema?: EntitySchema<TEntity>
}
interface CreateEntityResult<TEntity extends Entity = Entity> {
response: AxiosResponse
entity: TEntity
}
interface DeleteEntityResult {
response: AxiosResponse
}
interface EntityActionEndpoints {
post?: string
delete?: string
}
interface EntityCallbacks<TEntity extends Entity = Entity> {
onSuccess?(entity?: TEntity): void
}
function useEntityActions<TEntity extends Entity = Entity, P = any>(
path: EntityPath,
function useEntityActions<TEntity extends Entity = Entity, Data = any>(
expandedPath: ExpandedEntitiesPath,
endpoints: EntityActionEndpoints,
opts: UseEntityActionsOpts<TEntity> = {},
) {
const api = useApi();
const dispatch = useAppDispatch();
const getState = useGetState();
const [entityType, listKey] = path;
const { entityType, path } = parseEntitiesPath(expandedPath);
const [isLoading, setIsLoading] = useState<boolean>(false);
const { deleteEntity, isLoading: deleteLoading } =
useDeleteEntity(entityType, (entityId) => api.delete(endpoints.delete!.replaceAll(':id', entityId)));
function createEntity(params: P, callbacks: EntityCallbacks = {}): Promise<CreateEntityResult<TEntity>> {
if (!endpoints.post) return Promise.reject(endpoints);
setIsLoading(true);
return api.post(endpoints.post, params).then((response) => {
const schema = opts.schema || z.custom<TEntity>();
const entity = schema.parse(response.data);
// TODO: optimistic updating
dispatch(importEntities([entity], entityType, listKey));
if (callbacks.onSuccess) {
callbacks.onSuccess(entity);
}
setIsLoading(false);
return {
response,
entity,
};
});
}
function deleteEntity(entityId: string, callbacks: EntityCallbacks = {}): Promise<DeleteEntityResult> {
if (!endpoints.delete) return Promise.reject(endpoints);
// Get the entity before deleting, so we can reverse the action if the API request fails.
const entity = getState().entities[entityType]?.store[entityId];
// Optimistically delete the entity from the _store_ but keep the lists in tact.
dispatch(deleteEntities([entityId], entityType, { preserveLists: true }));
setIsLoading(true);
return api.delete(endpoints.delete.replaceAll(':id', entityId)).then((response) => {
if (callbacks.onSuccess) {
callbacks.onSuccess();
}
// Success - finish deleting entity from the state.
dispatch(deleteEntities([entityId], entityType));
return {
response,
};
}).catch((e) => {
if (entity) {
// If the API failed, reimport the entity.
dispatch(importEntities([entity], entityType));
}
throw e;
}).finally(() => {
setIsLoading(false);
});
}
const { createEntity, isLoading: createLoading } =
useCreateEntity<TEntity, Data>(path, (data) => api.post(endpoints.post!, data), opts);
return {
createEntity,
deleteEntity,
isLoading,
isLoading: createLoading || deleteLoading,
};
}

Wyświetl plik

@ -0,0 +1,37 @@
import { useAppDispatch, useLoading } from 'soapbox/hooks';
import { incrementEntities } from '../actions';
import { parseEntitiesPath } from './utils';
import type { EntityFn, ExpandedEntitiesPath } from './types';
/**
* Increases (or decreases) the `totalCount` in the entity list by the specified amount.
* This only works if the API returns an `X-Total-Count` header and your components read it.
*/
function useIncrementEntity(
expandedPath: ExpandedEntitiesPath,
diff: number,
entityFn: EntityFn<string>,
) {
const dispatch = useAppDispatch();
const [isLoading, setPromise] = useLoading();
const { entityType, listKey } = parseEntitiesPath(expandedPath);
async function incrementEntity(entityId: string): Promise<void> {
dispatch(incrementEntities(entityType, listKey, diff));
try {
await setPromise(entityFn(entityId));
} catch (e) {
dispatch(incrementEntities(entityType, listKey, diff * -1));
}
}
return {
incrementEntity,
isLoading,
};
}
export { useIncrementEntity };

Wyświetl plik

@ -0,0 +1,16 @@
import type { EntitiesPath, ExpandedEntitiesPath } from './types';
function parseEntitiesPath(expandedPath: ExpandedEntitiesPath) {
const [entityType, ...listKeys] = expandedPath;
const listKey = (listKeys || []).join(':');
const path: EntitiesPath = [entityType, listKey];
return {
entityType,
listKey,
path,
};
}
export { parseEntitiesPath };

Wyświetl plik

@ -3,10 +3,13 @@ import produce, { enableMapSet } from 'immer';
import {
ENTITIES_IMPORT,
ENTITIES_DELETE,
ENTITIES_DISMISS,
ENTITIES_FETCH_REQUEST,
ENTITIES_FETCH_SUCCESS,
ENTITIES_FETCH_FAIL,
EntityAction,
ENTITIES_INVALIDATE_LIST,
ENTITIES_INCREMENT,
} from './actions';
import { createCache, createList, updateStore, updateList } from './utils';
@ -27,17 +30,25 @@ const importEntities = (
entities: Entity[],
listKey?: string,
newState?: EntityListState,
overwrite = false,
): State => {
return produce(state, draft => {
const cache = draft[entityType] ?? createCache();
cache.store = updateStore(cache.store, entities);
if (typeof listKey === 'string') {
let list = { ...(cache.lists[listKey] ?? createList()) };
let list = cache.lists[listKey] ?? createList();
if (overwrite) {
list.ids = new Set();
}
list = updateList(list, entities);
if (newState) {
list.state = newState;
}
cache.lists[listKey] = list;
}
@ -59,7 +70,13 @@ const deleteEntities = (
if (!opts?.preserveLists) {
for (const list of Object.values(cache.lists)) {
list?.ids.delete(id);
if (list) {
list.ids.delete(id);
if (typeof list.state.totalCount === 'number') {
list.state.totalCount--;
}
}
}
}
}
@ -68,6 +85,47 @@ const deleteEntities = (
});
};
const dismissEntities = (
state: State,
entityType: string,
ids: Iterable<string>,
listKey: string,
) => {
return produce(state, draft => {
const cache = draft[entityType] ?? createCache();
const list = cache.lists[listKey];
if (list) {
for (const id of ids) {
list.ids.delete(id);
if (typeof list.state.totalCount === 'number') {
list.state.totalCount--;
}
}
draft[entityType] = cache;
}
});
};
const incrementEntities = (
state: State,
entityType: string,
listKey: string,
diff: number,
) => {
return produce(state, draft => {
const cache = draft[entityType] ?? createCache();
const list = cache.lists[listKey];
if (typeof list?.state?.totalCount === 'number') {
list.state.totalCount += diff;
draft[entityType] = cache;
}
});
};
const setFetching = (
state: State,
entityType: string,
@ -89,6 +147,14 @@ const setFetching = (
});
};
const invalidateEntityList = (state: State, entityType: string, listKey: string) => {
return produce(state, draft => {
const cache = draft[entityType] ?? createCache();
const list = cache.lists[listKey] ?? createList();
list.state.invalid = true;
});
};
/** Stores various entity data and lists in a one reducer. */
function reducer(state: Readonly<State> = {}, action: EntityAction): State {
switch (action.type) {
@ -96,12 +162,18 @@ function reducer(state: Readonly<State> = {}, action: EntityAction): State {
return importEntities(state, action.entityType, action.entities, action.listKey);
case ENTITIES_DELETE:
return deleteEntities(state, action.entityType, action.ids, action.opts);
case ENTITIES_DISMISS:
return dismissEntities(state, action.entityType, action.ids, action.listKey);
case ENTITIES_INCREMENT:
return incrementEntities(state, action.entityType, action.listKey, action.diff);
case ENTITIES_FETCH_SUCCESS:
return importEntities(state, action.entityType, action.entities, action.listKey, action.newState);
return importEntities(state, action.entityType, action.entities, action.listKey, action.newState, action.overwrite);
case ENTITIES_FETCH_REQUEST:
return setFetching(state, action.entityType, action.listKey, true);
case ENTITIES_FETCH_FAIL:
return setFetching(state, action.entityType, action.listKey, false, action.error);
case ENTITIES_INVALIDATE_LIST:
return invalidateEntityList(state, action.entityType, action.listKey);
default:
return state;
}

Wyświetl plik

@ -23,6 +23,8 @@ interface EntityListState {
next: string | undefined
/** Previous URL for pagination, if any. */
prev: string | undefined
/** Total number of items according to the API. */
totalCount: number | undefined
/** Error returned from the API, if any. */
error: any
/** Whether data has already been fetched */
@ -31,6 +33,8 @@ interface EntityListState {
fetching: boolean
/** Date of the last API fetch for this list. */
lastFetchedAt: Date | undefined
/** Whether the entities should be refetched on the next component mount. */
invalid: boolean
}
/** Cache data pertaining to a paritcular entity type.. */

Wyświetl plik

@ -11,9 +11,16 @@ const updateStore = (store: EntityStore, entities: Entity[]): EntityStore => {
/** Update the list with new entity IDs. */
const updateList = (list: EntityList, entities: Entity[]): EntityList => {
const newIds = entities.map(entity => entity.id);
const ids = new Set([...Array.from(list.ids), ...newIds]);
if (typeof list.state.totalCount === 'number') {
const sizeDiff = ids.size - list.ids.size;
list.state.totalCount += sizeDiff;
}
return {
...list,
ids: new Set([...Array.from(list.ids), ...newIds]),
ids,
};
};
@ -33,10 +40,12 @@ const createList = (): EntityList => ({
const createListState = (): EntityListState => ({
next: undefined,
prev: undefined,
totalCount: 0,
error: null,
fetched: false,
fetching: false,
lastFetchedAt: undefined,
invalid: false,
});
export {

Wyświetl plik

@ -12,7 +12,7 @@ import { mentionCompose, directCompose } from 'soapbox/actions/compose';
import { blockDomain, unblockDomain } from 'soapbox/actions/domain-blocks';
import { openModal } from 'soapbox/actions/modals';
import { initMuteModal } from 'soapbox/actions/mutes';
import { initReport } from 'soapbox/actions/reports';
import { initReport, ReportableEntities } from 'soapbox/actions/reports';
import { setSearchAccount } from 'soapbox/actions/search';
import { getSettings } from 'soapbox/actions/settings';
import Badge from 'soapbox/components/badge';
@ -136,7 +136,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
secondary: intl.formatMessage(messages.blockAndReport),
onSecondary: () => {
dispatch(blockAccount(account.id));
dispatch(initReport(account));
dispatch(initReport(ReportableEntities.ACCOUNT, account));
},
}));
}
@ -171,7 +171,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
};
const onReport = () => {
dispatch(initReport(account));
dispatch(initReport(ReportableEntities.ACCOUNT, account));
};
const onMute = () => {

Wyświetl plik

@ -3,7 +3,8 @@ import { defineMessages, useIntl } from 'react-intl';
import { approveUsers } from 'soapbox/actions/admin';
import { rejectUserModal } from 'soapbox/actions/moderation';
import { Stack, HStack, Text, IconButton } from 'soapbox/components/ui';
import { AuthorizeRejectButtons } from 'soapbox/components/authorize-reject-buttons';
import { Stack, HStack, Text } from 'soapbox/components/ui';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors';
import toast from 'soapbox/toast';
@ -29,19 +30,21 @@ const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
if (!account) return null;
const handleApprove = () => {
dispatch(approveUsers([account.id]))
return dispatch(approveUsers([account.id]))
.then(() => {
const message = intl.formatMessage(messages.approved, { acct: `@${account.acct}` });
toast.success(message);
})
.catch(() => {});
});
};
const handleReject = () => {
dispatch(rejectUserModal(intl, account.id, () => {
const message = intl.formatMessage(messages.rejected, { acct: `@${account.acct}` });
toast.info(message);
}));
return new Promise<void>((resolve) => {
dispatch(rejectUserModal(intl, account.id, () => {
const message = intl.formatMessage(messages.rejected, { acct: `@${account.acct}` });
toast.info(message);
resolve();
}));
});
};
return (
@ -55,20 +58,12 @@ const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
</Text>
</Stack>
<HStack space={2} alignItems='center'>
<IconButton
src={require('@tabler/icons/check.svg')}
onClick={handleApprove}
theme='outlined'
iconClassName='p-1 text-gray-600 dark:text-gray-400'
<Stack justifyContent='center'>
<AuthorizeRejectButtons
onAuthorize={handleApprove}
onReject={handleReject}
/>
<IconButton
src={require('@tabler/icons/x.svg')}
onClick={handleReject}
theme='outlined'
iconClassName='p-1 text-gray-600 dark:text-gray-400'
/>
</HStack>
</Stack>
</HStack>
);
};

Wyświetl plik

@ -6,7 +6,7 @@ import React, { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { openModal } from 'soapbox/actions/modals';
import { initReport } from 'soapbox/actions/reports';
import { initReport, ReportableEntities } from 'soapbox/actions/reports';
import DropdownMenu from 'soapbox/components/dropdown-menu';
import { HStack, Icon, Stack, Text } from 'soapbox/components/ui';
import emojify from 'soapbox/features/emoji';
@ -24,7 +24,7 @@ import ChatMessageReactionWrapper from './chat-message-reaction-wrapper/chat-mes
import type { Menu as IMenu } from 'soapbox/components/dropdown-menu';
import type { IMediaGallery } from 'soapbox/components/media-gallery';
import type { ChatMessage as ChatMessageEntity } from 'soapbox/types/entities';
import type { Account, ChatMessage as ChatMessageEntity } from 'soapbox/types/entities';
const messages = defineMessages({
copy: { id: 'chats.actions.copy', defaultMessage: 'Copy' },
@ -178,7 +178,7 @@ const ChatMessage = (props: IChatMessage) => {
if (features.reportChats) {
menu.push({
text: intl.formatMessage(messages.report),
action: () => dispatch(initReport(normalizeAccount(chat.account) as any, { chatMessage } as any)),
action: () => dispatch(initReport(ReportableEntities.CHAT_MESSAGE, normalizeAccount(chat.account) as Account, { chatMessage })),
icon: require('@tabler/icons/flag.svg'),
});
}

Wyświetl plik

@ -11,7 +11,7 @@ import { toggleBookmark, togglePin, toggleReblog } from 'soapbox/actions/interac
import { openModal } from 'soapbox/actions/modals';
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
import { initMuteModal } from 'soapbox/actions/mutes';
import { initReport } from 'soapbox/actions/reports';
import { initReport, ReportableEntities } from 'soapbox/actions/reports';
import { deleteStatus } from 'soapbox/actions/statuses';
import Icon from 'soapbox/components/icon';
import StillImage from 'soapbox/components/still-image';
@ -176,13 +176,13 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
secondary: intl.formatMessage(messages.blockAndReport),
onSecondary: () => {
dispatch(blockAccount(account.id));
dispatch(initReport(account, { status }));
dispatch(initReport(ReportableEntities.STATUS, account, { status }));
},
}));
};
const handleReport = () => {
dispatch(initReport(account, { status }));
dispatch(initReport(ReportableEntities.STATUS, account, { status }));
};
const handleModerate = () => {

Wyświetl plik

@ -1,36 +1,23 @@
import React, { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { authorizeFollowRequest, rejectFollowRequest } from 'soapbox/actions/accounts';
import Account from 'soapbox/components/account';
import { Button, HStack } from 'soapbox/components/ui';
import { AuthorizeRejectButtons } from 'soapbox/components/authorize-reject-buttons';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors';
const messages = defineMessages({
authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
reject: { id: 'follow_request.reject', defaultMessage: 'Reject' },
});
interface IAccountAuthorize {
id: string
}
const AccountAuthorize: React.FC<IAccountAuthorize> = ({ id }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const getAccount = useCallback(makeGetAccount(), []);
const account = useAppSelector((state) => getAccount(state, id));
const onAuthorize = () => {
dispatch(authorizeFollowRequest(id));
};
const onReject = () => {
dispatch(rejectFollowRequest(id));
};
const onAuthorize = () => dispatch(authorizeFollowRequest(id));
const onReject = () => dispatch(rejectFollowRequest(id));
if (!account) return null;
@ -39,22 +26,10 @@ const AccountAuthorize: React.FC<IAccountAuthorize> = ({ id }) => {
<Account
account={account}
action={
<HStack className='ml-1' space={2}>
<Button
theme='secondary'
size='sm'
text={intl.formatMessage(messages.authorize)}
icon={require('@tabler/icons/check.svg')}
onClick={onAuthorize}
/>
<Button
theme='danger'
size='sm'
text={intl.formatMessage(messages.reject)}
icon={require('@tabler/icons/x.svg')}
onClick={onReject}
/>
</HStack>
<AuthorizeRejectButtons
onAuthorize={onAuthorize}
onReject={onReject}
/>
}
/>
</div>

Wyświetl plik

@ -12,6 +12,7 @@ import { isDefaultHeader } from 'soapbox/utils/accounts';
import GroupActionButton from './group-action-button';
import GroupMemberCount from './group-member-count';
import GroupOptionsButton from './group-options-button';
import GroupPrivacy from './group-privacy';
import GroupRelationship from './group-relationship';
@ -140,7 +141,10 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
/>
</Stack>
<GroupActionButton group={group} />
<HStack alignItems='center' space={2}>
<GroupOptionsButton group={group} />
<GroupActionButton group={group} />
</HStack>
</Stack>
</div>
);

Wyświetl plik

@ -0,0 +1,46 @@
import React, { useMemo } from 'react';
import { initReport, ReportableEntities } from 'soapbox/actions/reports';
import DropdownMenu, { Menu } from 'soapbox/components/dropdown-menu';
import { IconButton } from 'soapbox/components/ui';
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import { GroupRoles } from 'soapbox/schemas/group-member';
import type { Account, Group } from 'soapbox/types/entities';
interface IGroupActionButton {
group: Group
}
const GroupOptionsButton = ({ group }: IGroupActionButton) => {
const dispatch = useAppDispatch();
const account = useOwnAccount();
const isMember = group.relationship?.role === GroupRoles.USER;
const isBlocked = group.relationship?.blocked_by;
const menu: Menu = useMemo(() => ([
{
text: 'Report',
icon: require('@tabler/icons/flag.svg'),
action: () => dispatch(initReport(ReportableEntities.GROUP, account as Account, { group })),
},
]), []);
if (isBlocked || !isMember || menu.length === 0) {
return null;
}
return (
<DropdownMenu items={menu} placement='bottom'>
<IconButton
src={require('@tabler/icons/dots.svg')}
theme='secondary'
iconClassName='h-5 w-5'
className='self-stretch px-2.5'
/>
</DropdownMenu>
);
};
export default GroupOptionsButton;

Wyświetl plik

@ -1,8 +1,11 @@
import clsx from 'clsx';
import React, { useMemo } from 'react';
import { PendingItemsRow } from 'soapbox/components/pending-items-row';
import ScrollableList from 'soapbox/components/scrollable-list';
import { useGroup } from 'soapbox/hooks';
import { useGroupMembershipRequests } from 'soapbox/hooks/api/groups/useGroupMembershipRequests';
import { useGroupMembers } from 'soapbox/hooks/api/useGroupMembers';
import { useGroup } from 'soapbox/queries/groups';
import { GroupRoles } from 'soapbox/schemas/group-member';
import PlaceholderAccount from '../placeholder/components/placeholder-account';
@ -22,8 +25,9 @@ const GroupMembers: React.FC<IGroupMembers> = (props) => {
const { groupMembers: owners, isFetching: isFetchingOwners } = useGroupMembers(groupId, GroupRoles.OWNER);
const { groupMembers: admins, isFetching: isFetchingAdmins } = useGroupMembers(groupId, GroupRoles.ADMIN);
const { groupMembers: users, isFetching: isFetchingUsers, fetchNextPage, hasNextPage } = useGroupMembers(groupId, GroupRoles.USER);
const { isFetching: isFetchingPending, count: pendingCount } = useGroupMembershipRequests(groupId);
const isLoading = isFetchingGroup || isFetchingOwners || isFetchingAdmins || isFetchingUsers;
const isLoading = isFetchingGroup || isFetchingOwners || isFetchingAdmins || isFetchingUsers || isFetchingPending;
const members = useMemo(() => [
...owners,
@ -37,12 +41,17 @@ const GroupMembers: React.FC<IGroupMembers> = (props) => {
scrollKey='group-members'
hasMore={hasNextPage}
onLoadMore={fetchNextPage}
isLoading={isLoading || !group}
showLoading={!group || isLoading && members.length === 0}
isLoading={!group || isLoading}
showLoading={!group || isFetchingPending || isLoading && members.length === 0}
placeholderComponent={PlaceholderAccount}
placeholderCount={3}
className='divide-y divide-solid divide-gray-300'
className='divide-y divide-solid divide-gray-200 dark:divide-gray-800'
itemClassName='py-3 last:pb-0'
prepend={(pendingCount > 0) && (
<div className={clsx('py-3', { 'border-b border-gray-200 dark:border-gray-800': members.length })}>
<PendingItemsRow to={`/groups/${groupId}/manage/requests`} count={pendingCount} />
</div>
)}
>
{members.map((member) => (
<GroupMemberListItem

Wyświetl plik

@ -1,70 +1,48 @@
import React, { useCallback, useEffect } from 'react';
import React from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { authorizeGroupMembershipRequest, fetchGroupMembershipRequests, rejectGroupMembershipRequest } from 'soapbox/actions/groups';
import Account from 'soapbox/components/account';
import { AuthorizeRejectButtons } from 'soapbox/components/authorize-reject-buttons';
import ScrollableList from 'soapbox/components/scrollable-list';
import { Button, Column, HStack, Spinner } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useGroup } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors';
import { Column, HStack, Spinner } from 'soapbox/components/ui';
import { useGroup } from 'soapbox/hooks';
import { useGroupMembershipRequests } from 'soapbox/hooks/api/groups/useGroupMembershipRequests';
import toast from 'soapbox/toast';
import ColumnForbidden from '../ui/components/column-forbidden';
import type { Account as AccountEntity } from 'soapbox/schemas';
type RouteParams = { id: string };
const messages = defineMessages({
heading: { id: 'column.group_pending_requests', defaultMessage: 'Pending requests' },
authorize: { id: 'group.group_mod_authorize', defaultMessage: 'Accept' },
authorized: { id: 'group.group_mod_authorize.success', defaultMessage: 'Accepted @{name} to group' },
reject: { id: 'group.group_mod_reject', defaultMessage: 'Reject' },
rejected: { id: 'group.group_mod_reject.success', defaultMessage: 'Rejected @{name} from group' },
authorizeFail: { id: 'group.group_mod_authorize.fail', defaultMessage: 'Failed to approve @{name}' },
rejectFail: { id: 'group.group_mod_reject.fail', defaultMessage: 'Failed to reject @{name}' },
});
interface IMembershipRequest {
accountId: string
groupId: string
account: AccountEntity
onAuthorize(account: AccountEntity): Promise<unknown>
onReject(account: AccountEntity): Promise<unknown>
}
const MembershipRequest: React.FC<IMembershipRequest> = ({ accountId, groupId }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const getAccount = useCallback(makeGetAccount(), []);
const account = useAppSelector((state) => getAccount(state, accountId));
const MembershipRequest: React.FC<IMembershipRequest> = ({ account, onAuthorize, onReject }) => {
if (!account) return null;
const handleAuthorize = () =>
dispatch(authorizeGroupMembershipRequest(groupId, accountId)).then(() => {
toast.success(intl.formatMessage(messages.authorized, { name: account.acct }));
});
const handleReject = () =>
dispatch(rejectGroupMembershipRequest(groupId, accountId)).then(() => {
toast.success(intl.formatMessage(messages.rejected, { name: account.acct }));
});
const handleAuthorize = () => onAuthorize(account);
const handleReject = () => onReject(account);
return (
<HStack space={1} alignItems='center' justifyContent='between' className='p-2.5'>
<div className='w-full'>
<Account account={account} withRelationship={false} />
</div>
<HStack space={2}>
<Button
theme='secondary'
size='sm'
text={intl.formatMessage(messages.authorize)}
onClick={handleAuthorize}
/>
<Button
theme='danger'
size='sm'
text={intl.formatMessage(messages.reject)}
onClick={handleReject}
/>
</HStack>
<AuthorizeRejectButtons
onAuthorize={handleAuthorize}
onReject={handleReject}
/>
</HStack>
);
};
@ -74,19 +52,14 @@ interface IGroupMembershipRequests {
}
const GroupMembershipRequests: React.FC<IGroupMembershipRequests> = ({ params }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const id = params?.id;
const intl = useIntl();
const { group } = useGroup(id);
const accountIds = useAppSelector((state) => state.user_lists.membership_requests.get(id)?.items);
useEffect(() => {
dispatch(fetchGroupMembershipRequests(id));
}, [id]);
const { accounts, authorize, reject, isLoading } = useGroupMembershipRequests(id);
if (!group || !group.relationship || !accountIds) {
if (!group || !group.relationship || isLoading) {
return (
<Column label={intl.formatMessage(messages.heading)}>
<Spinner />
@ -95,20 +68,39 @@ const GroupMembershipRequests: React.FC<IGroupMembershipRequests> = ({ params })
}
if (!group.relationship.role || !['owner', 'admin', 'moderator'].includes(group.relationship.role)) {
return (<ColumnForbidden />);
return <ColumnForbidden />;
}
const emptyMessage = <FormattedMessage id='empty_column.group_membership_requests' defaultMessage='There are no pending membership requests for this group.' />;
async function handleAuthorize(account: AccountEntity) {
try {
await authorize(account.id);
} catch (_e) {
toast.error(intl.formatMessage(messages.authorizeFail, { name: account.username }));
}
}
async function handleReject(account: AccountEntity) {
try {
await reject(account.id);
} catch (_e) {
toast.error(intl.formatMessage(messages.rejectFail, { name: account.username }));
}
}
return (
<Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${id}/manage`}>
<Column label={intl.formatMessage(messages.heading)}>
<ScrollableList
scrollKey='group_membership_requests'
emptyMessage={emptyMessage}
emptyMessage={<FormattedMessage id='empty_column.group_membership_requests' defaultMessage='There are no pending membership requests for this group.' />}
>
{accountIds.map((accountId) =>
<MembershipRequest key={accountId} accountId={accountId} groupId={id} />,
)}
{accounts.map((account) => (
<MembershipRequest
key={account.id}
account={account}
onAuthorize={handleAuthorize}
onReject={handleReject}
/>
))}
</ScrollableList>
</Column>
);

Wyświetl plik

@ -45,7 +45,7 @@ describe('<PendingGroupRows />', () => {
it('should not render', () => {
renderApp(store);
expect(screen.queryAllByTestId('pending-groups-row')).toHaveLength(0);
expect(screen.queryAllByTestId('pending-items-row')).toHaveLength(0);
});
});
@ -69,7 +69,7 @@ describe('<PendingGroupRows />', () => {
it('should not render', () => {
renderApp(store);
expect(screen.queryAllByTestId('pending-groups-row')).toHaveLength(0);
expect(screen.queryAllByTestId('pending-items-row')).toHaveLength(0);
});
});
@ -95,7 +95,7 @@ describe('<PendingGroupRows />', () => {
renderApp(store);
await waitFor(() => {
expect(screen.queryAllByTestId('pending-groups-row')).toHaveLength(1);
expect(screen.queryAllByTestId('pending-items-row')).toHaveLength(1);
});
});
});

Wyświetl plik

@ -1,8 +1,7 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { Divider, HStack, Icon, Text } from 'soapbox/components/ui';
import { PendingItemsRow } from 'soapbox/components/pending-items-row';
import { Divider } from 'soapbox/components/ui';
import { useFeatures } from 'soapbox/hooks';
import { usePendingGroups } from 'soapbox/queries/groups';
@ -17,31 +16,11 @@ export default () => {
return (
<>
<Link to='/groups/pending-requests' className='group' data-testid='pending-groups-row'>
<HStack alignItems='center' justifyContent='between'>
<HStack alignItems='center' space={2}>
<div className='rounded-full bg-primary-200 p-3 text-primary-500 dark:bg-primary-800 dark:text-primary-200'>
<Icon
src={require('@tabler/icons/exclamation-circle.svg')}
className='h-7 w-7'
/>
</div>
<Text weight='bold' size='md'>
<FormattedMessage
id='groups.pending.count'
defaultMessage='{number, plural, one {# pending request} other {# pending requests}}'
values={{ number: groups.length }}
/>
</Text>
</HStack>
<Icon
src={require('@tabler/icons/chevron-right.svg')}
className='h-5 w-5 text-gray-600 transition-colors group-hover:text-gray-700 dark:text-gray-600 dark:group-hover:text-gray-500'
/>
</HStack>
</Link>
<PendingItemsRow
to='/groups/pending-requests'
count={groups.length}
size='lg'
/>
<Divider />
</>

Wyświetl plik

@ -2,6 +2,7 @@ import userEvent from '@testing-library/user-event';
import { Map as ImmutableMap, Record as ImmutableRecord, Set as ImmutableSet } from 'immutable';
import React from 'react';
import { ReportableEntities } from 'soapbox/actions/reports';
import { __stub } from 'soapbox/api';
import { render, screen, waitFor } from '../../../../../../jest/test-helpers';
@ -29,6 +30,7 @@ describe('<ReportModal />', () => {
account_id: '1',
status_ids: ImmutableSet(['1']),
rule_ids: ImmutableSet(),
entityType: ReportableEntities.STATUS,
})(),
})(),
statuses: ImmutableMap({

Wyświetl plik

@ -2,9 +2,10 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { blockAccount } from 'soapbox/actions/accounts';
import { submitReport, submitReportSuccess, submitReportFail } from 'soapbox/actions/reports';
import { submitReport, submitReportSuccess, submitReportFail, ReportableEntities } from 'soapbox/actions/reports';
import { expandAccountTimeline } from 'soapbox/actions/timelines';
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
import GroupCard from 'soapbox/components/group-card';
import List, { ListItem } from 'soapbox/components/list';
import StatusContent from 'soapbox/components/status-content';
import { Avatar, HStack, Icon, Modal, ProgressBar, Stack, Text } from 'soapbox/components/ui';
@ -24,6 +25,7 @@ const messages = defineMessages({
submit: { id: 'report.submit', defaultMessage: 'Submit' },
reportContext: { id: 'report.chatMessage.context', defaultMessage: 'When reporting a users message, the five messages before and five messages after the one selected will be passed along to our moderation team for context.' },
reportMessage: { id: 'report.chatMessage.title', defaultMessage: 'Report message' },
reportGroup: { id: 'report.group.title', defaultMessage: 'Report Group' },
cancel: { id: 'common.cancel', defaultMessage: 'Cancel' },
previous: { id: 'report.previous', defaultMessage: 'Previous' },
});
@ -35,9 +37,26 @@ enum Steps {
}
const reportSteps = {
ONE: ReasonStep,
TWO: OtherActionsStep,
THREE: ConfirmationStep,
[ReportableEntities.ACCOUNT]: {
ONE: ReasonStep,
TWO: OtherActionsStep,
THREE: ConfirmationStep,
},
[ReportableEntities.CHAT_MESSAGE]: {
ONE: ReasonStep,
TWO: OtherActionsStep,
THREE: ConfirmationStep,
},
[ReportableEntities.STATUS]: {
ONE: ReasonStep,
TWO: OtherActionsStep,
THREE: ConfirmationStep,
},
[ReportableEntities.GROUP]: {
ONE: ReasonStep,
TWO: ConfirmationStep,
THREE: null,
},
};
const SelectedStatus = ({ statusId }: { statusId: string }) => {
@ -76,12 +95,6 @@ interface IReportModal {
onClose: () => void
}
enum ReportedEntities {
Account = 'Account',
Status = 'Status',
ChatMessage = 'ChatMessage'
}
const ReportModal = ({ onClose }: IReportModal) => {
const dispatch = useAppDispatch();
const intl = useIntl();
@ -89,27 +102,20 @@ const ReportModal = ({ onClose }: IReportModal) => {
const accountId = useAppSelector((state) => state.reports.new.account_id);
const account = useAccount(accountId as string);
const entityType = useAppSelector((state) => state.reports.new.entityType);
const isBlocked = useAppSelector((state) => state.reports.new.block);
const isSubmitting = useAppSelector((state) => state.reports.new.isSubmitting);
const rules = useAppSelector((state) => state.rules.items);
const ruleIds = useAppSelector((state) => state.reports.new.rule_ids);
const selectedStatusIds = useAppSelector((state) => state.reports.new.status_ids);
const selectedChatMessage = useAppSelector((state) => state.reports.new.chat_message);
const selectedGroup = useAppSelector((state) => state.reports.new.group);
const shouldRequireRule = rules.length > 0;
const reportedEntity = useMemo(() => {
if (selectedStatusIds.size === 0 && !selectedChatMessage) {
return ReportedEntities.Account;
} else if (selectedChatMessage) {
return ReportedEntities.ChatMessage;
} else {
return ReportedEntities.Status;
}
}, []);
const isReportingAccount = reportedEntity === ReportedEntities.Account;
const isReportingStatus = reportedEntity === ReportedEntities.Status;
const isReportingAccount = entityType === ReportableEntities.ACCOUNT;
const isReportingStatus = entityType === ReportableEntities.STATUS;
const isReportingGroup = entityType === ReportableEntities.GROUP;
const [currentStep, setCurrentStep] = useState<Steps>(Steps.ONE);
@ -160,22 +166,41 @@ const ReportModal = ({ onClose }: IReportModal) => {
const confirmationText = useMemo(() => {
switch (currentStep) {
case Steps.ONE:
if (isReportingGroup) {
return intl.formatMessage(messages.submit);
} else {
return intl.formatMessage(messages.next);
}
case Steps.TWO:
return intl.formatMessage(messages.submit);
if (isReportingGroup) {
return intl.formatMessage(messages.done);
} else {
return intl.formatMessage(messages.submit);
}
case Steps.THREE:
return intl.formatMessage(messages.done);
default:
return intl.formatMessage(messages.next);
}
}, [currentStep]);
}, [currentStep, isReportingGroup]);
const handleNextStep = () => {
switch (currentStep) {
case Steps.ONE:
setCurrentStep(Steps.TWO);
if (isReportingGroup) {
handleSubmit();
} else {
setCurrentStep(Steps.TWO);
}
break;
case Steps.TWO:
handleSubmit();
if (isReportingGroup) {
dispatch(submitReportSuccess());
onClose();
} else {
handleSubmit();
}
break;
case Steps.THREE:
dispatch(submitReportSuccess());
@ -212,19 +237,35 @@ const ReportModal = ({ onClose }: IReportModal) => {
}
};
const renderSelectedGroup = () => {
if (selectedGroup) {
return <GroupCard group={selectedGroup} />;
}
};
const renderSelectedEntity = () => {
switch (reportedEntity) {
case ReportedEntities.Status:
switch (entityType) {
case ReportableEntities.STATUS:
return renderSelectedStatuses();
case ReportedEntities.ChatMessage:
case ReportableEntities.CHAT_MESSAGE:
return renderSelectedChatMessage();
case ReportableEntities.GROUP:
if (currentStep === Steps.TWO) {
return null;
}
return renderSelectedGroup();
default:
return null;
}
};
const renderTitle = () => {
switch (reportedEntity) {
case ReportedEntities.ChatMessage:
switch (entityType) {
case ReportableEntities.CHAT_MESSAGE:
return intl.formatMessage(messages.reportMessage);
case ReportableEntities.GROUP:
return intl.formatMessage(messages.reportGroup);
default:
return <FormattedMessage id='report.target' defaultMessage='Reporting {target}' values={{ target: <strong>@{account?.acct}</strong> }} />;
}
@ -252,16 +293,16 @@ const ReportModal = ({ onClose }: IReportModal) => {
}, [currentStep]);
useEffect(() => {
if (account) {
if (account?.id) {
dispatch(expandAccountTimeline(account.id, { withReplies: true, maxId: null }));
}
}, [account]);
}, [account?.id]);
if (!account) {
return null;
}
const StepToRender = reportSteps[currentStep];
const StepToRender = reportSteps[entityType][currentStep];
return (
<Modal
@ -279,7 +320,9 @@ const ReportModal = ({ onClose }: IReportModal) => {
{(currentStep !== Steps.THREE && !isReportingAccount) && renderSelectedEntity()}
<StepToRender account={account} />
{StepToRender && (
<StepToRender account={account} />
)}
</Stack>
</Modal>
);

Wyświetl plik

@ -1,6 +1,7 @@
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { ReportableEntities } from 'soapbox/actions/reports';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { Stack, Text } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
@ -8,8 +9,10 @@ import { useAppSelector } from 'soapbox/hooks';
import type { ReducerAccount } from 'soapbox/reducers/accounts';
const messages = defineMessages({
accountEntity: { id: 'report.confirmation.entity.account', defaultMessage: 'account' },
groupEntity: { id: 'report.confirmation.entity.group', defaultMessage: 'group' },
title: { id: 'report.confirmation.title', defaultMessage: 'Thanks for submitting your report.' },
content: { id: 'report.confirmation.content', defaultMessage: 'If we find that this account is violating the {link} we will take further action on the matter.' },
content: { id: 'report.confirmation.content', defaultMessage: 'If we find that this {entity} is violating the {link} we will take further action on the matter.' },
});
interface IOtherActionsStep {
@ -34,6 +37,11 @@ const renderTermsOfServiceLink = (href: string) => (
const ConfirmationStep = ({ account }: IOtherActionsStep) => {
const intl = useIntl();
const links = useAppSelector((state) => getSoapboxConfig(state).get('links') as any);
const entityType = useAppSelector((state) => state.reports.new.entityType);
const entity = entityType === ReportableEntities.GROUP
? intl.formatMessage(messages.groupEntity)
: intl.formatMessage(messages.accountEntity);
return (
<Stack space={1}>
@ -43,6 +51,7 @@ const ConfirmationStep = ({ account }: IOtherActionsStep) => {
<Text>
{intl.formatMessage(messages.content, {
entity,
link: links.get('termsOfService') ?
renderTermsOfServiceLink(links.get('termsOfService')) :
termsOfServiceText,

Wyświetl plik

@ -1,8 +1,8 @@
import clsx from 'clsx';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { changeReportComment, changeReportRule } from 'soapbox/actions/reports';
import { changeReportComment, changeReportRule, ReportableEntities } from 'soapbox/actions/reports';
import { fetchRules } from 'soapbox/actions/rules';
import { FormGroup, Stack, Text, Textarea } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
@ -29,14 +29,12 @@ const ReasonStep = (_props: IReasonStep) => {
const [isNearBottom, setNearBottom] = useState<boolean>(false);
const [isNearTop, setNearTop] = useState<boolean>(true);
const entityType = useAppSelector((state) => state.reports.new.entityType);
const comment = useAppSelector((state) => state.reports.new.comment);
const rules = useAppSelector((state) => state.rules.items);
const ruleIds = useAppSelector((state) => state.reports.new.rule_ids);
const shouldRequireRule = rules.length > 0;
const selectedStatusIds = useAppSelector((state) => state.reports.new.status_ids);
const isReportingAccount = useMemo(() => selectedStatusIds.size === 0, []);
const handleCommentChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
dispatch(changeReportComment(event.target.value));
};
@ -60,7 +58,23 @@ const ReasonStep = (_props: IReasonStep) => {
};
const filterRuleType = (rule: any) => {
const ruleTypeToFilter = isReportingAccount ? 'account' : 'content';
let ruleTypeToFilter = 'content';
switch (entityType) {
case ReportableEntities.ACCOUNT:
ruleTypeToFilter = 'account';
break;
case ReportableEntities.STATUS:
case ReportableEntities.CHAT_MESSAGE:
ruleTypeToFilter = 'content';
break;
case ReportableEntities.GROUP:
ruleTypeToFilter = 'group';
break;
default:
ruleTypeToFilter = 'content';
break;
}
if (rule.rule_type) {
return rule.rule_type === ruleTypeToFilter;

Wyświetl plik

@ -0,0 +1,38 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities, useIncrementEntity } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks/useApi';
import { accountSchema } from 'soapbox/schemas';
import type { ExpandedEntitiesPath } from 'soapbox/entity-store/hooks/types';
function useGroupMembershipRequests(groupId: string) {
const api = useApi();
const path: ExpandedEntitiesPath = [Entities.ACCOUNTS, 'membership_requests', groupId];
const { entities, invalidate, ...rest } = useEntities(
path,
() => api.get(`/api/v1/groups/${groupId}/membership_requests`),
{ schema: accountSchema },
);
const { incrementEntity: authorize } = useIncrementEntity(path, -1, async (accountId: string) => {
const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/authorize`);
invalidate();
return response;
});
const { incrementEntity: reject } = useIncrementEntity(path, -1, async (accountId: string) => {
const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/reject`);
invalidate();
return response;
});
return {
accounts: entities,
authorize,
reject,
...rest,
};
}
export { useGroupMembershipRequests };

Wyświetl plik

@ -2,10 +2,14 @@ import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { GroupMember, groupMemberSchema } from 'soapbox/schemas';
import { useApi } from '../useApi';
function useGroupMembers(groupId: string, role: string) {
const api = useApi();
const { entities, ...result } = useEntities<GroupMember>(
[Entities.GROUP_MEMBERSHIPS, groupId, role],
`/api/v1/groups/${groupId}/memberships?role=${role}`,
() => api.get(`/api/v1/groups/${groupId}/memberships?role=${role}`),
{ schema: groupMemberSchema },
);

Wyświetl plik

@ -2,15 +2,17 @@ import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { Group, groupSchema } from 'soapbox/schemas';
import { useApi } from '../useApi';
import { useFeatures } from '../useFeatures';
import { useGroupRelationships } from '../useGroups';
function usePopularGroups() {
const api = useApi();
const features = useFeatures();
const { entities, ...result } = useEntities<Group>(
[Entities.GROUPS, 'popular'],
'/api/mock/groups', // '/api/v1/truth/trends/groups'
() => api.get('/api/mock/groups'), // '/api/v1/truth/trends/groups'
{
schema: groupSchema,
enabled: features.groupsDiscovery,

Wyświetl plik

@ -2,15 +2,17 @@ import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { Group, groupSchema } from 'soapbox/schemas';
import { useApi } from '../useApi';
import { useFeatures } from '../useFeatures';
import { useGroupRelationships } from '../useGroups';
function useSuggestedGroups() {
const api = useApi();
const features = useFeatures();
const { entities, ...result } = useEntities<Group>(
[Entities.GROUPS, 'suggested'],
'/api/mock/groups', // '/api/v1/truth/suggestions/groups'
() => api.get('/api/mock/groups'), // '/api/v1/truth/suggestions/groups'
{
schema: groupSchema,
enabled: features.groupsDiscovery,

Wyświetl plik

@ -11,6 +11,7 @@ export { useGroupsPath } from './useGroupsPath';
export { useDimensions } from './useDimensions';
export { useFeatures } from './useFeatures';
export { useInstance } from './useInstance';
export { useLoading } from './useLoading';
export { useLocale } from './useLocale';
export { useOnScreen } from './useOnScreen';
export { useOwnAccount } from './useOwnAccount';

Wyświetl plik

@ -2,17 +2,19 @@ import { z } from 'zod';
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities, useEntity } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks';
import { groupSchema, Group } from 'soapbox/schemas/group';
import { groupRelationshipSchema, GroupRelationship } from 'soapbox/schemas/group-relationship';
import { useFeatures } from './useFeatures';
function useGroups() {
const api = useApi();
const features = useFeatures();
const { entities, ...result } = useEntities<Group>(
[Entities.GROUPS, ''],
'/api/v1/groups',
[Entities.GROUPS],
() => api.get('/api/v1/groups'),
{ enabled: features.groups, schema: groupSchema },
);
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
@ -29,9 +31,11 @@ function useGroups() {
}
function useGroup(groupId: string, refetch = true) {
const api = useApi();
const { entity: group, ...result } = useEntity<Group>(
[Entities.GROUPS, groupId],
`/api/v1/groups/${groupId}`,
() => api.get(`/api/v1/groups/${groupId}`),
{ schema: groupSchema, refetch },
);
const { entity: relationship } = useGroupRelationship(groupId);
@ -43,20 +47,22 @@ function useGroup(groupId: string, refetch = true) {
}
function useGroupRelationship(groupId: string) {
const api = useApi();
return useEntity<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, groupId],
`/api/v1/groups/relationships?id[]=${groupId}`,
() => api.get(`/api/v1/groups/relationships?id[]=${groupId}`),
{ schema: z.array(groupRelationshipSchema).transform(arr => arr[0]) },
);
}
function useGroupRelationships(groupIds: string[]) {
const api = useApi();
const q = groupIds.map(id => `id[]=${id}`).join('&');
const endpoint = groupIds.length ? `/api/v1/groups/relationships?${q}` : undefined;
const { entities, ...result } = useEntities<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, ...groupIds],
endpoint,
{ schema: groupRelationshipSchema },
() => api.get(`/api/v1/groups/relationships?${q}`),
{ schema: groupRelationshipSchema, enabled: groupIds.length > 0 },
);
const relationships = entities.reduce<Record<string, GroupRelationship>>((map, relationship) => {

Wyświetl plik

@ -0,0 +1,19 @@
import { useState } from 'react';
function useLoading() {
const [isLoading, setIsLoading] = useState<boolean>(false);
function setPromise<T>(promise: Promise<T>) {
setIsLoading(true);
promise
.then(() => setIsLoading(false))
.catch(() => setIsLoading(false));
return promise;
}
return [isLoading, setPromise] as const;
}
export { useLoading };

Wyświetl plik

@ -192,6 +192,7 @@
"auth.invalid_credentials": "Wrong username or password",
"auth.logged_out": "Logged out.",
"auth_layout.register": "Create an account",
"authorize.success": "Approved",
"backups.actions.create": "Create backup",
"backups.empty_message": "No backups found. {action}",
"backups.empty_message.action": "Create one now?",
@ -768,16 +769,14 @@
"group.cancel_request": "Cancel Request",
"group.delete.success": "Group successfully deleted",
"group.demote.user.success": "@{name} is now a member",
"group.group_mod_authorize": "Accept",
"group.group_mod_authorize.success": "Accepted @{name} to group",
"group.group_mod_authorize.fail": "Failed to approve @{name}",
"group.group_mod_block": "Ban from group",
"group.group_mod_block.success": "@{name} is banned",
"group.group_mod_demote": "Remove {role} role",
"group.group_mod_kick": "Kick @{name} from group",
"group.group_mod_kick.success": "Kicked @{name} from group",
"group.group_mod_promote_mod": "Assign {role} role",
"group.group_mod_reject": "Reject",
"group.group_mod_reject.success": "Rejected @{name} from group",
"group.group_mod_reject.fail": "Failed to reject @{name}",
"group.group_mod_unblock": "Unblock",
"group.group_mod_unblock.success": "Unblocked @{name} from group",
"group.header.alt": "Group header",
@ -1202,6 +1201,7 @@
"registrations.unprocessable_entity": "This username has already been taken.",
"registrations.username.hint": "May only contain A-Z, 0-9, and underscores",
"registrations.username.label": "Your username",
"reject.success": "Rejected",
"relative_time.days": "{number}d",
"relative_time.hours": "{number}h",
"relative_time.just_now": "now",
@ -1241,11 +1241,14 @@
"report.block_hint": "Do you also want to block this account?",
"report.chatMessage.context": "When reporting a users message, the five messages before and five messages after the one selected will be passed along to our moderation team for context.",
"report.chatMessage.title": "Report message",
"report.confirmation.content": "If we find that this account is violating the {link} we will take further action on the matter.",
"report.confirmation.content": "If we find that this {entity} is violating the {link} we will take further action on the matter.",
"report.confirmation.entity.account": "account",
"report.confirmation.entity.group": "group",
"report.confirmation.title": "Thanks for submitting your report.",
"report.done": "Done",
"report.forward": "Forward to {target}",
"report.forward_hint": "The account is from another server. Send a copy of the report there as well?",
"report.group.title": "Report Group",
"report.next": "Next",
"report.otherActions.addAdditional": "Would you like to add additional statuses to this report?",
"report.otherActions.addMore": "Add more",

Wyświetl plik

@ -13,6 +13,7 @@ import {
SuggestedGroupsPanel,
} from 'soapbox/features/ui/util/async-components';
import { useGroup, useOwnAccount } from 'soapbox/hooks';
import { useGroupMembershipRequests } from 'soapbox/hooks/api/groups/useGroupMembershipRequests';
import { Group } from 'soapbox/schemas';
import { Tabs } from '../components/ui';
@ -64,17 +65,12 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
const id = params?.id || '';
const { group } = useGroup(id);
const { accounts: pending } = useGroupMembershipRequests(id);
const isMember = !!group?.relationship?.member;
const isBlocked = group?.relationship?.blocked_by;
const isPrivate = group?.locked;
// if ((group as any) === false) {
// return (
// <MissingIndicator />
// );
// }
const items = [
{
text: intl.formatMessage(messages.all),
@ -85,6 +81,7 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
text: intl.formatMessage(messages.members),
to: `/groups/${group?.id}/members`,
name: '/groups/:id/members',
count: pending.length,
},
];

Wyświetl plik

@ -8,6 +8,8 @@ describe('reports reducer', () => {
account_id: null,
status_ids: [],
chat_message: null,
group: null,
entityType: '',
comment: '',
forward: false,
block: false,

Wyświetl plik

@ -1,7 +1,5 @@
import { Record as ImmutableRecord, Set as ImmutableSet } from 'immutable';
import { ChatMessage } from 'soapbox/types/entities';
import {
REPORT_INIT,
REPORT_SUBMIT_REQUEST,
@ -13,15 +11,19 @@ import {
REPORT_FORWARD_CHANGE,
REPORT_BLOCK_CHANGE,
REPORT_RULE_CHANGE,
ReportableEntities,
} from '../actions/reports';
import type { AnyAction } from 'redux';
import type { ChatMessage, Group } from 'soapbox/types/entities';
const NewReportRecord = ImmutableRecord({
isSubmitting: false,
entityType: '' as ReportableEntities,
account_id: null as string | null,
status_ids: ImmutableSet<string>(),
chat_message: null as null | ChatMessage,
group: null as null | Group,
comment: '',
forward: false,
block: false,
@ -40,11 +42,16 @@ export default function reports(state: State = ReducerRecord(), action: AnyActio
return state.withMutations(map => {
map.setIn(['new', 'isSubmitting'], false);
map.setIn(['new', 'account_id'], action.account.id);
map.setIn(['new', 'entityType'], action.entityType);
if (action.chatMessage) {
map.setIn(['new', 'chat_message'], action.chatMessage);
}
if (action.group) {
map.setIn(['new', 'group'], action.group);
}
if (state.new.account_id !== action.account.id) {
map.setIn(['new', 'status_ids'], action.status ? ImmutableSet([action.status.reblog?.id || action.status.id]) : ImmutableSet());
map.setIn(['new', 'comment'], '');

Wyświetl plik

@ -359,6 +359,7 @@ const getInstanceFeatures = (instance: Instance) => {
]),
editStatuses: any([
v.software === FRIENDICA && gte(v.version, '2022.12.0'),
v.software === MASTODON && gte(v.version, '3.5.0'),
features.includes('editing'),
]),
@ -432,6 +433,7 @@ const getInstanceFeatures = (instance: Instance) => {
/** Whether the accounts who favourited or emoji-reacted to a status can be viewed through the API. */
exposableReactions: any([
v.software === FRIENDICA,
v.software === MASTODON,
v.software === TAKAHE && gte(v.version, '0.6.1'),
v.software === TRUTHSOCIAL,
@ -775,6 +777,7 @@ const getInstanceFeatures = (instance: Instance) => {
* @see {@link https://docs.joinmastodon.org/methods/scheduled_statuses/}
*/
scheduledStatuses: any([
v.software === FRIENDICA,
v.software === MASTODON && gte(v.version, '2.7.0'),
v.software === PLEROMA,
]),
@ -853,7 +856,10 @@ const getInstanceFeatures = (instance: Instance) => {
* Trending statuses.
* @see GET /api/v1/trends/statuses
*/
trendingStatuses: v.software === MASTODON && gte(v.compatVersion, '3.5.0'),
trendingStatuses: any([
v.software === FRIENDICA && gte(v.version, '2022.12.0'),
v.software === MASTODON && gte(v.compatVersion, '3.5.0'),
]),
/**
* Truth Social trending statuses API.
@ -866,6 +872,7 @@ const getInstanceFeatures = (instance: Instance) => {
* @see GET /api/v1/trends
*/
trends: any([
v.software === FRIENDICA && gte(v.version, '2022.12.0'),
v.software === MASTODON && gte(v.compatVersion, '3.0.0'),
v.software === TRUTHSOCIAL,
]),
@ -919,7 +926,9 @@ export const parseVersion = (version: string): Backend => {
const match = regex.exec(version);
const semverString = match && (match[3] || match[1]);
const semver = match ? semverParse(semverString) || semverCoerce(semverString) : null;
const semver = match ? semverParse(semverString) || semverCoerce(semverString, {
loose: true,
}) : null;
const compat = match ? semverParse(match[1]) || semverCoerce(match[1]) : null;
if (match && semver && compat) {

Wyświetl plik

@ -1,9 +1,13 @@
import React from 'react';
import { FormattedNumber } from 'react-intl';
import { z } from 'zod';
/** Check if a value is REALLY a number. */
export const isNumber = (value: unknown): value is number => typeof value === 'number' && !isNaN(value);
/** The input is a number and is not NaN. */
export const realNumberSchema = z.coerce.number().refine(n => !isNaN(n));
export const secondsToDays = (seconds: number) => Math.floor(seconds / (3600 * 24));
const roundDown = (num: number) => {