Groups: UI improvements
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
parent
683504c997
commit
d524a7c700
25 changed files with 681 additions and 279 deletions
|
@ -11,10 +11,16 @@ 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 } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
type GroupMedia = 'header' | 'avatar';
|
||||||
|
|
||||||
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';
|
||||||
|
|
||||||
|
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_REQUEST = 'GROUP_DELETE_REQUEST';
|
||||||
const GROUP_DELETE_SUCCESS = 'GROUP_DELETE_SUCCESS';
|
const GROUP_DELETE_SUCCESS = 'GROUP_DELETE_SUCCESS';
|
||||||
const GROUP_DELETE_FAIL = 'GROUP_DELETE_FAIL';
|
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_SUCCESS = 'GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS';
|
||||||
const GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL = 'GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL';
|
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_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<string, any>, shouldReset?: boolean) =>
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
dispatch(createGroupRequest());
|
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 }) => {
|
.then(({ data }) => {
|
||||||
dispatch(importFetchedGroups([data]));
|
dispatch(importFetchedGroups([data]));
|
||||||
dispatch(createGroupSuccess(data));
|
dispatch(createGroupSuccess(data));
|
||||||
|
@ -129,6 +142,36 @@ const createGroupFail = (error: AxiosError) => ({
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateGroup = (id: string, params: Record<string, any>, 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) => {
|
const deleteGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
dispatch(deleteGroupRequest(id));
|
dispatch(deleteGroupRequest(id));
|
||||||
|
|
||||||
|
@ -768,6 +811,17 @@ const changeGroupEditorDescription = (value: string) => ({
|
||||||
value,
|
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 = () => ({
|
const resetGroupEditor = () => ({
|
||||||
type: GROUP_EDITOR_RESET,
|
type: GROUP_EDITOR_RESET,
|
||||||
});
|
});
|
||||||
|
@ -776,9 +830,19 @@ const submitGroupEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, get
|
||||||
const groupId = getState().group_editor.groupId;
|
const groupId = getState().group_editor.groupId;
|
||||||
const displayName = getState().group_editor.displayName;
|
const displayName = getState().group_editor.displayName;
|
||||||
const note = getState().group_editor.note;
|
const note = getState().group_editor.note;
|
||||||
|
const avatar = getState().group_editor.avatar;
|
||||||
|
const header = getState().group_editor.header;
|
||||||
|
|
||||||
|
const params: Record<string, any> = {
|
||||||
|
display_name: displayName,
|
||||||
|
note,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (avatar) params.avatar = avatar;
|
||||||
|
if (header) params.header = header;
|
||||||
|
|
||||||
if (groupId === null) {
|
if (groupId === null) {
|
||||||
dispatch(createGroup(displayName, note, shouldReset));
|
dispatch(createGroup(params, shouldReset));
|
||||||
} else {
|
} else {
|
||||||
// TODO: dispatch(updateList(listId, title, shouldReset));
|
// TODO: dispatch(updateList(listId, title, shouldReset));
|
||||||
}
|
}
|
||||||
|
@ -788,6 +852,9 @@ export {
|
||||||
GROUP_CREATE_REQUEST,
|
GROUP_CREATE_REQUEST,
|
||||||
GROUP_CREATE_SUCCESS,
|
GROUP_CREATE_SUCCESS,
|
||||||
GROUP_CREATE_FAIL,
|
GROUP_CREATE_FAIL,
|
||||||
|
GROUP_UPDATE_REQUEST,
|
||||||
|
GROUP_UPDATE_SUCCESS,
|
||||||
|
GROUP_UPDATE_FAIL,
|
||||||
GROUP_DELETE_REQUEST,
|
GROUP_DELETE_REQUEST,
|
||||||
GROUP_DELETE_SUCCESS,
|
GROUP_DELETE_SUCCESS,
|
||||||
GROUP_DELETE_FAIL,
|
GROUP_DELETE_FAIL,
|
||||||
|
@ -850,11 +917,17 @@ export {
|
||||||
GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL,
|
GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL,
|
||||||
GROUP_EDITOR_TITLE_CHANGE,
|
GROUP_EDITOR_TITLE_CHANGE,
|
||||||
GROUP_EDITOR_DESCRIPTION_CHANGE,
|
GROUP_EDITOR_DESCRIPTION_CHANGE,
|
||||||
|
GROUP_EDITOR_PRIVACY_CHANGE,
|
||||||
|
GROUP_EDITOR_MEDIA_CHANGE,
|
||||||
GROUP_EDITOR_RESET,
|
GROUP_EDITOR_RESET,
|
||||||
createGroup,
|
createGroup,
|
||||||
createGroupRequest,
|
createGroupRequest,
|
||||||
createGroupSuccess,
|
createGroupSuccess,
|
||||||
createGroupFail,
|
createGroupFail,
|
||||||
|
updateGroup,
|
||||||
|
updateGroupRequest,
|
||||||
|
updateGroupSuccess,
|
||||||
|
updateGroupFail,
|
||||||
deleteGroup,
|
deleteGroup,
|
||||||
deleteGroupRequest,
|
deleteGroupRequest,
|
||||||
deleteGroupSuccess,
|
deleteGroupSuccess,
|
||||||
|
@ -937,6 +1010,8 @@ export {
|
||||||
rejectGroupMembershipRequestFail,
|
rejectGroupMembershipRequestFail,
|
||||||
changeGroupEditorTitle,
|
changeGroupEditorTitle,
|
||||||
changeGroupEditorDescription,
|
changeGroupEditorDescription,
|
||||||
|
changeGroupEditorPrivacy,
|
||||||
|
changeGroupEditorMedia,
|
||||||
resetGroupEditor,
|
resetGroupEditor,
|
||||||
submitGroupEditor,
|
submitGroupEditor,
|
||||||
};
|
};
|
||||||
|
|
|
@ -13,7 +13,7 @@ import VerificationBadge from './verification-badge';
|
||||||
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
|
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const messages = defineMessages({
|
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' },
|
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?' },
|
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<IEventPreview> = ({ status, className, hideAction,
|
||||||
{floatingAction && action}
|
{floatingAction && action}
|
||||||
</div>
|
</div>
|
||||||
<div className='bg-primary-200 dark:bg-gray-600 h-40'>
|
<div className='bg-primary-200 dark:bg-gray-600 h-40'>
|
||||||
{banner && <img className='h-full w-full object-cover' src={banner.url} alt={intl.formatMessage(messages.bannerHeader)} />}
|
{banner && <img className='h-full w-full object-cover' src={banner.url} alt={intl.formatMessage(messages.eventBanner)} />}
|
||||||
</div>
|
</div>
|
||||||
<Stack className='p-2.5' space={2}>
|
<Stack className='p-2.5' space={2}>
|
||||||
<HStack space={2} alignItems='center' justifyContent='between'>
|
<HStack space={2} alignItems='center' justifyContent='between'>
|
||||||
|
|
58
app/soapbox/components/group-card.tsx
Normal file
58
app/soapbox/components/group-card.tsx
Normal file
|
@ -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<IGroupCard> = ({ group }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack className='rounded-lg sm:rounded-xl shadow-lg dark:shadow-none overflow-hidden'>
|
||||||
|
<div className='bg-primary-100 dark:bg-gray-800 h-[120px] relative'>
|
||||||
|
{group.header && <img className='h-full w-full object-cover' src={group.header} alt={intl.formatMessage(messages.groupHeader)} />}
|
||||||
|
<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>
|
||||||
|
<Stack className='p-3 pt-9' alignItems='center' space={3}>
|
||||||
|
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||||
|
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||||
|
{group.relationship?.role === 'admin' ? (
|
||||||
|
<HStack space={1} alignItems='center'>
|
||||||
|
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
|
||||||
|
<span>Owner</span>
|
||||||
|
</HStack>
|
||||||
|
) : group.relationship?.role === 'moderator' && (
|
||||||
|
<HStack space={1} alignItems='center'>
|
||||||
|
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
|
||||||
|
<span>Moderator</span>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
{group.locked ? (
|
||||||
|
<HStack space={1} alignItems='center'>
|
||||||
|
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
|
||||||
|
<span>Private</span>
|
||||||
|
</HStack>
|
||||||
|
) : (
|
||||||
|
<HStack space={1} alignItems='center'>
|
||||||
|
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
|
||||||
|
<span>Public</span>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupCard;
|
|
@ -134,6 +134,14 @@ const SidebarNavigation = () => {
|
||||||
|
|
||||||
{renderMessagesLink()}
|
{renderMessagesLink()}
|
||||||
|
|
||||||
|
{features.groups && (
|
||||||
|
<SidebarNavigationLink
|
||||||
|
to='/groups'
|
||||||
|
icon={require('@tabler/icons/circles.svg')}
|
||||||
|
text={<FormattedMessage id='tabs_bar.groups' defaultMessage='Groups' />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<SidebarNavigationLink
|
<SidebarNavigationLink
|
||||||
to={`/@${account.acct}`}
|
to={`/@${account.acct}`}
|
||||||
icon={require('@tabler/icons/user.svg')}
|
icon={require('@tabler/icons/user.svg')}
|
||||||
|
|
|
@ -26,7 +26,7 @@ import { Card, HStack, Stack, Text } from './ui';
|
||||||
import type { Map as ImmutableMap } from 'immutable';
|
import type { Map as ImmutableMap } from 'immutable';
|
||||||
import type {
|
import type {
|
||||||
Account as AccountEntity,
|
Account as AccountEntity,
|
||||||
Group as GroupEntity,
|
// Group as GroupEntity,
|
||||||
Status as StatusEntity,
|
Status as StatusEntity,
|
||||||
} from 'soapbox/types/entities';
|
} from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
@ -300,7 +300,7 @@ const Status: React.FC<IStatus> = (props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const group = actualStatus.group as GroupEntity | null;
|
// const group = actualStatus.group as GroupEntity | null;
|
||||||
|
|
||||||
const handlers = muted ? undefined : {
|
const handlers = muted ? undefined : {
|
||||||
reply: handleHotkeyReply,
|
reply: handleHotkeyReply,
|
||||||
|
@ -345,10 +345,10 @@ const Status: React.FC<IStatus> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{group && (
|
{/* {group && (
|
||||||
<div className='pt-4 px-4'>
|
<div className='pt-4 px-4'>
|
||||||
<HStack alignItems='center' space={1}>
|
<HStack alignItems='center' space={1}>
|
||||||
<Icon src={require('@tabler/icons/users.svg')} className='text-gray-600 dark:text-gray-400' />
|
<Icon src={require('@tabler/icons/circles.svg')} className='text-gray-600 dark:text-gray-400' />
|
||||||
|
|
||||||
<Text size='sm' theme='muted' weight='medium'>
|
<Text size='sm' theme='muted' weight='medium'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
|
@ -363,7 +363,7 @@ const Status: React.FC<IStatus> = (props) => {
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)} */}
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
variant={variant}
|
variant={variant}
|
||||||
|
|
|
@ -40,6 +40,8 @@ interface IModal {
|
||||||
confirmationText?: React.ReactNode,
|
confirmationText?: React.ReactNode,
|
||||||
/** Confirmation button theme. */
|
/** Confirmation button theme. */
|
||||||
confirmationTheme?: ButtonThemes,
|
confirmationTheme?: ButtonThemes,
|
||||||
|
/** Whether to use full width style for confirmation button. */
|
||||||
|
confirmationFullWidth?: boolean,
|
||||||
/** Callback when the modal is closed. */
|
/** Callback when the modal is closed. */
|
||||||
onClose?: () => void,
|
onClose?: () => void,
|
||||||
/** Callback when the secondary action is chosen. */
|
/** Callback when the secondary action is chosen. */
|
||||||
|
@ -65,6 +67,7 @@ const Modal: React.FC<IModal> = ({
|
||||||
confirmationDisabled,
|
confirmationDisabled,
|
||||||
confirmationText,
|
confirmationText,
|
||||||
confirmationTheme,
|
confirmationTheme,
|
||||||
|
confirmationFullWidth,
|
||||||
onClose,
|
onClose,
|
||||||
secondaryAction,
|
secondaryAction,
|
||||||
secondaryDisabled = false,
|
secondaryDisabled = false,
|
||||||
|
@ -117,7 +120,7 @@ const Modal: React.FC<IModal> = ({
|
||||||
|
|
||||||
{confirmationAction && (
|
{confirmationAction && (
|
||||||
<HStack className='mt-5' justifyContent='between' data-testid='modal-actions'>
|
<HStack className='mt-5' justifyContent='between' data-testid='modal-actions'>
|
||||||
<div className='flex-grow'>
|
<div className={classNames({ 'flex-grow': !confirmationFullWidth })}>
|
||||||
{cancelAction && (
|
{cancelAction && (
|
||||||
<Button
|
<Button
|
||||||
theme='tertiary'
|
theme='tertiary'
|
||||||
|
@ -128,7 +131,7 @@ const Modal: React.FC<IModal> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HStack space={2}>
|
<HStack space={2} className={classNames({ 'flex-grow': confirmationFullWidth })}>
|
||||||
{secondaryAction && (
|
{secondaryAction && (
|
||||||
<Button
|
<Button
|
||||||
theme='secondary'
|
theme='secondary'
|
||||||
|
@ -144,6 +147,7 @@ const Modal: React.FC<IModal> = ({
|
||||||
onClick={confirmationAction}
|
onClick={confirmationAction}
|
||||||
disabled={confirmationDisabled}
|
disabled={confirmationDisabled}
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
|
block={confirmationFullWidth}
|
||||||
>
|
>
|
||||||
{confirmationText}
|
{confirmationText}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -1,16 +1,13 @@
|
||||||
import { List as ImmutableList } from 'immutable';
|
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 { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
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, IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui';
|
import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||||
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
|
|
||||||
import { normalizeAttachment } from 'soapbox/normalizers';
|
import { normalizeAttachment } from 'soapbox/normalizers';
|
||||||
|
|
||||||
import type { Menu as MenuType } from 'soapbox/components/dropdown-menu';
|
|
||||||
import type { Group } from 'soapbox/types/entities';
|
import type { Group } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
@ -25,8 +22,6 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const ownAccount = useOwnAccount();
|
|
||||||
|
|
||||||
if (!group) {
|
if (!group) {
|
||||||
return (
|
return (
|
||||||
<div className='-mt-4 -mx-4'>
|
<div className='-mt-4 -mx-4'>
|
||||||
|
@ -77,21 +72,14 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeMenu = () => {
|
|
||||||
const menu: MenuType = [];
|
|
||||||
|
|
||||||
return menu;
|
|
||||||
};
|
|
||||||
|
|
||||||
const makeActionButton = () => {
|
const makeActionButton = () => {
|
||||||
if (group.relationship?.role === 'admin') {
|
if (group.relationship?.role === 'admin') {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
theme='secondary'
|
||||||
theme='primary'
|
|
||||||
// to={`/@${account.acct}/events/${status.id}`}
|
// to={`/@${account.acct}/events/${status.id}`}
|
||||||
>
|
>
|
||||||
<FormattedMessage id='group.manage' defaultMessage='Manage' />
|
<FormattedMessage id='group.manage' defaultMessage='Edit group' />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -99,13 +87,12 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const menu = makeMenu();
|
|
||||||
const actionButton = makeActionButton();
|
const actionButton = makeActionButton();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='-mt-4 -mx-4'>
|
<div className='-mt-4 -mx-4'>
|
||||||
<div>
|
<div className='relative'>
|
||||||
<div className='relative flex flex-col justify-center h-32 w-full lg:h-48 md:rounded-t-xl bg-gray-200 dark:bg-gray-900/50 overflow-hidden isolate'>
|
<div className='relative flex flex-col justify-center h-32 w-full lg:h-[200px] md:rounded-t-xl bg-gray-200 dark:bg-gray-900/50 overflow-hidden isolate'>
|
||||||
{group.header && (
|
{group.header && (
|
||||||
<a href={group.header} onClick={handleHeaderClick} target='_blank'>
|
<a href={group.header} onClick={handleHeaderClick} target='_blank'>
|
||||||
<StillImage
|
<StillImage
|
||||||
|
@ -114,70 +101,43 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
<div className='absolute top-2 left-2'>
|
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
|
||||||
<HStack alignItems='center' space={1}>
|
<a href={group.avatar} onClick={handleAvatarClick} target='_blank'>
|
||||||
{/* {info} */}
|
<Avatar className='ring-[3px] ring-white dark:ring-primary-900' src={group.avatar} size={72} />
|
||||||
</HStack>
|
</a>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='px-4 sm:px-6'>
|
<Stack className='p-3 pt-12' alignItems='center' space={2}>
|
||||||
<HStack className='-mt-12' alignItems='bottom' space={5}>
|
<Text className='mb-1' size='xl' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||||
<div className='flex'>
|
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||||
<a href={group.avatar} onClick={handleAvatarClick} target='_blank'>
|
{group.relationship?.role === 'admin' ? (
|
||||||
<Avatar
|
<HStack space={1} alignItems='center'>
|
||||||
src={group.avatar}
|
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
|
||||||
size={96}
|
<span>Owner</span>
|
||||||
className='relative h-24 w-24 rounded-full ring-4 ring-white dark:ring-primary-900'
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mt-6 flex justify-end w-full sm:pb-1'>
|
|
||||||
<HStack space={2} className='mt-10'>
|
|
||||||
{ownAccount && (
|
|
||||||
<Menu>
|
|
||||||
<MenuButton
|
|
||||||
as={IconButton}
|
|
||||||
src={require('@tabler/icons/dots.svg')}
|
|
||||||
theme='outlined'
|
|
||||||
className='px-2'
|
|
||||||
iconClassName='w-4 h-4'
|
|
||||||
children={null}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MenuList className='w-56'>
|
|
||||||
{menu.map((menuItem, idx) => {
|
|
||||||
if (typeof menuItem?.text === 'undefined') {
|
|
||||||
return <MenuDivider key={idx} />;
|
|
||||||
} 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 (
|
|
||||||
<Comp key={idx} {...itemProps} className='group'>
|
|
||||||
<HStack space={3} alignItems='center'>
|
|
||||||
{menuItem.icon && (
|
|
||||||
<SvgIcon src={menuItem.icon} className='h-5 w-5 text-gray-400 flex-none group-hover:text-gray-500' />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='truncate'>{menuItem.text}</div>
|
|
||||||
</HStack>
|
|
||||||
</Comp>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</MenuList>
|
|
||||||
</Menu>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{actionButton}
|
|
||||||
</HStack>
|
</HStack>
|
||||||
</div>
|
) : group.relationship?.role === 'moderator' && (
|
||||||
|
<HStack space={1} alignItems='center'>
|
||||||
|
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
|
||||||
|
<span>Moderator</span>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
{group.locked ? (
|
||||||
|
<HStack space={1} alignItems='center'>
|
||||||
|
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
|
||||||
|
<span>Private</span>
|
||||||
|
</HStack>
|
||||||
|
) : (
|
||||||
|
<HStack space={1} alignItems='center'>
|
||||||
|
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
|
||||||
|
<span>Public</span>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
</div>
|
<Text theme='muted' dangerouslySetInnerHTML={{ __html: group.note_emojified }} />
|
||||||
|
{actionButton}
|
||||||
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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<IGroupInfoPanel> = ({ group }) => (
|
|
||||||
<div className='mt-6 min-w-0 flex-1 sm:px-2'>
|
|
||||||
<Stack space={2}>
|
|
||||||
<Stack>
|
|
||||||
<HStack space={1} alignItems='center'>
|
|
||||||
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
|
||||||
</HStack>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{group.note.length > 0 && (
|
|
||||||
<Markup size='sm' dangerouslySetInnerHTML={{ __html: group.note_emojified }} />
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default GroupInfoPanel;
|
|
|
@ -1,13 +1,14 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { groupCompose } from 'soapbox/actions/compose';
|
import { groupCompose } from 'soapbox/actions/compose';
|
||||||
import { fetchGroup } from 'soapbox/actions/groups';
|
import { fetchGroup } from 'soapbox/actions/groups';
|
||||||
import { connectGroupStream } from 'soapbox/actions/streaming';
|
import { connectGroupStream } from 'soapbox/actions/streaming';
|
||||||
import { expandGroupTimeline } from 'soapbox/actions/timelines';
|
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 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';
|
import Timeline from '../ui/components/timeline';
|
||||||
|
|
||||||
|
@ -18,6 +19,7 @@ interface IGroupTimeline {
|
||||||
}
|
}
|
||||||
|
|
||||||
const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
|
const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
|
||||||
|
const account = useOwnAccount();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const groupId = props.params.id;
|
const groupId = props.params.id;
|
||||||
|
@ -43,18 +45,29 @@ const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack space={2}>
|
<Stack space={2}>
|
||||||
<div className='p-2 pt-0 border-b border-solid border-gray-200 dark:border-gray-800'>
|
{!!account && (
|
||||||
<ComposeForm id={`group:${groupId}`} autoFocus={false} group={groupId} />
|
<div className='px-2 py-4 border-b border-solid border-gray-200 dark:border-gray-800'>
|
||||||
</div>
|
<HStack alignItems='start' space={4}>
|
||||||
<div className='p-0 sm:p-2 shadow-none'>
|
<Link to={`/@${account.acct}`}>
|
||||||
<Timeline
|
<Avatar src={account.avatar} size={46} />
|
||||||
scrollKey='group_timeline'
|
</Link>
|
||||||
timelineId={`group:${groupId}`}
|
|
||||||
onLoadMore={handleLoadMore}
|
<ComposeForm
|
||||||
emptyMessage={<FormattedMessage id='empty_column.group' defaultMessage='There is no post in this group yet.' />}
|
id={`group:${groupId}`}
|
||||||
divideType='space'
|
shouldCondense
|
||||||
/>
|
autoFocus={false}
|
||||||
</div>
|
group={groupId}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Timeline
|
||||||
|
scrollKey='group_timeline'
|
||||||
|
timelineId={`group:${groupId}`}
|
||||||
|
onLoadMore={handleLoadMore}
|
||||||
|
emptyMessage={<FormattedMessage id='empty_column.group' defaultMessage='There are no posts in this group yet.' />}
|
||||||
|
divideType='space'
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,21 +1,18 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
|
||||||
import { fetchGroups } from 'soapbox/actions/groups';
|
import { fetchGroups } from 'soapbox/actions/groups';
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
import GroupCard from 'soapbox/components/group-card';
|
||||||
import Icon from 'soapbox/components/icon';
|
|
||||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
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 { useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import type { List as ImmutableList } from 'immutable';
|
||||||
import type { RootState } from 'soapbox/store';
|
import type { RootState } from 'soapbox/store';
|
||||||
|
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
||||||
const messages = defineMessages({
|
|
||||||
heading: { id: 'column.groups', defaultMessage: 'Groups' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const getOrderedGroups = createSelector([
|
const getOrderedGroups = createSelector([
|
||||||
(state: RootState) => state.groups,
|
(state: RootState) => state.groups,
|
||||||
|
@ -25,12 +22,16 @@ const getOrderedGroups = createSelector([
|
||||||
return 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));
|
return (groups
|
||||||
|
.toList()
|
||||||
|
.filter((item: GroupEntity | false) => !!item) as ImmutableList<GroupEntity>)
|
||||||
|
.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 Lists: React.FC = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
const groups = useAppSelector((state) => getOrderedGroups(state));
|
const groups = useAppSelector((state) => getOrderedGroups(state));
|
||||||
|
|
||||||
|
@ -38,10 +39,6 @@ const Lists: React.FC = () => {
|
||||||
dispatch(fetchGroups());
|
dispatch(fetchGroups());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onCreateGroup = () => {
|
|
||||||
dispatch(openModal('MANAGE_GROUP'));
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!groups) {
|
if (!groups) {
|
||||||
return (
|
return (
|
||||||
<Column>
|
<Column>
|
||||||
|
@ -50,35 +47,38 @@ const Lists: React.FC = () => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyMessage = <FormattedMessage id='empty_column.groups' defaultMessage='You are not in any group yet. When you join one, it will show up here.' />;
|
const emptyMessage = (
|
||||||
|
<Stack space={6} alignItems='center' justifyContent='center' className='p-6 h-full'>
|
||||||
|
<Stack space={2} className='max-w-sm'>
|
||||||
|
<Text size='2xl' weight='bold' tag='h2' align='center'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='groups.empty.title'
|
||||||
|
defaultMessage='No Groups yet'
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text size='sm' theme='muted' align='center'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='groups.empty.subtitle'
|
||||||
|
defaultMessage='Start discovering groups to join or create your own.'
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column label={intl.formatMessage(messages.heading)}>
|
<ScrollableList
|
||||||
<HStack>
|
scrollKey='lists'
|
||||||
<Button
|
emptyMessage={emptyMessage}
|
||||||
className='ml-auto'
|
itemClassName='py-3 last:pb-0'
|
||||||
theme='primary'
|
>
|
||||||
size='sm'
|
{groups.map((group) => (
|
||||||
onClick={onCreateGroup}
|
<Link to={`/groups/${group.id}`}>
|
||||||
>
|
<GroupCard group={group as GroupEntity} />
|
||||||
<FormattedMessage id='groups.create_group' defaultMessage='Create group' />
|
</Link>
|
||||||
</Button>
|
))}
|
||||||
</HStack>
|
</ScrollableList>
|
||||||
<div className='space-y-4'>
|
|
||||||
<ScrollableList
|
|
||||||
scrollKey='lists'
|
|
||||||
emptyMessage={emptyMessage}
|
|
||||||
itemClassName='py-2'
|
|
||||||
>
|
|
||||||
{groups.map((group: any) => (
|
|
||||||
<Link key={group.id} to={`/groups/${group.id}`} className='flex items-center gap-1.5 p-2 text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg'>
|
|
||||||
<Icon src={require('@tabler/icons/users.svg')} fixedWidth />
|
|
||||||
<span className='flex-grow' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</ScrollableList>
|
|
||||||
</div>
|
|
||||||
</Column>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -214,7 +214,7 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
|
||||||
<FormGroup
|
<FormGroup
|
||||||
labelText={<FormattedMessage id='compose_event.fields.banner_label' defaultMessage='Event banner' />}
|
labelText={<FormattedMessage id='compose_event.fields.banner_label' defaultMessage='Event banner' />}
|
||||||
>
|
>
|
||||||
<div className='flex items-center justify-center bg-gray-200 dark:bg-gray-900/50 rounded-lg text-black dark:text-white sm:shadow dark:sm:shadow-inset overflow-hidden h-24 sm:h-32 relative'>
|
<div className='flex items-center justify-center bg-primary-100 dark:bg-gray-800 rounded-lg text-primary-500 dark:text-white sm:shadow dark:sm:shadow-inset overflow-hidden h-24 sm:h-32 relative'>
|
||||||
{banner ? (
|
{banner ? (
|
||||||
<>
|
<>
|
||||||
<img className='h-full w-full object-cover' src={banner.url} alt='' />
|
<img className='h-full w-full object-cover' src={banner.url} alt='' />
|
||||||
|
@ -223,7 +223,6 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
|
||||||
) : (
|
) : (
|
||||||
<UploadButton disabled={isUploading} onSelectFile={handleFiles} />
|
<UploadButton disabled={isUploading} onSelectFile={handleFiles} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup
|
<FormGroup
|
||||||
|
|
|
@ -1,23 +1,18 @@
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { IconButton } from 'soapbox/components/ui';
|
import Icon from 'soapbox/components/icon';
|
||||||
|
import { HStack, Text } from 'soapbox/components/ui';
|
||||||
import { useAppSelector } from 'soapbox/hooks';
|
import { useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
import type { List as ImmutableList } from 'immutable';
|
import type { List as ImmutableList } from 'immutable';
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
upload: { id: 'compose_event.upload_banner', defaultMessage: 'Upload event banner' },
|
|
||||||
});
|
|
||||||
|
|
||||||
interface IUploadButton {
|
interface IUploadButton {
|
||||||
disabled?: boolean,
|
disabled?: boolean,
|
||||||
onSelectFile: (files: FileList) => void,
|
onSelectFile: (files: FileList) => void,
|
||||||
}
|
}
|
||||||
|
|
||||||
const UploadButton: React.FC<IUploadButton> = ({ disabled, onSelectFile }) => {
|
const UploadButton: React.FC<IUploadButton> = ({ disabled, onSelectFile }) => {
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
const fileElement = useRef<HTMLInputElement>(null);
|
const fileElement = useRef<HTMLInputElement>(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/'));
|
||||||
|
|
||||||
|
@ -32,27 +27,25 @@ const UploadButton: React.FC<IUploadButton> = ({ disabled, onSelectFile }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<HStack className='h-full w-full text-primary-500 dark:text-accent-blue cursor-pointer' space={3} alignItems='center' justifyContent='center' element='label'>
|
||||||
<IconButton
|
<Icon
|
||||||
src={require('@tabler/icons/photo-plus.svg')}
|
src={require('@tabler/icons/photo-plus.svg')}
|
||||||
className='h-8 w-8 text-gray-600 hover:text-gray-700 dark:hover:text-gray-500'
|
className='h-7 w-7'
|
||||||
title={intl.formatMessage(messages.upload)}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label>
|
<Text size='sm' theme='primary' weight='semibold' transform='uppercase'>
|
||||||
<span className='sr-only'>{intl.formatMessage(messages.upload)}</span>
|
<FormattedMessage id='compose_event.upload_banner' defaultMessage='Upload photo' />
|
||||||
<input
|
</Text>
|
||||||
ref={fileElement}
|
<input
|
||||||
type='file'
|
ref={fileElement}
|
||||||
accept={attachmentTypes && attachmentTypes.toArray().join(',')}
|
type='file'
|
||||||
onChange={handleChange}
|
accept={attachmentTypes && attachmentTypes.toArray().join(',')}
|
||||||
disabled={disabled}
|
onChange={handleChange}
|
||||||
className='hidden'
|
disabled={disabled}
|
||||||
/>
|
className='hidden'
|
||||||
</label>
|
/>
|
||||||
</div>
|
</HStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,19 +1,29 @@
|
||||||
import React from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import {
|
import { submitGroupEditor } from 'soapbox/actions/groups';
|
||||||
changeGroupEditorTitle,
|
import { Modal, Stack } from 'soapbox/components/ui';
|
||||||
changeGroupEditorDescription,
|
|
||||||
submitGroupEditor,
|
|
||||||
} from 'soapbox/actions/groups';
|
|
||||||
import { Form, FormGroup, Input, Modal, Stack, Textarea } from 'soapbox/components/ui';
|
|
||||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import DetailsStep from './steps/details-step';
|
||||||
|
import PrivacyStep from './steps/privacy-step';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Name' },
|
next: { id: 'manage_group.next', defaultMessage: 'Next' },
|
||||||
groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' },
|
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 {
|
interface IManageGroupModal {
|
||||||
onClose: (type?: string) => void,
|
onClose: (type?: string) => void,
|
||||||
}
|
}
|
||||||
|
@ -22,20 +32,11 @@ const ManageGroupModal: React.FC<IManageGroupModal> = ({ onClose }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
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 id = useAppSelector((state) => state.group_editor.groupId);
|
||||||
|
|
||||||
const isSubmitting = useAppSelector((state) => state.group_editor.isSubmitting);
|
const isSubmitting = useAppSelector((state) => state.group_editor.isSubmitting);
|
||||||
|
|
||||||
const onChangeName: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
|
const [currentStep, setCurrentStep] = useState<Steps>(id ? Steps.TWO : Steps.ONE);
|
||||||
dispatch(changeGroupEditorTitle(target.value));
|
|
||||||
};
|
|
||||||
|
|
||||||
const onChangeDescription: React.ChangeEventHandler<HTMLTextAreaElement> = ({ target }) => {
|
|
||||||
dispatch(changeGroupEditorDescription(target.value));
|
|
||||||
};
|
|
||||||
|
|
||||||
const onClickClose = () => {
|
const onClickClose = () => {
|
||||||
onClose('manage_group');
|
onClose('manage_group');
|
||||||
|
@ -45,45 +46,44 @@ const ManageGroupModal: React.FC<IManageGroupModal> = ({ onClose }) => {
|
||||||
dispatch(submitGroupEditor(true));
|
dispatch(submitGroupEditor(true));
|
||||||
};
|
};
|
||||||
|
|
||||||
const body = (
|
const confirmationText = useMemo(() => {
|
||||||
<Form>
|
switch (currentStep) {
|
||||||
<FormGroup
|
case Steps.TWO:
|
||||||
labelText={<FormattedMessage id='manage_group.fields.name_label' defaultMessage='Group name' />}
|
return intl.formatMessage(id ? messages.update : messages.create);
|
||||||
>
|
default:
|
||||||
<Input
|
return intl.formatMessage(messages.next);
|
||||||
type='text'
|
}
|
||||||
placeholder={intl.formatMessage(messages.groupNamePlaceholder)}
|
}, [currentStep]);
|
||||||
value={name}
|
|
||||||
onChange={onChangeName}
|
const handleNextStep = () => {
|
||||||
/>
|
switch (currentStep) {
|
||||||
</FormGroup>
|
case Steps.ONE:
|
||||||
<FormGroup
|
setCurrentStep(Steps.TWO);
|
||||||
labelText={<FormattedMessage id='manage_group.fields.description_label' defaultMessage='Group description' />}
|
break;
|
||||||
>
|
case Steps.TWO:
|
||||||
<Textarea
|
handleSubmit();
|
||||||
autoComplete='off'
|
onClose();
|
||||||
placeholder={intl.formatMessage(messages.groupDescriptionPlaceholder)}
|
break;
|
||||||
value={description}
|
default:
|
||||||
onChange={onChangeDescription}
|
break;
|
||||||
/>
|
}
|
||||||
</FormGroup>
|
};
|
||||||
</Form>
|
|
||||||
);
|
const StepToRender = manageGroupSteps[currentStep];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={id
|
title={id
|
||||||
? <FormattedMessage id='navigation_bar.manage_group' defaultMessage='Manage group' />
|
? <FormattedMessage id='navigation_bar.manage_group' defaultMessage='Manage Group' />
|
||||||
: <FormattedMessage id='navigation_bar.create_group' defaultMessage='Create new group' />}
|
: <FormattedMessage id='navigation_bar.create_group' defaultMessage='Create Group' />}
|
||||||
confirmationAction={handleSubmit}
|
confirmationAction={handleNextStep}
|
||||||
confirmationText={id
|
confirmationText={confirmationText}
|
||||||
? <FormattedMessage id='manage_group.update' defaultMessage='Update' />
|
|
||||||
: <FormattedMessage id='manage_group.create' defaultMessage='Create' />}
|
|
||||||
confirmationDisabled={isSubmitting}
|
confirmationDisabled={isSubmitting}
|
||||||
|
confirmationFullWidth
|
||||||
onClose={onClickClose}
|
onClose={onClickClose}
|
||||||
>
|
>
|
||||||
<Stack space={2}>
|
<Stack space={2}>
|
||||||
{body}
|
<StepToRender />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,133 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import {
|
||||||
|
changeGroupEditorTitle,
|
||||||
|
changeGroupEditorDescription,
|
||||||
|
changeGroupEditorMedia,
|
||||||
|
} from 'soapbox/actions/groups';
|
||||||
|
import Icon from 'soapbox/components/icon';
|
||||||
|
import { Avatar, Form, FormGroup, HStack, IconButton, Input, Text, Textarea } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
import resizeImage from 'soapbox/utils/resize-image';
|
||||||
|
|
||||||
|
import type { List as ImmutableList } from 'immutable';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Group Name' },
|
||||||
|
groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const DetailsStep = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
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 [avatarSrc, setAvatarSrc] = 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 onChangeName: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
|
||||||
|
dispatch(changeGroupEditorTitle(target.value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChangeDescription: React.ChangeEventHandler<HTMLTextAreaElement> = ({ target }) => {
|
||||||
|
dispatch(changeGroupEditorDescription(target.value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = e => {
|
||||||
|
const rawFile = e.target.files?.item(0);
|
||||||
|
|
||||||
|
if (!rawFile) return;
|
||||||
|
|
||||||
|
if (e.target.name === 'avatar') {
|
||||||
|
resizeImage(rawFile, 400 * 400).then(file => {
|
||||||
|
dispatch(changeGroupEditorMedia('avatar', file));
|
||||||
|
setAvatarSrc(URL.createObjectURL(file));
|
||||||
|
}).catch(console.error);
|
||||||
|
} else {
|
||||||
|
resizeImage(rawFile, 1920 * 1080).then(file => {
|
||||||
|
dispatch(changeGroupEditorMedia('header', file));
|
||||||
|
setHeaderSrc(URL.createObjectURL(file));
|
||||||
|
}).catch(console.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<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'>
|
||||||
|
{headerSrc ? (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
<FormGroup
|
||||||
|
labelText={<FormattedMessage id='manage_group.fields.name_label' defaultMessage='Group name (required)' />}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type='text'
|
||||||
|
placeholder={intl.formatMessage(messages.groupNamePlaceholder)}
|
||||||
|
value={name}
|
||||||
|
onChange={onChangeName}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup
|
||||||
|
labelText={<FormattedMessage id='manage_group.fields.description_label' defaultMessage='Description' />}
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
autoComplete='off'
|
||||||
|
placeholder={intl.formatMessage(messages.groupDescriptionPlaceholder)}
|
||||||
|
value={description}
|
||||||
|
onChange={onChangeDescription}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DetailsStep;
|
|
@ -0,0 +1,54 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { changeGroupEditorPrivacy } from 'soapbox/actions/groups';
|
||||||
|
import List, { ListItem } from 'soapbox/components/list';
|
||||||
|
import { Form, FormGroup, Stack, Text } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
const PrivacyStep = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const locked = useAppSelector((state) => state.group_editor.locked);
|
||||||
|
|
||||||
|
const onChangePrivacy = (value: boolean) => {
|
||||||
|
dispatch(changeGroupEditorPrivacy(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack className='max-w-sm mx-auto' space={2}>
|
||||||
|
<Text size='3xl' weight='bold' align='center'>
|
||||||
|
<FormattedMessage id='manage_group.get_started' defaultMessage="Let's get started!" />
|
||||||
|
</Text>
|
||||||
|
<Text size='lg' theme='muted' align='center'>
|
||||||
|
<FormattedMessage id='manage_group.tagline' defaultMessage='Groups connect you with others based on shared interests.' />
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Form>
|
||||||
|
<FormGroup
|
||||||
|
labelText='Privacy settings'
|
||||||
|
>
|
||||||
|
<List>
|
||||||
|
<ListItem
|
||||||
|
label='Public'
|
||||||
|
hint='Discoverable. Anyone can join.'
|
||||||
|
onSelect={() => onChangePrivacy(false)}
|
||||||
|
isSelected={!locked}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
label='Private (Owner approval required)'
|
||||||
|
hint='Discoverable. Users can join after their request is approved.'
|
||||||
|
onSelect={() => onChangePrivacy(true)}
|
||||||
|
isSelected={locked}
|
||||||
|
/>
|
||||||
|
</List>
|
||||||
|
</FormGroup>
|
||||||
|
<Text size='sm' theme='muted' align='center'>These settings cannot be changed later.</Text>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PrivacyStep;
|
|
@ -0,0 +1,39 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
|
import { Button, Stack, Text } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
const NewGroupPanel = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const createGroup = () => {
|
||||||
|
dispatch(openModal('MANAGE_GROUP'));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack space={2}>
|
||||||
|
<Stack>
|
||||||
|
<Text size='lg' weight='bold'>
|
||||||
|
<FormattedMessage id='new_group_panel.title' defaultMessage='Create New Group' />
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text theme='muted' size='sm'>
|
||||||
|
<FormattedMessage id='new_group_panel.subtitle' defaultMessage="Can't find what you're looking for? Start your own private or public group." />
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
icon={require('@tabler/icons/circles.svg')}
|
||||||
|
onClick={createGroup}
|
||||||
|
theme='secondary'
|
||||||
|
block
|
||||||
|
>
|
||||||
|
<FormattedMessage id='new_group_panel.action' defaultMessage='Create group' />
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NewGroupPanel;
|
|
@ -31,6 +31,7 @@ import ChatsPage from 'soapbox/pages/chats-page';
|
||||||
import DefaultPage from 'soapbox/pages/default-page';
|
import DefaultPage from 'soapbox/pages/default-page';
|
||||||
import EventPage from 'soapbox/pages/event-page';
|
import EventPage from 'soapbox/pages/event-page';
|
||||||
import GroupPage from 'soapbox/pages/group-page';
|
import GroupPage from 'soapbox/pages/group-page';
|
||||||
|
import GroupsPage from 'soapbox/pages/groups-page';
|
||||||
import HomePage from 'soapbox/pages/home-page';
|
import HomePage from 'soapbox/pages/home-page';
|
||||||
import ProfilePage from 'soapbox/pages/profile-page';
|
import ProfilePage from 'soapbox/pages/profile-page';
|
||||||
import RemoteInstancePage from 'soapbox/pages/remote-instance-page';
|
import RemoteInstancePage from 'soapbox/pages/remote-instance-page';
|
||||||
|
@ -275,7 +276,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
|
||||||
<WrappedRoute path='/@:username/events/:statusId/discussion' publicRoute exact page={EventPage} component={EventDiscussion} content={children} />
|
<WrappedRoute path='/@:username/events/:statusId/discussion' publicRoute exact page={EventPage} component={EventDiscussion} content={children} />
|
||||||
<Redirect from='/@:username/:statusId' to='/@:username/posts/:statusId' />
|
<Redirect from='/@:username/:statusId' to='/@:username/posts/:statusId' />
|
||||||
|
|
||||||
<WrappedRoute path='/groups' exact page={DefaultPage} component={Groups} content={children} />
|
<WrappedRoute path='/groups' exact page={GroupsPage} component={Groups} content={children} />
|
||||||
<WrappedRoute path='/groups/:id' exact page={GroupPage} component={GroupTimeline} content={children} />
|
<WrappedRoute path='/groups/:id' exact page={GroupPage} component={GroupTimeline} content={children} />
|
||||||
|
|
||||||
<WrappedRoute path='/statuses/new' page={DefaultPage} component={NewStatus} content={children} exact />
|
<WrappedRoute path='/statuses/new' page={DefaultPage} component={NewStatus} content={children} exact />
|
||||||
|
|
|
@ -550,10 +550,10 @@ export function GroupTimeline() {
|
||||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-timeline');
|
return import(/* webpackChunkName: "features/groups" */'../../group/group-timeline');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GroupInfoPanel() {
|
|
||||||
return import(/* webpackChunkName: "features/groups" */'../../group/components/group-info-panel');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ManageGroupModal() {
|
export function ManageGroupModal() {
|
||||||
return import(/* webpackChunkName: "features/manage_group_modal" */'../components/modals/manage-group-modal/manage-group-modal');
|
return import(/* webpackChunkName: "features/manage_group_modal" */'../components/modals/manage-group-modal/manage-group-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function NewGroupPanel() {
|
||||||
|
return import(/* webpackChunkName: "features/groups" */'../components/panels/new-group-panel');
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
import { useRouteMatch } from 'react-router-dom';
|
||||||
|
|
||||||
import { fetchGroup } from 'soapbox/actions/groups';
|
import { fetchGroup } from 'soapbox/actions/groups';
|
||||||
import MissingIndicator from 'soapbox/components/missing-indicator';
|
import MissingIndicator from 'soapbox/components/missing-indicator';
|
||||||
|
@ -7,13 +8,14 @@ import GroupHeader from 'soapbox/features/group/components/group-header';
|
||||||
import LinkFooter from 'soapbox/features/ui/components/link-footer';
|
import LinkFooter from 'soapbox/features/ui/components/link-footer';
|
||||||
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
|
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
|
||||||
import {
|
import {
|
||||||
GroupInfoPanel,
|
|
||||||
SignUpPanel,
|
SignUpPanel,
|
||||||
CtaBanner,
|
CtaBanner,
|
||||||
} from 'soapbox/features/ui/util/async-components';
|
} from 'soapbox/features/ui/util/async-components';
|
||||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
import { makeGetGroup } from 'soapbox/selectors';
|
import { makeGetGroup } from 'soapbox/selectors';
|
||||||
|
|
||||||
|
import { Tabs } from '../components/ui';
|
||||||
|
|
||||||
interface IGroupPage {
|
interface IGroupPage {
|
||||||
params?: {
|
params?: {
|
||||||
id?: string,
|
id?: string,
|
||||||
|
@ -21,21 +23,21 @@ interface IGroupPage {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Page to display a group. */
|
/** Page to display a group. */
|
||||||
const ProfilePage: React.FC<IGroupPage> = ({ params, children }) => {
|
const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
||||||
const id = params?.id || '';
|
const match = useRouteMatch();
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const id = params?.id || '';
|
||||||
|
|
||||||
const getGroup = useCallback(makeGetGroup(), []);
|
const getGroup = useCallback(makeGetGroup(), []);
|
||||||
const group = useAppSelector(state => getGroup(state, id));
|
const group = useAppSelector(state => getGroup(state, id));
|
||||||
|
|
||||||
const me = useAppSelector(state => state.me);
|
const me = useAppSelector(state => state.me);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchGroup(id));
|
dispatch(fetchGroup(id));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
if (group === false) {
|
if ((group as any) === false) {
|
||||||
return (
|
return (
|
||||||
<MissingIndicator />
|
<MissingIndicator />
|
||||||
);
|
);
|
||||||
|
@ -45,17 +47,25 @@ const ProfilePage: React.FC<IGroupPage> = ({ params, children }) => {
|
||||||
<>
|
<>
|
||||||
<Layout.Main>
|
<Layout.Main>
|
||||||
<Column label={group ? group.display_name : ''} withHeader={false}>
|
<Column label={group ? group.display_name : ''} withHeader={false}>
|
||||||
<div className='space-y-4'>
|
<GroupHeader group={group} />
|
||||||
<GroupHeader group={group} />
|
|
||||||
|
|
||||||
{group && (
|
<Tabs
|
||||||
<BundleContainer fetchComponent={GroupInfoPanel}>
|
items={[
|
||||||
{Component => <Component group={group} />}
|
{
|
||||||
</BundleContainer>
|
text: 'All',
|
||||||
)}
|
to: `/groups/${group?.id}`,
|
||||||
|
name: '/groups/:id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Members',
|
||||||
|
to: `/groups/${group?.id}/members`,
|
||||||
|
name: '/groups/:id/members',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
activeItem={match.path}
|
||||||
|
/>
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
{!me && (
|
{!me && (
|
||||||
|
@ -77,4 +87,4 @@ const ProfilePage: React.FC<IGroupPage> = ({ params, children }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProfilePage;
|
export default GroupPage;
|
||||||
|
|
62
app/soapbox/pages/groups-page.tsx
Normal file
62
app/soapbox/pages/groups-page.tsx
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import React from 'react';
|
||||||
|
// import { useRouteMatch } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { Column, Layout } from 'soapbox/components/ui';
|
||||||
|
import LinkFooter from 'soapbox/features/ui/components/link-footer';
|
||||||
|
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
|
||||||
|
import {
|
||||||
|
NewGroupPanel,
|
||||||
|
CtaBanner,
|
||||||
|
} from 'soapbox/features/ui/util/async-components';
|
||||||
|
import { useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
// import { Tabs } from '../components/ui';
|
||||||
|
|
||||||
|
/** Page to display groups. */
|
||||||
|
const GroupsPage: React.FC = ({ children }) => {
|
||||||
|
const me = useAppSelector(state => state.me);
|
||||||
|
// const match = useRouteMatch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Layout.Main>
|
||||||
|
<Column withHeader={false}>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{/* <Tabs
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
text: 'My Groups',
|
||||||
|
to: '/groups',
|
||||||
|
name: '/groups',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Find Groups',
|
||||||
|
to: '/groups/explore',
|
||||||
|
name: '/groups/explore',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
activeItem={match.path}
|
||||||
|
/> */}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
{!me && (
|
||||||
|
<BundleContainer fetchComponent={CtaBanner}>
|
||||||
|
{Component => <Component key='cta-banner' />}
|
||||||
|
</BundleContainer>
|
||||||
|
)}
|
||||||
|
</Layout.Main>
|
||||||
|
|
||||||
|
<Layout.Aside>
|
||||||
|
<BundleContainer fetchComponent={NewGroupPanel}>
|
||||||
|
{Component => <Component key='new-group-panel' />}
|
||||||
|
</BundleContainer>
|
||||||
|
<LinkFooter key='link-footer' />
|
||||||
|
</Layout.Aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupsPage;
|
|
@ -4,22 +4,28 @@ import {
|
||||||
GROUP_EDITOR_RESET,
|
GROUP_EDITOR_RESET,
|
||||||
GROUP_EDITOR_TITLE_CHANGE,
|
GROUP_EDITOR_TITLE_CHANGE,
|
||||||
GROUP_EDITOR_DESCRIPTION_CHANGE,
|
GROUP_EDITOR_DESCRIPTION_CHANGE,
|
||||||
|
GROUP_EDITOR_PRIVACY_CHANGE,
|
||||||
|
GROUP_EDITOR_MEDIA_CHANGE,
|
||||||
GROUP_CREATE_REQUEST,
|
GROUP_CREATE_REQUEST,
|
||||||
GROUP_CREATE_FAIL,
|
GROUP_CREATE_FAIL,
|
||||||
GROUP_CREATE_SUCCESS,
|
GROUP_CREATE_SUCCESS,
|
||||||
|
GROUP_UPDATE_REQUEST,
|
||||||
|
GROUP_UPDATE_FAIL,
|
||||||
|
GROUP_UPDATE_SUCCESS,
|
||||||
} from 'soapbox/actions/groups';
|
} from 'soapbox/actions/groups';
|
||||||
|
|
||||||
import type { AnyAction } from 'redux';
|
import type { AnyAction } from 'redux';
|
||||||
|
|
||||||
const ReducerRecord = ImmutableRecord({
|
const ReducerRecord = ImmutableRecord({
|
||||||
groupId: null as string | null,
|
groupId: null as string | null,
|
||||||
|
progress: 0,
|
||||||
isUploading: false,
|
isUploading: false,
|
||||||
isSubmitting: false,
|
isSubmitting: false,
|
||||||
isChanged: false,
|
isChanged: false,
|
||||||
displayName: '',
|
displayName: '',
|
||||||
note: '',
|
note: '',
|
||||||
avatar: null,
|
avatar: null as File | null,
|
||||||
header: null,
|
header: null as File | null,
|
||||||
locked: false,
|
locked: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -39,14 +45,24 @@ export default function groupEditor(state: State = ReducerRecord(), action: AnyA
|
||||||
map.set('note', action.value);
|
map.set('note', action.value);
|
||||||
map.set('isChanged', true);
|
map.set('isChanged', true);
|
||||||
});
|
});
|
||||||
|
case GROUP_EDITOR_PRIVACY_CHANGE:
|
||||||
|
return state.withMutations(map => {
|
||||||
|
map.set('locked', action.value);
|
||||||
|
map.set('isChanged', true);
|
||||||
|
});
|
||||||
|
case GROUP_EDITOR_MEDIA_CHANGE:
|
||||||
|
return state.set(action.mediaType, action.value);
|
||||||
case GROUP_CREATE_REQUEST:
|
case GROUP_CREATE_REQUEST:
|
||||||
|
case GROUP_UPDATE_REQUEST:
|
||||||
return state.withMutations(map => {
|
return state.withMutations(map => {
|
||||||
map.set('isSubmitting', true);
|
map.set('isSubmitting', true);
|
||||||
map.set('isChanged', false);
|
map.set('isChanged', false);
|
||||||
});
|
});
|
||||||
case GROUP_CREATE_FAIL:
|
case GROUP_CREATE_FAIL:
|
||||||
|
case GROUP_UPDATE_FAIL:
|
||||||
return state.set('isSubmitting', false);
|
return state.set('isSubmitting', false);
|
||||||
case GROUP_CREATE_SUCCESS:
|
case GROUP_CREATE_SUCCESS:
|
||||||
|
case GROUP_UPDATE_SUCCESS:
|
||||||
return state.withMutations(map => {
|
return state.withMutations(map => {
|
||||||
map.set('isSubmitting', false);
|
map.set('isSubmitting', false);
|
||||||
map.set('groupId', action.group.id);
|
map.set('groupId', action.group.id);
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
GROUP_CREATE_SUCCESS,
|
GROUP_CREATE_SUCCESS,
|
||||||
|
GROUP_UPDATE_SUCCESS,
|
||||||
GROUP_DELETE_SUCCESS,
|
GROUP_DELETE_SUCCESS,
|
||||||
GROUP_RELATIONSHIPS_FETCH_SUCCESS,
|
GROUP_RELATIONSHIPS_FETCH_SUCCESS,
|
||||||
GROUP_JOIN_REQUEST,
|
GROUP_JOIN_REQUEST,
|
||||||
|
@ -32,6 +33,7 @@ const normalizeRelationships = (state: State, relationships: APIEntities) => {
|
||||||
export default function groupRelationships(state: State = ImmutableMap(), action: AnyAction) {
|
export default function groupRelationships(state: State = ImmutableMap(), action: AnyAction) {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case GROUP_CREATE_SUCCESS:
|
case GROUP_CREATE_SUCCESS:
|
||||||
|
case GROUP_UPDATE_SUCCESS:
|
||||||
return state.set(action.group.id, normalizeGroupRelationship({ id: action.group.id, member: true, requested: false, role: 'admin' }));
|
return state.set(action.group.id, normalizeGroupRelationship({ id: action.group.id, member: true, requested: false, role: 'admin' }));
|
||||||
case GROUP_DELETE_SUCCESS:
|
case GROUP_DELETE_SUCCESS:
|
||||||
return state.delete(action.id);
|
return state.delete(action.id);
|
||||||
|
|
|
@ -404,6 +404,8 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
*/
|
*/
|
||||||
frontendConfigurations: v.software === PLEROMA,
|
frontendConfigurations: v.software === PLEROMA,
|
||||||
|
|
||||||
|
groups: v.software === MASTODON,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Can hide follows/followers lists and counts.
|
* Can hide follows/followers lists and counts.
|
||||||
* @see PATCH /api/v1/accounts/update_credentials
|
* @see PATCH /api/v1/accounts/update_credentials
|
||||||
|
|
|
@ -67,7 +67,7 @@
|
||||||
"@sentry/browser": "^7.11.1",
|
"@sentry/browser": "^7.11.1",
|
||||||
"@sentry/react": "^7.11.1",
|
"@sentry/react": "^7.11.1",
|
||||||
"@sentry/tracing": "^7.11.1",
|
"@sentry/tracing": "^7.11.1",
|
||||||
"@tabler/icons": "^1.113.0",
|
"@tabler/icons": "^1.117.0",
|
||||||
"@tailwindcss/forms": "^0.5.3",
|
"@tailwindcss/forms": "^0.5.3",
|
||||||
"@tailwindcss/line-clamp": "^0.4.2",
|
"@tailwindcss/line-clamp": "^0.4.2",
|
||||||
"@tailwindcss/typography": "^0.5.7",
|
"@tailwindcss/typography": "^0.5.7",
|
||||||
|
|
|
@ -2272,10 +2272,10 @@
|
||||||
remark "^13.0.0"
|
remark "^13.0.0"
|
||||||
unist-util-find-all-after "^3.0.2"
|
unist-util-find-all-after "^3.0.2"
|
||||||
|
|
||||||
"@tabler/icons@^1.113.0":
|
"@tabler/icons@^1.117.0":
|
||||||
version "1.113.0"
|
version "1.117.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-1.113.0.tgz#aeee5f38284d9996abec1bda46c237ef53cde8d4"
|
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-1.117.0.tgz#2ffafca94f868940cf84a839e284c243e095c45a"
|
||||||
integrity sha512-DjxsvR/0HFHD/utQlM+q3wpl1W2n+jgEZkyfkCkc295rCoAfeXHIBfz/9ROrSHkr205Kq/M8KpQR0Nd4kjwODQ==
|
integrity sha512-4UGF8fMcROiy++CCNlzTz6p22rxFQD/fAMfaw/8Uanopl41X2SCZTmpnotS3C6Qdrk99m8eMZySa5w1y99gFqQ==
|
||||||
|
|
||||||
"@tailwindcss/forms@^0.5.3":
|
"@tailwindcss/forms@^0.5.3":
|
||||||
version "0.5.3"
|
version "0.5.3"
|
||||||
|
|
Loading…
Reference in a new issue