diff --git a/app/soapbox/actions/groups.ts b/app/soapbox/actions/groups.ts index 040b9a520e..bc68cc3f22 100644 --- a/app/soapbox/actions/groups.ts +++ b/app/soapbox/actions/groups.ts @@ -11,10 +11,16 @@ import type { AxiosError } from 'axios'; import type { AppDispatch, RootState } from 'soapbox/store'; import type { APIEntity } from 'soapbox/types/entities'; +type GroupMedia = 'header' | 'avatar'; + const GROUP_CREATE_REQUEST = 'GROUP_CREATE_REQUEST'; const GROUP_CREATE_SUCCESS = 'GROUP_CREATE_SUCCESS'; const GROUP_CREATE_FAIL = 'GROUP_CREATE_FAIL'; +const GROUP_UPDATE_REQUEST = 'GROUP_UPDATE_REQUEST'; +const GROUP_UPDATE_SUCCESS = 'GROUP_UPDATE_SUCCESS'; +const GROUP_UPDATE_FAIL = 'GROUP_UPDATE_FAIL'; + const GROUP_DELETE_REQUEST = 'GROUP_DELETE_REQUEST'; const GROUP_DELETE_SUCCESS = 'GROUP_DELETE_SUCCESS'; const GROUP_DELETE_FAIL = 'GROUP_DELETE_FAIL'; @@ -95,15 +101,22 @@ 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_TITLE_CHANGE = 'GROUP_EDITOR_TITLE_CHANGE'; const GROUP_EDITOR_DESCRIPTION_CHANGE = 'GROUP_EDITOR_DESCRIPTION_CHANGE'; -const GROUP_EDITOR_RESET = 'GROUP_EDITOR_RESET'; +const GROUP_EDITOR_PRIVACY_CHANGE = 'GROUP_EDITOR_PRIVACY_CHANGE'; +const GROUP_EDITOR_MEDIA_CHANGE = 'GROUP_EDITOR_MEDIA_CHANGE'; -const createGroup = (displayName: string, note: string, shouldReset?: boolean) => +const GROUP_EDITOR_RESET = 'GROUP_EDITOR_RESET'; + +const createGroup = (params: Record, shouldReset?: boolean) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch(createGroupRequest()); - api(getState).post('/api/v1/groups', { display_name: displayName, note }) + api(getState).post('/api/v1/groups', params, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) .then(({ data }) => { dispatch(importFetchedGroups([data])); dispatch(createGroupSuccess(data)); @@ -129,6 +142,36 @@ const createGroupFail = (error: AxiosError) => ({ error, }); +const updateGroup = (id: string, params: Record, shouldReset?: boolean) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(updateGroupRequest()); + + api(getState).put(`/api/v1/groups/${id}`, params) + .then(({ data }) => { + dispatch(importFetchedGroups([data])); + dispatch(updateGroupSuccess(data)); + + 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(deleteGroupRequest(id)); @@ -768,6 +811,17 @@ const changeGroupEditorDescription = (value: string) => ({ value, }); +const changeGroupEditorPrivacy = (value: boolean) => ({ + type: GROUP_EDITOR_PRIVACY_CHANGE, + value, +}); + +const changeGroupEditorMedia = (mediaType: GroupMedia, file: File) => ({ + type: GROUP_EDITOR_MEDIA_CHANGE, + mediaType, + value: file, +}); + const resetGroupEditor = () => ({ type: GROUP_EDITOR_RESET, }); @@ -776,9 +830,19 @@ const submitGroupEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, get 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 params: Record = { + display_name: displayName, + note, + }; + + if (avatar) params.avatar = avatar; + if (header) params.header = header; if (groupId === null) { - dispatch(createGroup(displayName, note, shouldReset)); + dispatch(createGroup(params, shouldReset)); } else { // TODO: dispatch(updateList(listId, title, shouldReset)); } @@ -788,6 +852,9 @@ export { GROUP_CREATE_REQUEST, GROUP_CREATE_SUCCESS, GROUP_CREATE_FAIL, + GROUP_UPDATE_REQUEST, + GROUP_UPDATE_SUCCESS, + GROUP_UPDATE_FAIL, GROUP_DELETE_REQUEST, GROUP_DELETE_SUCCESS, GROUP_DELETE_FAIL, @@ -850,11 +917,17 @@ export { GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL, GROUP_EDITOR_TITLE_CHANGE, GROUP_EDITOR_DESCRIPTION_CHANGE, + GROUP_EDITOR_PRIVACY_CHANGE, + GROUP_EDITOR_MEDIA_CHANGE, GROUP_EDITOR_RESET, createGroup, createGroupRequest, createGroupSuccess, createGroupFail, + updateGroup, + updateGroupRequest, + updateGroupSuccess, + updateGroupFail, deleteGroup, deleteGroupRequest, deleteGroupSuccess, @@ -937,6 +1010,8 @@ export { rejectGroupMembershipRequestFail, changeGroupEditorTitle, changeGroupEditorDescription, + changeGroupEditorPrivacy, + changeGroupEditorMedia, resetGroupEditor, submitGroupEditor, }; diff --git a/app/soapbox/components/event-preview.tsx b/app/soapbox/components/event-preview.tsx index 68cc135af6..2bbcec24c9 100644 --- a/app/soapbox/components/event-preview.tsx +++ b/app/soapbox/components/event-preview.tsx @@ -13,7 +13,7 @@ import VerificationBadge from './verification-badge'; import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities'; const messages = defineMessages({ - bannerHeader: { id: 'event.banner', defaultMessage: 'Event banner' }, + eventBanner: { id: 'event.banner', defaultMessage: 'Event banner' }, leaveConfirm: { id: 'confirmations.leave_event.confirm', defaultMessage: 'Leave event' }, leaveMessage: { id: 'confirmations.leave_event.message', defaultMessage: 'If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?' }, }); @@ -56,7 +56,7 @@ const EventPreview: React.FC = ({ status, className, hideAction, {floatingAction && action}
- {banner && {intl.formatMessage(messages.bannerHeader)}} + {banner && {intl.formatMessage(messages.eventBanner)}}
diff --git a/app/soapbox/components/group-card.tsx b/app/soapbox/components/group-card.tsx new file mode 100644 index 0000000000..a1386dac45 --- /dev/null +++ b/app/soapbox/components/group-card.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { Avatar, HStack, Icon, Stack, Text } from './ui'; + +import type { Group as GroupEntity } from 'soapbox/types/entities'; + +const messages = defineMessages({ + groupHeader: { id: 'group.header.alt', defaultMessage: 'Group header' }, +}); + +interface IGroupCard { + group: GroupEntity +} + +const GroupCard: React.FC = ({ group }) => { + const intl = useIntl(); + + return ( + +
+ {group.header && {intl.formatMessage(messages.groupHeader)}} +
+ +
+
+ + + + {group.relationship?.role === 'admin' ? ( + + + Owner + + ) : group.relationship?.role === 'moderator' && ( + + + Moderator + + )} + {group.locked ? ( + + + Private + + ) : ( + + + Public + + )} + + +
+ ); +}; + +export default GroupCard; diff --git a/app/soapbox/components/sidebar-navigation.tsx b/app/soapbox/components/sidebar-navigation.tsx index 6bce826ba7..786069b761 100644 --- a/app/soapbox/components/sidebar-navigation.tsx +++ b/app/soapbox/components/sidebar-navigation.tsx @@ -134,6 +134,14 @@ const SidebarNavigation = () => { {renderMessagesLink()} + {features.groups && ( + } + /> + )} + = (props) => { } } - const group = actualStatus.group as GroupEntity | null; + // const group = actualStatus.group as GroupEntity | null; const handlers = muted ? undefined : { reply: handleHotkeyReply, @@ -345,10 +345,10 @@ const Status: React.FC = (props) => { )} - {group && ( + {/* {group && (
- + = (props) => {
- )} + )} */} void, /** Callback when the secondary action is chosen. */ @@ -65,6 +67,7 @@ const Modal: React.FC = ({ confirmationDisabled, confirmationText, confirmationTheme, + confirmationFullWidth, onClose, secondaryAction, secondaryDisabled = false, @@ -117,7 +120,7 @@ const Modal: React.FC = ({ {confirmationAction && ( -
+
{cancelAction && (
- + {secondaryAction && ( diff --git a/app/soapbox/features/group/components/group-header.tsx b/app/soapbox/features/group/components/group-header.tsx index 9e63e32c97..fa79d1aaa6 100644 --- a/app/soapbox/features/group/components/group-header.tsx +++ b/app/soapbox/features/group/components/group-header.tsx @@ -1,16 +1,13 @@ import { List as ImmutableList } from 'immutable'; import React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { Link } from 'react-router-dom'; import { openModal } from 'soapbox/actions/modals'; import StillImage from 'soapbox/components/still-image'; -import { Avatar, Button, HStack, IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui'; -import SvgIcon from 'soapbox/components/ui/icon/svg-icon'; -import { useAppDispatch, useOwnAccount } from 'soapbox/hooks'; +import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui'; +import { useAppDispatch } from 'soapbox/hooks'; import { normalizeAttachment } from 'soapbox/normalizers'; -import type { Menu as MenuType } from 'soapbox/components/dropdown-menu'; import type { Group } from 'soapbox/types/entities'; const messages = defineMessages({ @@ -25,8 +22,6 @@ const GroupHeader: React.FC = ({ group }) => { const intl = useIntl(); const dispatch = useAppDispatch(); - const ownAccount = useOwnAccount(); - if (!group) { return (
@@ -77,21 +72,14 @@ const GroupHeader: React.FC = ({ group }) => { } }; - const makeMenu = () => { - const menu: MenuType = []; - - return menu; - }; - const makeActionButton = () => { if (group.relationship?.role === 'admin') { return ( ); } @@ -99,13 +87,12 @@ const GroupHeader: React.FC = ({ group }) => { return null; }; - const menu = makeMenu(); const actionButton = makeActionButton(); return (
-
-
+
+
{group.header && ( = ({ group }) => { /> )} - -
- - {/* {info} */} - -
+
+
-
- -
- - - -
- -
- - {ownAccount && ( - - - - - {menu.map((menuItem, idx) => { - if (typeof menuItem?.text === 'undefined') { - return ; - } else { - const Comp = (menuItem.action ? MenuItem : MenuLink) as any; - const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.newTab ? '_blank' : '_self' }; - - return ( - - - {menuItem.icon && ( - - )} - -
{menuItem.text}
-
-
- ); - } - })} -
-
- )} - - {actionButton} + + + + {group.relationship?.role === 'admin' ? ( + + + Owner -
+ ) : group.relationship?.role === 'moderator' && ( + + + Moderator + + )} + {group.locked ? ( + + + Private + + ) : ( + + + Public + + )}
-
+ + {actionButton} +
); }; diff --git a/app/soapbox/features/group/components/group-info-panel.tsx b/app/soapbox/features/group/components/group-info-panel.tsx deleted file mode 100644 index 82c3f4d3c3..0000000000 --- a/app/soapbox/features/group/components/group-info-panel.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; - -import Markup from 'soapbox/components/markup'; -import { HStack, Stack, Text } from 'soapbox/components/ui'; -import { Group } from 'soapbox/types/entities'; - -interface IGroupInfoPanel { - group: Group, -} - -const GroupInfoPanel: React.FC = ({ group }) => ( -
- - - - - - - - {group.note.length > 0 && ( - - )} - -
-); - -export default GroupInfoPanel; diff --git a/app/soapbox/features/group/group-timeline.tsx b/app/soapbox/features/group/group-timeline.tsx index 464f461c21..91e8ff2ce2 100644 --- a/app/soapbox/features/group/group-timeline.tsx +++ b/app/soapbox/features/group/group-timeline.tsx @@ -1,13 +1,14 @@ import React, { useEffect } from 'react'; import { FormattedMessage } from 'react-intl'; +import { Link } from 'react-router-dom'; import { groupCompose } from 'soapbox/actions/compose'; import { fetchGroup } from 'soapbox/actions/groups'; import { connectGroupStream } from 'soapbox/actions/streaming'; import { expandGroupTimeline } from 'soapbox/actions/timelines'; -import { Stack } from 'soapbox/components/ui'; +import { Avatar, HStack, Stack } from 'soapbox/components/ui'; import ComposeForm from 'soapbox/features/compose/components/compose-form'; -import { useAppDispatch } from 'soapbox/hooks'; +import { useAppDispatch, useOwnAccount } from 'soapbox/hooks'; import Timeline from '../ui/components/timeline'; @@ -18,6 +19,7 @@ interface IGroupTimeline { } const GroupTimeline: React.FC = (props) => { + const account = useOwnAccount(); const dispatch = useAppDispatch(); const groupId = props.params.id; @@ -43,18 +45,29 @@ const GroupTimeline: React.FC = (props) => { return ( -
- -
-
- } - divideType='space' - /> -
+ {!!account && ( +
+ + + + + + + +
+ )} + } + divideType='space' + />
); }; diff --git a/app/soapbox/features/groups/index.tsx b/app/soapbox/features/groups/index.tsx index 90e1eb362b..40ef4b8b74 100644 --- a/app/soapbox/features/groups/index.tsx +++ b/app/soapbox/features/groups/index.tsx @@ -1,21 +1,18 @@ import React, { useEffect } from 'react'; -import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; +import { FormattedMessage } from 'react-intl'; import { useDispatch } from 'react-redux'; import { Link } from 'react-router-dom'; import { createSelector } from 'reselect'; import { fetchGroups } from 'soapbox/actions/groups'; -import { openModal } from 'soapbox/actions/modals'; -import Icon from 'soapbox/components/icon'; +import GroupCard from 'soapbox/components/group-card'; import ScrollableList from 'soapbox/components/scrollable-list'; -import { Button, Column, HStack, Spinner } from 'soapbox/components/ui'; +import { Column, Spinner, Stack, Text } from 'soapbox/components/ui'; import { useAppSelector } from 'soapbox/hooks'; +import type { List as ImmutableList } from 'immutable'; import type { RootState } from 'soapbox/store'; - -const messages = defineMessages({ - heading: { id: 'column.groups', defaultMessage: 'Groups' }, -}); +import type { Group as GroupEntity } from 'soapbox/types/entities'; const getOrderedGroups = createSelector([ (state: RootState) => state.groups, @@ -25,12 +22,16 @@ const getOrderedGroups = createSelector([ return groups; } - return groups.toList().filter(item => !!item && group_relationships.get(item.id)?.member).sort((a, b) => a.display_name.localeCompare(b.display_name)); + return (groups + .toList() + .filter((item: GroupEntity | false) => !!item) as ImmutableList) + .map((item) => item.set('relationship', group_relationships.get(item.id) || null)) + .filter((item) => item.relationship?.member) + .sort((a, b) => a.display_name.localeCompare(b.display_name)); }); const Lists: React.FC = () => { const dispatch = useDispatch(); - const intl = useIntl(); const groups = useAppSelector((state) => getOrderedGroups(state)); @@ -38,10 +39,6 @@ const Lists: React.FC = () => { dispatch(fetchGroups()); }, []); - const onCreateGroup = () => { - dispatch(openModal('MANAGE_GROUP')); - }; - if (!groups) { return ( @@ -50,35 +47,38 @@ const Lists: React.FC = () => { ); } - const emptyMessage = ; + const emptyMessage = ( + + + + + + + + + + + + ); return ( - - - - -
- - {groups.map((group: any) => ( - - - - - ))} - -
-
+ + {groups.map((group) => ( + + + + ))} + ); }; diff --git a/app/soapbox/features/ui/components/modals/compose-event-modal/compose-event-modal.tsx b/app/soapbox/features/ui/components/modals/compose-event-modal/compose-event-modal.tsx index 1a62321878..eba0353010 100644 --- a/app/soapbox/features/ui/components/modals/compose-event-modal/compose-event-modal.tsx +++ b/app/soapbox/features/ui/components/modals/compose-event-modal/compose-event-modal.tsx @@ -214,7 +214,7 @@ const ComposeEventModal: React.FC = ({ onClose }) => { } > -
+
{banner ? ( <> @@ -223,7 +223,6 @@ const ComposeEventModal: React.FC = ({ onClose }) => { ) : ( )} -
void, } const UploadButton: React.FC = ({ disabled, onSelectFile }) => { - const intl = useIntl(); - const fileElement = useRef(null); const attachmentTypes = useAppSelector(state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList)?.filter(type => type.startsWith('image/')); @@ -32,27 +27,25 @@ const UploadButton: React.FC = ({ disabled, onSelectFile }) => { }; return ( -
- + - -
+ + + + + ); }; 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/manage-group-modal.tsx index cadadfbceb..011ccc8c6a 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/manage-group-modal.tsx @@ -1,19 +1,29 @@ -import React from 'react'; +import React, { useMemo, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { - changeGroupEditorTitle, - changeGroupEditorDescription, - submitGroupEditor, -} from 'soapbox/actions/groups'; -import { Form, FormGroup, Input, Modal, Stack, Textarea } from 'soapbox/components/ui'; +import { submitGroupEditor } from 'soapbox/actions/groups'; +import { Modal, Stack } from 'soapbox/components/ui'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import DetailsStep from './steps/details-step'; +import PrivacyStep from './steps/privacy-step'; + const messages = defineMessages({ - groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Name' }, - groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' }, + next: { id: 'manage_group.next', defaultMessage: 'Next' }, + create: { id: 'manage_group.create', defaultMessage: 'Create' }, + update: { id: 'manage_group.update', defaultMessage: 'Update' }, }); +enum Steps { + ONE = 'ONE', + TWO = 'TWO', +} + +const manageGroupSteps = { + ONE: PrivacyStep, + TWO: DetailsStep, +}; + interface IManageGroupModal { onClose: (type?: string) => void, } @@ -22,20 +32,11 @@ const ManageGroupModal: React.FC = ({ onClose }) => { const intl = useIntl(); const dispatch = useAppDispatch(); - const name = useAppSelector((state) => state.group_editor.displayName); - const description = useAppSelector((state) => state.group_editor.note); - const id = useAppSelector((state) => state.group_editor.groupId); const isSubmitting = useAppSelector((state) => state.group_editor.isSubmitting); - const onChangeName: React.ChangeEventHandler = ({ target }) => { - dispatch(changeGroupEditorTitle(target.value)); - }; - - const onChangeDescription: React.ChangeEventHandler = ({ target }) => { - dispatch(changeGroupEditorDescription(target.value)); - }; + const [currentStep, setCurrentStep] = useState(id ? Steps.TWO : Steps.ONE); const onClickClose = () => { onClose('manage_group'); @@ -45,45 +46,44 @@ const ManageGroupModal: React.FC = ({ onClose }) => { dispatch(submitGroupEditor(true)); }; - const body = ( -
- } - > - - - } - > -