Merge branch 'group-modal' into 'develop'

Group modal

See merge request soapbox-pub/soapbox!2408
This commit is contained in:
Alex Gleason 2023-04-04 17:54:24 +00:00
commit 21af38c13d
25 changed files with 255 additions and 538 deletions

View file

@ -1,21 +1,15 @@
import { defineMessages } from 'react-intl';
import { deleteEntities } from 'soapbox/entity-store/actions';
import toast from 'soapbox/toast';
import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedGroups, importFetchedAccounts } from './importer';
import { closeModal, openModal } from './modals';
import { deleteFromTimelines } from './timelines';
import type { AxiosError } from 'axios';
import type { GroupRole } from 'soapbox/reducers/group-memberships';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity, Group } from 'soapbox/types/entities';
const GROUP_EDITOR_SET = 'GROUP_EDITOR_SET';
import type { APIEntity } from 'soapbox/types/entities';
const GROUP_CREATE_REQUEST = 'GROUP_CREATE_REQUEST';
const GROUP_CREATE_SUCCESS = 'GROUP_CREATE_SUCCESS';
@ -97,100 +91,6 @@ const GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST = 'GROUP_MEMBERSHIP_REQUEST_REJECT
const GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS = 'GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS';
const GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL = 'GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL';
const GROUP_EDITOR_TITLE_CHANGE = 'GROUP_EDITOR_TITLE_CHANGE';
const GROUP_EDITOR_DESCRIPTION_CHANGE = 'GROUP_EDITOR_DESCRIPTION_CHANGE';
const GROUP_EDITOR_PRIVACY_CHANGE = 'GROUP_EDITOR_PRIVACY_CHANGE';
const GROUP_EDITOR_MEDIA_CHANGE = 'GROUP_EDITOR_MEDIA_CHANGE';
const GROUP_EDITOR_RESET = 'GROUP_EDITOR_RESET';
const messages = defineMessages({
success: { id: 'manage_group.submit_success', defaultMessage: 'The group was created' },
editSuccess: { id: 'manage_group.edit_success', defaultMessage: 'The group was edited' },
joinSuccess: { id: 'group.join.success', defaultMessage: 'Joined the group' },
joinRequestSuccess: { id: 'group.join.request_success', defaultMessage: 'Request sent to group owner' },
leaveSuccess: { id: 'group.leave.success', defaultMessage: 'Left the group' },
view: { id: 'toast.view', defaultMessage: 'View' },
});
const editGroup = (group: Group) => (dispatch: AppDispatch) => {
dispatch({
type: GROUP_EDITOR_SET,
group,
});
dispatch(openModal('MANAGE_GROUP'));
};
const createGroup = (params: Record<string, any>, shouldReset?: boolean) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(createGroupRequest());
return api(getState).post('/api/v1/groups', params, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
.then(({ data }) => {
dispatch(importFetchedGroups([data]));
dispatch(createGroupSuccess(data));
toast.success(messages.success, {
actionLabel: messages.view,
actionLink: `/groups/${data.id}`,
});
if (shouldReset) {
dispatch(resetGroupEditor());
}
return data;
}).catch(err => dispatch(createGroupFail(err)));
};
const createGroupRequest = () => ({
type: GROUP_CREATE_REQUEST,
});
const createGroupSuccess = (group: APIEntity) => ({
type: GROUP_CREATE_SUCCESS,
group,
});
const createGroupFail = (error: AxiosError) => ({
type: GROUP_CREATE_FAIL,
error,
});
const updateGroup = (id: string, params: Record<string, any>, shouldReset?: boolean) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(updateGroupRequest());
return api(getState).put(`/api/v1/groups/${id}`, params)
.then(({ data }) => {
dispatch(importFetchedGroups([data]));
dispatch(updateGroupSuccess(data));
toast.success(messages.editSuccess);
if (shouldReset) {
dispatch(resetGroupEditor());
}
dispatch(closeModal('MANAGE_GROUP'));
}).catch(err => dispatch(updateGroupFail(err)));
};
const updateGroupRequest = () => ({
type: GROUP_UPDATE_REQUEST,
});
const updateGroupSuccess = (group: APIEntity) => ({
type: GROUP_UPDATE_SUCCESS,
group,
});
const updateGroupFail = (error: AxiosError) => ({
type: GROUP_UPDATE_FAIL,
error,
});
const deleteGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(deleteEntities([id], 'Group'));
@ -758,57 +658,7 @@ const rejectGroupMembershipRequestFail = (groupId: string, accountId: string, er
error,
});
const changeGroupEditorTitle = (value: string) => ({
type: GROUP_EDITOR_TITLE_CHANGE,
value,
});
const changeGroupEditorDescription = (value: string) => ({
type: GROUP_EDITOR_DESCRIPTION_CHANGE,
value,
});
const changeGroupEditorPrivacy = (value: boolean) => ({
type: GROUP_EDITOR_PRIVACY_CHANGE,
value,
});
const changeGroupEditorMedia = (mediaType: 'header' | 'avatar', file: File) => ({
type: GROUP_EDITOR_MEDIA_CHANGE,
mediaType,
value: file,
});
const resetGroupEditor = () => ({
type: GROUP_EDITOR_RESET,
});
const submitGroupEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, getState: () => RootState) => {
const groupId = getState().group_editor.groupId;
const displayName = getState().group_editor.displayName;
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,
};
if (avatar) params.avatar = avatar;
if (header) params.header = header;
if (groupId === null) {
return dispatch(createGroup(params, shouldReset));
} else {
return dispatch(updateGroup(groupId, params, shouldReset));
}
};
export {
GROUP_EDITOR_SET,
GROUP_CREATE_REQUEST,
GROUP_CREATE_SUCCESS,
GROUP_CREATE_FAIL,
@ -869,20 +719,6 @@ export {
GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST,
GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS,
GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL,
GROUP_EDITOR_TITLE_CHANGE,
GROUP_EDITOR_DESCRIPTION_CHANGE,
GROUP_EDITOR_PRIVACY_CHANGE,
GROUP_EDITOR_MEDIA_CHANGE,
GROUP_EDITOR_RESET,
editGroup,
createGroup,
createGroupRequest,
createGroupSuccess,
createGroupFail,
updateGroup,
updateGroupRequest,
updateGroupSuccess,
updateGroupFail,
deleteGroup,
deleteGroupRequest,
deleteGroupSuccess,
@ -955,10 +791,4 @@ export {
rejectGroupMembershipRequestRequest,
rejectGroupMembershipRequestSuccess,
rejectGroupMembershipRequestFail,
changeGroupEditorTitle,
changeGroupEditorDescription,
changeGroupEditorPrivacy,
changeGroupEditorMedia,
resetGroupEditor,
submitGroupEditor,
};

View file

@ -20,7 +20,7 @@ function useCreateEntity<TEntity extends Entity = Entity, Data = unknown>(
) {
const dispatch = useAppDispatch();
const [isLoading, setPromise] = useLoading();
const [isSubmitting, setPromise] = useLoading();
const { entityType, listKey } = parseEntitiesPath(expandedPath);
async function createEntity(data: Data, callbacks: EntityCallbacks<TEntity> = {}): Promise<void> {
@ -44,7 +44,7 @@ function useCreateEntity<TEntity extends Entity = Entity, Data = unknown>(
return {
createEntity,
isLoading,
isSubmitting,
};
}

View file

@ -15,7 +15,7 @@ function useDeleteEntity(
) {
const dispatch = useAppDispatch();
const getState = useGetState();
const [isLoading, setPromise] = useLoading();
const [isSubmitting, 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.
@ -47,7 +47,7 @@ function useDeleteEntity(
return {
deleteEntity,
isLoading,
isSubmitting,
};
}

View file

@ -24,16 +24,16 @@ function useEntityActions<TEntity extends Entity = Entity, Data = any>(
const api = useApi();
const { entityType, path } = parseEntitiesPath(expandedPath);
const { deleteEntity, isLoading: deleteLoading } =
const { deleteEntity, isSubmitting: deleteSubmitting } =
useDeleteEntity(entityType, (entityId) => api.delete(endpoints.delete!.replaceAll(':id', entityId)));
const { createEntity, isLoading: createLoading } =
const { createEntity, isSubmitting: createSubmitting } =
useCreateEntity<TEntity, Data>(path, (data) => api.post(endpoints.post!, data), opts);
return {
createEntity,
deleteEntity,
isLoading: createLoading || deleteLoading,
isSubmitting: createSubmitting || deleteSubmitting,
};
}

View file

@ -11,7 +11,7 @@ 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]);
const ids = new Set([...newIds, ...Array.from(list.ids)]);
if (typeof list.state.totalCount === 'number') {
const sizeDiff = ids.size - list.ids.size;

View file

@ -94,7 +94,7 @@ const GroupActionButton = ({ group }: IGroupActionButton) => {
<Button
theme='primary'
onClick={onJoinGroup}
disabled={joinGroup.isLoading}
disabled={joinGroup.isSubmitting}
>
{group.locked
? <FormattedMessage id='group.join.private' defaultMessage='Request Access' />
@ -108,7 +108,7 @@ const GroupActionButton = ({ group }: IGroupActionButton) => {
<Button
theme='secondary'
onClick={onCancelRequest}
disabled={cancelRequest.isLoading}
disabled={cancelRequest.isSubmitting}
>
<FormattedMessage id='group.cancel_request' defaultMessage='Cancel Request' />
</Button>
@ -119,7 +119,7 @@ const GroupActionButton = ({ group }: IGroupActionButton) => {
<Button
theme='secondary'
onClick={onLeaveGroup}
disabled={leaveGroup.isLoading}
disabled={leaveGroup.isSubmitting}
>
<FormattedMessage id='group.leave' defaultMessage='Leave Group' />
</Button>

View file

@ -0,0 +1,45 @@
import clsx from 'clsx';
import React from 'react';
import Icon from 'soapbox/components/icon';
import { Avatar, HStack } from 'soapbox/components/ui';
interface IMediaInput {
src: string | undefined
accept: string
onChange: React.ChangeEventHandler<HTMLInputElement>
disabled?: boolean
}
const AvatarPicker = React.forwardRef<HTMLInputElement, IMediaInput>(({ src, onChange, accept, disabled }, ref) => {
return (
<label className='absolute bottom-0 left-1/2 h-20 w-20 -translate-x-1/2 translate-y-1/2 cursor-pointer rounded-full bg-primary-500 ring-2 ring-white dark:ring-primary-900'>
{src && <Avatar src={src} size={80} />}
<HStack
alignItems='center'
justifyContent='center'
className={clsx('absolute left-0 top-0 h-full w-full rounded-full transition-opacity', {
'opacity-0 hover:opacity-90 bg-primary-500': src,
})}
>
<Icon
src={require('@tabler/icons/camera-plus.svg')}
className='h-5 w-5 text-white'
/>
</HStack>
<span className='sr-only'>Upload avatar</span>
<input
ref={ref}
name='avatar'
type='file'
accept={accept}
onChange={onChange}
disabled={disabled}
className='hidden'
/>
</label>
);
});
export default AvatarPicker;

View file

@ -0,0 +1,52 @@
import clsx from 'clsx';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Icon from 'soapbox/components/icon';
import { HStack, Text } from 'soapbox/components/ui';
interface IMediaInput {
src: string | undefined
accept: string
onChange: React.ChangeEventHandler<HTMLInputElement>
disabled?: boolean
}
const HeaderPicker = React.forwardRef<HTMLInputElement, IMediaInput>(({ src, onChange, accept, disabled }, ref) => {
return (
<label
className='dark:sm:shadow-inset relative h-24 w-full cursor-pointer overflow-hidden rounded-lg bg-primary-100 text-primary-500 dark:bg-gray-800 dark:text-accent-blue sm:h-36 sm:shadow'
>
{src && <img className='h-full w-full object-cover' src={src} alt='' />}
<HStack
className={clsx('absolute top-0 h-full w-full transition-opacity', {
'opacity-0 hover:opacity-90 bg-primary-100 dark:bg-gray-800': src,
})}
space={1.5}
alignItems='center'
justifyContent='center'
>
<Icon
src={require('@tabler/icons/photo-plus.svg')}
className='h-4.5 w-4.5'
/>
<Text size='md' theme='primary' weight='semibold'>
<FormattedMessage id='group.upload_banner' defaultMessage='Upload photo' />
</Text>
<input
ref={ref}
name='header'
type='file'
accept={accept}
onChange={onChange}
disabled={disabled}
className='hidden'
/>
</HStack>
</label>
);
});
export default HeaderPicker;

View file

@ -1,100 +1,27 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import Icon from 'soapbox/components/icon';
import { Avatar, Button, Column, Form, FormActions, FormGroup, HStack, Input, Spinner, Text, Textarea } from 'soapbox/components/ui';
import { Button, Column, Form, FormActions, FormGroup, Input, Spinner, Textarea } from 'soapbox/components/ui';
import { useAppSelector, useInstance } from 'soapbox/hooks';
import { useGroup, useUpdateGroup } from 'soapbox/hooks/api';
import { useImageField, useTextField } from 'soapbox/hooks/forms';
import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts';
import AvatarPicker from './components/group-avatar-picker';
import HeaderPicker from './components/group-header-picker';
import type { List as ImmutableList } from 'immutable';
const nonDefaultAvatar = (url: string | undefined) => url && isDefaultAvatar(url) ? undefined : url;
const nonDefaultHeader = (url: string | undefined) => url && isDefaultHeader(url) ? undefined : url;
interface IMediaInput {
src: string | undefined
accept: string
onChange: React.ChangeEventHandler<HTMLInputElement>
disabled: boolean
}
const messages = defineMessages({
heading: { id: 'navigation_bar.edit_group', defaultMessage: 'Edit Group' },
groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Group Name' },
groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' },
});
const HeaderPicker = React.forwardRef<HTMLInputElement, IMediaInput>(({ src, onChange, accept, disabled }, ref) => {
return (
<label
className='dark:sm:shadow-inset relative h-24 w-full cursor-pointer overflow-hidden rounded-lg bg-primary-100 text-primary-500 dark:bg-gray-800 dark:text-accent-blue sm:h-36 sm:shadow'
>
{src && <img className='h-full w-full object-cover' src={src} alt='' />}
<HStack
className={clsx('absolute top-0 h-full w-full transition-opacity', {
'opacity-0 hover:opacity-90 bg-primary-100 dark:bg-gray-800': src,
})}
space={1.5}
alignItems='center'
justifyContent='center'
>
<Icon
src={require('@tabler/icons/photo-plus.svg')}
className='h-4.5 w-4.5'
/>
<Text size='md' theme='primary' weight='semibold'>
<FormattedMessage id='group.upload_banner' defaultMessage='Upload photo' />
</Text>
<input
ref={ref}
name='header'
type='file'
accept={accept}
onChange={onChange}
disabled={disabled}
className='hidden'
/>
</HStack>
</label>
);
});
const AvatarPicker = React.forwardRef<HTMLInputElement, IMediaInput>(({ src, onChange, accept, disabled }, ref) => {
return (
<label className='absolute bottom-0 left-1/2 h-20 w-20 -translate-x-1/2 translate-y-1/2 cursor-pointer rounded-full bg-primary-500 ring-2 ring-white dark:ring-primary-900'>
{src && <Avatar src={src} size={80} />}
<HStack
alignItems='center'
justifyContent='center'
className={clsx('absolute left-0 top-0 h-full w-full rounded-full transition-opacity', {
'opacity-0 hover:opacity-90 bg-primary-500': src,
})}
>
<Icon
src={require('@tabler/icons/camera-plus.svg')}
className='h-5 w-5 text-white'
/>
</HStack>
<span className='sr-only'>Upload avatar</span>
<input
ref={ref}
name='avatar'
type='file'
accept={accept}
onChange={onChange}
disabled={disabled}
className='hidden'
/>
</label>
);
});
interface IEditGroup {
params: {
id: string

View file

@ -33,7 +33,7 @@ const Groups: React.FC = () => {
const { groups, isLoading } = useGroups(debouncedValue);
const createGroup = () => {
dispatch(openModal('MANAGE_GROUP'));
dispatch(openModal('CREATE_GROUP'));
};
const renderBlankslate = () => (

View file

@ -26,7 +26,7 @@ import {
LandingPageModal,
ListAdder,
ListEditor,
ManageGroupModal,
CreateGroupModal,
MediaModal,
MentionsModal,
MissingDescriptionModal,
@ -59,6 +59,7 @@ const MODAL_COMPONENTS = {
'COMPOSE': ComposeModal,
'COMPOSE_EVENT': ComposeEventModal,
'CONFIRM': ConfirmationModal,
'CREATE_GROUP': CreateGroupModal,
'CRYPTO_DONATE': CryptoDonateModal,
'DISLIKES': DislikesModal,
'EDIT_ANNOUNCEMENT': EditAnnouncementModal,
@ -73,7 +74,6 @@ const MODAL_COMPONENTS = {
'LANDING_PAGE': LandingPageModal,
'LIST_ADDER': ListAdder,
'LIST_EDITOR': ListEditor,
'MANAGE_GROUP': ManageGroupModal,
'MEDIA': MediaModal,
'MENTIONS': MentionsModal,
'MISSING_DESCRIPTION': MissingDescriptionModal,

View file

@ -1,10 +1,12 @@
import { AxiosError } from 'axios';
import React, { useMemo, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { submitGroupEditor } from 'soapbox/actions/groups';
import { Modal, Stack } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useDebounce } from 'soapbox/hooks';
import { useGroupValidation } from 'soapbox/hooks/api';
import { useDebounce } from 'soapbox/hooks';
import { useCreateGroup, useGroupValidation, type CreateGroupParams } from 'soapbox/hooks/api';
import { type Group } from 'soapbox/schemas';
import toast from 'soapbox/toast';
import ConfirmationStep from './steps/confirmation-step';
import DetailsStep from './steps/details-step';
@ -13,7 +15,6 @@ import PrivacyStep from './steps/privacy-step';
const messages = defineMessages({
next: { id: 'manage_group.next', defaultMessage: 'Next' },
create: { id: 'manage_group.create', defaultMessage: 'Create' },
update: { id: 'manage_group.update', defaultMessage: 'Update' },
done: { id: 'manage_group.done', defaultMessage: 'Done' },
});
@ -23,47 +24,33 @@ enum Steps {
THREE = 'THREE',
}
const manageGroupSteps = {
ONE: PrivacyStep,
TWO: DetailsStep,
THREE: ConfirmationStep,
};
interface IManageGroupModal {
interface ICreateGroupModal {
onClose: (type?: string) => void
}
const ManageGroupModal: React.FC<IManageGroupModal> = ({ onClose }) => {
const CreateGroupModal: React.FC<ICreateGroupModal> = ({ onClose }) => {
const intl = useIntl();
const debounce = useDebounce;
const dispatch = useAppDispatch();
const id = useAppSelector((state) => state.group_editor.groupId);
const [group, setGroup] = useState<any | null>(null);
const [group, setGroup] = useState<Group | null>(null);
const [params, setParams] = useState<CreateGroupParams>({});
const [currentStep, setCurrentStep] = useState<Steps>(Steps.ONE);
const isSubmitting = useAppSelector((state) => state.group_editor.isSubmitting);
const [currentStep, setCurrentStep] = useState<Steps>(id ? Steps.TWO : Steps.ONE);
const name = useAppSelector((state) => state.group_editor.displayName);
const debouncedName = debounce(name, 300);
const { createGroup, isSubmitting } = useCreateGroup();
const debouncedName = debounce(params.display_name || '', 300);
const { data: { isValid } } = useGroupValidation(debouncedName);
const handleClose = () => {
onClose('MANAGE_GROUP');
};
const handleSubmit = () => {
return dispatch(submitGroupEditor(true));
};
const confirmationText = useMemo(() => {
switch (currentStep) {
case Steps.THREE:
return intl.formatMessage(messages.done);
case Steps.TWO:
return intl.formatMessage(id ? messages.update : messages.create);
return intl.formatMessage(messages.create);
default:
return intl.formatMessage(messages.next);
}
@ -75,12 +62,20 @@ const ManageGroupModal: React.FC<IManageGroupModal> = ({ onClose }) => {
setCurrentStep(Steps.TWO);
break;
case Steps.TWO:
handleSubmit()
.then((group) => {
createGroup(params, {
onSuccess(group) {
setCurrentStep(Steps.THREE);
setGroup(group);
})
.catch(() => {});
},
onError(error) {
if (error instanceof AxiosError) {
const msg = error.response?.data.error;
if (typeof msg === 'string') {
toast.error(msg);
}
}
},
});
break;
case Steps.THREE:
handleClose();
@ -90,13 +85,20 @@ const ManageGroupModal: React.FC<IManageGroupModal> = ({ onClose }) => {
}
};
const StepToRender = manageGroupSteps[currentStep];
const renderStep = () => {
switch (currentStep) {
case Steps.ONE:
return <PrivacyStep params={params} onChange={setParams} />;
case Steps.TWO:
return <DetailsStep params={params} onChange={setParams} />;
case Steps.THREE:
return <ConfirmationStep group={group!} />;
}
};
return (
<Modal
title={id
? <FormattedMessage id='navigation_bar.edit_group' defaultMessage='Edit Group' />
: <FormattedMessage id='navigation_bar.create_group' defaultMessage='Create Group' />}
title={<FormattedMessage id='navigation_bar.create_group' defaultMessage='Create Group' />}
confirmationAction={handleNextStep}
confirmationText={confirmationText}
confirmationDisabled={isSubmitting || (currentStep === Steps.TWO && !isValid)}
@ -104,11 +106,10 @@ const ManageGroupModal: React.FC<IManageGroupModal> = ({ onClose }) => {
onClose={handleClose}
>
<Stack space={2}>
{/* @ts-ignore */}
<StepToRender group={group} />
{renderStep()}
</Stack>
</Modal>
);
};
export default ManageGroupModal;
export default CreateGroupModal;

View file

@ -1,162 +1,73 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import {
changeGroupEditorTitle,
changeGroupEditorDescription,
changeGroupEditorMedia,
} from 'soapbox/actions/groups';
import { Avatar, Form, FormGroup, HStack, Icon, Input, Text, Textarea } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useDebounce, useInstance } from 'soapbox/hooks';
import { useGroupValidation } from 'soapbox/hooks/api';
import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts';
import { Form, FormGroup, Input, Textarea } from 'soapbox/components/ui';
import AvatarPicker from 'soapbox/features/group/components/group-avatar-picker';
import HeaderPicker from 'soapbox/features/group/components/group-header-picker';
import { useAppSelector, useDebounce, useInstance } from 'soapbox/hooks';
import { CreateGroupParams, useGroupValidation } from 'soapbox/hooks/api';
import { usePreview } from 'soapbox/hooks/forms';
import resizeImage from 'soapbox/utils/resize-image';
import type { List as ImmutableList } from 'immutable';
interface IMediaInput {
src: string | null
accept: string
onChange: React.ChangeEventHandler<HTMLInputElement>
disabled: boolean
}
const messages = defineMessages({
groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Group Name' },
groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' },
});
const HeaderPicker: React.FC<IMediaInput> = ({ src, onChange, accept, disabled }) => {
return (
<label
className='dark:sm:shadow-inset relative h-24 w-full cursor-pointer overflow-hidden rounded-xl bg-primary-100 text-primary-500 dark:bg-gray-800 dark:text-accent-blue sm:h-44'
>
{src && <img className='h-full w-full object-cover' src={src} alt='' />}
<HStack
className={clsx('absolute top-0 h-full w-full transition-opacity', {
'opacity-0 hover:opacity-90 bg-primary-100 dark:bg-gray-800': src,
})}
space={1.5}
alignItems='center'
justifyContent='center'
>
<Icon
src={require('@tabler/icons/photo-plus.svg')}
className='h-4.5 w-4.5'
/>
interface IDetailsStep {
params: CreateGroupParams
onChange(params: CreateGroupParams): void
}
<Text size='md' theme='primary' weight='semibold'>
<FormattedMessage id='group.upload_banner' defaultMessage='Upload photo' />
</Text>
<input
name='header'
type='file'
accept={accept}
onChange={onChange}
disabled={disabled}
className='hidden'
/>
</HStack>
</label>
);
};
const AvatarPicker: React.FC<IMediaInput> = ({ src, onChange, accept, disabled }) => {
return (
<label className='absolute bottom-0 left-1/2 h-20 w-20 -translate-x-1/2 translate-y-1/2 cursor-pointer rounded-full bg-primary-500 ring-4 ring-white dark:ring-primary-900'>
{src && <Avatar src={src} size={80} />}
<HStack
alignItems='center'
justifyContent='center'
className={clsx('absolute left-0 top-0 h-full w-full rounded-full transition-opacity', {
'opacity-0 hover:opacity-90 bg-primary-500': src,
})}
>
<Icon
src={require('@tabler/icons/camera-plus.svg')}
className='h-5 w-5 text-white'
/>
</HStack>
<span className='sr-only'>Upload avatar</span>
<input
name='avatar'
type='file'
accept={accept}
onChange={onChange}
disabled={disabled}
className='hidden'
/>
</label>
);
};
const DetailsStep = () => {
const DetailsStep: React.FC<IDetailsStep> = ({ params, onChange }) => {
const intl = useIntl();
const debounce = useDebounce;
const dispatch = useAppDispatch();
const instance = useInstance();
const groupId = useAppSelector((state) => state.group_editor.groupId);
const isUploading = useAppSelector((state) => state.group_editor.isUploading);
const name = useAppSelector((state) => state.group_editor.displayName);
const description = useAppSelector((state) => state.group_editor.note);
const debouncedName = debounce(name, 300);
const {
display_name: displayName = '',
note = '',
} = params;
const debouncedName = debounce(displayName, 300);
const { data: { isValid, message: errorMessage } } = useGroupValidation(debouncedName);
const [avatarSrc, setAvatarSrc] = useState<string | null>(null);
const [headerSrc, setHeaderSrc] = useState<string | null>(null);
const avatarSrc = usePreview(params.avatar);
const headerSrc = usePreview(params.header);
const attachmentTypes = useAppSelector(
state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>,
)?.filter(type => type.startsWith('image/')).toArray().join(',');
const onChangeName: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
dispatch(changeGroupEditorTitle(target.value));
const handleTextChange = (property: keyof CreateGroupParams): React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement> => {
return (e) => {
onChange({
...params,
[property]: e.target.value,
});
};
};
const onChangeDescription: React.ChangeEventHandler<HTMLTextAreaElement> = ({ target }) => {
dispatch(changeGroupEditorDescription(target.value));
const handleImageChange = (property: keyof CreateGroupParams, maxPixels?: number): React.ChangeEventHandler<HTMLInputElement> => {
return async ({ target: { files } }) => {
const file = files ? files[0] : undefined;
if (file) {
const resized = await resizeImage(file, maxPixels);
onChange({
...params,
[property]: resized,
});
}
};
};
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = e => {
const rawFile = e.target.files?.item(0);
if (!rawFile) return;
if (e.target.name === 'avatar') {
resizeImage(rawFile, 400 * 400).then(file => {
dispatch(changeGroupEditorMedia('avatar', file));
setAvatarSrc(URL.createObjectURL(file));
}).catch(console.error);
} else {
resizeImage(rawFile, 1920 * 1080).then(file => {
dispatch(changeGroupEditorMedia('header', file));
setHeaderSrc(URL.createObjectURL(file));
}).catch(console.error);
}
};
useEffect(() => {
if (!groupId) return;
dispatch((_, getState) => {
const group = getState().groups.items.get(groupId);
if (!group) return;
if (group.avatar && !isDefaultAvatar(group.avatar)) setAvatarSrc(group.avatar);
if (group.header && !isDefaultHeader(group.header)) setHeaderSrc(group.header);
});
}, [groupId]);
return (
<Form>
<div className='relative mb-12 flex'>
<HeaderPicker src={headerSrc} accept={attachmentTypes} onChange={handleFileChange} disabled={isUploading} />
<AvatarPicker src={avatarSrc} accept={attachmentTypes} onChange={handleFileChange} disabled={isUploading} />
<HeaderPicker src={headerSrc} accept={attachmentTypes} onChange={handleImageChange('header', 1920 * 1080)} />
<AvatarPicker src={avatarSrc} accept={attachmentTypes} onChange={handleImageChange('avatar', 400 * 400)} />
</div>
<FormGroup
@ -167,8 +78,8 @@ const DetailsStep = () => {
<Input
type='text'
placeholder={intl.formatMessage(messages.groupNamePlaceholder)}
value={name}
onChange={onChangeName}
value={displayName}
onChange={handleTextChange('display_name')}
maxLength={Number(instance.configuration.getIn(['groups', 'max_characters_name']))}
/>
</FormGroup>
@ -179,8 +90,8 @@ const DetailsStep = () => {
<Textarea
autoComplete='off'
placeholder={intl.formatMessage(messages.groupDescriptionPlaceholder)}
value={description}
onChange={onChangeDescription}
value={note}
onChange={handleTextChange('note')}
maxLength={Number(instance.configuration.getIn(['groups', 'max_characters_description']))}
/>
</FormGroup>

View file

@ -1,18 +1,20 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { changeGroupEditorPrivacy } from 'soapbox/actions/groups';
import List, { ListItem } from 'soapbox/components/list';
import { Form, FormGroup, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { type CreateGroupParams } from 'soapbox/hooks/api';
const PrivacyStep = () => {
const dispatch = useAppDispatch();
interface IPrivacyStep {
params: CreateGroupParams
onChange(params: CreateGroupParams): void
}
const locked = useAppSelector((state) => state.group_editor.locked);
const PrivacyStep: React.FC<IPrivacyStep> = ({ params, onChange }) => {
const visibility = params.group_visibility || 'everyone';
const onChangePrivacy = (value: boolean) => {
dispatch(changeGroupEditorPrivacy(value));
const onChangePrivacy = (group_visibility: CreateGroupParams['group_visibility']) => {
onChange({ ...params, group_visibility });
};
return (
@ -33,15 +35,15 @@ const PrivacyStep = () => {
<ListItem
label={<Text weight='medium'><FormattedMessage id='manage_group.privacy.public.label' defaultMessage='Public' /></Text>}
hint={<FormattedMessage id='manage_group.privacy.public.hint' defaultMessage='Discoverable. Anyone can join.' />}
onSelect={() => onChangePrivacy(false)}
isSelected={!locked}
onSelect={() => onChangePrivacy('everyone')}
isSelected={visibility === 'everyone'}
/>
<ListItem
label={<Text weight='medium'><FormattedMessage id='manage_group.privacy.private.label' defaultMessage='Private (Owner approval required)' /></Text>}
hint={<FormattedMessage id='manage_group.privacy.private.hint' defaultMessage='Discoverable. Users can join after their request is approved.' />}
onSelect={() => onChangePrivacy(true)}
isSelected={locked}
onSelect={() => onChangePrivacy('members_only')}
isSelected={visibility === 'members_only'}
/>
</List>
</FormGroup>

View file

@ -12,7 +12,7 @@ const NewGroupPanel = () => {
const canCreateGroup = useAppSelector((state) => hasPermission(state, PERMISSION_CREATE_GROUPS));
const createGroup = () => {
dispatch(openModal('MANAGE_GROUP'));
dispatch(openModal('CREATE_GROUP'));
};
if (!canCreateGroup) return null;

View file

@ -590,8 +590,8 @@ export function GroupMembershipRequests() {
return import(/* webpackChunkName: "features/groups" */'../../group/group-membership-requests');
}
export function ManageGroupModal() {
return import(/* webpackChunkName: "features/manage_group_modal" */'../components/modals/manage-group-modal/manage-group-modal');
export function CreateGroupModal() {
return import(/* webpackChunkName: "features/groups" */'../components/modals/manage-group-modal/create-group-modal');
}
export function NewGroupPanel() {

View file

@ -8,14 +8,14 @@ function useCancelMembershipRequest(group: Group) {
const api = useApi();
const me = useOwnAccount();
const { createEntity, isLoading } = useCreateEntity(
const { createEntity, isSubmitting } = useCreateEntity(
[Entities.GROUP_RELATIONSHIPS],
() => api.post(`/api/v1/groups/${group.id}/membership_requests/${me?.id}/reject`),
);
return {
mutate: createEntity,
isLoading,
isSubmitting,
};
}

View file

@ -0,0 +1,33 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useCreateEntity } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks/useApi';
import { groupSchema } from 'soapbox/schemas';
interface CreateGroupParams {
display_name?: string
note?: string
avatar?: File
header?: File
group_visibility?: 'members_only' | 'everyone'
discoverable?: boolean
tags?: string[]
}
function useCreateGroup() {
const api = useApi();
const { createEntity, ...rest } = useCreateEntity([Entities.GROUPS, 'search', ''], (params: CreateGroupParams) => {
return api.post('/api/v1/groups', params, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}, { schema: groupSchema });
return {
createGroup: createEntity,
...rest,
};
}
export { useCreateGroup, type CreateGroupParams };

View file

@ -4,14 +4,14 @@ import { useEntityActions } from 'soapbox/entity-store/hooks';
import type { Group } from 'soapbox/schemas';
function useDeleteGroup() {
const { deleteEntity, isLoading } = useEntityActions<Group>(
const { deleteEntity, isSubmitting } = useEntityActions<Group>(
[Entities.GROUPS],
{ delete: '/api/v1/groups/:id' },
);
return {
mutate: deleteEntity,
isLoading,
isSubmitting,
};
}

View file

@ -9,7 +9,7 @@ import type { Group, GroupRelationship } from 'soapbox/schemas';
function useJoinGroup(group: Group) {
const { invalidate } = useGroups();
const { createEntity, isLoading } = useEntityActions<GroupRelationship>(
const { createEntity, isSubmitting } = useEntityActions<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, group.id],
{ post: `/api/v1/groups/${group.id}/join` },
{ schema: groupRelationshipSchema },
@ -17,7 +17,7 @@ function useJoinGroup(group: Group) {
return {
mutate: createEntity,
isLoading,
isSubmitting,
invalidate,
};
}

View file

@ -9,7 +9,7 @@ import type { Group, GroupRelationship } from 'soapbox/schemas';
function useLeaveGroup(group: Group) {
const { invalidate } = useGroups();
const { createEntity, isLoading } = useEntityActions<GroupRelationship>(
const { createEntity, isSubmitting } = useEntityActions<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, group.id],
{ post: `/api/v1/groups/${group.id}/leave` },
{ schema: groupRelationshipSchema },
@ -17,7 +17,7 @@ function useLeaveGroup(group: Group) {
return {
mutate: createEntity,
isLoading,
isSubmitting,
invalidate,
};
}

View file

@ -8,6 +8,7 @@ export { useAccount } from './useAccount';
*/
export { useBlockGroupMember } from './groups/useBlockGroupMember';
export { useCancelMembershipRequest } from './groups/useCancelMembershipRequest';
export { useCreateGroup, type CreateGroupParams } from './groups/useCreateGroup';
export { useDeleteGroup } from './groups/useDeleteGroup';
export { useDemoteGroupMember } from './groups/useDemoteGroupMember';
export { useGroup, useGroups } from './groups/useGroups';

View file

@ -945,7 +945,6 @@
"manage_group.delete_group": "Delete group",
"manage_group.done": "Done",
"manage_group.edit_group": "Edit group",
"manage_group.edit_success": "The group was edited",
"manage_group.fields.cannot_change_hint": "This cannot be changed after the group is created.",
"manage_group.fields.description_label": "Description",
"manage_group.fields.description_placeholder": "Description",
@ -962,9 +961,7 @@
"manage_group.privacy.private.label": "Private (Owner approval required)",
"manage_group.privacy.public.hint": "Discoverable. Anyone can join.",
"manage_group.privacy.public.label": "Public",
"manage_group.submit_success": "The group was created",
"manage_group.tagline": "Groups connect you with others based on shared interests.",
"manage_group.update": "Update",
"media_panel.empty_message": "No media found.",
"media_panel.title": "Media",
"mfa.confirm.success_message": "MFA confirmed",

View file

@ -1,80 +0,0 @@
import { Record as ImmutableRecord } from 'immutable';
import {
GROUP_EDITOR_RESET,
GROUP_EDITOR_TITLE_CHANGE,
GROUP_EDITOR_DESCRIPTION_CHANGE,
GROUP_EDITOR_PRIVACY_CHANGE,
GROUP_EDITOR_MEDIA_CHANGE,
GROUP_CREATE_REQUEST,
GROUP_CREATE_FAIL,
GROUP_CREATE_SUCCESS,
GROUP_UPDATE_REQUEST,
GROUP_UPDATE_FAIL,
GROUP_UPDATE_SUCCESS,
GROUP_EDITOR_SET,
} from 'soapbox/actions/groups';
import type { AnyAction } from 'redux';
const ReducerRecord = ImmutableRecord({
groupId: null as string | null,
progress: 0,
isUploading: false,
isSubmitting: false,
isChanged: false,
displayName: '',
note: '',
avatar: null as File | null,
header: null as File | null,
locked: false,
});
type State = ReturnType<typeof ReducerRecord>;
export default function groupEditor(state: State = ReducerRecord(), action: AnyAction) {
switch (action.type) {
case GROUP_EDITOR_RESET:
return ReducerRecord();
case GROUP_EDITOR_SET:
return state.withMutations(map => {
map.set('groupId', action.group.id);
map.set('displayName', action.group.display_name);
map.set('note', action.group.note);
});
case GROUP_EDITOR_TITLE_CHANGE:
return state.withMutations(map => {
map.set('displayName', action.value);
map.set('isChanged', true);
});
case GROUP_EDITOR_DESCRIPTION_CHANGE:
return state.withMutations(map => {
map.set('note', action.value);
map.set('isChanged', true);
});
case GROUP_EDITOR_PRIVACY_CHANGE:
return state.withMutations(map => {
map.set('locked', action.value);
map.set('isChanged', true);
});
case GROUP_EDITOR_MEDIA_CHANGE:
return state.set(action.mediaType, action.value);
case GROUP_CREATE_REQUEST:
case GROUP_UPDATE_REQUEST:
return state.withMutations(map => {
map.set('isSubmitting', true);
map.set('isChanged', false);
});
case GROUP_CREATE_FAIL:
case GROUP_UPDATE_FAIL:
return state.set('isSubmitting', false);
case GROUP_CREATE_SUCCESS:
case GROUP_UPDATE_SUCCESS:
return state.withMutations(map => {
map.set('isSubmitting', false);
map.set('groupId', action.group.id);
});
default:
return state;
}
}

View file

@ -28,7 +28,6 @@ import custom_emojis from './custom-emojis';
import domain_lists from './domain-lists';
import dropdown_menu from './dropdown-menu';
import filters from './filters';
import group_editor from './group-editor';
import group_memberships from './group-memberships';
import group_relationships from './group-relationships';
import groups from './groups';
@ -93,7 +92,6 @@ const reducers = {
dropdown_menu,
entities,
filters,
group_editor,
group_memberships,
group_relationships,
groups,