diff --git a/app/soapbox/actions/groups.ts b/app/soapbox/actions/groups.ts index 582a2f8d03..040b9a520e 100644 --- a/app/soapbox/actions/groups.ts +++ b/app/soapbox/actions/groups.ts @@ -4,6 +4,7 @@ import api, { getLinks } from '../api'; import { fetchRelationships } from './accounts'; import { importFetchedGroups, importFetchedAccounts } from './importer'; +import { closeModal } from './modals'; import { deleteFromTimelines } from './timelines'; import type { AxiosError } from 'axios'; @@ -95,13 +96,14 @@ const GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS = 'GROUP_MEMBERSHIP_REQUEST_REJECT 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_RESET = 'GROUP_EDITOR_RESET'; -const createGroup = (displayName: string, shouldReset?: boolean) => +const createGroup = (displayName: string, note: string, shouldReset?: boolean) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch(createGroupRequest()); - api(getState).post('/api/v1/groups', { display_name: displayName }) + api(getState).post('/api/v1/groups', { display_name: displayName, note }) .then(({ data }) => { dispatch(importFetchedGroups([data])); dispatch(createGroupSuccess(data)); @@ -109,6 +111,7 @@ const createGroup = (displayName: string, shouldReset?: boolean) => if (shouldReset) { dispatch(resetGroupEditor()); } + dispatch(closeModal('MANAGE_GROUP')); }).catch(err => dispatch(createGroupFail(err))); }; @@ -760,6 +763,11 @@ const changeGroupEditorTitle = (value: string) => ({ value, }); +const changeGroupEditorDescription = (value: string) => ({ + type: GROUP_EDITOR_DESCRIPTION_CHANGE, + value, +}); + const resetGroupEditor = () => ({ type: GROUP_EDITOR_RESET, }); @@ -767,9 +775,10 @@ const resetGroupEditor = () => ({ 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; if (groupId === null) { - dispatch(createGroup(displayName, shouldReset)); + dispatch(createGroup(displayName, note, shouldReset)); } else { // TODO: dispatch(updateList(listId, title, shouldReset)); } @@ -840,6 +849,7 @@ export { GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS, GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL, GROUP_EDITOR_TITLE_CHANGE, + GROUP_EDITOR_DESCRIPTION_CHANGE, GROUP_EDITOR_RESET, createGroup, createGroupRequest, @@ -926,6 +936,7 @@ export { rejectGroupMembershipRequestSuccess, rejectGroupMembershipRequestFail, changeGroupEditorTitle, + changeGroupEditorDescription, resetGroupEditor, submitGroupEditor, }; diff --git a/app/soapbox/features/compose/components/compose-form.tsx b/app/soapbox/features/compose/components/compose-form.tsx index 6035681a3f..f87f50b6ff 100644 --- a/app/soapbox/features/compose/components/compose-form.tsx +++ b/app/soapbox/features/compose/components/compose-form.tsx @@ -79,7 +79,7 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab const scheduledStatusCount = useAppSelector((state) => state.get('scheduled_statuses').size); const features = useFeatures(); - const { text, suggestions, spoiler, spoiler_text: spoilerText, privacy, focusDate, caretPosition, is_submitting: isSubmitting, is_changing_upload: isChangingUpload, is_uploading: isUploading, schedule: scheduledAt } = compose; + const { text, suggestions, spoiler, spoiler_text: spoilerText, privacy, focusDate, caretPosition, is_submitting: isSubmitting, is_changing_upload: isChangingUpload, is_uploading: isUploading, schedule: scheduledAt, group_id: groupId } = compose; const prevSpoiler = usePrevious(spoiler); const hasPoll = !!compose.poll; @@ -229,7 +229,7 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab {features.media && } {features.polls && } - {features.privacyScopes && !group && } + {features.privacyScopes && !group && !groupId && } {features.scheduledStatuses && } {features.spoilers && } {features.richText && } diff --git a/app/soapbox/features/group/components/group-header.tsx b/app/soapbox/features/group/components/group-header.tsx index 919ed8adf9..9e63e32c97 100644 --- a/app/soapbox/features/group/components/group-header.tsx +++ b/app/soapbox/features/group/components/group-header.tsx @@ -1,11 +1,11 @@ import { List as ImmutableList } from 'immutable'; import React from 'react'; -import { defineMessages, useIntl } from 'react-intl'; +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, HStack, IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui'; +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 { normalizeAttachment } from 'soapbox/normalizers'; @@ -83,7 +83,24 @@ const GroupHeader: React.FC = ({ group }) => { return menu; }; + const makeActionButton = () => { + if (group.relationship?.role === 'admin') { + return ( + + ); + } + + return null; + }; + const menu = makeMenu(); + const actionButton = makeActionButton(); return (
@@ -155,6 +172,8 @@ const GroupHeader: React.FC = ({ group }) => { )} + + {actionButton}
diff --git a/app/soapbox/features/groups/index.tsx b/app/soapbox/features/groups/index.tsx new file mode 100644 index 0000000000..90e1eb362b --- /dev/null +++ b/app/soapbox/features/groups/index.tsx @@ -0,0 +1,85 @@ +import React, { useEffect } from 'react'; +import { defineMessages, useIntl, 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 ScrollableList from 'soapbox/components/scrollable-list'; +import { Button, Column, HStack, Spinner } from 'soapbox/components/ui'; +import { useAppSelector } from 'soapbox/hooks'; + +import type { RootState } from 'soapbox/store'; + +const messages = defineMessages({ + heading: { id: 'column.groups', defaultMessage: 'Groups' }, +}); + +const getOrderedGroups = createSelector([ + (state: RootState) => state.groups, + (state: RootState) => state.group_relationships, +], (groups, group_relationships) => { + if (!groups) { + return groups; + } + + return groups.toList().filter(item => !!item && group_relationships.get(item.id)?.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)); + + useEffect(() => { + dispatch(fetchGroups()); + }, []); + + const onCreateGroup = () => { + dispatch(openModal('MANAGE_GROUP')); + }; + + if (!groups) { + return ( + + + + ); + } + + const emptyMessage = ; + + return ( + + + + +
+ + {groups.map((group: any) => ( + + + + + ))} + +
+
+ ); +}; + +export default Lists; diff --git a/app/soapbox/features/ui/components/modal-root.tsx b/app/soapbox/features/ui/components/modal-root.tsx index 41f8147eaa..39a575672b 100644 --- a/app/soapbox/features/ui/components/modal-root.tsx +++ b/app/soapbox/features/ui/components/modal-root.tsx @@ -36,6 +36,7 @@ import { EventMapModal, EventParticipantsModal, PolicyModal, + ManageGroupModal, } from 'soapbox/features/ui/util/async-components'; import BundleContainer from '../containers/bundle-container'; @@ -79,6 +80,7 @@ const MODAL_COMPONENTS = { 'EVENT_MAP': EventMapModal, 'EVENT_PARTICIPANTS': EventParticipantsModal, 'POLICY': PolicyModal, + 'MANAGE_GROUP': ManageGroupModal, }; export type ModalType = keyof typeof MODAL_COMPONENTS | null; 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 ca041a758c..1a62321878 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 @@ -45,7 +45,6 @@ const messages = defineMessages({ cancelEditing: { id: 'confirmations.cancel_editing.confirm', defaultMessage: 'Cancel editing' }, }); - interface IAccount { eventId: string, id: string, 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 new file mode 100644 index 0000000000..cadadfbceb --- /dev/null +++ b/app/soapbox/features/ui/components/modals/manage-group-modal/manage-group-modal.tsx @@ -0,0 +1,92 @@ +import React 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 { useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +const messages = defineMessages({ + groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Name' }, + groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' }, +}); + +interface IManageGroupModal { + onClose: (type?: string) => void, +} + +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 onClickClose = () => { + onClose('manage_group'); + }; + + const handleSubmit = () => { + dispatch(submitGroupEditor(true)); + }; + + const body = ( +
+ } + > + + + } + > +