More group actions, allow to edit groups, visuals

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
environments/review-mastodon-g-0qbqe2/deployments/1756
marcin mikołajczak 2 years ago
parent d524a7c700
commit 36350f1cc4

@ -4,15 +4,17 @@ import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts'; import { fetchRelationships } from './accounts';
import { importFetchedGroups, importFetchedAccounts } from './importer'; import { importFetchedGroups, importFetchedAccounts } from './importer';
import { closeModal } from './modals'; import { closeModal, openModal } from './modals';
import { deleteFromTimelines } from './timelines'; import { deleteFromTimelines } from './timelines';
import type { AxiosError } from 'axios'; import type { AxiosError } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store'; import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity } from 'soapbox/types/entities'; import type { APIEntity, Group } from 'soapbox/types/entities';
type GroupMedia = 'header' | 'avatar'; type GroupMedia = 'header' | 'avatar';
const GROUP_EDITOR_SET = 'GROUP_EDITOR_SET';
const GROUP_CREATE_REQUEST = 'GROUP_CREATE_REQUEST'; const GROUP_CREATE_REQUEST = 'GROUP_CREATE_REQUEST';
const GROUP_CREATE_SUCCESS = 'GROUP_CREATE_SUCCESS'; const GROUP_CREATE_SUCCESS = 'GROUP_CREATE_SUCCESS';
const GROUP_CREATE_FAIL = 'GROUP_CREATE_FAIL'; const GROUP_CREATE_FAIL = 'GROUP_CREATE_FAIL';
@ -108,6 +110,14 @@ const GROUP_EDITOR_MEDIA_CHANGE = 'GROUP_EDITOR_MEDIA_CHANGE';
const GROUP_EDITOR_RESET = 'GROUP_EDITOR_RESET'; const GROUP_EDITOR_RESET = 'GROUP_EDITOR_RESET';
const editGroup = (group: Group) => (dispatch: AppDispatch) => {
dispatch({
type: GROUP_EDITOR_SET,
group,
});
dispatch(openModal('MANAGE_GROUP'));
};
const createGroup = (params: Record<string, any>, shouldReset?: boolean) => const createGroup = (params: Record<string, any>, shouldReset?: boolean) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(createGroupRequest()); dispatch(createGroupRequest());
@ -844,11 +854,12 @@ const submitGroupEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, get
if (groupId === null) { if (groupId === null) {
dispatch(createGroup(params, shouldReset)); dispatch(createGroup(params, shouldReset));
} else { } else {
// TODO: dispatch(updateList(listId, title, shouldReset)); dispatch(updateGroup(groupId, params, shouldReset));
} }
}; };
export { export {
GROUP_EDITOR_SET,
GROUP_CREATE_REQUEST, GROUP_CREATE_REQUEST,
GROUP_CREATE_SUCCESS, GROUP_CREATE_SUCCESS,
GROUP_CREATE_FAIL, GROUP_CREATE_FAIL,
@ -920,6 +931,7 @@ export {
GROUP_EDITOR_PRIVACY_CHANGE, GROUP_EDITOR_PRIVACY_CHANGE,
GROUP_EDITOR_MEDIA_CHANGE, GROUP_EDITOR_MEDIA_CHANGE,
GROUP_EDITOR_RESET, GROUP_EDITOR_RESET,
editGroup,
createGroup, createGroup,
createGroupRequest, createGroupRequest,
createGroupSuccess, createGroupSuccess,

@ -17,41 +17,43 @@ const GroupCard: React.FC<IGroupCard> = ({ group }) => {
const intl = useIntl(); const intl = useIntl();
return ( return (
<Stack className='rounded-lg sm:rounded-xl shadow-lg dark:shadow-none overflow-hidden'> <div className='overflow-hidden'>
<div className='bg-primary-100 dark:bg-gray-800 h-[120px] relative'> <Stack className='bg-white dark:bg-primary-900 border border-solid border-gray-300 dark:border-primary-800 rounded-lg sm:rounded-xl'>
{group.header && <img className='h-full w-full object-cover' src={group.header} alt={intl.formatMessage(messages.groupHeader)} />} <div className='bg-primary-100 dark:bg-gray-800 h-[120px] relative -m-[1px] mb-0 rounded-t-lg sm:rounded-t-xl'>
<div className='absolute left-1/2 -translate-x-1/2 -translate-y-1/2'> {group.header && <img className='h-full w-full object-cover rounded-t-lg sm:rounded-t-xl' src={group.header} alt={intl.formatMessage(messages.groupHeader)} />}
<Avatar className='ring-2 ring-white dark:ring-primary-900' src={group.avatar} size={64} /> <div className='absolute left-1/2 -translate-x-1/2 -translate-y-1/2'>
<Avatar className='ring-2 ring-white dark:ring-primary-900' src={group.avatar} size={64} />
</div>
</div> </div>
</div> <Stack className='p-3 pt-9' alignItems='center' space={3}>
<Stack className='p-3 pt-9' alignItems='center' space={3}> <Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} /> <HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap> {group.relationship?.role === 'admin' ? (
{group.relationship?.role === 'admin' ? ( <HStack space={1} alignItems='center'>
<HStack space={1} alignItems='center'> <Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} /> <span>Owner</span>
<span>Owner</span> </HStack>
</HStack> ) : group.relationship?.role === 'moderator' && (
) : group.relationship?.role === 'moderator' && ( <HStack space={1} alignItems='center'>
<HStack space={1} alignItems='center'> <Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} /> <span>Moderator</span>
<span>Moderator</span> </HStack>
</HStack> )}
)} {group.locked ? (
{group.locked ? ( <HStack space={1} alignItems='center'>
<HStack space={1} alignItems='center'> <Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} /> <span>Private</span>
<span>Private</span> </HStack>
</HStack> ) : (
) : ( <HStack space={1} alignItems='center'>
<HStack space={1} alignItems='center'> <Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} /> <span>Public</span>
<span>Public</span> </HStack>
</HStack> )}
)} </HStack>
</HStack> </Stack>
</Stack> </Stack>
</Stack> </div>
); );
}; };

@ -2,6 +2,7 @@ import { List as ImmutableList } from 'immutable';
import React from 'react'; import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { editGroup, joinGroup, leaveGroup } from 'soapbox/actions/groups';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import StillImage from 'soapbox/components/still-image'; import StillImage from 'soapbox/components/still-image';
import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui'; import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
@ -42,6 +43,25 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
); );
} }
const onJoinGroup = () => {
dispatch(joinGroup(group.id));
};
const onLeaveGroup = () => {
dispatch(openModal('CONFIRM', {
heading: 'Leave group',
message: 'You are about to leave the group. Do you want to continue?',
confirm: 'Leave',
onConfirm: () => {
dispatch(leaveGroup(group.id));
},
}));
};
const onEditGroup = () => {
dispatch(editGroup(group));
};
const onAvatarClick = () => { const onAvatarClick = () => {
const avatar = normalizeAttachment({ const avatar = normalizeAttachment({
type: 'image', type: 'image',
@ -73,18 +93,36 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
}; };
const makeActionButton = () => { const makeActionButton = () => {
if (!group.relationship || !group.relationship.member) {
return (
<Button
theme='primary'
onClick={onJoinGroup}
>
{group.locked ? 'Request to join group' : 'Join group'}
</Button>
);
}
if (group.relationship?.role === 'admin') { if (group.relationship?.role === 'admin') {
return ( return (
<Button <Button
theme='secondary' theme='secondary'
// to={`/@${account.acct}/events/${status.id}`} onClick={onEditGroup}
> >
<FormattedMessage id='group.manage' defaultMessage='Edit group' /> <FormattedMessage id='group.manage' defaultMessage='Edit group' />
</Button> </Button>
); );
} }
return null; return (
<Button
theme='secondary'
onClick={onLeaveGroup}
>
<FormattedMessage id='group.leave' defaultMessage='Leave group' />
</Button>
);
}; };
const actionButton = makeActionButton(); const actionButton = makeActionButton();

@ -24,8 +24,8 @@ const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
const groupId = props.params.id; const groupId = props.params.id;
const handleLoadMore = () => { const handleLoadMore = (maxId: string) => {
return dispatch(expandGroupTimeline(groupId)); dispatch(expandGroupTimeline(groupId, { maxId }));
}; };
useEffect(() => { useEffect(() => {

@ -1,4 +1,5 @@
import React, { useState } from 'react'; import classNames from 'clsx';
import React, { useEffect, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { import {
@ -7,21 +8,95 @@ import {
changeGroupEditorMedia, changeGroupEditorMedia,
} from 'soapbox/actions/groups'; } from 'soapbox/actions/groups';
import Icon from 'soapbox/components/icon'; import Icon from 'soapbox/components/icon';
import { Avatar, Form, FormGroup, HStack, IconButton, Input, Text, Textarea } from 'soapbox/components/ui'; import { Avatar, Form, FormGroup, HStack, Input, Text, Textarea } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import resizeImage from 'soapbox/utils/resize-image'; import resizeImage from 'soapbox/utils/resize-image';
import type { List as ImmutableList } from 'immutable'; import type { List as ImmutableList } from 'immutable';
interface IMediaInput {
src: string | null,
accept: string,
onChange: React.ChangeEventHandler<HTMLInputElement>
disabled: boolean
}
const messages = defineMessages({ const messages = defineMessages({
groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Group Name' }, groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Group Name' },
groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' }, groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' },
}); });
const HeaderPicker: React.FC<IMediaInput> = ({ src, onChange, accept, disabled }) => {
return (
<label
className='h-24 sm:h-36 w-full text-primary-500 dark:text-accent-blue bg-primary-100 dark:bg-gray-800 cursor-pointer relative rounded-lg sm:shadow dark:sm:shadow-inset overflow-hidden'
>
{src && <img className='h-full w-full object-cover' src={src} alt='' />}
<HStack
className={classNames('h-full w-full top-0 absolute transition-opacity', {
'opacity-0 hover:opacity-90 bg-primary-100 dark:bg-gray-800': src,
})}
space={3}
alignItems='center'
justifyContent='center'
>
<Icon
src={require('@tabler/icons/photo-plus.svg')}
className='h-7 w-7'
/>
<Text size='sm' theme='primary' weight='semibold' transform='uppercase'>
<FormattedMessage id='compose_event.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='h-[72px] w-[72px] bg-primary-500 cursor-pointer rounded-full absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2 ring-2 ring-white dark:ring-primary-900'>
{src && <Avatar src={src} size={72} />}
<HStack
alignItems='center'
justifyContent='center'
className={classNames('h-full w-full left-0 top-0 rounded-full absolute transition-opacity', {
'opacity-0 hover:opacity-90 bg-primary-500': src,
})}
>
<Icon
src={require('@tabler/icons/camera-plus.svg')}
className='h-7 w-7 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 = () => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const groupId = useAppSelector((state) => state.group_editor.groupId);
const isUploading = useAppSelector((state) => state.group_editor.isUploading); const isUploading = useAppSelector((state) => state.group_editor.isUploading);
const name = useAppSelector((state) => state.group_editor.displayName); const name = useAppSelector((state) => state.group_editor.displayName);
const description = useAppSelector((state) => state.group_editor.note); const description = useAppSelector((state) => state.group_editor.note);
@ -29,7 +104,9 @@ const DetailsStep = () => {
const [avatarSrc, setAvatarSrc] = useState<string | null>(null); const [avatarSrc, setAvatarSrc] = useState<string | null>(null);
const [headerSrc, setHeaderSrc] = useState<string | null>(null); const [headerSrc, setHeaderSrc] = useState<string | null>(null);
const attachmentTypes = useAppSelector(state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>)?.filter(type => type.startsWith('image/')); 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 }) => { const onChangeName: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
dispatch(changeGroupEditorTitle(target.value)); dispatch(changeGroupEditorTitle(target.value));
@ -56,55 +133,24 @@ const DetailsStep = () => {
}).catch(console.error); }).catch(console.error);
} }
}; };
useEffect(() => {
if (!groupId) return;
dispatch((_, getState) => {
const group = getState().groups.get(groupId);
if (!group) return;
if (group.avatar) setAvatarSrc(group.avatar);
if (group.header) setHeaderSrc(group.header);
});
}, [groupId]);
return ( return (
<Form> <Form>
<div className='flex items-center justify-center mb-12 bg-primary-100 dark:bg-gray-800 rounded-lg text-black dark:text-white sm:shadow dark:sm:shadow-inset h-24 sm:h-36 relative'> <div className='flex mb-12 relative'>
{headerSrc ? ( <HeaderPicker src={headerSrc} accept={attachmentTypes} onChange={handleFileChange} disabled={isUploading} />
<> <AvatarPicker src={avatarSrc} accept={attachmentTypes} onChange={handleFileChange} disabled={isUploading} />
<img className='h-full w-full object-cover' src={headerSrc} alt='' />
<IconButton className='absolute top-2 right-2' src={require('@tabler/icons/x.svg')} onClick={() => {}} />
</>
) : (
<HStack className='h-full w-full text-primary-500 dark:text-accent-blue cursor-pointer' space={3} alignItems='center' justifyContent='center' element='label'>
<Icon
src={require('@tabler/icons/photo-plus.svg')}
className='h-7 w-7'
/>
<Text size='sm' theme='primary' weight='semibold' transform='uppercase'>
<FormattedMessage id='compose_event.upload_banner' defaultMessage='Upload photo' />
</Text>
<input
name='header'
type='file'
accept={attachmentTypes && attachmentTypes.toArray().join(',')}
onChange={handleFileChange}
disabled={isUploading}
className='hidden'
/>
</HStack>
)}
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
{avatarSrc ? (
<Avatar className='ring-2 ring-white dark:ring-primary-900' src={avatarSrc} size={72} />
) : (
<label className='flex items-center justify-center h-[72px] w-[72px] bg-primary-500 rounded-full ring-2 ring-white dark:ring-primary-900'>
<Icon
src={require('@tabler/icons/camera-plus.svg')}
className='h-7 w-7 text-white'
/>
<span className='sr-only'>Upload avatar</span>
<input
name='avatar'
type='file'
accept={attachmentTypes && attachmentTypes.toArray().join(',')}
onChange={handleFileChange}
disabled={isUploading}
className='hidden'
/>
</label>
)}
</div>
</div> </div>
<FormGroup <FormGroup
labelText={<FormattedMessage id='manage_group.fields.name_label' defaultMessage='Group name (required)' />} labelText={<FormattedMessage id='manage_group.fields.name_label' defaultMessage='Group name (required)' />}

@ -12,6 +12,7 @@ import {
GROUP_UPDATE_REQUEST, GROUP_UPDATE_REQUEST,
GROUP_UPDATE_FAIL, GROUP_UPDATE_FAIL,
GROUP_UPDATE_SUCCESS, GROUP_UPDATE_SUCCESS,
GROUP_EDITOR_SET,
} from 'soapbox/actions/groups'; } from 'soapbox/actions/groups';
import type { AnyAction } from 'redux'; import type { AnyAction } from 'redux';
@ -35,6 +36,12 @@ export default function groupEditor(state: State = ReducerRecord(), action: AnyA
switch (action.type) { switch (action.type) {
case GROUP_EDITOR_RESET: case GROUP_EDITOR_RESET:
return ReducerRecord(); 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: case GROUP_EDITOR_TITLE_CHANGE:
return state.withMutations(map => { return state.withMutations(map => {
map.set('displayName', action.value); map.set('displayName', action.value);

Loading…
Cancel
Save