Merge remote-tracking branch 'soapbox/develop' into lexical
This commit is contained in:
commit
d4cb94b1c6
|
@ -80,7 +80,7 @@ const GroupPopover = (props: IGroupPopoverContainer) => {
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<div className='px-4 pb-4'>
|
<div className='px-4 pb-4'>
|
||||||
<Link to={`/groups/${group.id}`}>
|
<Link to={`/group/${group.slug}`}>
|
||||||
<Button type='button' theme='secondary' block>
|
<Button type='button' theme='secondary' block>
|
||||||
{intl.formatMessage(messages.action)}
|
{intl.formatMessage(messages.action)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -0,0 +1,62 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import ColumnLoading from 'soapbox/features/ui/components/column-loading';
|
||||||
|
import { useGroupLookup } from 'soapbox/hooks/api/groups/useGroupLookup';
|
||||||
|
|
||||||
|
import { Layout } from '../ui';
|
||||||
|
|
||||||
|
interface IGroupLookup {
|
||||||
|
params: {
|
||||||
|
groupSlug: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IMaybeGroupLookup {
|
||||||
|
params?: {
|
||||||
|
groupSlug?: string
|
||||||
|
groupId?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupLookupHoc(Component: React.ComponentType<{ params: { groupId: string } }>) {
|
||||||
|
const GroupLookup: React.FC<IGroupLookup> = (props) => {
|
||||||
|
const { entity: group } = useGroupLookup(props.params.groupSlug);
|
||||||
|
|
||||||
|
if (!group) return (
|
||||||
|
<>
|
||||||
|
<Layout.Main>
|
||||||
|
<ColumnLoading />
|
||||||
|
</Layout.Main>
|
||||||
|
|
||||||
|
<Layout.Aside />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const newProps = {
|
||||||
|
...props,
|
||||||
|
params: {
|
||||||
|
...props.params,
|
||||||
|
id: group.id,
|
||||||
|
groupId: group.id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Component {...newProps} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MaybeGroupLookup: React.FC<IMaybeGroupLookup> = (props) => {
|
||||||
|
const { params } = props;
|
||||||
|
|
||||||
|
if (params?.groupId) {
|
||||||
|
return <Component {...props} params={{ ...params, groupId: params.groupId }} />;
|
||||||
|
} else {
|
||||||
|
return <GroupLookup {...props} params={{ ...params, groupSlug: params?.groupSlug || '' }} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return MaybeGroupLookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GroupLookupHoc;
|
|
@ -0,0 +1,11 @@
|
||||||
|
type HOC<P, R> = (Component: React.ComponentType<P>) => React.ComponentType<R>
|
||||||
|
type AsyncComponent<P> = () => Promise<{ default: React.ComponentType<P> }>
|
||||||
|
|
||||||
|
const withHoc = <P, R>(asyncComponent: AsyncComponent<P>, hoc: HOC<P, R>) => {
|
||||||
|
return async () => {
|
||||||
|
const { default: component } = await asyncComponent();
|
||||||
|
return { default: hoc(component) };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withHoc;
|
|
@ -106,7 +106,7 @@ export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }
|
||||||
onMouseEnter={handleMouseEnter(dispatch)}
|
onMouseEnter={handleMouseEnter(dispatch)}
|
||||||
onMouseLeave={handleMouseLeave(dispatch)}
|
onMouseLeave={handleMouseLeave(dispatch)}
|
||||||
>
|
>
|
||||||
<Card variant='rounded' className='relative isolate'>
|
<Card variant='rounded' className='relative isolate overflow-hidden'>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<Stack space={2}>
|
<Stack space={2}>
|
||||||
<BundleContainer fetchComponent={UserPanel}>
|
<BundleContainer fetchComponent={UserPanel}>
|
||||||
|
|
|
@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||||
|
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
|
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
|
||||||
|
import { GroupLinkPreview } from 'soapbox/features/groups/components/group-link-preview';
|
||||||
import PlaceholderCard from 'soapbox/features/placeholder/components/placeholder-card';
|
import PlaceholderCard from 'soapbox/features/placeholder/components/placeholder-card';
|
||||||
import Card from 'soapbox/features/status/components/card';
|
import Card from 'soapbox/features/status/components/card';
|
||||||
import Bundle from 'soapbox/features/ui/components/bundle';
|
import Bundle from 'soapbox/features/ui/components/bundle';
|
||||||
|
@ -153,6 +154,10 @@ const StatusMedia: React.FC<IStatusMedia> = ({
|
||||||
</Bundle>
|
</Bundle>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (status.spoiler_text.length === 0 && !status.quote && status.card?.group) {
|
||||||
|
media = (
|
||||||
|
<GroupLinkPreview card={status.card} />
|
||||||
|
);
|
||||||
} else if (status.spoiler_text.length === 0 && !status.quote && status.card) {
|
} else if (status.spoiler_text.length === 0 && !status.quote && status.card) {
|
||||||
media = (
|
media = (
|
||||||
<Card
|
<Card
|
||||||
|
|
|
@ -253,7 +253,7 @@ const Status: React.FC<IStatus> = (props) => {
|
||||||
return (
|
return (
|
||||||
<StatusInfo
|
<StatusInfo
|
||||||
avatarSize={avatarSize}
|
avatarSize={avatarSize}
|
||||||
to={`/groups/${group.id}`}
|
to={`/group/${group.slug}`}
|
||||||
icon={<Icon src={require('@tabler/icons/circles.svg')} className='h-4 w-4 text-primary-600 dark:text-accent-blue' />}
|
icon={<Icon src={require('@tabler/icons/circles.svg')} className='h-4 w-4 text-primary-600 dark:text-accent-blue' />}
|
||||||
text={
|
text={
|
||||||
<Text size='xs' theme='muted' weight='medium'>
|
<Text size='xs' theme='muted' weight='medium'>
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
:root {
|
|
||||||
--reach-tooltip: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-reach-tooltip] {
|
|
||||||
@apply pointer-events-none absolute px-2.5 py-1.5 rounded shadow whitespace-nowrap text-xs font-medium bg-gray-800 text-gray-100 dark:bg-gray-100 dark:text-gray-900;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-reach-tooltip-arrow] {
|
|
||||||
@apply absolute z-50 w-0 h-0 border-l-8 border-solid border-l-transparent border-r-8 border-r-transparent border-b-8 border-b-gray-800 dark:border-b-gray-100;
|
|
||||||
}
|
|
|
@ -1,66 +1,87 @@
|
||||||
import { TooltipPopup, useTooltip } from '@reach/tooltip';
|
import {
|
||||||
import React from 'react';
|
arrow,
|
||||||
|
FloatingArrow,
|
||||||
import Portal from '../portal/portal';
|
FloatingPortal,
|
||||||
|
offset,
|
||||||
import './tooltip.css';
|
useFloating,
|
||||||
|
useHover,
|
||||||
|
useInteractions,
|
||||||
|
useTransitionStyles,
|
||||||
|
} from '@floating-ui/react';
|
||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
|
||||||
interface ITooltip {
|
interface ITooltip {
|
||||||
|
/** Element to display the tooltip around. */
|
||||||
|
children: React.ReactElement<any, string | React.JSXElementConstructor<any>>
|
||||||
/** Text to display in the tooltip. */
|
/** Text to display in the tooltip. */
|
||||||
text: string
|
text: string
|
||||||
/** Element to display the tooltip around. */
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const centered = (triggerRect: any, tooltipRect: any) => {
|
/**
|
||||||
const triggerCenter = triggerRect.left + triggerRect.width / 2;
|
* Tooltip
|
||||||
const left = triggerCenter - tooltipRect.width / 2;
|
*/
|
||||||
const maxLeft = window.innerWidth - tooltipRect.width - 2;
|
const Tooltip: React.FC<ITooltip> = (props) => {
|
||||||
return {
|
const { children, text } = props;
|
||||||
left: Math.min(Math.max(2, left), maxLeft) + window.scrollX,
|
|
||||||
top: triggerRect.bottom + 8 + window.scrollY,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Hoverable tooltip element. */
|
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||||
const Tooltip: React.FC<ITooltip> = ({
|
|
||||||
children,
|
|
||||||
text,
|
|
||||||
}) => {
|
|
||||||
// get the props from useTooltip
|
|
||||||
const [trigger, tooltip] = useTooltip();
|
|
||||||
|
|
||||||
// destructure off what we need to position the triangle
|
const arrowRef = useRef<SVGSVGElement>(null);
|
||||||
const { isVisible, triggerRect } = tooltip;
|
|
||||||
|
const { x, y, strategy, refs, context } = useFloating({
|
||||||
|
open: isOpen,
|
||||||
|
onOpenChange: setIsOpen,
|
||||||
|
placement: 'top',
|
||||||
|
middleware: [
|
||||||
|
offset(6),
|
||||||
|
arrow({
|
||||||
|
element: arrowRef,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const hover = useHover(context);
|
||||||
|
const { isMounted, styles } = useTransitionStyles(context, {
|
||||||
|
initial: {
|
||||||
|
opacity: 0,
|
||||||
|
transform: 'scale(0.8)',
|
||||||
|
},
|
||||||
|
duration: {
|
||||||
|
open: 200,
|
||||||
|
close: 200,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||||
|
hover,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<>
|
||||||
{React.cloneElement(children as any, trigger)}
|
{React.cloneElement(children, {
|
||||||
|
ref: refs.setReference,
|
||||||
|
...getReferenceProps(),
|
||||||
|
})}
|
||||||
|
|
||||||
{isVisible && (
|
{(isMounted) && (
|
||||||
// The Triangle. We position it relative to the trigger, not the popup
|
<FloatingPortal>
|
||||||
// so that collisions don't have a triangle pointing off to nowhere.
|
|
||||||
// Using a Portal may seem a little extreme, but we can keep the
|
|
||||||
// positioning logic simpler here instead of needing to consider
|
|
||||||
// the popup's position relative to the trigger and collisions
|
|
||||||
<Portal>
|
|
||||||
<div
|
<div
|
||||||
data-reach-tooltip-arrow='true'
|
ref={refs.setFloating}
|
||||||
style={{
|
style={{
|
||||||
left:
|
position: strategy,
|
||||||
triggerRect && triggerRect.left - 10 + triggerRect.width / 2 as any,
|
top: y ?? 0,
|
||||||
top: triggerRect && triggerRect.bottom + window.scrollY as any,
|
left: x ?? 0,
|
||||||
|
...styles,
|
||||||
}}
|
}}
|
||||||
/>
|
className='pointer-events-none z-[100] whitespace-nowrap rounded bg-gray-800 px-2.5 py-1.5 text-xs font-medium text-gray-100 shadow dark:bg-gray-100 dark:text-gray-900'
|
||||||
</Portal>
|
{...getFloatingProps()}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
|
||||||
|
<FloatingArrow ref={arrowRef} context={context} className='fill-gray-800 dark:fill-gray-100' />
|
||||||
|
</div>
|
||||||
|
</FloatingPortal>
|
||||||
)}
|
)}
|
||||||
<TooltipPopup
|
</>
|
||||||
{...tooltip}
|
|
||||||
label={text}
|
|
||||||
aria-label={text}
|
|
||||||
position={centered}
|
|
||||||
/>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
export enum Entities {
|
export enum Entities {
|
||||||
ACCOUNTS = 'Accounts',
|
ACCOUNTS = 'Accounts',
|
||||||
GROUPS = 'Groups',
|
GROUPS = 'Groups',
|
||||||
GROUP_RELATIONSHIPS = 'GroupRelationships',
|
|
||||||
GROUP_MEMBERSHIPS = 'GroupMemberships',
|
GROUP_MEMBERSHIPS = 'GroupMemberships',
|
||||||
|
GROUP_RELATIONSHIPS = 'GroupRelationships',
|
||||||
|
GROUP_TAGS = 'GroupTags',
|
||||||
RELATIONSHIPS = 'Relationships',
|
RELATIONSHIPS = 'Relationships',
|
||||||
STATUSES = 'Statuses',
|
STATUSES = 'Statuses'
|
||||||
}
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
export { useEntities } from './useEntities';
|
export { useEntities } from './useEntities';
|
||||||
export { useEntity } from './useEntity';
|
export { useEntity } from './useEntity';
|
||||||
export { useEntityActions } from './useEntityActions';
|
export { useEntityActions } from './useEntityActions';
|
||||||
|
export { useEntityLookup } from './useEntityLookup';
|
||||||
export { useCreateEntity } from './useCreateEntity';
|
export { useCreateEntity } from './useCreateEntity';
|
||||||
export { useDeleteEntity } from './useDeleteEntity';
|
export { useDeleteEntity } from './useDeleteEntity';
|
||||||
export { useDismissEntity } from './useDismissEntity';
|
export { useDismissEntity } from './useDismissEntity';
|
||||||
|
|
|
@ -59,4 +59,5 @@ function useEntity<TEntity extends Entity>(
|
||||||
|
|
||||||
export {
|
export {
|
||||||
useEntity,
|
useEntity,
|
||||||
|
type UseEntityOpts,
|
||||||
};
|
};
|
|
@ -12,8 +12,9 @@ interface UseEntityActionsOpts<TEntity extends Entity = Entity> {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EntityActionEndpoints {
|
interface EntityActionEndpoints {
|
||||||
post?: string
|
|
||||||
delete?: string
|
delete?: string
|
||||||
|
patch?: string
|
||||||
|
post?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function useEntityActions<TEntity extends Entity = Entity, Data = any>(
|
function useEntityActions<TEntity extends Entity = Entity, Data = any>(
|
||||||
|
@ -30,10 +31,14 @@ function useEntityActions<TEntity extends Entity = Entity, Data = any>(
|
||||||
const { createEntity, isSubmitting: createSubmitting } =
|
const { createEntity, isSubmitting: createSubmitting } =
|
||||||
useCreateEntity<TEntity, Data>(path, (data) => api.post(endpoints.post!, data), opts);
|
useCreateEntity<TEntity, Data>(path, (data) => api.post(endpoints.post!, data), opts);
|
||||||
|
|
||||||
|
const { createEntity: updateEntity, isSubmitting: updateSubmitting } =
|
||||||
|
useCreateEntity<TEntity, Data>(path, (data) => api.patch(endpoints.patch!, data), opts);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createEntity,
|
createEntity,
|
||||||
deleteEntity,
|
deleteEntity,
|
||||||
isSubmitting: createSubmitting || deleteSubmitting,
|
updateEntity,
|
||||||
|
isSubmitting: createSubmitting || deleteSubmitting || updateSubmitting,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useAppDispatch, useAppSelector, useLoading } from 'soapbox/hooks';
|
||||||
|
import { type RootState } from 'soapbox/store';
|
||||||
|
|
||||||
|
import { importEntities } from '../actions';
|
||||||
|
import { Entity } from '../types';
|
||||||
|
|
||||||
|
import { EntityFn } from './types';
|
||||||
|
import { type UseEntityOpts } from './useEntity';
|
||||||
|
|
||||||
|
/** Entities will be filtered through this function until it returns true. */
|
||||||
|
type LookupFn<TEntity extends Entity> = (entity: TEntity) => boolean
|
||||||
|
|
||||||
|
function useEntityLookup<TEntity extends Entity>(
|
||||||
|
entityType: string,
|
||||||
|
lookupFn: LookupFn<TEntity>,
|
||||||
|
entityFn: EntityFn<void>,
|
||||||
|
opts: UseEntityOpts<TEntity> = {},
|
||||||
|
) {
|
||||||
|
const { schema = z.custom<TEntity>() } = opts;
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const [isFetching, setPromise] = useLoading(true);
|
||||||
|
|
||||||
|
const entity = useAppSelector(state => findEntity(state, entityType, lookupFn));
|
||||||
|
const isLoading = isFetching && !entity;
|
||||||
|
|
||||||
|
const fetchEntity = async () => {
|
||||||
|
try {
|
||||||
|
const response = await setPromise(entityFn());
|
||||||
|
const entity = schema.parse(response.data);
|
||||||
|
dispatch(importEntities([entity], entityType));
|
||||||
|
} catch (e) {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!entity || opts.refetch) {
|
||||||
|
fetchEntity();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
entity,
|
||||||
|
fetchEntity,
|
||||||
|
isFetching,
|
||||||
|
isLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findEntity<TEntity extends Entity>(
|
||||||
|
state: RootState,
|
||||||
|
entityType: string,
|
||||||
|
lookupFn: LookupFn<TEntity>,
|
||||||
|
) {
|
||||||
|
const cache = state.entities[entityType];
|
||||||
|
|
||||||
|
if (cache) {
|
||||||
|
return (Object.values(cache.store) as TEntity[]).find(lookupFn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useEntityLookup };
|
|
@ -82,7 +82,7 @@ const GroupActionButton = ({ group }: IGroupActionButton) => {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
theme='secondary'
|
theme='secondary'
|
||||||
to={`/groups/${group.id}/manage`}
|
to={`/group/${group.slug}/manage`}
|
||||||
>
|
>
|
||||||
<FormattedMessage id='group.manage' defaultMessage='Manage Group' />
|
<FormattedMessage id='group.manage' defaultMessage='Manage Group' />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -0,0 +1,175 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { HStack, IconButton, Stack, Text, Tooltip } from 'soapbox/components/ui';
|
||||||
|
import { importEntities } from 'soapbox/entity-store/actions';
|
||||||
|
import { Entities } from 'soapbox/entity-store/entities';
|
||||||
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
import { useUpdateGroupTag } from 'soapbox/hooks/api';
|
||||||
|
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||||
|
import toast from 'soapbox/toast';
|
||||||
|
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||||
|
|
||||||
|
import type { Group, GroupTag } from 'soapbox/schemas';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
hideTag: { id: 'group.tags.hide', defaultMessage: 'Hide topic' },
|
||||||
|
showTag: { id: 'group.tags.show', defaultMessage: 'Show topic' },
|
||||||
|
total: { id: 'group.tags.total', defaultMessage: 'Total Posts' },
|
||||||
|
pinTag: { id: 'group.tags.pin', defaultMessage: 'Pin topic' },
|
||||||
|
unpinTag: { id: 'group.tags.unpin', defaultMessage: 'Unpin topic' },
|
||||||
|
pinSuccess: { id: 'group.tags.pin.success', defaultMessage: 'Pinned!' },
|
||||||
|
unpinSuccess: { id: 'group.tags.unpin.success', defaultMessage: 'Unpinned!' },
|
||||||
|
visibleSuccess: { id: 'group.tags.visible.success', defaultMessage: 'Topic marked as visible' },
|
||||||
|
hiddenSuccess: { id: 'group.tags.hidden.success', defaultMessage: 'Topic marked as hidden' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IGroupMemberListItem {
|
||||||
|
tag: GroupTag
|
||||||
|
group: Group
|
||||||
|
isPinnable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupTagListItem = (props: IGroupMemberListItem) => {
|
||||||
|
const { group, tag, isPinnable } = props;
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const intl = useIntl();
|
||||||
|
const { updateGroupTag } = useUpdateGroupTag(group.id, tag.id);
|
||||||
|
|
||||||
|
const isOwner = group.relationship?.role === GroupRoles.OWNER;
|
||||||
|
const isAdmin = group.relationship?.role === GroupRoles.ADMIN;
|
||||||
|
const canEdit = isOwner || isAdmin;
|
||||||
|
|
||||||
|
const toggleVisibility = () => {
|
||||||
|
updateGroupTag({
|
||||||
|
group_tag_type: tag.visible ? 'hidden' : 'normal',
|
||||||
|
}, {
|
||||||
|
onSuccess() {
|
||||||
|
const entity = {
|
||||||
|
...tag,
|
||||||
|
visible: !tag.visible,
|
||||||
|
};
|
||||||
|
dispatch(importEntities([entity], Entities.GROUP_TAGS));
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
entity.visible ?
|
||||||
|
intl.formatMessage(messages.visibleSuccess) :
|
||||||
|
intl.formatMessage(messages.hiddenSuccess),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePin = () => {
|
||||||
|
updateGroupTag({
|
||||||
|
group_tag_type: tag.pinned ? 'normal' : 'pinned',
|
||||||
|
}, {
|
||||||
|
onSuccess() {
|
||||||
|
const entity = {
|
||||||
|
...tag,
|
||||||
|
pinned: !tag.pinned,
|
||||||
|
};
|
||||||
|
dispatch(importEntities([entity], Entities.GROUP_TAGS));
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
entity.pinned ?
|
||||||
|
intl.formatMessage(messages.pinSuccess) :
|
||||||
|
intl.formatMessage(messages.unpinSuccess),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPinIcon = () => {
|
||||||
|
if (isPinnable) {
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
text={
|
||||||
|
tag.pinned ?
|
||||||
|
intl.formatMessage(messages.unpinTag) :
|
||||||
|
intl.formatMessage(messages.pinTag)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={togglePin}
|
||||||
|
theme='transparent'
|
||||||
|
src={
|
||||||
|
tag.pinned ?
|
||||||
|
require('@tabler/icons/pin-filled.svg') :
|
||||||
|
require('@tabler/icons/pin.svg')
|
||||||
|
}
|
||||||
|
iconClassName='h-5 w-5 text-primary-500 dark:text-accent-blue'
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPinnable && tag.pinned) {
|
||||||
|
return (
|
||||||
|
<Tooltip text={intl.formatMessage(messages.unpinTag)}>
|
||||||
|
<IconButton
|
||||||
|
onClick={togglePin}
|
||||||
|
theme='transparent'
|
||||||
|
src={require('@tabler/icons/pin-filled.svg')}
|
||||||
|
iconClassName='h-5 w-5 text-primary-500 dark:text-accent-blue'
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack alignItems='center' justifyContent='between'>
|
||||||
|
<Link to={`/groups/${group.id}/tag/${tag.id}`} className='group grow'>
|
||||||
|
<Stack>
|
||||||
|
<Text
|
||||||
|
weight='bold'
|
||||||
|
theme={(tag.visible || !canEdit) ? 'default' : 'subtle'}
|
||||||
|
className='group-hover:underline'
|
||||||
|
>
|
||||||
|
#{tag.name}
|
||||||
|
</Text>
|
||||||
|
<Text size='sm' theme={(tag.visible || !canEdit) ? 'muted' : 'subtle'}>
|
||||||
|
{intl.formatMessage(messages.total)}:
|
||||||
|
{' '}
|
||||||
|
<Text size='sm' theme='inherit' weight='semibold' tag='span'>
|
||||||
|
{shortNumberFormat(tag.uses)}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{canEdit ? (
|
||||||
|
<HStack alignItems='center' space={2}>
|
||||||
|
{tag.visible ? (
|
||||||
|
renderPinIcon()
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
text={
|
||||||
|
tag.visible ?
|
||||||
|
intl.formatMessage(messages.hideTag) :
|
||||||
|
intl.formatMessage(messages.showTag)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={toggleVisibility}
|
||||||
|
theme='transparent'
|
||||||
|
src={
|
||||||
|
tag.visible ?
|
||||||
|
require('@tabler/icons/eye.svg') :
|
||||||
|
require('@tabler/icons/eye-off.svg')
|
||||||
|
}
|
||||||
|
iconClassName='h-5 w-5 text-primary-500 dark:text-accent-blue'
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</HStack>
|
||||||
|
) : null}
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupTagListItem;
|
|
@ -26,11 +26,11 @@ const messages = defineMessages({
|
||||||
|
|
||||||
interface IEditGroup {
|
interface IEditGroup {
|
||||||
params: {
|
params: {
|
||||||
id: string
|
groupId: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const EditGroup: React.FC<IEditGroup> = ({ params: { id: groupId } }) => {
|
const EditGroup: React.FC<IEditGroup> = ({ params: { groupId } }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const instance = useInstance();
|
const instance = useInstance();
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,7 @@ import toast from 'soapbox/toast';
|
||||||
|
|
||||||
import ColumnForbidden from '../ui/components/column-forbidden';
|
import ColumnForbidden from '../ui/components/column-forbidden';
|
||||||
|
|
||||||
type RouteParams = { id: string };
|
type RouteParams = { groupId: string };
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
heading: { id: 'column.group_blocked_members', defaultMessage: 'Banned Members' },
|
heading: { id: 'column.group_blocked_members', defaultMessage: 'Banned Members' },
|
||||||
|
@ -62,7 +62,7 @@ const GroupBlockedMembers: React.FC<IGroupBlockedMembers> = ({ params }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const id = params?.id;
|
const id = params?.groupId;
|
||||||
|
|
||||||
const { group } = useGroup(id);
|
const { group } = useGroup(id);
|
||||||
const accountIds = useAppSelector((state) => state.user_lists.group_blocks.get(id)?.items);
|
const accountIds = useAppSelector((state) => state.user_lists.group_blocks.get(id)?.items);
|
||||||
|
@ -86,7 +86,7 @@ const GroupBlockedMembers: React.FC<IGroupBlockedMembers> = ({ params }) => {
|
||||||
const emptyMessage = <FormattedMessage id='empty_column.group_blocks' defaultMessage="The group hasn't banned any users yet." />;
|
const emptyMessage = <FormattedMessage id='empty_column.group_blocks' defaultMessage="The group hasn't banned any users yet." />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${id}/manage`}>
|
<Column label={intl.formatMessage(messages.heading)} backHref={`/group/${group.slug}/manage`}>
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='group_blocks'
|
scrollKey='group_blocks'
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
import LoadMore from 'soapbox/components/load-more';
|
import LoadMore from 'soapbox/components/load-more';
|
||||||
|
@ -13,10 +12,14 @@ import MediaItem from '../account-gallery/components/media-item';
|
||||||
|
|
||||||
import type { Attachment, Status } from 'soapbox/types/entities';
|
import type { Attachment, Status } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const GroupGallery = () => {
|
interface IGroupGallery {
|
||||||
const dispatch = useAppDispatch();
|
params: { groupId: string }
|
||||||
|
}
|
||||||
|
|
||||||
const { id: groupId } = useParams<{ id: string }>();
|
const GroupGallery: React.FC<IGroupGallery> = (props) => {
|
||||||
|
const { groupId } = props.params;
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const { group, isLoading: groupIsLoading } = useGroup(groupId);
|
const { group, isLoading: groupIsLoading } = useGroup(groupId);
|
||||||
|
|
||||||
|
@ -24,6 +27,7 @@ const GroupGallery = () => {
|
||||||
entities: statuses,
|
entities: statuses,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isFetching,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
} = useGroupMedia(groupId);
|
} = useGroupMedia(groupId);
|
||||||
|
|
||||||
|
@ -45,21 +49,25 @@ const GroupGallery = () => {
|
||||||
|
|
||||||
if (isLoading || groupIsLoading) {
|
if (isLoading || groupIsLoading) {
|
||||||
return (
|
return (
|
||||||
<Column>
|
<Column transparent withHeader={false}>
|
||||||
|
<div className='pt-6'>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
|
</div>
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!group) {
|
if (!group) {
|
||||||
return (
|
return (
|
||||||
<MissingIndicator />
|
<div className='pt-6'>
|
||||||
|
<MissingIndicator nested />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column label={group.display_name} transparent withHeader={false}>
|
<Column label={group.display_name} transparent withHeader={false}>
|
||||||
<div role='feed' className='grid grid-cols-2 gap-2 sm:grid-cols-3'>
|
<div role='feed' className='mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3'>
|
||||||
{attachments.map((attachment) => (
|
{attachments.map((attachment) => (
|
||||||
<MediaItem
|
<MediaItem
|
||||||
key={`${attachment.status.id}+${attachment.id}`}
|
key={`${attachment.status.id}+${attachment.id}`}
|
||||||
|
@ -73,16 +81,10 @@ const GroupGallery = () => {
|
||||||
<FormattedMessage id='account_gallery.none' defaultMessage='No media to show.' />
|
<FormattedMessage id='account_gallery.none' defaultMessage='No media to show.' />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(hasNextPage && !isLoading) && (
|
|
||||||
<LoadMore className='my-auto' visible={!isLoading} onClick={fetchNextPage} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && (
|
{hasNextPage && (
|
||||||
<div className='slist__append'>
|
<LoadMore className='mt-4' disabled={isFetching} onClick={fetchNextPage} />
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
|
|
|
@ -16,13 +16,13 @@ import GroupMemberListItem from './components/group-member-list-item';
|
||||||
import type { Group } from 'soapbox/types/entities';
|
import type { Group } from 'soapbox/types/entities';
|
||||||
|
|
||||||
interface IGroupMembers {
|
interface IGroupMembers {
|
||||||
params: { id: string }
|
params: { groupId: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MAX_ADMIN_COUNT = 5;
|
export const MAX_ADMIN_COUNT = 5;
|
||||||
|
|
||||||
const GroupMembers: React.FC<IGroupMembers> = (props) => {
|
const GroupMembers: React.FC<IGroupMembers> = (props) => {
|
||||||
const groupId = props.params.id;
|
const { groupId } = props.params;
|
||||||
|
|
||||||
const features = useFeatures();
|
const features = useFeatures();
|
||||||
|
|
||||||
|
@ -58,7 +58,10 @@ const GroupMembers: React.FC<IGroupMembers> = (props) => {
|
||||||
itemClassName='py-3 last:pb-0'
|
itemClassName='py-3 last:pb-0'
|
||||||
prepend={(pendingCount > 0) && (
|
prepend={(pendingCount > 0) && (
|
||||||
<div className={clsx('py-3', { 'border-b border-gray-200 dark:border-gray-800': members.length })}>
|
<div className={clsx('py-3', { 'border-b border-gray-200 dark:border-gray-800': members.length })}>
|
||||||
<PendingItemsRow to={`/groups/${groupId}/manage/requests`} count={pendingCount} />
|
<PendingItemsRow
|
||||||
|
to={`/group/${group?.slug}/manage/requests`}
|
||||||
|
count={pendingCount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
@ -14,7 +14,7 @@ import ColumnForbidden from '../ui/components/column-forbidden';
|
||||||
|
|
||||||
import type { Account as AccountEntity } from 'soapbox/schemas';
|
import type { Account as AccountEntity } from 'soapbox/schemas';
|
||||||
|
|
||||||
type RouteParams = { id: string };
|
type RouteParams = { groupId: string };
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
heading: { id: 'column.group_pending_requests', defaultMessage: 'Pending requests' },
|
heading: { id: 'column.group_pending_requests', defaultMessage: 'Pending requests' },
|
||||||
|
@ -54,7 +54,7 @@ interface IGroupMembershipRequests {
|
||||||
}
|
}
|
||||||
|
|
||||||
const GroupMembershipRequests: React.FC<IGroupMembershipRequests> = ({ params }) => {
|
const GroupMembershipRequests: React.FC<IGroupMembershipRequests> = ({ params }) => {
|
||||||
const id = params?.id;
|
const id = params?.groupId;
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const { group } = useGroup(id);
|
const { group } = useGroup(id);
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Column } from 'soapbox/components/ui';
|
||||||
|
import { useGroup, useGroupTag } from 'soapbox/hooks/api';
|
||||||
|
|
||||||
|
type RouteParams = { id: string, groupId: string };
|
||||||
|
|
||||||
|
interface IGroupTimeline {
|
||||||
|
params: RouteParams
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupTagTimeline: React.FC<IGroupTimeline> = (props) => {
|
||||||
|
const groupId = props.params.groupId;
|
||||||
|
const tagId = props.params.id;
|
||||||
|
|
||||||
|
const { group } = useGroup(groupId);
|
||||||
|
const { tag } = useGroupTag(tagId);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column label={`#${tag}`}>
|
||||||
|
{/* TODO */}
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupTagTimeline;
|
|
@ -0,0 +1,69 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||||
|
import { Icon, Stack, Text } from 'soapbox/components/ui';
|
||||||
|
import { useGroupTags } from 'soapbox/hooks/api';
|
||||||
|
import { useGroup } from 'soapbox/queries/groups';
|
||||||
|
|
||||||
|
import PlaceholderAccount from '../placeholder/components/placeholder-account';
|
||||||
|
|
||||||
|
import GroupTagListItem from './components/group-tag-list-item';
|
||||||
|
|
||||||
|
import type { Group } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
interface IGroupTopics {
|
||||||
|
params: { groupId: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupTopics: React.FC<IGroupTopics> = (props) => {
|
||||||
|
const { groupId } = props.params;
|
||||||
|
|
||||||
|
const { group, isFetching: isFetchingGroup } = useGroup(groupId);
|
||||||
|
const { tags, isFetching: isFetchingTags, hasNextPage, fetchNextPage } = useGroupTags(groupId);
|
||||||
|
|
||||||
|
const isLoading = isFetchingGroup || isFetchingTags;
|
||||||
|
|
||||||
|
const pinnedTags = tags.filter((tag) => tag.pinned);
|
||||||
|
const isPinnable = pinnedTags.length < 3;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollableList
|
||||||
|
scrollKey='group-tags'
|
||||||
|
hasMore={hasNextPage}
|
||||||
|
onLoadMore={fetchNextPage}
|
||||||
|
isLoading={isLoading || !group}
|
||||||
|
showLoading={!group || isLoading && tags.length === 0}
|
||||||
|
placeholderComponent={PlaceholderAccount}
|
||||||
|
placeholderCount={3}
|
||||||
|
className='divide-y divide-solid divide-gray-300'
|
||||||
|
itemClassName='py-3 last:pb-0'
|
||||||
|
emptyMessage={
|
||||||
|
<Stack space={4} className='pt-6' justifyContent='center' alignItems='center'>
|
||||||
|
<div className='rounded-full bg-gray-200 p-4 dark:bg-gray-800'>
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/hash.svg')}
|
||||||
|
className='h-6 w-6 text-gray-600'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Text theme='muted'>
|
||||||
|
<FormattedMessage id='group.tags.empty' defaultMessage='There are no topics in this group yet.' />
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
emptyMessageCard={false}
|
||||||
|
>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<GroupTagListItem
|
||||||
|
key={tag.id}
|
||||||
|
group={group as Group}
|
||||||
|
isPinnable={isPinnable}
|
||||||
|
tag={tag}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollableList>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupTopics;
|
|
@ -12,7 +12,7 @@ import { useGroup } from 'soapbox/hooks/api';
|
||||||
|
|
||||||
import Timeline from '../ui/components/timeline';
|
import Timeline from '../ui/components/timeline';
|
||||||
|
|
||||||
type RouteParams = { id: string };
|
type RouteParams = { groupId: string };
|
||||||
|
|
||||||
interface IGroupTimeline {
|
interface IGroupTimeline {
|
||||||
params: RouteParams
|
params: RouteParams
|
||||||
|
@ -22,7 +22,7 @@ const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
|
||||||
const account = useOwnAccount();
|
const account = useOwnAccount();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const groupId = props.params.id;
|
const { groupId } = props.params;
|
||||||
|
|
||||||
const { group } = useGroup(groupId);
|
const { group } = useGroup(groupId);
|
||||||
|
|
||||||
|
|
|
@ -5,16 +5,15 @@ import { useHistory } from 'react-router-dom';
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
import List, { ListItem } from 'soapbox/components/list';
|
import List, { ListItem } from 'soapbox/components/list';
|
||||||
import { CardBody, CardHeader, CardTitle, Column, Spinner, Text } from 'soapbox/components/ui';
|
import { CardBody, CardHeader, CardTitle, Column, Spinner, Text } from 'soapbox/components/ui';
|
||||||
import { useAppDispatch, useGroupsPath } from 'soapbox/hooks';
|
import { useAppDispatch, useBackend, useGroupsPath } from 'soapbox/hooks';
|
||||||
import { useDeleteGroup, useGroup } from 'soapbox/hooks/api';
|
import { useDeleteGroup, useGroup } from 'soapbox/hooks/api';
|
||||||
import { useBackend } from 'soapbox/hooks/useBackend';
|
|
||||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||||
import toast from 'soapbox/toast';
|
import toast from 'soapbox/toast';
|
||||||
import { TRUTHSOCIAL } from 'soapbox/utils/features';
|
import { TRUTHSOCIAL } from 'soapbox/utils/features';
|
||||||
|
|
||||||
import ColumnForbidden from '../ui/components/column-forbidden';
|
import ColumnForbidden from '../ui/components/column-forbidden';
|
||||||
|
|
||||||
type RouteParams = { id: string };
|
type RouteParams = { groupId: string };
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
heading: { id: 'column.manage_group', defaultMessage: 'Manage group' },
|
heading: { id: 'column.manage_group', defaultMessage: 'Manage group' },
|
||||||
|
@ -35,7 +34,7 @@ interface IManageGroup {
|
||||||
}
|
}
|
||||||
|
|
||||||
const ManageGroup: React.FC<IManageGroup> = ({ params }) => {
|
const ManageGroup: React.FC<IManageGroup> = ({ params }) => {
|
||||||
const { id } = params;
|
const { groupId: id } = params;
|
||||||
|
|
||||||
const backend = useBackend();
|
const backend = useBackend();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
@ -77,12 +76,12 @@ const ManageGroup: React.FC<IManageGroup> = ({ params }) => {
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const navigateToEdit = () => history.push(`/groups/${id}/manage/edit`);
|
const navigateToEdit = () => history.push(`/group/${group.slug}/manage/edit`);
|
||||||
const navigateToPending = () => history.push(`/groups/${id}/manage/requests`);
|
const navigateToPending = () => history.push(`/group/${group.slug}/manage/requests`);
|
||||||
const navigateToBlocks = () => history.push(`/groups/${id}/manage/blocks`);
|
const navigateToBlocks = () => history.push(`/group/${group.slug}/manage/blocks`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${id}`}>
|
<Column label={intl.formatMessage(messages.heading)} backHref={`/group/${group.slug}`}>
|
||||||
<CardBody className='space-y-4'>
|
<CardBody className='space-y-4'>
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -25,7 +25,7 @@ const GroupGridItem = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDiv
|
||||||
width,
|
width,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Link to={`/groups/${group.id}`}>
|
<Link to={`/group/${group.slug}`}>
|
||||||
<Stack
|
<Stack
|
||||||
className='aspect-h-7 aspect-w-10 h-full w-full overflow-hidden rounded-lg'
|
className='aspect-h-7 aspect-w-10 h-full w-full overflow-hidden rounded-lg'
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
|
@ -21,7 +21,7 @@ const GroupListItem = (props: IGroup) => {
|
||||||
alignItems='center'
|
alignItems='center'
|
||||||
justifyContent='between'
|
justifyContent='between'
|
||||||
>
|
>
|
||||||
<Link key={group.id} to={`/groups/${group.id}`}>
|
<Link key={group.id} to={`/group/${group.slug}`}>
|
||||||
<HStack alignItems='center' space={2}>
|
<HStack alignItems='center' space={2}>
|
||||||
<GroupAvatar
|
<GroupAvatar
|
||||||
group={group}
|
group={group}
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import Link from 'soapbox/components/link';
|
||||||
|
import { HStack, Stack, Text } from 'soapbox/components/ui';
|
||||||
|
import { usePopularTags } from 'soapbox/hooks/api';
|
||||||
|
|
||||||
|
import TagListItem from './tag-list-item';
|
||||||
|
|
||||||
|
const PopularTags = () => {
|
||||||
|
const { tags, isFetched, isError } = usePopularTags();
|
||||||
|
const isEmpty = (isFetched && tags.length === 0) || isError;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack space={4}>
|
||||||
|
<HStack alignItems='center' justifyContent='between'>
|
||||||
|
<Text size='xl' weight='bold'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='groups.discover.tags.title'
|
||||||
|
defaultMessage='Browse Topics'
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Link to='/groups/tags'>
|
||||||
|
<Text tag='span' weight='medium' size='sm' theme='inherit'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='groups.discover.tags.show_more'
|
||||||
|
defaultMessage='Show More'
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{isEmpty ? (
|
||||||
|
<Text theme='muted'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='groups.discover.tags.empty'
|
||||||
|
defaultMessage='Unable to fetch popular topics at this time. Please check back later.'
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack space={4}>
|
||||||
|
{tags.slice(0, 10).map((tag) => (
|
||||||
|
<TagListItem key={tag.id} tag={tag} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PopularTags;
|
|
@ -0,0 +1,39 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { Stack, Text } from 'soapbox/components/ui';
|
||||||
|
|
||||||
|
import type { GroupTag } from 'soapbox/schemas';
|
||||||
|
|
||||||
|
interface ITagListItem {
|
||||||
|
tag: GroupTag
|
||||||
|
}
|
||||||
|
|
||||||
|
const TagListItem = (props: ITagListItem) => {
|
||||||
|
const { tag } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to={`/groups/discover/tags/${tag.id}`} className='group'>
|
||||||
|
<Stack>
|
||||||
|
<Text
|
||||||
|
weight='bold'
|
||||||
|
className='group-hover:text-primary-600 group-hover:underline dark:group-hover:text-accent-blue'
|
||||||
|
>
|
||||||
|
#{tag.name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text size='sm' theme='muted' weight='medium'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='groups.discovery.tags.no_of_groups'
|
||||||
|
defaultMessage='Number of groups'
|
||||||
|
/>
|
||||||
|
:{' '}
|
||||||
|
{tag.uses}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TagListItem;
|
|
@ -0,0 +1,43 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { Avatar, Button, CardTitle, Stack } from 'soapbox/components/ui';
|
||||||
|
import { type Card as StatusCard } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
interface IGroupLinkPreview {
|
||||||
|
card: StatusCard
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupLinkPreview: React.FC<IGroupLinkPreview> = ({ card }) => {
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const { group } = card;
|
||||||
|
if (!group) return null;
|
||||||
|
|
||||||
|
const navigateToGroup = () => history.push(`/group/${group.slug}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack className='cursor-default overflow-hidden rounded-lg border border-gray-300 text-center dark:border-gray-800'>
|
||||||
|
<div
|
||||||
|
className='-mb-8 h-32 w-full bg-center'
|
||||||
|
style={{ backgroundImage: `url(${group.header})` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Avatar
|
||||||
|
className='mx-auto border-4 border-white dark:border-primary-900'
|
||||||
|
src={group.avatar}
|
||||||
|
size={64}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack space={4} className='p-4'>
|
||||||
|
<CardTitle title={<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />} />
|
||||||
|
|
||||||
|
<Button theme='primary' onClick={navigateToGroup} block>
|
||||||
|
View Group
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { GroupLinkPreview };
|
|
@ -4,6 +4,7 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||||
import { HStack, Icon, IconButton, Input, Stack } from 'soapbox/components/ui';
|
import { HStack, Icon, IconButton, Input, Stack } from 'soapbox/components/ui';
|
||||||
|
|
||||||
import PopularGroups from './components/discover/popular-groups';
|
import PopularGroups from './components/discover/popular-groups';
|
||||||
|
import PopularTags from './components/discover/popular-tags';
|
||||||
import Search from './components/discover/search/search';
|
import Search from './components/discover/search/search';
|
||||||
import SuggestedGroups from './components/discover/suggested-groups';
|
import SuggestedGroups from './components/discover/suggested-groups';
|
||||||
import TabBar, { TabItems } from './components/tab-bar';
|
import TabBar, { TabItems } from './components/tab-bar';
|
||||||
|
@ -71,6 +72,7 @@ const Discover: React.FC = () => {
|
||||||
<>
|
<>
|
||||||
<PopularGroups />
|
<PopularGroups />
|
||||||
<SuggestedGroups />
|
<SuggestedGroups />
|
||||||
|
<PopularTags />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
@ -106,7 +106,7 @@ const Groups: React.FC = () => {
|
||||||
placeholderCount={3}
|
placeholderCount={3}
|
||||||
>
|
>
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<Link key={group.id} to={`/groups/${group.id}`}>
|
<Link key={group.id} to={`/group/${group.slug}`}>
|
||||||
<GroupCard group={group} />
|
<GroupCard group={group} />
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -57,7 +57,7 @@ export default () => {
|
||||||
showLoading={isLoading && groups.length === 0}
|
showLoading={isLoading && groups.length === 0}
|
||||||
>
|
>
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<Link key={group.id} to={`/groups/${group.id}`}>
|
<Link key={group.id} to={`/group/${group.slug}`}>
|
||||||
<GroupCard group={group} />
|
<GroupCard group={group} />
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -0,0 +1,117 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso';
|
||||||
|
|
||||||
|
import { Column, HStack, Icon } from 'soapbox/components/ui';
|
||||||
|
import { useGroupTag, useGroupsFromTag } from 'soapbox/hooks/api';
|
||||||
|
|
||||||
|
import GroupGridItem from './components/discover/group-grid-item';
|
||||||
|
import GroupListItem from './components/discover/group-list-item';
|
||||||
|
|
||||||
|
import type { Group } from 'soapbox/schemas';
|
||||||
|
|
||||||
|
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' />;
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ITag {
|
||||||
|
params: { id: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
const Tag: React.FC<ITag> = (props) => {
|
||||||
|
const tagId = props.params.id;
|
||||||
|
|
||||||
|
const [layout, setLayout] = useState<Layout>(Layout.LIST);
|
||||||
|
|
||||||
|
const { tag, isLoading } = useGroupTag(tagId);
|
||||||
|
const { groups, hasNextPage, fetchNextPage } = useGroupsFromTag(tagId);
|
||||||
|
|
||||||
|
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>
|
||||||
|
), []);
|
||||||
|
|
||||||
|
if (isLoading || !tag) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column
|
||||||
|
label={`#${tag.name}`}
|
||||||
|
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 Tag;
|
|
@ -0,0 +1,62 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||||
|
import { Virtuoso } from 'react-virtuoso';
|
||||||
|
|
||||||
|
import { Column, Text } from 'soapbox/components/ui';
|
||||||
|
import { usePopularTags } from 'soapbox/hooks/api';
|
||||||
|
|
||||||
|
import TagListItem from './components/discover/tag-list-item';
|
||||||
|
|
||||||
|
import type { GroupTag } from 'soapbox/schemas';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: { id: 'groups.tags.title', defaultMessage: 'Browse Topics' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const Tags: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const { tags, isFetched, isError, hasNextPage, fetchNextPage } = usePopularTags();
|
||||||
|
const isEmpty = (isFetched && tags.length === 0) || isError;
|
||||||
|
|
||||||
|
const handleLoadMore = () => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem = (index: number, tag: GroupTag) => (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
clsx({
|
||||||
|
'pt-4': index !== 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TagListItem key={tag.id} tag={tag} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column label={intl.formatMessage(messages.title)}>
|
||||||
|
{isEmpty ? (
|
||||||
|
<Text theme='muted'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='groups.discover.tags.empty'
|
||||||
|
defaultMessage='Unable to fetch popular topics at this time. Please check back later.'
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Virtuoso
|
||||||
|
useWindowScroll
|
||||||
|
data={tags}
|
||||||
|
itemContent={renderItem}
|
||||||
|
endReached={handleLoadMore}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Tags;
|
|
@ -2,20 +2,20 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { FormattedDate, FormattedMessage, useIntl } from 'react-intl';
|
import { FormattedDate, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import Account from 'soapbox/components/account';
|
import Account from 'soapbox/components/account';
|
||||||
import Icon from 'soapbox/components/icon';
|
|
||||||
import StatusContent from 'soapbox/components/status-content';
|
import StatusContent from 'soapbox/components/status-content';
|
||||||
import StatusMedia from 'soapbox/components/status-media';
|
import StatusMedia from 'soapbox/components/status-media';
|
||||||
import StatusReplyMentions from 'soapbox/components/status-reply-mentions';
|
import StatusReplyMentions from 'soapbox/components/status-reply-mentions';
|
||||||
import SensitiveContentOverlay from 'soapbox/components/statuses/sensitive-content-overlay';
|
import SensitiveContentOverlay from 'soapbox/components/statuses/sensitive-content-overlay';
|
||||||
|
import StatusInfo from 'soapbox/components/statuses/status-info';
|
||||||
import TranslateButton from 'soapbox/components/translate-button';
|
import TranslateButton from 'soapbox/components/translate-button';
|
||||||
import { HStack, Stack, Text } from 'soapbox/components/ui';
|
import { HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||||
import QuotedStatus from 'soapbox/features/status/containers/quoted-status-container';
|
import QuotedStatus from 'soapbox/features/status/containers/quoted-status-container';
|
||||||
import { getActualStatus } from 'soapbox/utils/status';
|
import { getActualStatus } from 'soapbox/utils/status';
|
||||||
|
|
||||||
import StatusInteractionBar from './status-interaction-bar';
|
import StatusInteractionBar from './status-interaction-bar';
|
||||||
|
|
||||||
import type { List as ImmutableList } from 'immutable';
|
import type { List as ImmutableList } from 'immutable';
|
||||||
import type { Attachment as AttachmentEntity, Status as StatusEntity } from 'soapbox/types/entities';
|
import type { Attachment as AttachmentEntity, Group, Status as StatusEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
interface IDetailedStatus {
|
interface IDetailedStatus {
|
||||||
status: StatusEntity
|
status: StatusEntity
|
||||||
|
@ -50,6 +50,35 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
|
||||||
onOpenCompareHistoryModal(status);
|
onOpenCompareHistoryModal(status);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderStatusInfo = () => {
|
||||||
|
if (status.group) {
|
||||||
|
return (
|
||||||
|
<div className='mb-4'>
|
||||||
|
<StatusInfo
|
||||||
|
avatarSize={42}
|
||||||
|
to={`/groups/${(status.group as Group).id}`}
|
||||||
|
icon={
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/circles.svg')}
|
||||||
|
className='h-4 w-4 text-primary-600 dark:text-accent-blue'
|
||||||
|
/>}
|
||||||
|
text={
|
||||||
|
<Text size='xs' theme='muted' weight='medium'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='status.group'
|
||||||
|
defaultMessage='Posted in {group}'
|
||||||
|
values={{ group: (
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: (status.group as Group).display_name_html }} />
|
||||||
|
) }}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const actualStatus = getActualStatus(status);
|
const actualStatus = getActualStatus(status);
|
||||||
if (!actualStatus) return null;
|
if (!actualStatus) return null;
|
||||||
const { account } = actualStatus;
|
const { account } = actualStatus;
|
||||||
|
@ -75,14 +104,16 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actualStatus.visibility === 'direct') {
|
if (actualStatus.visibility === 'direct') {
|
||||||
statusTypeIcon = <Icon className='text-gray-700 dark:text-gray-600' src={require('@tabler/icons/mail.svg')} />;
|
statusTypeIcon = <Icon className='h-4 w-4 text-gray-700 dark:text-gray-600' src={require('@tabler/icons/mail.svg')} />;
|
||||||
} else if (actualStatus.visibility === 'private') {
|
} else if (actualStatus.visibility === 'private') {
|
||||||
statusTypeIcon = <Icon className='text-gray-700 dark:text-gray-600' src={require('@tabler/icons/lock.svg')} />;
|
statusTypeIcon = <Icon className='h-4 w-4 text-gray-700 dark:text-gray-600' src={require('@tabler/icons/lock.svg')} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='border-box'>
|
<div className='border-box'>
|
||||||
<div ref={node} className='detailed-actualStatus' tabIndex={-1}>
|
<div ref={node} className='detailed-actualStatus' tabIndex={-1}>
|
||||||
|
{renderStatusInfo()}
|
||||||
|
|
||||||
<div className='mb-4'>
|
<div className='mb-4'>
|
||||||
<Account
|
<Account
|
||||||
key={account.id}
|
key={account.id}
|
||||||
|
|
|
@ -52,7 +52,6 @@ import type {
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'status.title', defaultMessage: 'Post Details' },
|
title: { id: 'status.title', defaultMessage: 'Post Details' },
|
||||||
titleDirect: { id: 'status.title_direct', defaultMessage: 'Direct message' },
|
titleDirect: { id: 'status.title_direct', defaultMessage: 'Direct message' },
|
||||||
titleGroup: { id: 'status.title_group', defaultMessage: 'Group Post Details' },
|
|
||||||
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||||
deleteHeading: { id: 'confirmations.delete.heading', defaultMessage: 'Delete post' },
|
deleteHeading: { id: 'confirmations.delete.heading', defaultMessage: 'Delete post' },
|
||||||
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this post?' },
|
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this post?' },
|
||||||
|
@ -119,6 +118,7 @@ type DisplayMedia = 'default' | 'hide_all' | 'show_all';
|
||||||
type RouteParams = {
|
type RouteParams = {
|
||||||
statusId: string
|
statusId: string
|
||||||
groupId?: string
|
groupId?: string
|
||||||
|
groupSlug?: string
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IThread {
|
interface IThread {
|
||||||
|
@ -517,13 +517,15 @@ const Thread: React.FC<IThread> = (props) => {
|
||||||
children.push(...renderChildren(descendantsIds).toArray());
|
children.push(...renderChildren(descendantsIds).toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.group && typeof status.group === 'object' && !props.params.groupId) {
|
if (status.group && typeof status.group === 'object') {
|
||||||
return <Redirect to={`/groups/${status.group.id}/posts/${props.params.statusId}`} />;
|
if (status.group.slug && !props.params.groupSlug) {
|
||||||
|
return <Redirect to={`/group/${status.group.slug}/posts/${props.params.statusId}`} />;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const titleMessage = () => {
|
const titleMessage = () => {
|
||||||
if (status.visibility === 'direct') return messages.titleDirect;
|
if (status.visibility === 'direct') return messages.titleDirect;
|
||||||
return status.group ? messages.titleGroup : messages.title;
|
return messages.title;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -20,6 +20,8 @@ import { fetchScheduledStatuses } from 'soapbox/actions/scheduled-statuses';
|
||||||
import { connectUserStream } from 'soapbox/actions/streaming';
|
import { connectUserStream } from 'soapbox/actions/streaming';
|
||||||
import { fetchSuggestionsForTimeline } from 'soapbox/actions/suggestions';
|
import { fetchSuggestionsForTimeline } from 'soapbox/actions/suggestions';
|
||||||
import { expandHomeTimeline } from 'soapbox/actions/timelines';
|
import { expandHomeTimeline } from 'soapbox/actions/timelines';
|
||||||
|
import GroupLookupHoc from 'soapbox/components/hoc/group-lookup-hoc';
|
||||||
|
import withHoc from 'soapbox/components/hoc/with-hoc';
|
||||||
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
|
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
|
||||||
import ThumbNavigation from 'soapbox/components/thumb-navigation';
|
import ThumbNavigation from 'soapbox/components/thumb-navigation';
|
||||||
import { Layout } from 'soapbox/components/ui';
|
import { Layout } from 'soapbox/components/ui';
|
||||||
|
@ -122,8 +124,12 @@ import {
|
||||||
GroupsDiscover,
|
GroupsDiscover,
|
||||||
GroupsPopular,
|
GroupsPopular,
|
||||||
GroupsSuggested,
|
GroupsSuggested,
|
||||||
|
GroupsTag,
|
||||||
|
GroupsTags,
|
||||||
PendingGroupRequests,
|
PendingGroupRequests,
|
||||||
GroupMembers,
|
GroupMembers,
|
||||||
|
GroupTags,
|
||||||
|
GroupTagTimeline,
|
||||||
GroupTimeline,
|
GroupTimeline,
|
||||||
ManageGroup,
|
ManageGroup,
|
||||||
GroupBlockedMembers,
|
GroupBlockedMembers,
|
||||||
|
@ -137,6 +143,16 @@ import { WrappedRoute } from './util/react-router-helpers';
|
||||||
// Without this it ends up in ~8 very commonly used bundles.
|
// Without this it ends up in ~8 very commonly used bundles.
|
||||||
import 'soapbox/components/status';
|
import 'soapbox/components/status';
|
||||||
|
|
||||||
|
const GroupTagsSlug = withHoc(GroupTags as any, GroupLookupHoc);
|
||||||
|
const GroupTagTimelineSlug = withHoc(GroupTagTimeline as any, GroupLookupHoc);
|
||||||
|
const GroupTimelineSlug = withHoc(GroupTimeline as any, GroupLookupHoc);
|
||||||
|
const GroupMembersSlug = withHoc(GroupMembers as any, GroupLookupHoc);
|
||||||
|
const GroupGallerySlug = withHoc(GroupGallery as any, GroupLookupHoc);
|
||||||
|
const ManageGroupSlug = withHoc(ManageGroup as any, GroupLookupHoc);
|
||||||
|
const EditGroupSlug = withHoc(EditGroup as any, GroupLookupHoc);
|
||||||
|
const GroupBlockedMembersSlug = withHoc(GroupBlockedMembers as any, GroupLookupHoc);
|
||||||
|
const GroupMembershipRequestsSlug = withHoc(GroupMembershipRequests as any, GroupLookupHoc);
|
||||||
|
|
||||||
const EmptyPage = HomePage;
|
const EmptyPage = HomePage;
|
||||||
|
|
||||||
const keyMap = {
|
const keyMap = {
|
||||||
|
@ -296,16 +312,31 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ 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/popular' exact page={GroupsPendingPage} component={GroupsPopular} content={children} />}
|
||||||
{features.groupsDiscovery && <WrappedRoute path='/groups/suggested' exact page={GroupsPendingPage} component={GroupsSuggested} content={children} />}
|
{features.groupsDiscovery && <WrappedRoute path='/groups/suggested' exact page={GroupsPendingPage} component={GroupsSuggested} content={children} />}
|
||||||
|
{features.groupsDiscovery && <WrappedRoute path='/groups/tags' exact page={GroupsPendingPage} component={GroupsTags} content={children} />}
|
||||||
|
{features.groupsDiscovery && <WrappedRoute path='/groups/discover/tags/:id' exact page={GroupsPendingPage} component={GroupsTag} 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.groupsTags && <WrappedRoute path='/groups/:groupId/tags' exact page={GroupPage} component={GroupTags} content={children} />}
|
||||||
{features.groups && <WrappedRoute path='/groups/:id/members' exact page={GroupPage} component={GroupMembers} content={children} />}
|
{features.groupsTags && <WrappedRoute path='/groups/:groupId/tag/:id' exact page={GroupsPendingPage} component={GroupTagTimeline} content={children} />}
|
||||||
{features.groups && <WrappedRoute path='/groups/:id/media' publicRoute={!authenticatedProfile} component={GroupGallery} page={GroupPage} content={children} />}
|
{features.groups && <WrappedRoute path='/groups/:groupId' exact page={GroupPage} component={GroupTimeline} content={children} />}
|
||||||
{features.groups && <WrappedRoute path='/groups/:id/manage' exact page={ManageGroupsPage} component={ManageGroup} content={children} />}
|
{features.groups && <WrappedRoute path='/groups/:groupId/members' exact page={GroupPage} component={GroupMembers} content={children} />}
|
||||||
{features.groups && <WrappedRoute path='/groups/:id/manage/edit' exact page={ManageGroupsPage} component={EditGroup} content={children} />}
|
{features.groups && <WrappedRoute path='/groups/:groupId/media' publicRoute={!authenticatedProfile} component={GroupGallery} page={GroupPage} content={children} />}
|
||||||
{features.groups && <WrappedRoute path='/groups/:id/manage/blocks' exact page={ManageGroupsPage} component={GroupBlockedMembers} content={children} />}
|
{features.groups && <WrappedRoute path='/groups/:groupId/manage' exact page={ManageGroupsPage} component={ManageGroup} content={children} />}
|
||||||
{features.groups && <WrappedRoute path='/groups/:id/manage/requests' exact page={ManageGroupsPage} component={GroupMembershipRequests} content={children} />}
|
{features.groups && <WrappedRoute path='/groups/:groupId/manage/edit' exact page={ManageGroupsPage} component={EditGroup} content={children} />}
|
||||||
|
{features.groups && <WrappedRoute path='/groups/:groupId/manage/blocks' exact page={ManageGroupsPage} component={GroupBlockedMembers} content={children} />}
|
||||||
|
{features.groups && <WrappedRoute path='/groups/:groupId/manage/requests' exact page={ManageGroupsPage} component={GroupMembershipRequests} content={children} />}
|
||||||
{features.groups && <WrappedRoute path='/groups/:groupId/posts/:statusId' exact page={StatusPage} component={Status} content={children} />}
|
{features.groups && <WrappedRoute path='/groups/:groupId/posts/:statusId' exact page={StatusPage} component={Status} content={children} />}
|
||||||
|
|
||||||
|
{features.groupsTags && <WrappedRoute path='/group/:groupSlug/tags' exact page={GroupPage} component={GroupTagsSlug} content={children} />}
|
||||||
|
{features.groupsTags && <WrappedRoute path='/group/:groupSlug/tag/:id' exact page={GroupsPendingPage} component={GroupTagTimelineSlug} content={children} />}
|
||||||
|
{features.groups && <WrappedRoute path='/group/:groupSlug' exact page={GroupPage} component={GroupTimelineSlug} content={children} />}
|
||||||
|
{features.groups && <WrappedRoute path='/group/:groupSlug/members' exact page={GroupPage} component={GroupMembersSlug} content={children} />}
|
||||||
|
{features.groups && <WrappedRoute path='/group/:groupSlug/media' publicRoute={!authenticatedProfile} component={GroupGallerySlug} page={GroupPage} content={children} />}
|
||||||
|
{features.groups && <WrappedRoute path='/group/:groupSlug/manage' exact page={ManageGroupsPage} component={ManageGroupSlug} content={children} />}
|
||||||
|
{features.groups && <WrappedRoute path='/group/:groupSlug/manage/edit' exact page={ManageGroupsPage} component={EditGroupSlug} content={children} />}
|
||||||
|
{features.groups && <WrappedRoute path='/group/:groupSlug/manage/blocks' exact page={ManageGroupsPage} component={GroupBlockedMembersSlug} content={children} />}
|
||||||
|
{features.groups && <WrappedRoute path='/group/:groupSlug/manage/requests' exact page={ManageGroupsPage} component={GroupMembershipRequestsSlug} content={children} />}
|
||||||
|
{features.groups && <WrappedRoute path='/group/:groupSlug/posts/:statusId' exact page={StatusPage} component={Status} content={children} />}
|
||||||
|
|
||||||
<WrappedRoute path='/statuses/new' page={DefaultPage} component={NewStatus} content={children} exact />
|
<WrappedRoute path='/statuses/new' page={DefaultPage} component={NewStatus} content={children} exact />
|
||||||
<WrappedRoute path='/statuses/:statusId' exact page={StatusPage} component={Status} content={children} />
|
<WrappedRoute path='/statuses/:statusId' exact page={StatusPage} component={Status} content={children} />
|
||||||
{features.scheduledStatuses && <WrappedRoute path='/scheduled_statuses' page={DefaultPage} component={ScheduledStatuses} content={children} />}
|
{features.scheduledStatuses && <WrappedRoute path='/scheduled_statuses' page={DefaultPage} component={ScheduledStatuses} content={children} />}
|
||||||
|
|
|
@ -562,6 +562,14 @@ export function GroupsSuggested() {
|
||||||
return import(/* webpackChunkName: "features/groups" */'../../groups/suggested');
|
return import(/* webpackChunkName: "features/groups" */'../../groups/suggested');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GroupsTag() {
|
||||||
|
return import(/* webpackChunkName: "features/groups" */'../../groups/tag');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupsTags() {
|
||||||
|
return import(/* webpackChunkName: "features/groups" */'../../groups/tags');
|
||||||
|
}
|
||||||
|
|
||||||
export function PendingGroupRequests() {
|
export function PendingGroupRequests() {
|
||||||
return import(/* webpackChunkName: "features/groups" */'../../groups/pending-requests');
|
return import(/* webpackChunkName: "features/groups" */'../../groups/pending-requests');
|
||||||
}
|
}
|
||||||
|
@ -570,6 +578,14 @@ export function GroupMembers() {
|
||||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-members');
|
return import(/* webpackChunkName: "features/groups" */'../../group/group-members');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GroupTags() {
|
||||||
|
return import(/* webpackChunkName: "features/groups" */'../../group/group-tags');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupTagTimeline() {
|
||||||
|
return import(/* webpackChunkName: "features/groups" */'../../group/group-tag-timeline');
|
||||||
|
}
|
||||||
|
|
||||||
export function GroupTimeline() {
|
export function GroupTimeline() {
|
||||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-timeline');
|
return import(/* webpackChunkName: "features/groups" */'../../group/group-timeline');
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { Entities } from 'soapbox/entity-store/entities';
|
||||||
|
import { useEntityLookup } from 'soapbox/entity-store/hooks';
|
||||||
|
import { useApi } from 'soapbox/hooks/useApi';
|
||||||
|
import { groupSchema } from 'soapbox/schemas';
|
||||||
|
|
||||||
|
function useGroupLookup(slug: string) {
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
|
return useEntityLookup(
|
||||||
|
Entities.GROUPS,
|
||||||
|
(group) => group.slug === slug,
|
||||||
|
() => api.get(`/api/v1/groups/lookup?name=${slug}`),
|
||||||
|
{ schema: groupSchema },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useGroupLookup };
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { Entities } from 'soapbox/entity-store/entities';
|
||||||
|
import { useEntity } from 'soapbox/entity-store/hooks';
|
||||||
|
import { useApi } from 'soapbox/hooks';
|
||||||
|
import { type GroupTag, groupTagSchema } from 'soapbox/schemas';
|
||||||
|
|
||||||
|
function useGroupTag(tagId: string) {
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
|
const { entity: tag, ...result } = useEntity<GroupTag>(
|
||||||
|
[Entities.GROUP_TAGS, tagId],
|
||||||
|
() => api.get(`/api/v1/tags/${tagId }`),
|
||||||
|
{ schema: groupTagSchema },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
tag,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useGroupTag };
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { Entities } from 'soapbox/entity-store/entities';
|
||||||
|
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||||
|
import { useApi } from 'soapbox/hooks/useApi';
|
||||||
|
import { groupTagSchema } from 'soapbox/schemas';
|
||||||
|
|
||||||
|
import type { GroupTag } from 'soapbox/schemas';
|
||||||
|
|
||||||
|
function useGroupTags(groupId: string) {
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
|
const { entities, ...result } = useEntities<GroupTag>(
|
||||||
|
[Entities.GROUP_TAGS, groupId],
|
||||||
|
() => api.get(`/api/v1/truth/trends/groups/${groupId}/tags`),
|
||||||
|
{ schema: groupTagSchema },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
tags: entities,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useGroupTags };
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { Entities } from 'soapbox/entity-store/entities';
|
||||||
|
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||||
|
import { groupSchema } from 'soapbox/schemas';
|
||||||
|
|
||||||
|
import { useApi } from '../../useApi';
|
||||||
|
import { useFeatures } from '../../useFeatures';
|
||||||
|
|
||||||
|
import { useGroupRelationships } from './useGroups';
|
||||||
|
|
||||||
|
import type { Group } from 'soapbox/schemas';
|
||||||
|
|
||||||
|
function useGroupsFromTag(tagId: string) {
|
||||||
|
const api = useApi();
|
||||||
|
const features = useFeatures();
|
||||||
|
|
||||||
|
const { entities, ...result } = useEntities<Group>(
|
||||||
|
[Entities.GROUPS, 'tags', tagId],
|
||||||
|
() => api.get(`/api/v1/tags/${tagId}/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 { useGroupsFromTag };
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { Entities } from 'soapbox/entity-store/entities';
|
||||||
|
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||||
|
import { GroupTag, groupTagSchema } from 'soapbox/schemas';
|
||||||
|
|
||||||
|
import { useApi } from '../../useApi';
|
||||||
|
import { useFeatures } from '../../useFeatures';
|
||||||
|
|
||||||
|
function usePopularTags() {
|
||||||
|
const api = useApi();
|
||||||
|
const features = useFeatures();
|
||||||
|
|
||||||
|
const { entities, ...result } = useEntities<GroupTag>(
|
||||||
|
[Entities.GROUP_TAGS],
|
||||||
|
() => api.get('/api/v1/groups/tags'),
|
||||||
|
{
|
||||||
|
schema: groupTagSchema,
|
||||||
|
enabled: features.groupsDiscovery,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
tags: entities,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { usePopularTags };
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { Entities } from 'soapbox/entity-store/entities';
|
||||||
|
import { useEntityActions } from 'soapbox/entity-store/hooks';
|
||||||
|
|
||||||
|
import type { GroupTag } from 'soapbox/schemas';
|
||||||
|
|
||||||
|
function useUpdateGroupTag(groupId: string, tagId: string) {
|
||||||
|
const { updateEntity, ...rest } = useEntityActions<GroupTag>(
|
||||||
|
[Entities.GROUP_TAGS, groupId, tagId],
|
||||||
|
{ patch: `/api/v1/groups/${groupId}/tags/${tagId}` },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateGroupTag: updateEntity,
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useUpdateGroupTag };
|
|
@ -11,15 +11,20 @@ export { useCancelMembershipRequest } from './groups/useCancelMembershipRequest'
|
||||||
export { useCreateGroup, type CreateGroupParams } from './groups/useCreateGroup';
|
export { useCreateGroup, type CreateGroupParams } from './groups/useCreateGroup';
|
||||||
export { useDeleteGroup } from './groups/useDeleteGroup';
|
export { useDeleteGroup } from './groups/useDeleteGroup';
|
||||||
export { useDemoteGroupMember } from './groups/useDemoteGroupMember';
|
export { useDemoteGroupMember } from './groups/useDemoteGroupMember';
|
||||||
export { useGroupMedia } from './groups/useGroupMedia';
|
|
||||||
export { useGroup, useGroups } from './groups/useGroups';
|
export { useGroup, useGroups } from './groups/useGroups';
|
||||||
|
export { useGroupMedia } from './groups/useGroupMedia';
|
||||||
export { useGroupMembershipRequests } from './groups/useGroupMembershipRequests';
|
export { useGroupMembershipRequests } from './groups/useGroupMembershipRequests';
|
||||||
export { useGroupSearch } from './groups/useGroupSearch';
|
export { useGroupSearch } from './groups/useGroupSearch';
|
||||||
|
export { useGroupTag } from './groups/useGroupTag';
|
||||||
|
export { useGroupTags } from './groups/useGroupTags';
|
||||||
export { useGroupValidation } from './groups/useGroupValidation';
|
export { useGroupValidation } from './groups/useGroupValidation';
|
||||||
|
export { useGroupsFromTag } from './groups/useGroupsFromTag';
|
||||||
export { useJoinGroup } from './groups/useJoinGroup';
|
export { useJoinGroup } from './groups/useJoinGroup';
|
||||||
export { useLeaveGroup } from './groups/useLeaveGroup';
|
export { useLeaveGroup } from './groups/useLeaveGroup';
|
||||||
|
export { usePopularTags } from './groups/usePopularTags';
|
||||||
export { usePromoteGroupMember } from './groups/usePromoteGroupMember';
|
export { usePromoteGroupMember } from './groups/usePromoteGroupMember';
|
||||||
export { useUpdateGroup } from './groups/useUpdateGroup';
|
export { useUpdateGroup } from './groups/useUpdateGroup';
|
||||||
|
export { useUpdateGroupTag } from './groups/useUpdateGroupTag';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Relationships
|
* Relationships
|
||||||
|
|
|
@ -2,6 +2,7 @@ export { useAccount } from './useAccount';
|
||||||
export { useApi } from './useApi';
|
export { useApi } from './useApi';
|
||||||
export { useAppDispatch } from './useAppDispatch';
|
export { useAppDispatch } from './useAppDispatch';
|
||||||
export { useAppSelector } from './useAppSelector';
|
export { useAppSelector } from './useAppSelector';
|
||||||
|
export { useBackend } from './useBackend';
|
||||||
export { useClickOutside } from './useClickOutside';
|
export { useClickOutside } from './useClickOutside';
|
||||||
export { useCompose } from './useCompose';
|
export { useCompose } from './useCompose';
|
||||||
export { useDebounce } from './useDebounce';
|
export { useDebounce } from './useDebounce';
|
||||||
|
|
|
@ -807,8 +807,19 @@
|
||||||
"group.tabs.all": "All",
|
"group.tabs.all": "All",
|
||||||
"group.tabs.media": "Media",
|
"group.tabs.media": "Media",
|
||||||
"group.tabs.members": "Members",
|
"group.tabs.members": "Members",
|
||||||
|
"group.tabs.tags": "Topics",
|
||||||
|
"group.tags.empty": "There are no topics in this group yet.",
|
||||||
|
"group.tags.hidden.success": "Topic marked as hidden",
|
||||||
|
"group.tags.hide": "Hide topic",
|
||||||
"group.tags.hint": "Add up to 3 keywords that will serve as core topics of discussion in the group.",
|
"group.tags.hint": "Add up to 3 keywords that will serve as core topics of discussion in the group.",
|
||||||
"group.tags.label": "Tags",
|
"group.tags.label": "Tags",
|
||||||
|
"group.tags.pin": "Pin topic",
|
||||||
|
"group.tags.pin.success": "Pinned!",
|
||||||
|
"group.tags.show": "Show topic",
|
||||||
|
"group.tags.total": "Total Posts",
|
||||||
|
"group.tags.unpin": "Unpin topic",
|
||||||
|
"group.tags.unpin.success": "Unpinned!",
|
||||||
|
"group.tags.visible.success": "Topic marked as visible",
|
||||||
"group.update.success": "Group successfully saved",
|
"group.update.success": "Group successfully saved",
|
||||||
"group.upload_banner": "Upload photo",
|
"group.upload_banner": "Upload photo",
|
||||||
"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.",
|
||||||
|
@ -828,6 +839,10 @@
|
||||||
"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.show_more": "Show More",
|
||||||
"groups.discover.suggested.title": "Suggested For You",
|
"groups.discover.suggested.title": "Suggested For You",
|
||||||
|
"groups.discover.tags.empty": "Unable to fetch popular topics at this time. Please check back later.",
|
||||||
|
"groups.discover.tags.show_more": "Show More",
|
||||||
|
"groups.discover.tags.title": "Browse Topics",
|
||||||
|
"groups.discovery.tags.no_of_groups": "Number of groups",
|
||||||
"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",
|
||||||
"groups.pending.count": "{number, plural, one {# pending request} other {# pending requests}}",
|
"groups.pending.count": "{number, plural, one {# pending request} other {# pending requests}}",
|
||||||
|
@ -836,6 +851,7 @@
|
||||||
"groups.pending.label": "Pending Requests",
|
"groups.pending.label": "Pending Requests",
|
||||||
"groups.popular.label": "Suggested Groups",
|
"groups.popular.label": "Suggested Groups",
|
||||||
"groups.search.placeholder": "Search My Groups",
|
"groups.search.placeholder": "Search My Groups",
|
||||||
|
"groups.tags.title": "Browse Topics",
|
||||||
"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}",
|
||||||
|
@ -1467,7 +1483,6 @@
|
||||||
"status.show_original": "Show original",
|
"status.show_original": "Show original",
|
||||||
"status.title": "Post Details",
|
"status.title": "Post Details",
|
||||||
"status.title_direct": "Direct message",
|
"status.title_direct": "Direct message",
|
||||||
"status.title_group": "Group Post Details",
|
|
||||||
"status.translate": "Translate",
|
"status.translate": "Translate",
|
||||||
"status.translated_from_with": "Translated from {lang} using {provider}",
|
"status.translated_from_with": "Translated from {lang} using {provider}",
|
||||||
"status.unbookmark": "Remove bookmark",
|
"status.unbookmark": "Remove bookmark",
|
||||||
|
|
|
@ -1464,7 +1464,6 @@
|
||||||
"status.show_original": "显示原文本",
|
"status.show_original": "显示原文本",
|
||||||
"status.title": "帖文详情",
|
"status.title": "帖文详情",
|
||||||
"status.title_direct": "私信",
|
"status.title_direct": "私信",
|
||||||
"status.title_group": "群组帖文详情",
|
|
||||||
"status.translate": "翻译",
|
"status.translate": "翻译",
|
||||||
"status.translated_from_with": "使用 {provider} 从 {lang} 翻译而来",
|
"status.translated_from_with": "使用 {provider} 从 {lang} 翻译而来",
|
||||||
"status.unbookmark": "移除书签",
|
"status.unbookmark": "移除书签",
|
||||||
|
|
|
@ -7,6 +7,7 @@ import punycode from 'punycode';
|
||||||
|
|
||||||
import { Record as ImmutableRecord, Map as ImmutableMap, fromJS } from 'immutable';
|
import { Record as ImmutableRecord, Map as ImmutableMap, fromJS } from 'immutable';
|
||||||
|
|
||||||
|
import { groupSchema, type Group } from 'soapbox/schemas';
|
||||||
import { mergeDefined } from 'soapbox/utils/normalizers';
|
import { mergeDefined } from 'soapbox/utils/normalizers';
|
||||||
|
|
||||||
// https://docs.joinmastodon.org/entities/card/
|
// https://docs.joinmastodon.org/entities/card/
|
||||||
|
@ -16,6 +17,7 @@ export const CardRecord = ImmutableRecord({
|
||||||
blurhash: null as string | null,
|
blurhash: null as string | null,
|
||||||
description: '',
|
description: '',
|
||||||
embed_url: '',
|
embed_url: '',
|
||||||
|
group: null as null | Group,
|
||||||
height: 0,
|
height: 0,
|
||||||
html: '',
|
html: '',
|
||||||
image: null as string | null,
|
image: null as string | null,
|
||||||
|
@ -60,11 +62,21 @@ const normalizeProviderName = (card: ImmutableMap<string, any>) => {
|
||||||
return card.set('provider_name', providerName);
|
return card.set('provider_name', providerName);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeGroup = (card: ImmutableMap<string, any>) => {
|
||||||
|
try {
|
||||||
|
const group = groupSchema.parse(card.get('group').toJS());
|
||||||
|
return card.set('group', group);
|
||||||
|
} catch (_e) {
|
||||||
|
return card.set('group', null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const normalizeCard = (card: Record<string, any>) => {
|
export const normalizeCard = (card: Record<string, any>) => {
|
||||||
return CardRecord(
|
return CardRecord(
|
||||||
ImmutableMap(fromJS(card)).withMutations(card => {
|
ImmutableMap(fromJS(card)).withMutations(card => {
|
||||||
normalizePleromaOpengraph(card);
|
normalizePleromaOpengraph(card);
|
||||||
normalizeProviderName(card);
|
normalizeProviderName(card);
|
||||||
|
normalizeGroup(card);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -33,6 +33,7 @@ export const GroupRecord = ImmutableRecord({
|
||||||
members_count: 0,
|
members_count: 0,
|
||||||
note: '',
|
note: '',
|
||||||
statuses_visibility: 'public',
|
statuses_visibility: 'public',
|
||||||
|
slug: '',
|
||||||
tags: [],
|
tags: [],
|
||||||
uri: '',
|
uri: '',
|
||||||
url: '',
|
url: '',
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
import { useRouteMatch } from 'react-router-dom';
|
import { useRouteMatch } from 'react-router-dom';
|
||||||
|
|
||||||
|
import GroupLookupHoc from 'soapbox/components/hoc/group-lookup-hoc';
|
||||||
import { Column, Icon, Layout, Stack, Text } from 'soapbox/components/ui';
|
import { Column, Icon, Layout, Stack, Text } from 'soapbox/components/ui';
|
||||||
import GroupHeader from 'soapbox/features/group/components/group-header';
|
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';
|
||||||
|
@ -12,7 +13,7 @@ import {
|
||||||
SignUpPanel,
|
SignUpPanel,
|
||||||
SuggestedGroupsPanel,
|
SuggestedGroupsPanel,
|
||||||
} from 'soapbox/features/ui/util/async-components';
|
} from 'soapbox/features/ui/util/async-components';
|
||||||
import { useOwnAccount } from 'soapbox/hooks';
|
import { useFeatures, useOwnAccount } from 'soapbox/hooks';
|
||||||
import { useGroup } from 'soapbox/hooks/api';
|
import { useGroup } from 'soapbox/hooks/api';
|
||||||
import { useGroupMembershipRequests } from 'soapbox/hooks/api/groups/useGroupMembershipRequests';
|
import { useGroupMembershipRequests } from 'soapbox/hooks/api/groups/useGroupMembershipRequests';
|
||||||
import { Group } from 'soapbox/schemas';
|
import { Group } from 'soapbox/schemas';
|
||||||
|
@ -23,11 +24,12 @@ const messages = defineMessages({
|
||||||
all: { id: 'group.tabs.all', defaultMessage: 'All' },
|
all: { id: 'group.tabs.all', defaultMessage: 'All' },
|
||||||
members: { id: 'group.tabs.members', defaultMessage: 'Members' },
|
members: { id: 'group.tabs.members', defaultMessage: 'Members' },
|
||||||
media: { id: 'group.tabs.media', defaultMessage: 'Media' },
|
media: { id: 'group.tabs.media', defaultMessage: 'Media' },
|
||||||
|
tags: { id: 'group.tabs.tags', defaultMessage: 'Topics' },
|
||||||
});
|
});
|
||||||
|
|
||||||
interface IGroupPage {
|
interface IGroupPage {
|
||||||
params?: {
|
params?: {
|
||||||
id?: string
|
groupId?: string
|
||||||
}
|
}
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
@ -61,10 +63,11 @@ const BlockedBlankslate = ({ group }: { group: Group }) => (
|
||||||
/** Page to display a group. */
|
/** Page to display a group. */
|
||||||
const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const features = useFeatures();
|
||||||
const match = useRouteMatch();
|
const match = useRouteMatch();
|
||||||
const me = useOwnAccount();
|
const me = useOwnAccount();
|
||||||
|
|
||||||
const id = params?.id || '';
|
const id = params?.groupId || '';
|
||||||
|
|
||||||
const { group } = useGroup(id);
|
const { group } = useGroup(id);
|
||||||
const { accounts: pending } = useGroupMembershipRequests(id);
|
const { accounts: pending } = useGroupMembershipRequests(id);
|
||||||
|
@ -73,24 +76,42 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
||||||
const isBlocked = group?.relationship?.blocked_by;
|
const isBlocked = group?.relationship?.blocked_by;
|
||||||
const isPrivate = group?.locked;
|
const isPrivate = group?.locked;
|
||||||
|
|
||||||
const items = [
|
// if ((group as any) === false) {
|
||||||
{
|
// return (
|
||||||
|
// <MissingIndicator />
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
const tabItems = useMemo(() => {
|
||||||
|
const items = [];
|
||||||
|
items.push({
|
||||||
text: intl.formatMessage(messages.all),
|
text: intl.formatMessage(messages.all),
|
||||||
to: `/groups/${group?.id}`,
|
to: `/group/${group?.slug}`,
|
||||||
name: '/groups/:id',
|
name: '/group/:groupSlug',
|
||||||
},
|
});
|
||||||
{
|
|
||||||
|
if (features.groupsTags) {
|
||||||
|
items.push({
|
||||||
|
text: intl.formatMessage(messages.tags),
|
||||||
|
to: `/group/${group?.slug}/tags`,
|
||||||
|
name: '/group/:groupSlug/tags',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
text: intl.formatMessage(messages.members),
|
text: intl.formatMessage(messages.members),
|
||||||
to: `/groups/${group?.id}/members`,
|
to: `/group/${group?.slug}/members`,
|
||||||
name: '/groups/:id/members',
|
name: '/group/:groupSlug/members',
|
||||||
count: pending.length,
|
count: pending.length,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: intl.formatMessage(messages.media),
|
text: intl.formatMessage(messages.media),
|
||||||
to: `/groups/${group?.id}/media`,
|
to: `/group/${group?.slug}/media`,
|
||||||
name: '/groups/:id/media',
|
name: '/group/:groupSlug/media',
|
||||||
},
|
});
|
||||||
];
|
|
||||||
|
return items;
|
||||||
|
}, [features.groupsTags]);
|
||||||
|
|
||||||
const renderChildren = () => {
|
const renderChildren = () => {
|
||||||
if (!isMember && isPrivate) {
|
if (!isMember && isPrivate) {
|
||||||
|
@ -109,7 +130,7 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
||||||
<GroupHeader group={group} />
|
<GroupHeader group={group} />
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
items={items}
|
items={tabItems}
|
||||||
activeItem={match.path}
|
activeItem={match.path}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -141,4 +162,4 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default GroupPage;
|
export default GroupLookupHoc(GroupPage as any) as any;
|
||||||
|
|
|
@ -1,7 +1,12 @@
|
||||||
import { z } from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
const groupTagSchema = z.object({
|
const groupTagSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
uses: z.number().optional(),
|
||||||
|
url: z.string().optional(),
|
||||||
|
pinned: z.boolean().optional().catch(false),
|
||||||
|
visible: z.boolean().optional().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
type GroupTag = z.infer<typeof groupTagSchema>;
|
type GroupTag = z.infer<typeof groupTagSchema>;
|
||||||
|
|
|
@ -28,6 +28,7 @@ const groupSchema = z.object({
|
||||||
members_count: z.number().catch(0),
|
members_count: z.number().catch(0),
|
||||||
note: z.string().transform(note => note === '<p></p>' ? '' : note).catch(''),
|
note: z.string().transform(note => note === '<p></p>' ? '' : note).catch(''),
|
||||||
relationship: groupRelationshipSchema.nullable().catch(null), // Dummy field to be overwritten later
|
relationship: groupRelationshipSchema.nullable().catch(null), // Dummy field to be overwritten later
|
||||||
|
slug: z.string().catch(''), // TruthSocial
|
||||||
statuses_visibility: z.string().catch('public'),
|
statuses_visibility: z.string().catch('public'),
|
||||||
tags: z.array(groupTagSchema).catch([]),
|
tags: z.array(groupTagSchema).catch([]),
|
||||||
uri: z.string().catch(''),
|
uri: z.string().catch(''),
|
||||||
|
|
|
@ -6,6 +6,7 @@ export { customEmojiSchema } from './custom-emoji';
|
||||||
export { groupSchema } from './group';
|
export { groupSchema } from './group';
|
||||||
export { groupMemberSchema } from './group-member';
|
export { groupMemberSchema } from './group-member';
|
||||||
export { groupRelationshipSchema } from './group-relationship';
|
export { groupRelationshipSchema } from './group-relationship';
|
||||||
|
export { groupTagSchema } from './group-tag';
|
||||||
export { relationshipSchema } from './relationship';
|
export { relationshipSchema } from './relationship';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -16,4 +17,5 @@ export type { CustomEmoji } from './custom-emoji';
|
||||||
export type { Group } from './group';
|
export type { Group } from './group';
|
||||||
export type { GroupMember } from './group-member';
|
export type { GroupMember } from './group-member';
|
||||||
export type { GroupRelationship } from './group-relationship';
|
export type { GroupRelationship } from './group-relationship';
|
||||||
|
export type { GroupTag } from './group-tag';
|
||||||
export type { Relationship } from './relationship';
|
export type { Relationship } from './relationship';
|
||||||
|
|
|
@ -559,6 +559,11 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
*/
|
*/
|
||||||
groupsSearch: v.software === TRUTHSOCIAL,
|
groupsSearch: v.software === TRUTHSOCIAL,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can see topics for Groups.
|
||||||
|
*/
|
||||||
|
groupsTags: v.software === TRUTHSOCIAL,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Can validate group names.
|
* Can validate group names.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -70,7 +70,6 @@
|
||||||
"@reach/popover": "^0.18.0",
|
"@reach/popover": "^0.18.0",
|
||||||
"@reach/rect": "^0.18.0",
|
"@reach/rect": "^0.18.0",
|
||||||
"@reach/tabs": "^0.18.0",
|
"@reach/tabs": "^0.18.0",
|
||||||
"@reach/tooltip": "^0.18.0",
|
|
||||||
"@reduxjs/toolkit": "^1.8.1",
|
"@reduxjs/toolkit": "^1.8.1",
|
||||||
"@sentry/browser": "^7.37.2",
|
"@sentry/browser": "^7.37.2",
|
||||||
"@sentry/react": "^7.37.2",
|
"@sentry/react": "^7.37.2",
|
||||||
|
|
19
yarn.lock
19
yarn.lock
|
@ -2922,30 +2922,11 @@
|
||||||
"@reach/polymorphic" "0.18.0"
|
"@reach/polymorphic" "0.18.0"
|
||||||
"@reach/utils" "0.18.0"
|
"@reach/utils" "0.18.0"
|
||||||
|
|
||||||
"@reach/tooltip@^0.18.0":
|
|
||||||
version "0.18.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/@reach/tooltip/-/tooltip-0.18.0.tgz#6d416e77a82543af9a57d122962f9c0294fc2a5f"
|
|
||||||
integrity sha512-yugoTmTjB3qoMk/nUvcnw99MqpyE2TQMOXE29qnQhSqHriRwQhfftjXlTAGTSzsUJmbyms3A/1gQW0X61kjFZw==
|
|
||||||
dependencies:
|
|
||||||
"@reach/auto-id" "0.18.0"
|
|
||||||
"@reach/polymorphic" "0.18.0"
|
|
||||||
"@reach/portal" "0.18.0"
|
|
||||||
"@reach/rect" "0.18.0"
|
|
||||||
"@reach/utils" "0.18.0"
|
|
||||||
"@reach/visually-hidden" "0.18.0"
|
|
||||||
|
|
||||||
"@reach/utils@0.18.0":
|
"@reach/utils@0.18.0":
|
||||||
version "0.18.0"
|
version "0.18.0"
|
||||||
resolved "https://registry.yarnpkg.com/@reach/utils/-/utils-0.18.0.tgz#4f3cebe093dd436eeaff633809bf0f68f4f9d2ee"
|
resolved "https://registry.yarnpkg.com/@reach/utils/-/utils-0.18.0.tgz#4f3cebe093dd436eeaff633809bf0f68f4f9d2ee"
|
||||||
integrity sha512-KdVMdpTgDyK8FzdKO9SCpiibuy/kbv3pwgfXshTI6tEcQT1OOwj7BAksnzGC0rPz0UholwC+AgkqEl3EJX3M1A==
|
integrity sha512-KdVMdpTgDyK8FzdKO9SCpiibuy/kbv3pwgfXshTI6tEcQT1OOwj7BAksnzGC0rPz0UholwC+AgkqEl3EJX3M1A==
|
||||||
|
|
||||||
"@reach/visually-hidden@0.18.0":
|
|
||||||
version "0.18.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/@reach/visually-hidden/-/visually-hidden-0.18.0.tgz#17923c08acc5946624c2836b2b09d359b3aa8c27"
|
|
||||||
integrity sha512-NsJ3oeHJtPc6UOeV6MHMuzQ5sl1ouKhW85i3C0S7VM+klxVlYScBZ2J4UVnWB50A2c+evdVpCnld2YeuyYYwBw==
|
|
||||||
dependencies:
|
|
||||||
"@reach/polymorphic" "0.18.0"
|
|
||||||
|
|
||||||
"@reduxjs/toolkit@^1.8.1":
|
"@reduxjs/toolkit@^1.8.1":
|
||||||
version "1.8.1"
|
version "1.8.1"
|
||||||
resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.8.1.tgz#94ee1981b8cf9227cda40163a04704a9544c9a9f"
|
resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.8.1.tgz#94ee1981b8cf9227cda40163a04704a9544c9a9f"
|
||||||
|
|
Loading…
Reference in New Issue