diff --git a/app/soapbox/actions/groups.ts b/app/soapbox/actions/groups.ts index 8a6ad065e..690b74540 100644 --- a/app/soapbox/actions/groups.ts +++ b/app/soapbox/actions/groups.ts @@ -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: 'Requested to join the group' }, - 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, 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, 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 = { - 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, }; diff --git a/app/soapbox/components/authorize-reject-buttons.tsx b/app/soapbox/components/authorize-reject-buttons.tsx index 9edd44189..a5fddc61a 100644 --- a/app/soapbox/components/authorize-reject-buttons.tsx +++ b/app/soapbox/components/authorize-reject-buttons.tsx @@ -14,6 +14,23 @@ interface IAuthorizeRejectButtons { const AuthorizeRejectButtons: React.FC = ({ onAuthorize, onReject, countdown }) => { const [state, setState] = useState<'authorizing' | 'rejecting' | 'authorized' | 'rejected' | 'pending'>('pending'); const timeout = useRef(); + const interval = useRef>(); + + const [progress, setProgress] = useState(0); + + const startProgressInterval = () => { + let startValue = 1; + interval.current = setInterval(() => { + startValue++; + const newValue = startValue * 3.6; // get to 360 (deg) + setProgress(newValue); + + if (newValue >= 360) { + clearInterval(interval.current as NodeJS.Timeout); + setProgress(0); + } + }, (countdown as number) / 100); + }; function handleAction( present: 'authorizing' | 'rejecting', @@ -21,6 +38,9 @@ const AuthorizeRejectButtons: React.FC = ({ onAuthorize action: () => Promise | unknown, ): void { if (state === present) { + if (interval.current) { + clearInterval(interval.current); + } if (timeout.current) { clearTimeout(timeout.current); } @@ -37,6 +57,7 @@ const AuthorizeRejectButtons: React.FC = ({ onAuthorize if (typeof countdown === 'number') { setState(present); timeout.current = setTimeout(doAction, countdown); + startProgressInterval(); } else { doAction(); } @@ -46,11 +67,28 @@ const AuthorizeRejectButtons: React.FC = ({ onAuthorize const handleAuthorize = async () => handleAction('authorizing', 'authorized', onAuthorize); const handleReject = async () => handleAction('rejecting', 'rejected', onReject); + const renderStyle = (selectedState: typeof state) => { + if (state === 'authorizing' && selectedState === 'authorizing') { + return { + background: `conic-gradient(rgb(var(--color-primary-500)) ${progress}deg, rgb(var(--color-primary-500) / 0.1) 0deg)`, + }; + } else if (state === 'rejecting' && selectedState === 'rejecting') { + return { + background: `conic-gradient(rgb(var(--color-danger-600)) ${progress}deg, rgb(var(--color-danger-600) / 0.1) 0deg)`, + }; + } + + return {}; + }; + useEffect(() => { return () => { if (timeout.current) { clearTimeout(timeout.current); } + if (interval.current) { + clearInterval(interval.current); + } }; }, []); @@ -72,6 +110,7 @@ const AuthorizeRejectButtons: React.FC = ({ onAuthorize action={handleReject} isLoading={state === 'rejecting'} disabled={state === 'authorizing'} + style={renderStyle('rejecting')} /> = ({ onAuthorize action={handleAuthorize} isLoading={state === 'authorizing'} disabled={state === 'rejecting'} + style={renderStyle('authorizing')} /> ); @@ -105,33 +145,34 @@ interface IAuthorizeRejectButton { action(): void isLoading?: boolean disabled?: boolean + style: React.CSSProperties } -const AuthorizeRejectButton: React.FC = ({ theme, icon, action, isLoading, disabled }) => { +const AuthorizeRejectButton: React.FC = ({ theme, icon, action, isLoading, style, disabled }) => { return (
- - {(isLoading) && ( -
+ - )} +
); }; diff --git a/app/soapbox/components/list.tsx b/app/soapbox/components/list.tsx index ee04b2d92..e1b8cdcd9 100644 --- a/app/soapbox/components/list.tsx +++ b/app/soapbox/components/list.tsx @@ -63,7 +63,7 @@ const ListItem: React.FC = ({ label, hint, children, onClick, onSelec {...linkProps} >
- {label} + {label} {hint ? ( {hint} diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 3ec072394..65291a420 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -8,7 +8,6 @@ import { mentionCompose, replyCompose } from 'soapbox/actions/compose'; import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions'; import { openModal } from 'soapbox/actions/modals'; import { toggleStatusHidden, unfilterStatus } from 'soapbox/actions/statuses'; -import Icon from 'soapbox/components/icon'; import TranslateButton from 'soapbox/components/translate-button'; import AccountContainer from 'soapbox/containers/account-container'; import QuotedStatus from 'soapbox/features/status/containers/quoted-status-container'; @@ -22,7 +21,7 @@ import StatusMedia from './status-media'; import StatusReplyMentions from './status-reply-mentions'; import SensitiveContentOverlay from './statuses/sensitive-content-overlay'; import StatusInfo from './statuses/status-info'; -import { Card, Stack, Text } from './ui'; +import { Card, Icon, Stack, Text } from './ui'; import type { Account as AccountEntity, @@ -217,7 +216,7 @@ const Status: React.FC = (props) => { } + icon={} text={ = (props) => { return ( } + icon={} text={ @@ -255,7 +254,7 @@ const Status: React.FC = (props) => { } + icon={} text={ { src: string /** Text to display next ot the button. */ text?: string - /** Don't render a background behind the icon. */ - transparent?: boolean /** Predefined styles to display for the button. */ - theme?: 'seamless' | 'outlined' | 'secondary' + theme?: 'seamless' | 'outlined' | 'secondary' | 'transparent' /** Override the data-testid */ 'data-testid'?: string } /** A clickable icon. */ const IconButton = React.forwardRef((props: IIconButton, ref: React.ForwardedRef): JSX.Element => { - const { src, className, iconClassName, text, transparent = false, theme = 'seamless', ...filteredProps } = props; + const { src, className, iconClassName, text, theme = 'seamless', ...filteredProps } = props; return (
); diff --git a/app/soapbox/entity-store/entities.ts b/app/soapbox/entity-store/entities.ts index 44f2db3c9..7f9f84e2a 100644 --- a/app/soapbox/entity-store/entities.ts +++ b/app/soapbox/entity-store/entities.ts @@ -3,4 +3,5 @@ export enum Entities { GROUPS = 'Groups', GROUP_RELATIONSHIPS = 'GroupRelationships', GROUP_MEMBERSHIPS = 'GroupMemberships', + RELATIONSHIPS = 'Relationships' } \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/useCreateEntity.ts b/app/soapbox/entity-store/hooks/useCreateEntity.ts index ba9dd802b..31299344e 100644 --- a/app/soapbox/entity-store/hooks/useCreateEntity.ts +++ b/app/soapbox/entity-store/hooks/useCreateEntity.ts @@ -20,7 +20,7 @@ function useCreateEntity( ) { const dispatch = useAppDispatch(); - const [isLoading, setPromise] = useLoading(); + const [isSubmitting, setPromise] = useLoading(); const { entityType, listKey } = parseEntitiesPath(expandedPath); async function createEntity(data: Data, callbacks: EntityCallbacks = {}): Promise { @@ -44,7 +44,7 @@ function useCreateEntity( return { createEntity, - isLoading, + isSubmitting, }; } diff --git a/app/soapbox/entity-store/hooks/useDeleteEntity.ts b/app/soapbox/entity-store/hooks/useDeleteEntity.ts index 767224af6..dac1d9a26 100644 --- a/app/soapbox/entity-store/hooks/useDeleteEntity.ts +++ b/app/soapbox/entity-store/hooks/useDeleteEntity.ts @@ -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 = {}): Promise { // 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, }; } diff --git a/app/soapbox/entity-store/hooks/useEntities.ts b/app/soapbox/entity-store/hooks/useEntities.ts index f2e84c93e..c1bf47e84 100644 --- a/app/soapbox/entity-store/hooks/useEntities.ts +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -112,7 +112,7 @@ function useEntities( if (isInvalid || isUnset || isStale) { fetchEntities(); } - }, [isEnabled]); + }, [isEnabled, ...path]); return { entities, diff --git a/app/soapbox/entity-store/hooks/useEntity.ts b/app/soapbox/entity-store/hooks/useEntity.ts index f30c9a18a..37027f73f 100644 --- a/app/soapbox/entity-store/hooks/useEntity.ts +++ b/app/soapbox/entity-store/hooks/useEntity.ts @@ -21,7 +21,7 @@ function useEntity( entityFn: EntityFn, opts: UseEntityOpts = {}, ) { - const [isFetching, setPromise] = useLoading(); + const [isFetching, setPromise] = useLoading(true); const dispatch = useAppDispatch(); const [entityType, entityId] = path; diff --git a/app/soapbox/entity-store/hooks/useEntityActions.ts b/app/soapbox/entity-store/hooks/useEntityActions.ts index dab6f7f77..8b87c52fd 100644 --- a/app/soapbox/entity-store/hooks/useEntityActions.ts +++ b/app/soapbox/entity-store/hooks/useEntityActions.ts @@ -24,16 +24,16 @@ function useEntityActions( 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(path, (data) => api.post(endpoints.post!, data), opts); return { createEntity, deleteEntity, - isLoading: createLoading || deleteLoading, + isSubmitting: createSubmitting || deleteSubmitting, }; } diff --git a/app/soapbox/entity-store/utils.ts b/app/soapbox/entity-store/utils.ts index e108639c2..3f65f1ee0 100644 --- a/app/soapbox/entity-store/utils.ts +++ b/app/soapbox/entity-store/utils.ts @@ -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; diff --git a/app/soapbox/features/group/components/__tests__/group-options-button.test.tsx b/app/soapbox/features/group/components/__tests__/group-options-button.test.tsx index 4d7779799..8704b9351 100644 --- a/app/soapbox/features/group/components/__tests__/group-options-button.test.tsx +++ b/app/soapbox/features/group/components/__tests__/group-options-button.test.tsx @@ -40,10 +40,11 @@ describe('', () => { }); }); - it('should render null', () => { + it('should render one option for leaving the group', () => { render(); - expect(screen.queryAllByTestId('dropdown-menu-button')).toHaveLength(0); + // Leave group option only + expect(screen.queryAllByTestId('dropdown-menu-button')).toHaveLength(1); }); }); diff --git a/app/soapbox/features/group/components/group-action-button.tsx b/app/soapbox/features/group/components/group-action-button.tsx index c697bc4ee..96f807f9d 100644 --- a/app/soapbox/features/group/components/group-action-button.tsx +++ b/app/soapbox/features/group/components/group-action-button.tsx @@ -3,14 +3,14 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { openModal } from 'soapbox/actions/modals'; import { Button } from 'soapbox/components/ui'; -import { deleteEntities } from 'soapbox/entity-store/actions'; +import { importEntities } from 'soapbox/entity-store/actions'; import { Entities } from 'soapbox/entity-store/entities'; import { useAppDispatch } from 'soapbox/hooks'; import { useCancelMembershipRequest, useJoinGroup, useLeaveGroup } from 'soapbox/hooks/api'; import { GroupRoles } from 'soapbox/schemas/group-member'; import toast from 'soapbox/toast'; -import type { Group } from 'soapbox/types/entities'; +import type { Group, GroupRelationship } from 'soapbox/types/entities'; interface IGroupActionButton { group: Group @@ -20,7 +20,7 @@ const messages = defineMessages({ confirmationConfirm: { id: 'confirmations.leave_group.confirm', defaultMessage: 'Leave' }, confirmationHeading: { id: 'confirmations.leave_group.heading', defaultMessage: 'Leave group' }, confirmationMessage: { id: 'confirmations.leave_group.message', defaultMessage: 'You are about to leave the group. Do you want to continue?' }, - joinRequestSuccess: { id: 'group.join.request_success', defaultMessage: 'Requested to join the group' }, + joinRequestSuccess: { id: 'group.join.request_success', defaultMessage: 'Request sent to group owner' }, joinSuccess: { id: 'group.join.success', defaultMessage: 'Group joined successfully!' }, leaveSuccess: { id: 'group.leave.success', defaultMessage: 'Left the group' }, }); @@ -36,6 +36,7 @@ const GroupActionButton = ({ group }: IGroupActionButton) => { const isRequested = group.relationship?.requested; const isNonMember = !group.relationship?.member && !isRequested; const isOwner = group.relationship?.role === GroupRoles.OWNER; + const isAdmin = group.relationship?.role === GroupRoles.ADMIN; const isBlocked = group.relationship?.blocked_by; const onJoinGroup = () => joinGroup.mutate({}, { @@ -65,7 +66,11 @@ const GroupActionButton = ({ group }: IGroupActionButton) => { const onCancelRequest = () => cancelRequest.mutate({}, { onSuccess() { - dispatch(deleteEntities([group.id], Entities.GROUP_RELATIONSHIPS)); + const entity = { + ...group.relationship as GroupRelationship, + requested: false, + }; + dispatch(importEntities([entity], Entities.GROUP_RELATIONSHIPS)); }, }); @@ -73,7 +78,7 @@ const GroupActionButton = ({ group }: IGroupActionButton) => { return null; } - if (isOwner) { + if (isOwner || isAdmin) { return ( @@ -114,7 +119,7 @@ const GroupActionButton = ({ group }: IGroupActionButton) => { diff --git a/app/soapbox/features/group/components/group-avatar-picker.tsx b/app/soapbox/features/group/components/group-avatar-picker.tsx new file mode 100644 index 000000000..b13dfe80e --- /dev/null +++ b/app/soapbox/features/group/components/group-avatar-picker.tsx @@ -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 + disabled?: boolean +} + +const AvatarPicker = React.forwardRef(({ src, onChange, accept, disabled }, ref) => { + return ( + + ); +}); + +export default AvatarPicker; \ No newline at end of file diff --git a/app/soapbox/features/group/components/group-header-picker.tsx b/app/soapbox/features/group/components/group-header-picker.tsx new file mode 100644 index 000000000..d2457ac1e --- /dev/null +++ b/app/soapbox/features/group/components/group-header-picker.tsx @@ -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 + disabled?: boolean +} + +const HeaderPicker = React.forwardRef(({ src, onChange, accept, disabled }, ref) => { + return ( + + ); +}); + +export default HeaderPicker; \ No newline at end of file diff --git a/app/soapbox/features/group/components/group-member-list-item.tsx b/app/soapbox/features/group/components/group-member-list-item.tsx index 10ad4e0cb..4fa3ccb7a 100644 --- a/app/soapbox/features/group/components/group-member-list-item.tsx +++ b/app/soapbox/features/group/components/group-member-list-item.tsx @@ -9,13 +9,14 @@ import DropdownMenu from 'soapbox/components/dropdown-menu/dropdown-menu'; import { HStack } from 'soapbox/components/ui'; import { deleteEntities } from 'soapbox/entity-store/actions'; import { Entities } from 'soapbox/entity-store/entities'; -import { useAccount, useAppDispatch, useFeatures } from 'soapbox/hooks'; -import { useBlockGroupMember, useDemoteGroupMember, usePromoteGroupMember } from 'soapbox/hooks/api'; +import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder-account'; +import { useAppDispatch, useFeatures } from 'soapbox/hooks'; +import { useAccount, useBlockGroupMember, useDemoteGroupMember, usePromoteGroupMember } from 'soapbox/hooks/api'; import { GroupRoles } from 'soapbox/schemas/group-member'; import toast from 'soapbox/toast'; import type { Menu as IMenu } from 'soapbox/components/dropdown-menu'; -import type { Account as AccountEntity, Group, GroupMember } from 'soapbox/types/entities'; +import type { Group, GroupMember } from 'soapbox/types/entities'; const messages = defineMessages({ blockConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Ban' }, @@ -51,7 +52,7 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { const promoteGroupMember = usePromoteGroupMember(group, member); const demoteGroupMember = useDemoteGroupMember(group, member); - const account = useAccount(member.account.id) as AccountEntity; + const { account, isLoading } = useAccount(member.account.id); // Current user role const isCurrentUserOwner = group.relationship?.role === GroupRoles.OWNER; @@ -64,10 +65,10 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { const handleKickFromGroup = () => { dispatch(openModal('CONFIRM', { - message: intl.formatMessage(messages.kickFromGroupMessage, { name: account.username }), + message: intl.formatMessage(messages.kickFromGroupMessage, { name: account?.username }), confirm: intl.formatMessage(messages.kickConfirm), - onConfirm: () => dispatch(groupKick(group.id, account.id)).then(() => - toast.success(intl.formatMessage(messages.kicked, { name: account.acct })), + onConfirm: () => dispatch(groupKick(group.id, account?.id as string)).then(() => + toast.success(intl.formatMessage(messages.kicked, { name: account?.acct })), ), })); }; @@ -75,13 +76,13 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { const handleBlockFromGroup = () => { dispatch(openModal('CONFIRM', { heading: intl.formatMessage(messages.blockFromGroupHeading), - message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }), + message: intl.formatMessage(messages.blockFromGroupMessage, { name: account?.username }), confirm: intl.formatMessage(messages.blockConfirm), onConfirm: () => { blockGroupMember({ account_ids: [member.account.id] }, { onSuccess() { dispatch(deleteEntities([member.id], Entities.GROUP_MEMBERSHIPS)); - toast.success(intl.formatMessage(messages.blocked, { name: account.acct })); + toast.success(intl.formatMessage(messages.blocked, { name: account?.acct })); }, }); }, @@ -91,14 +92,14 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { const handleAdminAssignment = () => { dispatch(openModal('CONFIRM', { heading: intl.formatMessage(messages.promoteConfirm), - message: intl.formatMessage(messages.promoteConfirmMessage, { name: account.username }), + message: intl.formatMessage(messages.promoteConfirmMessage, { name: account?.username }), confirm: intl.formatMessage(messages.promoteConfirm), confirmationTheme: 'primary', onConfirm: () => { - promoteGroupMember({ role: GroupRoles.ADMIN, account_ids: [account.id] }, { + promoteGroupMember({ role: GroupRoles.ADMIN, account_ids: [account?.id] }, { onSuccess() { toast.success( - intl.formatMessage(messages.promotedToAdmin, { name: account.acct }), + intl.formatMessage(messages.promotedToAdmin, { name: account?.acct }), ); }, }); @@ -107,9 +108,9 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { }; const handleUserAssignment = () => { - demoteGroupMember({ role: GroupRoles.USER, account_ids: [account.id] }, { + demoteGroupMember({ role: GroupRoles.USER, account_ids: [account?.id] }, { onSuccess() { - toast.success(intl.formatMessage(messages.demotedToUser, { name: account.acct })); + toast.success(intl.formatMessage(messages.demotedToUser, { name: account?.acct })); }, }); }; @@ -160,7 +161,11 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { } return items; - }, [group, account]); + }, [group, account?.id]); + + if (isLoading) { + return ; + } return ( diff --git a/app/soapbox/features/group/components/group-options-button.tsx b/app/soapbox/features/group/components/group-options-button.tsx index b6e83ae06..d3f877292 100644 --- a/app/soapbox/features/group/components/group-options-button.tsx +++ b/app/soapbox/features/group/components/group-options-button.tsx @@ -1,15 +1,23 @@ import React, { useMemo } from 'react'; import { defineMessages, useIntl } from 'react-intl'; +import { openModal } from 'soapbox/actions/modals'; 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 { useLeaveGroup } from 'soapbox/hooks/api'; import { GroupRoles } from 'soapbox/schemas/group-member'; +import toast from 'soapbox/toast'; import type { Account, Group } from 'soapbox/types/entities'; const messages = defineMessages({ + confirmationConfirm: { id: 'confirmations.leave_group.confirm', defaultMessage: 'Leave' }, + confirmationHeading: { id: 'confirmations.leave_group.heading', defaultMessage: 'Leave group' }, + confirmationMessage: { id: 'confirmations.leave_group.message', defaultMessage: 'You are about to leave the group. Do you want to continue?' }, + leave: { id: 'group.leave.label', defaultMessage: 'Leave' }, + leaveSuccess: { id: 'group.leave.success', defaultMessage: 'Left the group' }, report: { id: 'group.report.label', defaultMessage: 'Report' }, }); @@ -21,19 +29,48 @@ const GroupOptionsButton = ({ group }: IGroupActionButton) => { const account = useOwnAccount(); const dispatch = useAppDispatch(); const intl = useIntl(); + const leaveGroup = useLeaveGroup(group); const isMember = group.relationship?.role === GroupRoles.USER; + const isAdmin = group.relationship?.role === GroupRoles.ADMIN; const isBlocked = group.relationship?.blocked_by; - const menu: Menu = useMemo(() => ([ - { - text: intl.formatMessage(messages.report), - icon: require('@tabler/icons/flag.svg'), - action: () => dispatch(initReport(ReportableEntities.GROUP, account as Account, { group })), - }, - ]), []); + const onLeaveGroup = () => + dispatch(openModal('CONFIRM', { + heading: intl.formatMessage(messages.confirmationHeading), + message: intl.formatMessage(messages.confirmationMessage), + confirm: intl.formatMessage(messages.confirmationConfirm), + onConfirm: () => leaveGroup.mutate(group.relationship?.id as string, { + onSuccess() { + leaveGroup.invalidate(); + toast.success(intl.formatMessage(messages.leaveSuccess)); + }, + }), + })); - if (isBlocked || !isMember || menu.length === 0) { + const menu: Menu = useMemo(() => { + const items = []; + + if (isMember) { + items.push({ + text: intl.formatMessage(messages.report), + icon: require('@tabler/icons/flag.svg'), + action: () => dispatch(initReport(ReportableEntities.GROUP, account as Account, { group })), + }); + } + + if (isAdmin) { + items.push({ + text: intl.formatMessage(messages.leave), + icon: require('@tabler/icons/logout.svg'), + action: onLeaveGroup, + }); + } + + return items; + }, [isMember, isAdmin]); + + if (isBlocked || menu.length === 0) { return null; } diff --git a/app/soapbox/features/group/edit-group.tsx b/app/soapbox/features/group/edit-group.tsx index d385fb580..23ff9ed98 100644 --- a/app/soapbox/features/group/edit-group.tsx +++ b/app/soapbox/features/group/edit-group.tsx @@ -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 - 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(({ src, onChange, accept, disabled }, ref) => { - return ( - - ); -}); - -const AvatarPicker = React.forwardRef(({ src, onChange, accept, disabled }, ref) => { - return ( - - ); -}); - interface IEditGroup { params: { id: string diff --git a/app/soapbox/features/group/group-blocked-members.tsx b/app/soapbox/features/group/group-blocked-members.tsx index 8931575ef..a82f889c1 100644 --- a/app/soapbox/features/group/group-blocked-members.tsx +++ b/app/soapbox/features/group/group-blocked-members.tsx @@ -15,9 +15,9 @@ import ColumnForbidden from '../ui/components/column-forbidden'; type RouteParams = { id: string }; const messages = defineMessages({ - heading: { id: 'column.group_blocked_members', defaultMessage: 'Blocked members' }, - unblock: { id: 'group.group_mod_unblock', defaultMessage: 'Unblock' }, - unblocked: { id: 'group.group_mod_unblock.success', defaultMessage: 'Unblocked @{name} from group' }, + heading: { id: 'column.group_blocked_members', defaultMessage: 'Banned Members' }, + unblock: { id: 'group.group_mod_unblock', defaultMessage: 'Unban' }, + unblocked: { id: 'group.group_mod_unblock.success', defaultMessage: 'Unbanned @{name} from group' }, }); interface IBlockedMember { @@ -36,18 +36,17 @@ const BlockedMember: React.FC = ({ accountId, groupId }) => { if (!account) return null; const handleUnblock = () => - dispatch(groupUnblock(groupId, accountId)).then(() => { - toast.success(intl.formatMessage(messages.unblocked, { name: account.acct })); - }); + dispatch(groupUnblock(groupId, accountId)) + .then(() => toast.success(intl.formatMessage(messages.unblocked, { name: account.acct }))); return (
+ + + +
+); + +export { LayoutButtons as default, GroupLayout }; \ No newline at end of file diff --git a/app/soapbox/features/groups/components/discover/search/recent-searches.tsx b/app/soapbox/features/groups/components/discover/search/recent-searches.tsx index 1fe5e1d2f..44f134b4c 100644 --- a/app/soapbox/features/groups/components/discover/search/recent-searches.tsx +++ b/app/soapbox/features/groups/components/discover/search/recent-searches.tsx @@ -62,7 +62,7 @@ export default (props: Props) => {
diff --git a/app/soapbox/features/groups/components/discover/search/results.tsx b/app/soapbox/features/groups/components/discover/search/results.tsx index 14e1e5a67..9df350375 100644 --- a/app/soapbox/features/groups/components/discover/search/results.tsx +++ b/app/soapbox/features/groups/components/discover/search/results.tsx @@ -3,22 +3,19 @@ import React, { useCallback, useState } from 'react'; import { FormattedMessage } from 'react-intl'; import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso'; -import { HStack, Icon, Stack, Text } from 'soapbox/components/ui'; +import { HStack, Stack, Text } from 'soapbox/components/ui'; import { useGroupSearch } from 'soapbox/hooks/api'; -import { Group } from 'soapbox/types/entities'; import GroupGridItem from '../group-grid-item'; import GroupListItem from '../group-list-item'; +import LayoutButtons, { GroupLayout } from '../layout-buttons'; + +import type { Group } from 'soapbox/types/entities'; interface Props { groupSearchResult: ReturnType } -enum Layout { - LIST = 'LIST', - GRID = 'GRID' -} - const GridList: Components['List'] = React.forwardRef((props, ref) => { const { context, ...rest } = props; return
; @@ -27,7 +24,7 @@ const GridList: Components['List'] = React.forwardRef((props, ref) => { export default (props: Props) => { const { groupSearchResult } = props; - const [layout, setLayout] = useState(Layout.LIST); + const [layout, setLayout] = useState(GroupLayout.LIST); const { groups, hasNextPage, isFetching, fetchNextPage } = groupSearchResult; @@ -65,32 +62,13 @@ export default (props: Props) => { /> - - - - - + setLayout(selectedLayout)} + /> - {layout === Layout.LIST ? ( + {layout === GroupLayout.LIST ? ( { const { groups, isLoading } = useGroups(debouncedValue); const createGroup = () => { - dispatch(openModal('MANAGE_GROUP')); + dispatch(openModal('CREATE_GROUP')); }; const renderBlankslate = () => ( diff --git a/app/soapbox/features/groups/popular.tsx b/app/soapbox/features/groups/popular.tsx index 61d030173..7f2d43a64 100644 --- a/app/soapbox/features/groups/popular.tsx +++ b/app/soapbox/features/groups/popular.tsx @@ -3,11 +3,12 @@ import React, { useCallback, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso'; -import { Column, HStack, Icon } from 'soapbox/components/ui'; +import { Column } from 'soapbox/components/ui'; import { usePopularGroups } from 'soapbox/hooks/api/usePopularGroups'; import GroupGridItem from './components/discover/group-grid-item'; import GroupListItem from './components/discover/group-list-item'; +import LayoutButtons, { GroupLayout } from './components/discover/layout-buttons'; import type { Group } from 'soapbox/schemas'; @@ -15,21 +16,15 @@ const messages = defineMessages({ label: { id: 'groups.popular.label', defaultMessage: 'Popular Groups' }, }); -enum Layout { - LIST = 'LIST', - GRID = 'GRID' -} - const GridList: Components['List'] = React.forwardRef((props, ref) => { const { context, ...rest } = props; return
; }); - const Popular: React.FC = () => { const intl = useIntl(); - const [layout, setLayout] = useState(Layout.LIST); + const [layout, setLayout] = useState(GroupLayout.LIST); const { groups, hasNextPage, fetchNextPage } = usePopularGroups(); @@ -61,32 +56,13 @@ const Popular: React.FC = () => { - - - - + setLayout(selectedLayout)} + /> } > - {layout === Layout.LIST ? ( + {layout === GroupLayout.LIST ? ( { const { context, ...rest } = props; return
; }); - const Suggested: React.FC = () => { const intl = useIntl(); - const [layout, setLayout] = useState(Layout.LIST); + const [layout, setLayout] = useState(GroupLayout.LIST); const { groups, hasNextPage, fetchNextPage } = useSuggestedGroups(); @@ -61,32 +56,13 @@ const Suggested: React.FC = () => { - - - - + setLayout(selectedLayout)} + /> } > - {layout === Layout.LIST ? ( + {layout === GroupLayout.LIST ? ( ( ); -export default PlaceholderAccount; +export default React.memo(PlaceholderAccount); diff --git a/app/soapbox/features/placeholder/components/placeholder-display-name.tsx b/app/soapbox/features/placeholder/components/placeholder-display-name.tsx index e09e73439..3a2e0e641 100644 --- a/app/soapbox/features/placeholder/components/placeholder-display-name.tsx +++ b/app/soapbox/features/placeholder/components/placeholder-display-name.tsx @@ -21,4 +21,4 @@ const PlaceholderDisplayName: React.FC = ({ minLength, ); }; -export default PlaceholderDisplayName; +export default React.memo(PlaceholderDisplayName); diff --git a/app/soapbox/features/public-layout/components/header.tsx b/app/soapbox/features/public-layout/components/header.tsx index 64e273e26..caa5a3fa3 100644 --- a/app/soapbox/features/public-layout/components/header.tsx +++ b/app/soapbox/features/public-layout/components/header.tsx @@ -147,7 +147,6 @@ const Header = () => { src={require('@tabler/icons/help.svg')} className='cursor-pointer bg-transparent text-gray-700 hover:text-gray-800 dark:text-gray-600 dark:hover:text-gray-500' iconClassName='h-5 w-5' - transparent /> diff --git a/app/soapbox/features/ui/components/modal-root.tsx b/app/soapbox/features/ui/components/modal-root.tsx index 55e53c268..08fd4c88c 100644 --- a/app/soapbox/features/ui/components/modal-root.tsx +++ b/app/soapbox/features/ui/components/modal-root.tsx @@ -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, diff --git a/app/soapbox/features/ui/components/modals/manage-group-modal/manage-group-modal.tsx b/app/soapbox/features/ui/components/modals/manage-group-modal/create-group-modal.tsx similarity index 53% rename from app/soapbox/features/ui/components/modals/manage-group-modal/manage-group-modal.tsx rename to app/soapbox/features/ui/components/modals/manage-group-modal/create-group-modal.tsx index fcc7c14da..bbeb2d43e 100644 --- a/app/soapbox/features/ui/components/modals/manage-group-modal/manage-group-modal.tsx +++ b/app/soapbox/features/ui/components/modals/manage-group-modal/create-group-modal.tsx @@ -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 = ({ onClose }) => { +const CreateGroupModal: React.FC = ({ onClose }) => { const intl = useIntl(); const debounce = useDebounce; - const dispatch = useAppDispatch(); - const id = useAppSelector((state) => state.group_editor.groupId); - const [group, setGroup] = useState(null); + const [group, setGroup] = useState(null); + const [params, setParams] = useState({}); + const [currentStep, setCurrentStep] = useState(Steps.ONE); - const isSubmitting = useAppSelector((state) => state.group_editor.isSubmitting); - - const [currentStep, setCurrentStep] = useState(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 = ({ 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 = ({ onClose }) => { } }; - const StepToRender = manageGroupSteps[currentStep]; + const renderStep = () => { + switch (currentStep) { + case Steps.ONE: + return ; + case Steps.TWO: + return ; + case Steps.THREE: + return ; + } + }; return ( - : } + title={} confirmationAction={handleNextStep} confirmationText={confirmationText} confirmationDisabled={isSubmitting || (currentStep === Steps.TWO && !isValid)} @@ -104,11 +106,10 @@ const ManageGroupModal: React.FC = ({ onClose }) => { onClose={handleClose} > - {/* @ts-ignore */} - + {renderStep()} ); }; -export default ManageGroupModal; +export default CreateGroupModal; diff --git a/app/soapbox/features/ui/components/modals/manage-group-modal/steps/details-step.tsx b/app/soapbox/features/ui/components/modals/manage-group-modal/steps/details-step.tsx index 59b59b2ec..66b7b4b1d 100644 --- a/app/soapbox/features/ui/components/modals/manage-group-modal/steps/details-step.tsx +++ b/app/soapbox/features/ui/components/modals/manage-group-modal/steps/details-step.tsx @@ -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 - 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 = ({ src, onChange, accept, disabled }) => { - return ( - - ); -}; - -const AvatarPicker: React.FC = ({ src, onChange, accept, disabled }) => { - return ( - - ); -}; - -const DetailsStep = () => { +const DetailsStep: React.FC = ({ 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(null); - const [headerSrc, setHeaderSrc] = useState(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, )?.filter(type => type.startsWith('image/')).toArray().join(','); - const onChangeName: React.ChangeEventHandler = ({ target }) => { - dispatch(changeGroupEditorTitle(target.value)); + const handleTextChange = (property: keyof CreateGroupParams): React.ChangeEventHandler => { + return (e) => { + onChange({ + ...params, + [property]: e.target.value, + }); + }; }; - const onChangeDescription: React.ChangeEventHandler = ({ target }) => { - dispatch(changeGroupEditorDescription(target.value)); + const handleImageChange = (property: keyof CreateGroupParams, maxPixels?: number): React.ChangeEventHandler => { + 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 = 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 (
- - + +
{ @@ -179,8 +90,8 @@ const DetailsStep = () => {