sforkowany z mirror/soapbox
Merge branch 'group-composer' into 'develop'
Update Group Members to use Entity Store See merge request soapbox-pub/soapbox!2342develop^2
commit
c6c12fa60f
|
@ -7,10 +7,10 @@ import { useSoapboxConfig } from 'soapbox/hooks';
|
||||||
|
|
||||||
import { Card, CardBody, CardHeader, CardTitle } from '../card/card';
|
import { Card, CardBody, CardHeader, CardTitle } from '../card/card';
|
||||||
|
|
||||||
type IColumnHeader = Pick<IColumn, 'label' | 'backHref' |'className'>;
|
type IColumnHeader = Pick<IColumn, 'label' | 'backHref' | 'className' | 'action'>;
|
||||||
|
|
||||||
/** Contains the column title with optional back button. */
|
/** Contains the column title with optional back button. */
|
||||||
const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, className }) => {
|
const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, className, action }) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
const handleBackClick = () => {
|
const handleBackClick = () => {
|
||||||
|
@ -29,6 +29,12 @@ const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, className }) =
|
||||||
return (
|
return (
|
||||||
<CardHeader className={className} onBackClick={handleBackClick}>
|
<CardHeader className={className} onBackClick={handleBackClick}>
|
||||||
<CardTitle title={label} />
|
<CardTitle title={label} />
|
||||||
|
|
||||||
|
{action && (
|
||||||
|
<div className='flex grow justify-end'>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -48,11 +54,12 @@ export interface IColumn {
|
||||||
ref?: React.Ref<HTMLDivElement>
|
ref?: React.Ref<HTMLDivElement>
|
||||||
/** Children to display in the column. */
|
/** Children to display in the column. */
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
|
action?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A backdrop for the main section of the UI. */
|
/** A backdrop for the main section of the UI. */
|
||||||
const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => {
|
const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => {
|
||||||
const { backHref, children, label, transparent = false, withHeader = true, className } = props;
|
const { backHref, children, label, transparent = false, withHeader = true, className, action } = props;
|
||||||
const soapboxConfig = useSoapboxConfig();
|
const soapboxConfig = useSoapboxConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -75,6 +82,7 @@ const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedR
|
||||||
label={label}
|
label={label}
|
||||||
backHref={backHref}
|
backHref={backHref}
|
||||||
className={clsx({ 'px-4 pt-4 sm:p-0': transparent })}
|
className={clsx({ 'px-4 pt-4 sm:p-0': transparent })}
|
||||||
|
action={action}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
export enum Entities {
|
||||||
|
GROUPS = 'Groups',
|
||||||
|
GROUP_RELATIONSHIPS = 'GroupRelationships',
|
||||||
|
GROUP_MEMBERSHIPS = 'GroupMemberships',
|
||||||
|
POPULAR_GROUPS = 'PopularGroups',
|
||||||
|
SUGGESTED_GROUPS = 'SuggestedGroups',
|
||||||
|
}
|
|
@ -14,8 +14,12 @@ import type { RootState } from 'soapbox/store';
|
||||||
type EntityPath = [
|
type EntityPath = [
|
||||||
/** Name of the entity type for use in the global cache, eg `'Notification'`. */
|
/** Name of the entity type for use in the global cache, eg `'Notification'`. */
|
||||||
entityType: string,
|
entityType: string,
|
||||||
/** Name of a particular index of this entity type. You can use empty-string (`''`) if you don't need separate lists. */
|
/**
|
||||||
listKey: string,
|
* Name of a particular index of this entity type.
|
||||||
|
* Multiple params get combined into one string with a `:` separator.
|
||||||
|
* You can use empty-string (`''`) if you don't need separate lists.
|
||||||
|
*/
|
||||||
|
...listKeys: string[],
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Additional options for the hook. */
|
/** Additional options for the hook. */
|
||||||
|
@ -27,6 +31,8 @@ interface UseEntitiesOpts<TEntity extends Entity> {
|
||||||
* It is 1 minute by default, and can be set to `Infinity` to opt-out of automatic fetching.
|
* It is 1 minute by default, and can be set to `Infinity` to opt-out of automatic fetching.
|
||||||
*/
|
*/
|
||||||
staleTime?: number
|
staleTime?: number
|
||||||
|
/** A flag to potentially disable sending requests to the API. */
|
||||||
|
enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A hook for fetching and displaying API entities. */
|
/** A hook for fetching and displaying API entities. */
|
||||||
|
@ -42,11 +48,16 @@ function useEntities<TEntity extends Entity>(
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const getState = useGetState();
|
const getState = useGetState();
|
||||||
|
|
||||||
const [entityType, listKey] = path;
|
const [entityType, ...listKeys] = path;
|
||||||
|
const listKey = listKeys.join(':');
|
||||||
|
|
||||||
const entities = useAppSelector(state => selectEntities<TEntity>(state, path));
|
const entities = useAppSelector(state => selectEntities<TEntity>(state, path));
|
||||||
|
|
||||||
|
const isEnabled = opts.enabled ?? true;
|
||||||
const isFetching = useListState(path, 'fetching');
|
const isFetching = useListState(path, 'fetching');
|
||||||
const lastFetchedAt = useListState(path, 'lastFetchedAt');
|
const lastFetchedAt = useListState(path, 'lastFetchedAt');
|
||||||
|
const isFetched = useListState(path, 'fetched');
|
||||||
|
const isError = !!useListState(path, 'error');
|
||||||
|
|
||||||
const next = useListState(path, 'next');
|
const next = useListState(path, 'next');
|
||||||
const prev = useListState(path, 'prev');
|
const prev = useListState(path, 'prev');
|
||||||
|
@ -66,6 +77,7 @@ function useEntities<TEntity extends Entity>(
|
||||||
next: getNextLink(response),
|
next: getNextLink(response),
|
||||||
prev: getPrevLink(response),
|
prev: getPrevLink(response),
|
||||||
fetching: false,
|
fetching: false,
|
||||||
|
fetched: true,
|
||||||
error: null,
|
error: null,
|
||||||
lastFetchedAt: new Date(),
|
lastFetchedAt: new Date(),
|
||||||
}));
|
}));
|
||||||
|
@ -95,20 +107,22 @@ function useEntities<TEntity extends Entity>(
|
||||||
const staleTime = opts.staleTime ?? 60000;
|
const staleTime = opts.staleTime ?? 60000;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFetching && (!lastFetchedAt || lastFetchedAt.getTime() + staleTime <= Date.now())) {
|
if (isEnabled && !isFetching && (!lastFetchedAt || lastFetchedAt.getTime() + staleTime <= Date.now())) {
|
||||||
fetchEntities();
|
fetchEntities();
|
||||||
}
|
}
|
||||||
}, [endpoint]);
|
}, [endpoint, isEnabled]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
entities,
|
entities,
|
||||||
fetchEntities,
|
fetchEntities,
|
||||||
isFetching,
|
|
||||||
isLoading: isFetching && entities.length === 0,
|
|
||||||
hasNextPage: !!next,
|
|
||||||
hasPreviousPage: !!prev,
|
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
fetchPreviousPage,
|
fetchPreviousPage,
|
||||||
|
hasNextPage: !!next,
|
||||||
|
hasPreviousPage: !!prev,
|
||||||
|
isError,
|
||||||
|
isFetched,
|
||||||
|
isFetching,
|
||||||
|
isLoading: isFetching && entities.length === 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,8 @@ interface EntityListState {
|
||||||
prev: string | undefined
|
prev: string | undefined
|
||||||
/** Error returned from the API, if any. */
|
/** Error returned from the API, if any. */
|
||||||
error: any
|
error: any
|
||||||
|
/** Whether data has already been fetched */
|
||||||
|
fetched: boolean
|
||||||
/** Whether data for this list is currently being fetched. */
|
/** Whether data for this list is currently being fetched. */
|
||||||
fetching: boolean
|
fetching: boolean
|
||||||
/** Date of the last API fetch for this list. */
|
/** Date of the last API fetch for this list. */
|
||||||
|
|
|
@ -29,8 +29,9 @@ const createList = (): EntityList => ({
|
||||||
state: {
|
state: {
|
||||||
next: undefined,
|
next: undefined,
|
||||||
prev: undefined,
|
prev: undefined,
|
||||||
fetching: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
|
fetched: false,
|
||||||
|
fetching: false,
|
||||||
lastFetchedAt: undefined,
|
lastFetchedAt: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -118,7 +118,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Stack alignItems='center' space={3} className='mt-10 py-4'>
|
<Stack alignItems='center' space={3} className='mx-auto mt-10 w-5/6 py-4'>
|
||||||
<Text
|
<Text
|
||||||
size='xl'
|
size='xl'
|
||||||
weight='bold'
|
weight='bold'
|
||||||
|
@ -132,7 +132,11 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
||||||
<GroupMemberCount group={group} />
|
<GroupMemberCount group={group} />
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
<Text theme='muted' dangerouslySetInnerHTML={{ __html: group.note_emojified }} />
|
<Text
|
||||||
|
theme='muted'
|
||||||
|
align='center'
|
||||||
|
dangerouslySetInnerHTML={{ __html: group.note_emojified }}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<GroupActionButton group={group} />
|
<GroupActionButton group={group} />
|
||||||
|
|
|
@ -0,0 +1,218 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { groupBlock, groupDemoteAccount, groupKick, groupPromoteAccount } from 'soapbox/actions/groups';
|
||||||
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
|
import Account from 'soapbox/components/account';
|
||||||
|
import { HStack, IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui';
|
||||||
|
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
||||||
|
import { useAccount, useAppDispatch } from 'soapbox/hooks';
|
||||||
|
import { BaseGroupRoles, useGroupRoles } from 'soapbox/hooks/useGroupRoles';
|
||||||
|
import toast from 'soapbox/toast';
|
||||||
|
|
||||||
|
import type { Menu as IMenu } from 'soapbox/components/dropdown-menu';
|
||||||
|
import type { Account as AccountEntity, Group, GroupMember } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
blockConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Block' },
|
||||||
|
blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to block @{name} from interacting with this group?' },
|
||||||
|
blocked: { id: 'group.group_mod_block.success', defaultMessage: 'Blocked @{name} from group' },
|
||||||
|
demotedToUser: { id: 'group.group_mod_demote.success', defaultMessage: 'Demoted @{name} to group user' },
|
||||||
|
groupModBlock: { id: 'group.group_mod_block', defaultMessage: 'Block @{name} from group' },
|
||||||
|
groupModDemote: { id: 'group.group_mod_demote', defaultMessage: 'Demote @{name}' },
|
||||||
|
groupModKick: { id: 'group.group_mod_kick', defaultMessage: 'Kick @{name} from group' },
|
||||||
|
groupModPromoteAdmin: { id: 'group.group_mod_promote_admin', defaultMessage: 'Promote @{name} to group administrator' },
|
||||||
|
groupModPromoteMod: { id: 'group.group_mod_promote_mod', defaultMessage: 'Promote @{name} to group moderator' },
|
||||||
|
kickConfirm: { id: 'confirmations.kick_from_group.confirm', defaultMessage: 'Kick' },
|
||||||
|
kickFromGroupMessage: { id: 'confirmations.kick_from_group.message', defaultMessage: 'Are you sure you want to kick @{name} from this group?' },
|
||||||
|
kicked: { id: 'group.group_mod_kick.success', defaultMessage: 'Kicked @{name} from group' },
|
||||||
|
promoteConfirm: { id: 'confirmations.promote_in_group.confirm', defaultMessage: 'Promote' },
|
||||||
|
promoteConfirmMessage: { id: 'confirmations.promote_in_group.message', defaultMessage: 'Are you sure you want to promote @{name}? You will not be able to demote them.' },
|
||||||
|
promotedToAdmin: { id: 'group.group_mod_promote_admin.success', defaultMessage: 'Promoted @{name} to group administrator' },
|
||||||
|
promotedToMod: { id: 'group.group_mod_promote_mod.success', defaultMessage: 'Promoted @{name} to group moderator' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IGroupMemberListItem {
|
||||||
|
member: GroupMember
|
||||||
|
group: Group
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupMemberListItem = (props: IGroupMemberListItem) => {
|
||||||
|
const { member, group } = props;
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const { normalizeRole } = useGroupRoles();
|
||||||
|
|
||||||
|
const account = useAccount(member.account.id) as AccountEntity;
|
||||||
|
|
||||||
|
// Current user role
|
||||||
|
const isCurrentUserAdmin = normalizeRole(group.relationship?.role as any) === BaseGroupRoles.ADMIN;
|
||||||
|
const isCurrentUserModerator = normalizeRole(group.relationship?.role as any) === BaseGroupRoles.MODERATOR;
|
||||||
|
|
||||||
|
// Member role
|
||||||
|
const isMemberAdmin = normalizeRole(member.role as any) === BaseGroupRoles.ADMIN;
|
||||||
|
const isMemberModerator = normalizeRole(member.role as any) === BaseGroupRoles.MODERATOR;
|
||||||
|
const isMemberUser = normalizeRole(member.role as any) === BaseGroupRoles.USER;
|
||||||
|
|
||||||
|
const handleKickFromGroup = () => {
|
||||||
|
dispatch(openModal('CONFIRM', {
|
||||||
|
message: intl.formatMessage(messages.kickFromGroupMessage, { name: account.username }),
|
||||||
|
confirm: intl.formatMessage(messages.kickConfirm),
|
||||||
|
onConfirm: () => dispatch(groupKick(group.id, account.id)).then(() =>
|
||||||
|
toast.success(intl.formatMessage(messages.kicked, { name: account.acct })),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlockFromGroup = () => {
|
||||||
|
dispatch(openModal('CONFIRM', {
|
||||||
|
message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }),
|
||||||
|
confirm: intl.formatMessage(messages.blockConfirm),
|
||||||
|
onConfirm: () => dispatch(groupBlock(group.id, account.id)).then(() =>
|
||||||
|
toast.success(intl.formatMessage(messages.blocked, { name: account.acct })),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPromote = (role: 'admin' | 'moderator', warning?: boolean) => {
|
||||||
|
if (warning) {
|
||||||
|
return dispatch(openModal('CONFIRM', {
|
||||||
|
message: intl.formatMessage(messages.promoteConfirmMessage, { name: account.username }),
|
||||||
|
confirm: intl.formatMessage(messages.promoteConfirm),
|
||||||
|
onConfirm: () => dispatch(groupPromoteAccount(group.id, account.id, role)).then(() =>
|
||||||
|
toast.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct })),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
return dispatch(groupPromoteAccount(group.id, account.id, role)).then(() =>
|
||||||
|
toast.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePromoteToGroupAdmin = () => onPromote('admin', true);
|
||||||
|
|
||||||
|
const handlePromoteToGroupMod = () => {
|
||||||
|
onPromote('moderator', group.relationship!.role === 'moderator');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDemote = () => {
|
||||||
|
dispatch(groupDemoteAccount(group.id, account.id, 'user')).then(() =>
|
||||||
|
toast.success(intl.formatMessage(messages.demotedToUser, { name: account.acct })),
|
||||||
|
).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const menu: IMenu = useMemo(() => {
|
||||||
|
const items: IMenu = [];
|
||||||
|
|
||||||
|
if (!group || !account || !group.relationship?.role) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(isCurrentUserAdmin || isCurrentUserModerator) &&
|
||||||
|
(isMemberModerator || isMemberUser) &&
|
||||||
|
member.role !== group.relationship.role
|
||||||
|
) {
|
||||||
|
items.push({
|
||||||
|
text: intl.formatMessage(messages.groupModKick, { name: account.username }),
|
||||||
|
icon: require('@tabler/icons/user-minus.svg'),
|
||||||
|
action: handleKickFromGroup,
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
text: intl.formatMessage(messages.groupModBlock, { name: account.username }),
|
||||||
|
icon: require('@tabler/icons/ban.svg'),
|
||||||
|
action: handleBlockFromGroup,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCurrentUserAdmin && !isMemberAdmin && account.acct === account.username) {
|
||||||
|
items.push(null);
|
||||||
|
|
||||||
|
if (isMemberModerator) {
|
||||||
|
items.push({
|
||||||
|
text: intl.formatMessage(messages.groupModPromoteAdmin, { name: account.username }),
|
||||||
|
icon: require('@tabler/icons/arrow-up-circle.svg'),
|
||||||
|
action: handlePromoteToGroupAdmin,
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
text: intl.formatMessage(messages.groupModDemote, { name: account.username }),
|
||||||
|
icon: require('@tabler/icons/arrow-down-circle.svg'),
|
||||||
|
action: handleDemote,
|
||||||
|
});
|
||||||
|
} else if (isMemberUser) {
|
||||||
|
items.push({
|
||||||
|
text: intl.formatMessage(messages.groupModPromoteMod, { name: account.username }),
|
||||||
|
icon: require('@tabler/icons/arrow-up-circle.svg'),
|
||||||
|
action: handlePromoteToGroupMod,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [group, account]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack alignItems='center' justifyContent='between'>
|
||||||
|
<div className='w-full'>
|
||||||
|
<Account account={member.account} withRelationship={false} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HStack alignItems='center' space={2}>
|
||||||
|
{(isMemberAdmin || isMemberModerator) ? (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
clsx('inline-flex items-center rounded px-2 py-1 text-xs font-medium capitalize', {
|
||||||
|
'bg-primary-200 text-primary-500': isMemberAdmin,
|
||||||
|
'bg-gray-200 text-gray-900': isMemberModerator,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{member.role}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{menu.length > 0 && (
|
||||||
|
<Menu>
|
||||||
|
<MenuButton
|
||||||
|
as={IconButton}
|
||||||
|
src={require('@tabler/icons/dots.svg')}
|
||||||
|
className='px-2'
|
||||||
|
iconClassName='h-4 w-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.target || '_self' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp key={idx} {...itemProps} className='group'>
|
||||||
|
<HStack space={2} alignItems='center'>
|
||||||
|
{menuItem.icon && (
|
||||||
|
<SvgIcon src={menuItem.icon} className='h-4 w-4 flex-none text-gray-700 group-hover:text-gray-800' />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='truncate'>{menuItem.text}</div>
|
||||||
|
</HStack>
|
||||||
|
</Comp>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</MenuList>
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupMemberListItem;
|
|
@ -1,283 +1,59 @@
|
||||||
import debounce from 'lodash/debounce';
|
import React, { useMemo } from 'react';
|
||||||
import React, { useCallback, useEffect } from 'react';
|
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { expandGroupMemberships, fetchGroup, fetchGroupMemberships, groupBlock, groupDemoteAccount, groupKick, groupPromoteAccount } from 'soapbox/actions/groups';
|
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
|
||||||
import Account from 'soapbox/components/account';
|
|
||||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||||
import { CardHeader, CardTitle, HStack, IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui';
|
import { useGroupMembers } from 'soapbox/hooks/api/useGroupMembers';
|
||||||
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
import { useGroupRoles } from 'soapbox/hooks/useGroupRoles';
|
||||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
import { useGroup } from 'soapbox/queries/groups';
|
||||||
import { makeGetAccount } from 'soapbox/selectors';
|
|
||||||
import toast from 'soapbox/toast';
|
|
||||||
|
|
||||||
import PlaceholderAccount from '../placeholder/components/placeholder-account';
|
import PlaceholderAccount from '../placeholder/components/placeholder-account';
|
||||||
|
|
||||||
import type { Menu as MenuType } from 'soapbox/components/dropdown-menu';
|
import GroupMemberListItem from './components/group-member-list-item';
|
||||||
import type { GroupRole, List } from 'soapbox/reducers/group-memberships';
|
|
||||||
import type { GroupRelationship } from 'soapbox/types/entities';
|
|
||||||
|
|
||||||
type RouteParams = { id: string };
|
import type { Group } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
adminSubheading: { id: 'group.admin_subheading', defaultMessage: 'Group administrators' },
|
|
||||||
moderatorSubheading: { id: 'group.moderator_subheading', defaultMessage: 'Group moderators' },
|
|
||||||
userSubheading: { id: 'group.user_subheading', defaultMessage: 'Users' },
|
|
||||||
groupModKick: { id: 'group.group_mod_kick', defaultMessage: 'Kick @{name} from group' },
|
|
||||||
groupModBlock: { id: 'group.group_mod_block', defaultMessage: 'Block @{name} from group' },
|
|
||||||
groupModPromoteAdmin: { id: 'group.group_mod_promote_admin', defaultMessage: 'Promote @{name} to group administrator' },
|
|
||||||
groupModPromoteMod: { id: 'group.group_mod_promote_mod', defaultMessage: 'Promote @{name} to group moderator' },
|
|
||||||
groupModDemote: { id: 'group.group_mod_demote', defaultMessage: 'Demote @{name}' },
|
|
||||||
kickFromGroupMessage: { id: 'confirmations.kick_from_group.message', defaultMessage: 'Are you sure you want to kick @{name} from this group?' },
|
|
||||||
kickConfirm: { id: 'confirmations.kick_from_group.confirm', defaultMessage: 'Kick' },
|
|
||||||
blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to block @{name} from interacting with this group?' },
|
|
||||||
blockConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Block' },
|
|
||||||
promoteConfirmMessage: { id: 'confirmations.promote_in_group.message', defaultMessage: 'Are you sure you want to promote @{name}? You will not be able to demote them.' },
|
|
||||||
promoteConfirm: { id: 'confirmations.promote_in_group.confirm', defaultMessage: 'Promote' },
|
|
||||||
kicked: { id: 'group.group_mod_kick.success', defaultMessage: 'Kicked @{name} from group' },
|
|
||||||
blocked: { id: 'group.group_mod_block.success', defaultMessage: 'Blocked @{name} from group' },
|
|
||||||
promotedToAdmin: { id: 'group.group_mod_promote_admin.success', defaultMessage: 'Promoted @{name} to group administrator' },
|
|
||||||
promotedToMod: { id: 'group.group_mod_promote_mod.success', defaultMessage: 'Promoted @{name} to group moderator' },
|
|
||||||
demotedToUser: { id: 'group.group_mod_demote.success', defaultMessage: 'Demoted @{name} to group user' },
|
|
||||||
});
|
|
||||||
|
|
||||||
interface IGroupMember {
|
|
||||||
accountId: string
|
|
||||||
accountRole: GroupRole
|
|
||||||
groupId: string
|
|
||||||
relationship?: GroupRelationship
|
|
||||||
}
|
|
||||||
|
|
||||||
const GroupMember: React.FC<IGroupMember> = ({ accountId, accountRole, groupId, relationship }) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const getAccount = useCallback(makeGetAccount(), []);
|
|
||||||
|
|
||||||
const account = useAppSelector((state) => getAccount(state, accountId));
|
|
||||||
|
|
||||||
if (!account) return null;
|
|
||||||
|
|
||||||
const handleKickFromGroup = () => {
|
|
||||||
dispatch(openModal('CONFIRM', {
|
|
||||||
message: intl.formatMessage(messages.kickFromGroupMessage, { name: account.username }),
|
|
||||||
confirm: intl.formatMessage(messages.kickConfirm),
|
|
||||||
onConfirm: () => dispatch(groupKick(groupId, account.id)).then(() =>
|
|
||||||
toast.success(intl.formatMessage(messages.kicked, { name: account.acct })),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBlockFromGroup = () => {
|
|
||||||
dispatch(openModal('CONFIRM', {
|
|
||||||
message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }),
|
|
||||||
confirm: intl.formatMessage(messages.blockConfirm),
|
|
||||||
onConfirm: () => dispatch(groupBlock(groupId, account.id)).then(() =>
|
|
||||||
toast.success(intl.formatMessage(messages.blocked, { name: account.acct })),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPromote = (role: 'admin' | 'moderator', warning?: boolean) => {
|
|
||||||
if (warning) {
|
|
||||||
return dispatch(openModal('CONFIRM', {
|
|
||||||
message: intl.formatMessage(messages.promoteConfirmMessage, { name: account.username }),
|
|
||||||
confirm: intl.formatMessage(messages.promoteConfirm),
|
|
||||||
onConfirm: () => dispatch(groupPromoteAccount(groupId, account.id, role)).then(() =>
|
|
||||||
toast.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct })),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
return dispatch(groupPromoteAccount(groupId, account.id, role)).then(() =>
|
|
||||||
toast.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct })),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePromoteToGroupAdmin = () => {
|
|
||||||
onPromote('admin', true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePromoteToGroupMod = () => {
|
|
||||||
onPromote('moderator', relationship!.role === 'moderator');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDemote = () => {
|
|
||||||
dispatch(groupDemoteAccount(groupId, account.id, 'user')).then(() =>
|
|
||||||
toast.success(intl.formatMessage(messages.demotedToUser, { name: account.acct })),
|
|
||||||
).catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const makeMenu = () => {
|
|
||||||
const menu: MenuType = [];
|
|
||||||
|
|
||||||
if (!relationship || !relationship.role) return menu;
|
|
||||||
|
|
||||||
if (['admin', 'moderator'].includes(relationship.role) && ['moderator', 'user'].includes(accountRole) && accountRole !== relationship.role) {
|
|
||||||
menu.push({
|
|
||||||
text: intl.formatMessage(messages.groupModKick, { name: account.username }),
|
|
||||||
icon: require('@tabler/icons/user-minus.svg'),
|
|
||||||
action: handleKickFromGroup,
|
|
||||||
});
|
|
||||||
menu.push({
|
|
||||||
text: intl.formatMessage(messages.groupModBlock, { name: account.username }),
|
|
||||||
icon: require('@tabler/icons/ban.svg'),
|
|
||||||
action: handleBlockFromGroup,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (relationship.role === 'admin' && accountRole !== 'admin' && account.acct === account.username) {
|
|
||||||
menu.push(null);
|
|
||||||
switch (accountRole) {
|
|
||||||
case 'moderator':
|
|
||||||
menu.push({
|
|
||||||
text: intl.formatMessage(messages.groupModPromoteAdmin, { name: account.username }),
|
|
||||||
icon: require('@tabler/icons/arrow-up-circle.svg'),
|
|
||||||
action: handlePromoteToGroupAdmin,
|
|
||||||
});
|
|
||||||
menu.push({
|
|
||||||
text: intl.formatMessage(messages.groupModDemote, { name: account.username }),
|
|
||||||
icon: require('@tabler/icons/arrow-down-circle.svg'),
|
|
||||||
action: handleDemote,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'user':
|
|
||||||
menu.push({
|
|
||||||
text: intl.formatMessage(messages.groupModPromoteMod, { name: account.username }),
|
|
||||||
icon: require('@tabler/icons/arrow-up-circle.svg'),
|
|
||||||
action: handlePromoteToGroupMod,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return menu;
|
|
||||||
};
|
|
||||||
|
|
||||||
const menu = makeMenu();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HStack space={1} alignItems='center' justifyContent='between' className='p-2.5'>
|
|
||||||
<div className='w-full'>
|
|
||||||
<Account account={account} withRelationship={false} />
|
|
||||||
</div>
|
|
||||||
{menu.length > 0 && (
|
|
||||||
<Menu>
|
|
||||||
<MenuButton
|
|
||||||
as={IconButton}
|
|
||||||
src={require('@tabler/icons/dots.svg')}
|
|
||||||
theme='outlined'
|
|
||||||
className='px-2'
|
|
||||||
iconClassName='h-4 w-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.target || '_self' };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp key={idx} {...itemProps} className='group'>
|
|
||||||
<HStack space={3} alignItems='center'>
|
|
||||||
{menuItem.icon && (
|
|
||||||
<SvgIcon src={menuItem.icon} className='h-5 w-5 flex-none text-gray-400 group-hover:text-gray-500' />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='truncate'>{menuItem.text}</div>
|
|
||||||
</HStack>
|
|
||||||
</Comp>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</MenuList>
|
|
||||||
</Menu>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface IGroupMembers {
|
interface IGroupMembers {
|
||||||
params: RouteParams
|
params: { id: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
const GroupMembers: React.FC<IGroupMembers> = (props) => {
|
const GroupMembers: React.FC<IGroupMembers> = (props) => {
|
||||||
const intl = useIntl();
|
const { roles: { admin, moderator, user } } = useGroupRoles();
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const groupId = props.params.id;
|
const groupId = props.params.id;
|
||||||
|
|
||||||
const relationship = useAppSelector((state) => state.group_relationships.get(groupId));
|
const { group, isFetching: isFetchingGroup } = useGroup(groupId);
|
||||||
const admins = useAppSelector((state) => state.group_memberships.admin.get(groupId));
|
const { groupMembers: admins, isFetching: isFetchingAdmins } = useGroupMembers(groupId, admin);
|
||||||
const moderators = useAppSelector((state) => state.group_memberships.moderator.get(groupId));
|
const { groupMembers: moderators, isFetching: isFetchingModerators } = useGroupMembers(groupId, moderator);
|
||||||
const users = useAppSelector((state) => state.group_memberships.user.get(groupId));
|
const { groupMembers: users, isFetching: isFetchingUsers, fetchNextPage, hasNextPage } = useGroupMembers(groupId, user);
|
||||||
|
|
||||||
const handleLoadMore = (role: 'admin' | 'moderator' | 'user') => {
|
const isLoading = isFetchingGroup || isFetchingAdmins || isFetchingModerators || isFetchingUsers;
|
||||||
dispatch(expandGroupMemberships(groupId, role));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLoadMoreAdmins = useCallback(debounce(() => {
|
const members = useMemo(() => [
|
||||||
handleLoadMore('admin');
|
...admins,
|
||||||
}, 300, { leading: true }), []);
|
...moderators,
|
||||||
|
...users,
|
||||||
const handleLoadMoreModerators = useCallback(debounce(() => {
|
], [admins, moderators, users]);
|
||||||
handleLoadMore('moderator');
|
|
||||||
}, 300, { leading: true }), []);
|
|
||||||
|
|
||||||
const handleLoadMoreUsers = useCallback(debounce(() => {
|
|
||||||
handleLoadMore('user');
|
|
||||||
}, 300, { leading: true }), []);
|
|
||||||
|
|
||||||
const renderMemberships = (memberships: List | undefined, role: GroupRole, handler: () => void) => {
|
|
||||||
if (!memberships?.isLoading && !memberships?.items.count()) return;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment key={role}>
|
|
||||||
<CardHeader className='mt-4'>
|
|
||||||
<CardTitle title={intl.formatMessage(messages[`${role}Subheading`])} />
|
|
||||||
</CardHeader>
|
|
||||||
<ScrollableList
|
|
||||||
scrollKey={`group_${role}s-${groupId}`}
|
|
||||||
hasMore={!!memberships?.next}
|
|
||||||
onLoadMore={handler}
|
|
||||||
isLoading={memberships?.isLoading}
|
|
||||||
showLoading={memberships?.isLoading && !memberships?.items?.count()}
|
|
||||||
placeholderComponent={PlaceholderAccount}
|
|
||||||
placeholderCount={3}
|
|
||||||
itemClassName='pb-4 last:pb-0'
|
|
||||||
>
|
|
||||||
{memberships?.items?.map(accountId => (
|
|
||||||
<GroupMember
|
|
||||||
key={accountId}
|
|
||||||
accountId={accountId}
|
|
||||||
accountRole={role}
|
|
||||||
groupId={groupId}
|
|
||||||
relationship={relationship}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ScrollableList>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(fetchGroup(groupId));
|
|
||||||
|
|
||||||
dispatch(fetchGroupMemberships(groupId, 'admin'));
|
|
||||||
dispatch(fetchGroupMemberships(groupId, 'moderator'));
|
|
||||||
dispatch(fetchGroupMemberships(groupId, 'user'));
|
|
||||||
}, [groupId]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{renderMemberships(admins, 'admin', handleLoadMoreAdmins)}
|
<ScrollableList
|
||||||
{renderMemberships(moderators, 'moderator', handleLoadMoreModerators)}
|
scrollKey='group-members'
|
||||||
{renderMemberships(users, 'user', handleLoadMoreUsers)}
|
hasMore={hasNextPage}
|
||||||
|
onLoadMore={fetchNextPage}
|
||||||
|
isLoading={isLoading || !group}
|
||||||
|
showLoading={!group || isLoading && members.length === 0}
|
||||||
|
placeholderComponent={PlaceholderAccount}
|
||||||
|
placeholderCount={3}
|
||||||
|
className='divide-y divide-solid divide-gray-300'
|
||||||
|
itemClassName='py-3 last:pb-0'
|
||||||
|
>
|
||||||
|
{members.map((member) => (
|
||||||
|
<GroupMemberListItem
|
||||||
|
group={group as Group}
|
||||||
|
member={member}
|
||||||
|
key={member?.account}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollableList>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -5,9 +5,9 @@ import { Link } from 'react-router-dom';
|
||||||
import { groupCompose } from 'soapbox/actions/compose';
|
import { groupCompose } from 'soapbox/actions/compose';
|
||||||
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 { Avatar, HStack, Stack } from 'soapbox/components/ui';
|
import { Avatar, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||||
import ComposeForm from 'soapbox/features/compose/components/compose-form';
|
import ComposeForm from 'soapbox/features/compose/components/compose-form';
|
||||||
import { useAppDispatch, useAppSelector, useOwnAccount } from 'soapbox/hooks';
|
import { useAppDispatch, useGroup, useOwnAccount } from 'soapbox/hooks';
|
||||||
|
|
||||||
import Timeline from '../ui/components/timeline';
|
import Timeline from '../ui/components/timeline';
|
||||||
|
|
||||||
|
@ -23,7 +23,9 @@ const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
|
||||||
|
|
||||||
const groupId = props.params.id;
|
const groupId = props.params.id;
|
||||||
|
|
||||||
const relationship = useAppSelector((state) => state.group_relationships.get(groupId));
|
const { group } = useGroup(groupId);
|
||||||
|
|
||||||
|
const canComposeGroupStatus = !!account && group?.relationship?.member;
|
||||||
|
|
||||||
const handleLoadMore = (maxId: string) => {
|
const handleLoadMore = (maxId: string) => {
|
||||||
dispatch(expandGroupTimeline(groupId, { maxId }));
|
dispatch(expandGroupTimeline(groupId, { maxId }));
|
||||||
|
@ -41,9 +43,13 @@ const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
|
||||||
};
|
};
|
||||||
}, [groupId]);
|
}, [groupId]);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack space={2}>
|
<Stack space={2}>
|
||||||
{!!account && relationship?.member && (
|
{canComposeGroupStatus && (
|
||||||
<div className='border-b border-solid border-gray-200 px-2 py-4 dark:border-gray-800'>
|
<div className='border-b border-solid border-gray-200 px-2 py-4 dark:border-gray-800'>
|
||||||
<HStack alignItems='start' space={4}>
|
<HStack alignItems='start' space={4}>
|
||||||
<Link to={`/@${account.acct}`}>
|
<Link to={`/@${account.acct}`}>
|
||||||
|
@ -59,11 +65,26 @@ const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
|
||||||
</HStack>
|
</HStack>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Timeline
|
<Timeline
|
||||||
scrollKey='group_timeline'
|
scrollKey='group_timeline'
|
||||||
timelineId={`group:${groupId}`}
|
timelineId={`group:${groupId}`}
|
||||||
onLoadMore={handleLoadMore}
|
onLoadMore={handleLoadMore}
|
||||||
emptyMessage={<FormattedMessage id='empty_column.group' defaultMessage='There are no posts in this group yet.' />}
|
emptyMessage={
|
||||||
|
<Stack space={4} className='py-6' justifyContent='center' alignItems='center'>
|
||||||
|
<div className='rounded-full bg-gray-200 p-4'>
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/message-2.svg')}
|
||||||
|
className='h-6 w-6 text-gray-600'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Text theme='muted'>
|
||||||
|
<FormattedMessage id='empty_column.group' defaultMessage='There are no posts in this group yet.' />
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
emptyMessageCard={false}
|
||||||
divideType='border'
|
divideType='border'
|
||||||
showGroup={false}
|
showGroup={false}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { Carousel, Stack, Text } from 'soapbox/components/ui';
|
import Link from 'soapbox/components/link';
|
||||||
|
import { Carousel, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||||
import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover';
|
import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover';
|
||||||
import { usePopularGroups } from 'soapbox/queries/groups';
|
import { usePopularGroups } from 'soapbox/hooks/api/usePopularGroups';
|
||||||
|
|
||||||
import GroupGridItem from './group-grid-item';
|
import GroupGridItem from './group-grid-item';
|
||||||
|
|
||||||
|
@ -15,12 +16,23 @@ const PopularGroups = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack space={4}>
|
<Stack space={4}>
|
||||||
<Text size='xl' weight='bold'>
|
<HStack alignItems='center' justifyContent='between'>
|
||||||
<FormattedMessage
|
<Text size='xl' weight='bold'>
|
||||||
id='groups.discover.popular.title'
|
<FormattedMessage
|
||||||
defaultMessage='Popular Groups'
|
id='groups.discover.popular.title'
|
||||||
/>
|
defaultMessage='Popular Groups'
|
||||||
</Text>
|
/>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Link to='/groups/popular'>
|
||||||
|
<Text tag='span' weight='medium' size='sm' theme='inherit'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='groups.discover.popular.show_more'
|
||||||
|
defaultMessage='Show More'
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
<Text theme='muted'>
|
<Text theme='muted'>
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { Carousel, Stack, Text } from 'soapbox/components/ui';
|
import Link from 'soapbox/components/link';
|
||||||
|
import { Carousel, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||||
import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover';
|
import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover';
|
||||||
import { useSuggestedGroups } from 'soapbox/queries/groups';
|
import { useSuggestedGroups } from 'soapbox/hooks/api/useSuggestedGroups';
|
||||||
|
|
||||||
import GroupGridItem from './group-grid-item';
|
import GroupGridItem from './group-grid-item';
|
||||||
|
|
||||||
|
@ -15,12 +16,23 @@ const SuggestedGroups = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack space={4}>
|
<Stack space={4}>
|
||||||
<Text size='xl' weight='bold'>
|
<HStack alignItems='center' justifyContent='between'>
|
||||||
<FormattedMessage
|
<Text size='xl' weight='bold'>
|
||||||
id='groups.discover.suggested.title'
|
<FormattedMessage
|
||||||
defaultMessage='Suggested For You'
|
id='groups.discover.suggested.title'
|
||||||
/>
|
defaultMessage='Suggested For You'
|
||||||
</Text>
|
/>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Link to='/groups/suggested'>
|
||||||
|
<Text tag='span' weight='medium' size='sm' theme='inherit'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='groups.discover.suggested.show_more'
|
||||||
|
defaultMessage='Show More'
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
<Text theme='muted'>
|
<Text theme='muted'>
|
||||||
|
|
|
@ -60,9 +60,13 @@ const Groups: React.FC = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack space={4}>
|
<Stack space={4}>
|
||||||
|
{features.groupsDiscovery && (
|
||||||
|
<TabBar activeTab={TabItems.MY_GROUPS} />
|
||||||
|
)}
|
||||||
|
|
||||||
{canCreateGroup && (
|
{canCreateGroup && (
|
||||||
<Button
|
<Button
|
||||||
className='sm:w-fit sm:self-end xl:hidden'
|
className='xl:hidden'
|
||||||
icon={require('@tabler/icons/circles.svg')}
|
icon={require('@tabler/icons/circles.svg')}
|
||||||
onClick={createGroup}
|
onClick={createGroup}
|
||||||
theme='secondary'
|
theme='secondary'
|
||||||
|
@ -72,10 +76,6 @@ const Groups: React.FC = () => {
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{features.groupsDiscovery && (
|
|
||||||
<TabBar activeTab={TabItems.MY_GROUPS} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PendingGroupsRow />
|
<PendingGroupsRow />
|
||||||
|
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
|
|
|
@ -0,0 +1,114 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso';
|
||||||
|
|
||||||
|
import { Column, HStack, Icon } from 'soapbox/components/ui';
|
||||||
|
import { usePopularGroups } from 'soapbox/hooks/api/usePopularGroups';
|
||||||
|
|
||||||
|
import GroupGridItem from './components/discover/group-grid-item';
|
||||||
|
import GroupListItem from './components/discover/group-list-item';
|
||||||
|
|
||||||
|
import type { Group } from 'soapbox/schemas';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
label: { id: 'groups.popular.label', defaultMessage: 'Popular Groups' },
|
||||||
|
});
|
||||||
|
|
||||||
|
enum Layout {
|
||||||
|
LIST = 'LIST',
|
||||||
|
GRID = 'GRID'
|
||||||
|
}
|
||||||
|
|
||||||
|
const GridList: Components['List'] = React.forwardRef((props, ref) => {
|
||||||
|
const { context, ...rest } = props;
|
||||||
|
return <div ref={ref} {...rest} className='flex flex-wrap' />;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const Popular: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const [layout, setLayout] = useState<Layout>(Layout.LIST);
|
||||||
|
|
||||||
|
const { groups, hasNextPage, fetchNextPage } = usePopularGroups();
|
||||||
|
|
||||||
|
const handleLoadMore = () => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderGroupList = useCallback((group: Group, index: number) => (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
clsx({
|
||||||
|
'pt-4': index !== 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<GroupListItem group={group} withJoinAction />
|
||||||
|
</div>
|
||||||
|
), []);
|
||||||
|
|
||||||
|
const renderGroupGrid = useCallback((group: Group, index: number) => (
|
||||||
|
<div className='pb-4'>
|
||||||
|
<GroupGridItem group={group} />
|
||||||
|
</div>
|
||||||
|
), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column
|
||||||
|
label={intl.formatMessage(messages.label)}
|
||||||
|
action={
|
||||||
|
<HStack alignItems='center'>
|
||||||
|
<button onClick={() => setLayout(Layout.LIST)}>
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/layout-list.svg')}
|
||||||
|
className={
|
||||||
|
clsx('h-5 w-5 text-gray-600', {
|
||||||
|
'text-primary-600': layout === Layout.LIST,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => setLayout(Layout.GRID)}>
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/layout-grid.svg')}
|
||||||
|
className={
|
||||||
|
clsx('h-5 w-5 text-gray-600', {
|
||||||
|
'text-primary-600': layout === Layout.GRID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</HStack>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{layout === Layout.LIST ? (
|
||||||
|
<Virtuoso
|
||||||
|
useWindowScroll
|
||||||
|
data={groups}
|
||||||
|
itemContent={(index, group) => renderGroupList(group, index)}
|
||||||
|
endReached={handleLoadMore}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<VirtuosoGrid
|
||||||
|
useWindowScroll
|
||||||
|
data={groups}
|
||||||
|
itemContent={(index, group) => renderGroupGrid(group, index)}
|
||||||
|
components={{
|
||||||
|
Item: (props) => (
|
||||||
|
<div {...props} className='w-1/2 flex-none' />
|
||||||
|
),
|
||||||
|
List: GridList,
|
||||||
|
}}
|
||||||
|
endReached={handleLoadMore}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Popular;
|
|
@ -0,0 +1,114 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso';
|
||||||
|
|
||||||
|
import { Column, HStack, Icon } from 'soapbox/components/ui';
|
||||||
|
import { useSuggestedGroups } from 'soapbox/hooks/api/useSuggestedGroups';
|
||||||
|
|
||||||
|
import GroupGridItem from './components/discover/group-grid-item';
|
||||||
|
import GroupListItem from './components/discover/group-list-item';
|
||||||
|
|
||||||
|
import type { Group } from 'soapbox/schemas';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
label: { id: 'groups.popular.label', defaultMessage: 'Suggested Groups' },
|
||||||
|
});
|
||||||
|
|
||||||
|
enum Layout {
|
||||||
|
LIST = 'LIST',
|
||||||
|
GRID = 'GRID'
|
||||||
|
}
|
||||||
|
|
||||||
|
const GridList: Components['List'] = React.forwardRef((props, ref) => {
|
||||||
|
const { context, ...rest } = props;
|
||||||
|
return <div ref={ref} {...rest} className='flex flex-wrap' />;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const Suggested: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const [layout, setLayout] = useState<Layout>(Layout.LIST);
|
||||||
|
|
||||||
|
const { groups, hasNextPage, fetchNextPage } = useSuggestedGroups();
|
||||||
|
|
||||||
|
const handleLoadMore = () => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderGroupList = useCallback((group: Group, index: number) => (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
clsx({
|
||||||
|
'pt-4': index !== 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<GroupListItem group={group} withJoinAction />
|
||||||
|
</div>
|
||||||
|
), []);
|
||||||
|
|
||||||
|
const renderGroupGrid = useCallback((group: Group, index: number) => (
|
||||||
|
<div className='pb-4'>
|
||||||
|
<GroupGridItem group={group} />
|
||||||
|
</div>
|
||||||
|
), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column
|
||||||
|
label={intl.formatMessage(messages.label)}
|
||||||
|
action={
|
||||||
|
<HStack alignItems='center'>
|
||||||
|
<button onClick={() => setLayout(Layout.LIST)}>
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/layout-list.svg')}
|
||||||
|
className={
|
||||||
|
clsx('h-5 w-5 text-gray-600', {
|
||||||
|
'text-primary-600': layout === Layout.LIST,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => setLayout(Layout.GRID)}>
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/layout-grid.svg')}
|
||||||
|
className={
|
||||||
|
clsx('h-5 w-5 text-gray-600', {
|
||||||
|
'text-primary-600': layout === Layout.GRID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</HStack>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{layout === Layout.LIST ? (
|
||||||
|
<Virtuoso
|
||||||
|
useWindowScroll
|
||||||
|
data={groups}
|
||||||
|
itemContent={(index, group) => renderGroupList(group, index)}
|
||||||
|
endReached={handleLoadMore}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<VirtuosoGrid
|
||||||
|
useWindowScroll
|
||||||
|
data={groups}
|
||||||
|
itemContent={(index, group) => renderGroupGrid(group, index)}
|
||||||
|
components={{
|
||||||
|
Item: (props) => (
|
||||||
|
<div {...props} className='w-1/2 flex-none' />
|
||||||
|
),
|
||||||
|
List: GridList,
|
||||||
|
}}
|
||||||
|
endReached={handleLoadMore}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Suggested;
|
|
@ -3,7 +3,7 @@ import React from 'react';
|
||||||
import { Widget } from 'soapbox/components/ui';
|
import { Widget } from 'soapbox/components/ui';
|
||||||
import GroupListItem from 'soapbox/features/groups/components/discover/group-list-item';
|
import GroupListItem from 'soapbox/features/groups/components/discover/group-list-item';
|
||||||
import PlaceholderGroupSearch from 'soapbox/features/placeholder/components/placeholder-group-search';
|
import PlaceholderGroupSearch from 'soapbox/features/placeholder/components/placeholder-group-search';
|
||||||
import { useSuggestedGroups } from 'soapbox/queries/groups';
|
import { useSuggestedGroups } from 'soapbox/hooks/api/useSuggestedGroups';
|
||||||
|
|
||||||
const SuggestedGroupsPanel = () => {
|
const SuggestedGroupsPanel = () => {
|
||||||
const { groups, isFetching, isFetched, isError } = useSuggestedGroups();
|
const { groups, isFetching, isFetched, isError } = useSuggestedGroups();
|
||||||
|
|
|
@ -118,6 +118,8 @@ import {
|
||||||
Events,
|
Events,
|
||||||
Groups,
|
Groups,
|
||||||
GroupsDiscover,
|
GroupsDiscover,
|
||||||
|
GroupsPopular,
|
||||||
|
GroupsSuggested,
|
||||||
PendingGroupRequests,
|
PendingGroupRequests,
|
||||||
GroupMembers,
|
GroupMembers,
|
||||||
GroupTimeline,
|
GroupTimeline,
|
||||||
|
@ -289,6 +291,8 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
|
||||||
|
|
||||||
{features.groups && <WrappedRoute path='/groups' exact page={GroupsPage} component={Groups} content={children} />}
|
{features.groups && <WrappedRoute path='/groups' exact page={GroupsPage} component={Groups} content={children} />}
|
||||||
{features.groupsDiscovery && <WrappedRoute path='/groups/discover' exact page={GroupsPage} component={GroupsDiscover} content={children} />}
|
{features.groupsDiscovery && <WrappedRoute path='/groups/discover' exact page={GroupsPage} component={GroupsDiscover} content={children} />}
|
||||||
|
{features.groupsDiscovery && <WrappedRoute path='/groups/popular' exact page={GroupsPendingPage} component={GroupsPopular} content={children} />}
|
||||||
|
{features.groupsDiscovery && <WrappedRoute path='/groups/suggested' exact page={GroupsPendingPage} component={GroupsSuggested} content={children} />}
|
||||||
{features.groupsPending && <WrappedRoute path='/groups/pending-requests' exact page={GroupsPendingPage} component={PendingGroupRequests} content={children} />}
|
{features.groupsPending && <WrappedRoute path='/groups/pending-requests' exact page={GroupsPendingPage} component={PendingGroupRequests} content={children} />}
|
||||||
{features.groups && <WrappedRoute path='/groups/:id' exact page={GroupPage} component={GroupTimeline} content={children} />}
|
{features.groups && <WrappedRoute path='/groups/:id' exact page={GroupPage} component={GroupTimeline} content={children} />}
|
||||||
{features.groups && <WrappedRoute path='/groups/:id/members' exact page={GroupPage} component={GroupMembers} content={children} />}
|
{features.groups && <WrappedRoute path='/groups/:id/members' exact page={GroupPage} component={GroupMembers} content={children} />}
|
||||||
|
|
|
@ -550,6 +550,14 @@ export function GroupsDiscover() {
|
||||||
return import(/* webpackChunkName: "features/groups/discover" */'../../groups/discover');
|
return import(/* webpackChunkName: "features/groups/discover" */'../../groups/discover');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GroupsPopular() {
|
||||||
|
return import(/* webpackChunkName: "features/groups/discover" */'../../groups/popular');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupsSuggested() {
|
||||||
|
return import(/* webpackChunkName: "features/groups/discover" */'../../groups/suggested');
|
||||||
|
}
|
||||||
|
|
||||||
export function PendingGroupRequests() {
|
export function PendingGroupRequests() {
|
||||||
return import(/* webpackChunkName: "features/groups/discover" */'../../groups/pending-requests');
|
return import(/* webpackChunkName: "features/groups/discover" */'../../groups/pending-requests');
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { Entities } from 'soapbox/entity-store/entities';
|
||||||
|
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||||
|
import { GroupMember, groupMemberSchema } from 'soapbox/schemas';
|
||||||
|
|
||||||
|
function useGroupMembers(groupId: string, role: string) {
|
||||||
|
const { entities, ...result } = useEntities<GroupMember>(
|
||||||
|
[Entities.GROUP_MEMBERSHIPS, groupId, role],
|
||||||
|
`/api/v1/groups/${groupId}/memberships?role=${role}`,
|
||||||
|
{ schema: groupMemberSchema },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
groupMembers: entities,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useGroupMembers };
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { Entities } from 'soapbox/entity-store/entities';
|
||||||
|
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||||
|
import { Group, groupSchema } from 'soapbox/schemas';
|
||||||
|
|
||||||
|
import { useFeatures } from '../useFeatures';
|
||||||
|
import { useGroupRelationships } from '../useGroups';
|
||||||
|
|
||||||
|
function usePopularGroups() {
|
||||||
|
const features = useFeatures();
|
||||||
|
|
||||||
|
const { entities, ...result } = useEntities<Group>(
|
||||||
|
[Entities.POPULAR_GROUPS, ''],
|
||||||
|
'/api/mock/groups', // '/api/v1/truth/trends/groups'
|
||||||
|
{
|
||||||
|
schema: groupSchema,
|
||||||
|
enabled: features.groupsDiscovery,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
|
||||||
|
|
||||||
|
const groups = entities.map((group) => ({
|
||||||
|
...group,
|
||||||
|
relationship: relationships[group.id] || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
groups,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { usePopularGroups };
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { Entities } from 'soapbox/entity-store/entities';
|
||||||
|
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||||
|
import { Group, groupSchema } from 'soapbox/schemas';
|
||||||
|
|
||||||
|
import { useFeatures } from '../useFeatures';
|
||||||
|
import { useGroupRelationships } from '../useGroups';
|
||||||
|
|
||||||
|
function useSuggestedGroups() {
|
||||||
|
const features = useFeatures();
|
||||||
|
|
||||||
|
const { entities, ...result } = useEntities<Group>(
|
||||||
|
[Entities.SUGGESTED_GROUPS, ''],
|
||||||
|
'/api/mock/groups', // '/api/v1/truth/suggestions/groups'
|
||||||
|
{
|
||||||
|
schema: groupSchema,
|
||||||
|
enabled: features.groupsDiscovery,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
|
||||||
|
|
||||||
|
const groups = entities.map((group) => ({
|
||||||
|
...group,
|
||||||
|
relationship: relationships[group.id] || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
groups,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useSuggestedGroups };
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { parseVersion } from 'soapbox/utils/features';
|
||||||
|
|
||||||
|
import { useInstance } from './useInstance';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Backend version.
|
||||||
|
*
|
||||||
|
* @returns Backend
|
||||||
|
*/
|
||||||
|
const useBackend = () => {
|
||||||
|
const instance = useInstance();
|
||||||
|
|
||||||
|
return parseVersion(instance.version);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { useBackend };
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { TRUTHSOCIAL } from 'soapbox/utils/features';
|
||||||
|
|
||||||
|
import { useBackend } from './useBackend';
|
||||||
|
|
||||||
|
enum TruthSocialGroupRoles {
|
||||||
|
ADMIN = 'owner',
|
||||||
|
MODERATOR = 'admin',
|
||||||
|
USER = 'user'
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BaseGroupRoles {
|
||||||
|
ADMIN = 'admin',
|
||||||
|
MODERATOR = 'moderator',
|
||||||
|
USER = 'user'
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleMap = {
|
||||||
|
[TruthSocialGroupRoles.ADMIN]: BaseGroupRoles.ADMIN,
|
||||||
|
[TruthSocialGroupRoles.MODERATOR]: BaseGroupRoles.MODERATOR,
|
||||||
|
[TruthSocialGroupRoles.USER]: BaseGroupRoles.USER,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the correct role name depending on the used backend.
|
||||||
|
*
|
||||||
|
* @returns Object
|
||||||
|
*/
|
||||||
|
const useGroupRoles = () => {
|
||||||
|
const version = useBackend();
|
||||||
|
const isTruthSocial = version.software === TRUTHSOCIAL;
|
||||||
|
const selectedRoles = isTruthSocial ? TruthSocialGroupRoles : BaseGroupRoles;
|
||||||
|
|
||||||
|
const normalizeRole = (role: TruthSocialGroupRoles) => {
|
||||||
|
if (isTruthSocial) {
|
||||||
|
return roleMap[role];
|
||||||
|
}
|
||||||
|
|
||||||
|
return role;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
normalizeRole,
|
||||||
|
roles: {
|
||||||
|
admin: selectedRoles.ADMIN,
|
||||||
|
moderator: selectedRoles.MODERATOR,
|
||||||
|
user: selectedRoles.USER,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export { useGroupRoles, TruthSocialGroupRoles, BaseGroupRoles };
|
|
@ -1,12 +1,20 @@
|
||||||
|
import { Entities } from 'soapbox/entity-store/entities';
|
||||||
import { useEntities, useEntity } from 'soapbox/entity-store/hooks';
|
import { useEntities, useEntity } from 'soapbox/entity-store/hooks';
|
||||||
import { groupSchema, Group } from 'soapbox/schemas/group';
|
import { groupSchema, Group } from 'soapbox/schemas/group';
|
||||||
import { groupRelationshipSchema, GroupRelationship } from 'soapbox/schemas/group-relationship';
|
import { groupRelationshipSchema, GroupRelationship } from 'soapbox/schemas/group-relationship';
|
||||||
|
|
||||||
function useGroups() {
|
function useGroups() {
|
||||||
const { entities, ...result } = useEntities<Group>(['Group', ''], '/api/v1/groups', { schema: groupSchema });
|
const { entities, ...result } = useEntities<Group>(
|
||||||
|
[Entities.GROUPS, ''],
|
||||||
|
'/api/v1/groups',
|
||||||
|
{ schema: groupSchema },
|
||||||
|
);
|
||||||
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
|
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
|
||||||
|
|
||||||
const groups = entities.map((group) => ({ ...group, relationship: relationships[group.id] || null }));
|
const groups = entities.map((group) => ({
|
||||||
|
...group,
|
||||||
|
relationship: relationships[group.id] || null,
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
|
@ -15,7 +23,11 @@ function useGroups() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function useGroup(groupId: string, refetch = true) {
|
function useGroup(groupId: string, refetch = true) {
|
||||||
const { entity: group, ...result } = useEntity<Group>(['Group', groupId], `/api/v1/groups/${groupId}`, { schema: groupSchema, refetch });
|
const { entity: group, ...result } = useEntity<Group>(
|
||||||
|
[Entities.GROUPS, groupId],
|
||||||
|
`/api/v1/groups/${groupId}`,
|
||||||
|
{ schema: groupSchema, refetch },
|
||||||
|
);
|
||||||
const { entity: relationship } = useGroupRelationship(groupId);
|
const { entity: relationship } = useGroupRelationship(groupId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -25,13 +37,21 @@ function useGroup(groupId: string, refetch = true) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function useGroupRelationship(groupId: string) {
|
function useGroupRelationship(groupId: string) {
|
||||||
return useEntity<GroupRelationship>(['GroupRelationship', groupId], `/api/v1/groups/relationships?id[]=${groupId}`, { schema: groupRelationshipSchema });
|
return useEntity<GroupRelationship>(
|
||||||
|
[Entities.GROUP_RELATIONSHIPS, groupId],
|
||||||
|
`/api/v1/groups/relationships?id[]=${groupId}`,
|
||||||
|
{ schema: groupRelationshipSchema },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useGroupRelationships(groupIds: string[]) {
|
function useGroupRelationships(groupIds: string[]) {
|
||||||
const q = groupIds.map(id => `id[]=${id}`).join('&');
|
const q = groupIds.map(id => `id[]=${id}`).join('&');
|
||||||
const endpoint = groupIds.length ? `/api/v1/groups/relationships?${q}` : undefined;
|
const endpoint = groupIds.length ? `/api/v1/groups/relationships?${q}` : undefined;
|
||||||
const { entities, ...result } = useEntities<GroupRelationship>(['GroupRelationship', q], endpoint, { schema: groupRelationshipSchema });
|
const { entities, ...result } = useEntities<GroupRelationship>(
|
||||||
|
[Entities.GROUP_RELATIONSHIPS, q],
|
||||||
|
endpoint,
|
||||||
|
{ schema: groupRelationshipSchema },
|
||||||
|
);
|
||||||
|
|
||||||
const relationships = entities.reduce<Record<string, GroupRelationship>>((map, relationship) => {
|
const relationships = entities.reduce<Record<string, GroupRelationship>>((map, relationship) => {
|
||||||
map[relationship.id] = relationship;
|
map[relationship.id] = relationship;
|
||||||
|
@ -44,4 +64,4 @@ function useGroupRelationships(groupIds: string[]) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useGroup, useGroups };
|
export { useGroup, useGroups, useGroupRelationships };
|
||||||
|
|
|
@ -767,7 +767,6 @@
|
||||||
"gdpr.message": "{siteTitle} uses session cookies, which are essential to the website's functioning.",
|
"gdpr.message": "{siteTitle} uses session cookies, which are essential to the website's functioning.",
|
||||||
"gdpr.title": "{siteTitle} uses cookies",
|
"gdpr.title": "{siteTitle} uses cookies",
|
||||||
"getting_started.open_source_notice": "{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).",
|
"getting_started.open_source_notice": "{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).",
|
||||||
"group.admin_subheading": "Group administrators",
|
|
||||||
"group.cancel_request": "Cancel Request",
|
"group.cancel_request": "Cancel Request",
|
||||||
"group.group_mod_authorize": "Accept",
|
"group.group_mod_authorize": "Accept",
|
||||||
"group.group_mod_authorize.success": "Accepted @{name} to group",
|
"group.group_mod_authorize.success": "Accepted @{name} to group",
|
||||||
|
@ -793,7 +792,6 @@
|
||||||
"group.leave": "Leave Group",
|
"group.leave": "Leave Group",
|
||||||
"group.leave.success": "Left the group",
|
"group.leave.success": "Left the group",
|
||||||
"group.manage": "Manage Group",
|
"group.manage": "Manage Group",
|
||||||
"group.moderator_subheading": "Group moderators",
|
|
||||||
"group.privacy.locked": "Private",
|
"group.privacy.locked": "Private",
|
||||||
"group.privacy.public": "Public",
|
"group.privacy.public": "Public",
|
||||||
"group.role.admin": "Admin",
|
"group.role.admin": "Admin",
|
||||||
|
@ -801,8 +799,8 @@
|
||||||
"group.tabs.all": "All",
|
"group.tabs.all": "All",
|
||||||
"group.tabs.members": "Members",
|
"group.tabs.members": "Members",
|
||||||
"group.upload_banner": "Upload photo",
|
"group.upload_banner": "Upload photo",
|
||||||
"group.user_subheading": "Users",
|
|
||||||
"groups.discover.popular.empty": "Unable to fetch popular groups at this time. Please check back later.",
|
"groups.discover.popular.empty": "Unable to fetch popular groups at this time. Please check back later.",
|
||||||
|
"groups.discover.popular.show_more": "Show More",
|
||||||
"groups.discover.popular.title": "Popular Groups",
|
"groups.discover.popular.title": "Popular Groups",
|
||||||
"groups.discover.search.error.subtitle": "Please try again later.",
|
"groups.discover.search.error.subtitle": "Please try again later.",
|
||||||
"groups.discover.search.error.title": "An error occurred",
|
"groups.discover.search.error.title": "An error occurred",
|
||||||
|
@ -816,6 +814,7 @@
|
||||||
"groups.discover.search.results.groups": "Groups",
|
"groups.discover.search.results.groups": "Groups",
|
||||||
"groups.discover.search.results.member_count": "{members, plural, one {member} other {members}}",
|
"groups.discover.search.results.member_count": "{members, plural, one {member} other {members}}",
|
||||||
"groups.discover.suggested.empty": "Unable to fetch suggested groups at this time. Please check back later.",
|
"groups.discover.suggested.empty": "Unable to fetch suggested groups at this time. Please check back later.",
|
||||||
|
"groups.discover.suggested.show_more": "Show More",
|
||||||
"groups.discover.suggested.title": "Suggested For You",
|
"groups.discover.suggested.title": "Suggested For You",
|
||||||
"groups.empty.subtitle": "Start discovering groups to join or create your own.",
|
"groups.empty.subtitle": "Start discovering groups to join or create your own.",
|
||||||
"groups.empty.title": "No Groups yet",
|
"groups.empty.title": "No Groups yet",
|
||||||
|
@ -823,6 +822,7 @@
|
||||||
"groups.pending.empty.subtitle": "You have no pending requests at this time.",
|
"groups.pending.empty.subtitle": "You have no pending requests at this time.",
|
||||||
"groups.pending.empty.title": "No pending requests",
|
"groups.pending.empty.title": "No pending requests",
|
||||||
"groups.pending.label": "Pending Requests",
|
"groups.pending.label": "Pending Requests",
|
||||||
|
"groups.popular.label": "Suggested Groups",
|
||||||
"hashtag.column_header.tag_mode.all": "and {additional}",
|
"hashtag.column_header.tag_mode.all": "and {additional}",
|
||||||
"hashtag.column_header.tag_mode.any": "or {additional}",
|
"hashtag.column_header.tag_mode.any": "or {additional}",
|
||||||
"hashtag.column_header.tag_mode.none": "without {additional}",
|
"hashtag.column_header.tag_mode.none": "without {additional}",
|
||||||
|
|
|
@ -149,48 +149,6 @@ const usePendingGroups = () => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const usePopularGroups = () => {
|
|
||||||
const features = useFeatures();
|
|
||||||
const { fetchGroups } = useGroupsApi();
|
|
||||||
|
|
||||||
const getQuery = async () => {
|
|
||||||
const { groups } = await fetchGroups('/api/v1/truth/trends/groups');
|
|
||||||
|
|
||||||
return groups;
|
|
||||||
};
|
|
||||||
|
|
||||||
const queryInfo = useQuery<Group[]>(GroupKeys.popularGroups, getQuery, {
|
|
||||||
enabled: features.groupsDiscovery,
|
|
||||||
placeholderData: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
groups: queryInfo.data || [],
|
|
||||||
...queryInfo,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const useSuggestedGroups = () => {
|
|
||||||
const features = useFeatures();
|
|
||||||
const { fetchGroups } = useGroupsApi();
|
|
||||||
|
|
||||||
const getQuery = async () => {
|
|
||||||
const { groups } = await fetchGroups('/api/v1/truth/suggestions/groups');
|
|
||||||
|
|
||||||
return groups;
|
|
||||||
};
|
|
||||||
|
|
||||||
const queryInfo = useQuery<Group[]>(GroupKeys.suggestedGroups, getQuery, {
|
|
||||||
enabled: features.groupsDiscovery,
|
|
||||||
placeholderData: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
groups: queryInfo.data || [],
|
|
||||||
...queryInfo,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const useGroup = (id: string) => {
|
const useGroup = (id: string) => {
|
||||||
const features = useFeatures();
|
const features = useFeatures();
|
||||||
const { fetchGroups } = useGroupsApi();
|
const { fetchGroups } = useGroupsApi();
|
||||||
|
@ -200,7 +158,7 @@ const useGroup = (id: string) => {
|
||||||
return groups[0];
|
return groups[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryInfo = useQuery(GroupKeys.group(id), getGroup, {
|
const queryInfo = useQuery<Group>(GroupKeys.group(id), getGroup, {
|
||||||
enabled: features.groups && !!id,
|
enabled: features.groups && !!id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -256,6 +214,4 @@ export {
|
||||||
useJoinGroup,
|
useJoinGroup,
|
||||||
useLeaveGroup,
|
useLeaveGroup,
|
||||||
usePendingGroups,
|
usePendingGroups,
|
||||||
usePopularGroups,
|
|
||||||
useSuggestedGroups,
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useApi } from 'soapbox/hooks';
|
||||||
|
import { useGroupRoles } from 'soapbox/hooks/useGroupRoles';
|
||||||
|
import { normalizeAccount } from 'soapbox/normalizers';
|
||||||
|
|
||||||
|
const GroupMemberKeys = {
|
||||||
|
members: (id: string, role: string) => ['group', id, role] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const useGroupMembers = (groupId: string, role: ReturnType<typeof useGroupRoles>['roles']['admin']) => {
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
|
const getQuery = async () => {
|
||||||
|
const { data } = await api.get(`/api/v1/groups/${groupId}/memberships`, {
|
||||||
|
params: {
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = data.map((member: any) => {
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
account: normalizeAccount(member.account),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
return useQuery(
|
||||||
|
GroupMemberKeys.members(groupId, role),
|
||||||
|
getQuery,
|
||||||
|
{
|
||||||
|
placeholderData: [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { useGroupMembers };
|
|
@ -0,0 +1,26 @@
|
||||||
|
import z from 'zod';
|
||||||
|
|
||||||
|
enum TruthSocialGroupRoles {
|
||||||
|
ADMIN = 'owner',
|
||||||
|
MODERATOR = 'admin',
|
||||||
|
USER = 'user'
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BaseGroupRoles {
|
||||||
|
ADMIN = 'admin',
|
||||||
|
MODERATOR = 'moderator',
|
||||||
|
USER = 'user'
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupMemberSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
account: z.any(),
|
||||||
|
role: z.union([
|
||||||
|
z.nativeEnum(TruthSocialGroupRoles),
|
||||||
|
z.nativeEnum(BaseGroupRoles),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
type GroupMember = z.infer<typeof groupMemberSchema>;
|
||||||
|
|
||||||
|
export { groupMemberSchema, GroupMember };
|
|
@ -2,5 +2,7 @@ export { customEmojiSchema } from './custom-emoji';
|
||||||
export type { CustomEmoji } from './custom-emoji';
|
export type { CustomEmoji } from './custom-emoji';
|
||||||
export { groupSchema } from './group';
|
export { groupSchema } from './group';
|
||||||
export type { Group } from './group';
|
export type { Group } from './group';
|
||||||
|
export { groupMemberSchema } from './group-member';
|
||||||
|
export type { GroupMember } from './group-member';
|
||||||
export { groupRelationshipSchema } from './group-relationship';
|
export { groupRelationshipSchema } from './group-relationship';
|
||||||
export type { GroupRelationship } from './group-relationship';
|
export type { GroupRelationship } from './group-relationship';
|
||||||
|
|
|
@ -111,5 +111,6 @@ export {
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
Group,
|
Group,
|
||||||
|
GroupMember,
|
||||||
GroupRelationship,
|
GroupRelationship,
|
||||||
} from 'soapbox/schemas';
|
} from 'soapbox/schemas';
|
Ładowanie…
Reference in New Issue